Hold'em: every seat on the ring is the same seat, and the ring is measured before it is believed #122

Open
jheaps wants to merge 27 commits from polish/holdem-seats-one-size into master
49 changed files with 9670 additions and 1102 deletions
+243 -85
View File
@@ -26,7 +26,10 @@
</div>
<div id="holdemHeaderActions">
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
<button id="nextHandBtn" class="holdemBtn" title="Move the button, small blind and big blind to the next occupied seat">Next Hand ⟳</button>
@* Both the label and the title are rewritten every render from whether a hand
is out — see Render.renderHeaderActions. What is here is what is true before
the first render: no hand, so pressing it only moves the button. *@
<button id="nextHandBtn" class="holdemBtn" title="Pass the button on again without dealing — for a hand nobody played">Move Button ⟳</button>
@* Not the accent any more. It is a shortcut to a sheet, available all night,
and it is never the next step — the bar always has that, and at showdown
the bar says so in as many words. See .actionAdvance in holdem.css. *@
@@ -44,7 +47,7 @@
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
body.seatSheetOpen, and it is `display: none` above 900px where the panel is
just a panel. *@
<div id="seatSheetScrim"></div>
@@ -89,11 +92,59 @@
half-way through a night. *@
<details class="sidebarPanel" id="tableSetupPanel">
<summary><h2>Table Setup</h2> <span class="panelGlance" id="glanceTableSetup" hidden></span></summary>
@* The numbers first, and only the numbers.
This panel is the one thing that is open when the tool is first
opened, and it was 886px of a 667px phone — the two figures its own
summary glances at, the blinds, sat below 218px of prose explaining
three switches a dealer may never touch. The three have gone into
"While you deal" below, with the paragraphs that explain them, which
is where House take and Without live betting already were: the panel
had the vocabulary for this and was only using it for money. *@
@* Every field below takes digits and none of them is type="number".
That is deliberate, and it is the second half of a fix — the first
half is Render.setField, which stopped a re-render writing over the
text somebody was still typing.
type="number" was costing more than it paid here. It silently drops
characters it dislikes with no feedback to the person or to assistive
tech; it reports a half-typed "0." as "0", which is what let the
decimal point be written away; it does not expose selectionStart, so
nothing can put a caret back where it was; it draws a spin button
into a 54px-wide blind field, where five digits already need 45 of
the 52 available; and it is the control GOV.UK dropped from their
number patterns after user testing, for the same reasons plus Dragon
and NVDA.
inputmode is what actually summons the keypad on a phone, and it is
the better keypad: "decimal" carries the separator for the reader's
locale, which is the key a dealer needs to type 0.25 and the one
type="number" leaves them hunting for on iOS.
min/max/step go with it. Nothing relied on them: the seat count is
clamped to 2..24 in main.js before it is used, and the money fields
are read through a finite-number check that a spinner never provided
anyway. *@
<div class="panelBody">
<label class="fieldRow">
<span>Seats at table</span>
<input type="number" id="seatCountInput" min="2" max="24" value="10" />
<input type="text" id="seatCountInput" inputmode="numeric" autocomplete="off" value="10" />
</label>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
<input type="text" id="smallBlindInput" inputmode="decimal" autocomplete="off" />
</label>
<label class="fieldRow">
<span>Big blind</span>
<input type="text" id="bigBlindInput" inputmode="decimal" autocomplete="off" />
</label>
</div>
<label class="fieldRow">
<span>Starting stack ($)</span>
<input type="text" id="defaultStackInput" inputmode="decimal" autocomplete="off" placeholder="e.g. 200" />
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
<label class="fieldRow">
<span>Blinds move</span>
<select id="directionSelect">
@@ -101,6 +152,14 @@
<option value="counterclockwise">Counterclockwise</option>
</select>
</label>
@* All three change what the tool does rather than what the table is,
and all three are set once and then left alone. The glance says so
when one of them is not on its default, because a switch you cannot
see is a switch you cannot remember turning on. *@
<details class="subPanel">
<summary>While you deal <span class="panelGlance subPanelGlance" id="glanceWhileYouDeal" hidden></span></summary>
<div class="subPanelBody">
<label class="fieldRow">
<span>Dedicated dealer (not a seat)</span>
<input type="checkbox" id="dedicatedDealerToggle" />
@@ -122,21 +181,8 @@
opt in to the tool doing more than they asked; they should not have
to opt in to it staying readable. *@
<p class="fieldHint">Stops the phone dimming and locking between the flop and the turn. It only holds while betting is open, so the screen sleeps as usual during breaks &mdash; and the tool says so the first time it takes hold.</p>
<div class="blindsRow">
<label class="fieldRow">
<span>Small blind</span>
<input type="number" id="smallBlindInput" min="0" step="0.5" />
</label>
<label class="fieldRow">
<span>Big blind</span>
<input type="number" id="bigBlindInput" min="0" step="0.5" />
</label>
</div>
<label class="fieldRow">
<span>Starting stack ($)</span>
<input type="number" id="defaultStackInput" min="0" step="1" placeholder="e.g. 200" />
</label>
<p class="fieldHint">Blinds post themselves each hand. Seats without their own stack start here.</p>
</details>
<details class="subPanel">
<summary>House take</summary>
@@ -144,11 +190,11 @@
<div class="blindsRow">
<label class="fieldRow">
<span>Rake (%)</span>
<input type="number" id="rakePercentInput" min="0" max="100" step="0.5" placeholder="0" />
<input type="text" id="rakePercentInput" inputmode="decimal" autocomplete="off" placeholder="0" />
</label>
<label class="fieldRow">
<span>Cap ($)</span>
<input type="number" id="rakeCapInput" min="0" step="1" placeholder="none" />
<input type="text" id="rakeCapInput" inputmode="decimal" autocomplete="off" placeholder="none" />
</label>
</div>
<label class="fieldRow">
@@ -169,7 +215,7 @@
<div class="subPanelBody">
<label class="fieldRow">
<span>Base pot amount ($)</span>
<input type="number" id="basePotInput" min="0" step="1" placeholder="e.g. 500" />
<input type="text" id="basePotInput" inputmode="decimal" autocomplete="off" placeholder="e.g. 500" />
</label>
<p class="fieldHint">For tracking a hand by hand instead of tapping the action: everyone is assumed to have put in this much, unless their seat says otherwise.</p>
</div>
@@ -275,16 +321,36 @@
<details class="sidebarPanel">
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
@* The panel was 793px on an iPhone and 420 of them were the colour form:
five rows of swatch, value box and a red delete block, then an add
button and a paragraph of instructions — all of it above "Stacks in
chips", which is the part a dealer opens this panel *during* a game to
read. Setting the colours is a thing you do once, on the first night,
and it was sitting on top of the thing you do at every colour-up.
So the order is the other way round now, and the form is behind a
disclosure, which is the shape Table Setup already uses for House take
and Without live betting. The legend line stays out in the open: it is
what the panel is named for, it is two lines rather than four hundred
pixels, and it answers the other question a dealer opens this for —
what is the green one worth. *@
<div class="panelBody">
<div id="chipLegendList"></div>
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
<ul id="chipLegendStrip" class="chipLegendStrip"></ul>
<div id="colourUp" hidden>
<h3 class="panelSubhead">Stacks in chips</h3>
<dl id="colourUpList" class="colourUpList"></dl>
<p class="fieldHint">The fewest chips that make each stack — for colouring up, or counting one out.</p>
</div>
<details class="subPanel">
<summary>Colours and values</summary>
<div class="subPanelBody">
<div id="chipLegendList"></div>
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
<p class="fieldHint">Give each chip color a value, then count chips on a seat instead of typing a total.</p>
</div>
</details>
</div>
</details>
@@ -374,19 +440,29 @@
<span>Name</span>
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
</label>
<div class="seatCardToggles">
<label class="inHandToggle" title="This seat is playing the current hand">
<input type="checkbox" class="inHandCheckbox" />
<span>In hand</span>
</label>
<label class="foldToggle" title="Folded">
<input type="checkbox" class="foldCheckbox" />
<span>Fold</span>
</label>
@* One question with three answers, not two boxes with four corners.
A seat is sitting out, in the hand, or folded — and "folded" only means
anything while the seat is in. So the two checkboxes here could be ticked
into a state that means nothing, and the code that stopped them doing it
`disabled` the Fold box whenever In hand was clear. Measured: that left the
label at the same opacity and the same colour with a `title` on it, so a
dealer pressing Fold on a seat that was sitting out got no movement and no
reason — the exact thing round 16 went through the tool to remove, hiding
from the rule that finds it because the title sat on the label rather than
on the control.
Three named states cannot contradict each other and none of them is ever
unavailable, so there is nothing left to dim. The radiogroup is the same
shape the card picker's suits use. *@
<div class="seatState" role="radiogroup" aria-label="This seat">
<button type="button" class="seatStateOption" data-seat-state="out" role="radio" aria-checked="false">Sitting out</button>
<button type="button" class="seatStateOption" data-seat-state="in" role="radio" aria-checked="false">In the hand</button>
<button type="button" class="seatStateOption" data-seat-state="folded" role="radio" aria-checked="false">Folded</button>
</div>
<label class="fieldRow stackRow">
<span>Stack</span>
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
<input type="text" class="stackInput" inputmode="decimal" autocomplete="off" placeholder="table default" />
</label>
<div class="seatBuyIns" hidden></div>
@@ -410,7 +486,7 @@
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
<input type="text" class="amountOverrideInput" inputmode="decimal" autocomplete="off" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
@@ -429,7 +505,7 @@
<template id="chipLegendRowTemplate">
<div class="chipLegendRow">
<input type="color" class="chipColorInput" aria-label="Chip colour" />
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" aria-label="Chip value" />
<input type="text" class="chipValueInput" inputmode="decimal" autocomplete="off" placeholder="value" aria-label="Chip value" />
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
</div>
</template>
@@ -442,7 +518,7 @@
<label class="chipCountRow">
<span class="chipSwatch"></span>
<span class="chipCountValue"></span>
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
<input type="text" class="chipCountInput" inputmode="numeric" autocomplete="off" value="0" />
</label>
</template>
@@ -480,7 +556,7 @@
<div id="addChipsSheet" role="dialog" aria-labelledby="addChipsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="addChipsHeader">
<h2 id="addChipsTitle"></h2>
<h2 id="addChipsTitle" tabindex="-1" data-modal-focus></h2>
<span id="addChipsSubtitle"></span>
</div>
<div id="addChipsQuick"></div>
@@ -497,9 +573,13 @@
<div id="rulingsOverlay" hidden>
<div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
@* The grip belongs inside the header, not above it, because the header is stuck
to the top of the scrollport — anything above a sticky thing is something the
list scrolls out through. The seat menu's header is built the same way, and
for the same reason. *@
<div class="rulingsHeader">
<h2 id="rulingsTitle">Common rulings</h2>
<div class="seatSheetGrip" aria-hidden="true"></div>
<h2 id="rulingsTitle" tabindex="-1" data-modal-focus>Common rulings</h2>
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
@@ -509,37 +589,61 @@
not during it.
</p>
<nav id="rulingsIndex" class="rulingsIndex" aria-label="Jump to a ruling"></nav>
@* Twelve rulings, shut.
<section class="ruling" data-short="Misdeal">
<h3>Redeal it — misdeal</h3>
They used to be twelve headings with all their prose under them — 2,480px of
it on a phone, of which 1,754 was below the fold — and a row of twelve chips
across the top to jump between them. The chips were an index of the twelve
headings printed directly above the same twelve headings, which is the same
list twice: 172px of navigation before the first word, and the dealer who
used it landed in the middle of a scroll with eleven rulings they didn't ask
for still wrapped around them.
The headings are the index now. NN/g's rule for this shape — a list of
independent topics somebody opens one of — is an accordion, shut by default,
and half of these headings answer the question without being opened at all:
"A short all-in doesn't reopen the betting" is the ruling, not the topic.
Not exclusive (no `name` attribute): closing one because another opened moves
everything under the dealer's finger, which is the disorientation the
accordion is here to avoid. *@
@* Six and six, down one column and then the next. Two boxes rather than one
list so that landscape can lay them side by side without the columns
sharing rows: opening a ruling in one of them moves what is under it in
that column and nothing else. Everywhere but landscape the wrappers are
`display: contents` and the twelve are one column, exactly as before. *@
<div class="rulingsList">
<div class="rulingsColumn">
<details class="ruling">
<summary><h3>Redeal it — misdeal</h3></summary>
<ul>
<li>The first card goes to the wrong seat.</li>
<li>A seat entitled to a hand gets dealt out, or a seat that isn't entitled gets dealt in.</li>
<li>Two or more boxed cards turn up on the initial deal.</li>
<li>Either of the first two cards is exposed by dealer error.</li>
</ul>
</section>
</details>
<section class="ruling" data-short="Board card early">
<h3>A board card turned too early</h3>
<details class="ruling">
<summary><h3>A board card turned too early</h3></summary>
<p>
If the fourth board card gets turned before the betting round is finished,
that card is out of play for the round. Finish the betting, then burn and
turn what would have been the fifth card in the fourth card's place.
</p>
</section>
</details>
<section class="ruling" data-short="Out of turn">
<h3>Acting out of turn</h3>
<details class="ruling">
<summary><h3>Acting out of turn</h3></summary>
<p>
A one-off is usually just backed up if no one has acted behind them.
Doing it repeatedly is what earns a penalty.
</p>
</section>
</details>
<section class="ruling" data-short="Clock">
<h3>Calling the clock on a player</h3>
<details class="ruling">
<summary><h3>Calling the clock on a player</h3></summary>
<p>
Any player may ask for a clock once a decision is taking unreasonably
long; it is the dealer, not the table, who grants it. The player then
@@ -553,10 +657,10 @@
count once you tap <em>Call the clock</em>, and offers the ruling as one
button when it ends.
</p>
</section>
</details>
<section class="ruling" data-short="Straddle">
<h3>A straddle is a blind raise</h3>
<details class="ruling">
<summary><h3>A straddle is a blind raise</h3></summary>
<p>
The player left of the big blind may put out twice it before the cards,
without looking. It is a blind bet, not a raise made in turn, so it does
@@ -570,10 +674,10 @@
posts it, puts the right player on the clock and prices the raise;
the seat wears an <em>STR</em> badge for the hand.
</p>
</section>
</details>
<section class="ruling" data-short="Short all-in">
<h3>A short all-in doesn't reopen the betting</h3>
<details class="ruling">
<summary><h3>A short all-in doesn't reopen the betting</h3></summary>
<p>
Somebody shoving in less than a full raise — say $3 more when the last
raise was $8 — does not give the players behind them another go. Anyone
@@ -585,20 +689,22 @@
the minimum raise stays measured off the last <em>full</em> raise, not
off the short one. The action bar follows this for you.
</p>
</section>
</details>
</div>
<section class="ruling" data-short="Uncalled bet">
<h3>Chips nobody called come back</h3>
<div class="rulingsColumn">
<details class="ruling">
<summary><h3>Chips nobody called come back</h3></summary>
<p>
Bet more than anyone was able to call and the excess is returned to you
before the showdown — it was never contested, so it isn't part of the
pot. The pot breakdown shows it as "uncalled" rather than pretending it's
a side pot you could win.
</p>
</section>
</details>
<section class="ruling" data-short="Oversized chip">
<h3>One big chip, nothing said</h3>
<details class="ruling">
<summary><h3>One big chip, nothing said</h3></summary>
<p>
The most common argument at a home table. A player facing a bet who
pushes out a single oversized chip without saying anything has
@@ -611,10 +717,10 @@
clear verbal declaration in turn is binding either way — if words and
chips disagree, whichever came first is the action.
</p>
</section>
</details>
<section class="ruling" data-short="Minimum raise">
<h3>A raise has to be a full raise</h3>
<details class="ruling">
<summary><h3>A raise has to be a full raise</h3></summary>
<p>
At least as much again as the last raise, or the big blind if there
hasn't been one. Anything between a call and that minimum isn't a raise
@@ -622,10 +728,10 @@
in. The raise pad says so if you type one, and won't stop you: your
ruling beats the arithmetic.
</p>
</section>
</details>
<section class="ruling" data-short="Who shows first">
<h3>Who turns their hand over first</h3>
<details class="ruling">
<summary><h3>Who turns their hand over first</h3></summary>
<p>
If there was a bet on the river, the last player to bet or raise shows
first. If the river was checked all the way round, it's the first live
@@ -638,20 +744,20 @@
was. The action bar names the seat and the reason at every showdown, so
the call is already made before anyone asks.
</p>
</section>
</details>
<section class="ruling" data-short="Odd chip">
<h3>The odd chip in a split pot</h3>
<details class="ruling">
<summary><h3>The odd chip in a split pot</h3></summary>
<p>
When a chopped pot won't divide evenly, the odd chip goes to the first
player left of the button, not to whoever the dealer happened to tick
first. Pot payouts here order the winners that way, so the leftover lands
on the right seat every time.
</p>
</section>
</details>
<section class="ruling" data-short="String bet">
<h3>Betting in one motion — the string bet</h3>
<details class="ruling">
<summary><h3>Betting in one motion — the string bet</h3></summary>
<p>
A bet is whatever goes in on the first trip to the stack, unless the
amount was said out loud first. Reaching back for more chips without
@@ -664,7 +770,9 @@
fine — the point is that a player can't watch the table react and then
decide how much to bet. Say it, or push it all at once.
</p>
</section>
</details>
</div>
</div>
<p class="rulingsSource">
Follows the Tournament Directors Association rules, which most rooms and
@@ -675,26 +783,65 @@
<div id="seatMenuOverlay" hidden>
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
@* The grip was the only thing at the top of this sheet, and NN/g is explicit
that a grab handle is not a close affordance — a sheet wants a real one. It
earns its place twice over here: this sheet is the one that can outgrow the
screen, and with the header stuck to the top the way out never scrolls away.
Third job, the quiet one: Escape finds this close button rather than the one
at the foot, so Escape closes the sheet while Back steps back inside it.
It used to carry a fourth. Modal focused a dialog's first control and the
first control here was Fold, so putting a close button above the list was
also what moved the landing spot off the destructive choice. That was the
right half of the answer — Roselli is clear that a close button is a landing
spot for a sentence, not for a sheet long enough to scroll, where a stray
Space dismisses the thing the dealer just opened. The heading below names
the spot outright now, and it says which seat. *@
<div class="seatMenuHeader">
<h2 id="seatMenuTitle"></h2>
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatMenuHeading">
<h2 id="seatMenuTitle" tabindex="-1" data-modal-focus></h2>
<span id="seatMenuSubtitle"></span>
</div>
<button id="seatMenuCloseBtn" data-modal-close class="seatMenuClose" type="button" aria-label="Close this menu">×</button>
</div>
<div id="seatMenuActions"></div>
<button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
</div>
</div>
<div id="cardPickerOverlay" hidden>
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
@* `data-why-note` is this sheet claiming the job of saying why a card can't be
picked. The toast that answers everywhere else is fixed to the bottom of the
window, and this sheet stands on top of it. *@
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle" data-why-note="cardPickerNote">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="cardPickerHeader">
<h2 id="cardPickerTitle"></h2>
@* Not the first control any more. Modal used to land on whatever came
first in here, and what came first in here was Clear: the sheet a
dealer opens to write a card down opened on the button that rubs one
out. The heading says which seat and which card, which is the thing
worth hearing before any of the fifty-two. *@
<h2 id="cardPickerTitle" tabindex="-1" data-modal-focus></h2>
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<div id="cardPickerSlots"></div>
<div id="cardPickerGrid"></div>
@* Empty until a dealt card is pressed, and holding its line the whole time so
that filling it doesn't shove fifty-two cards down the screen under a thumb
that is already reaching for one. *@
<p id="cardPickerNote" class="pickerNote" role="status"></p>
@* The first of the two questions a card is. A radiogroup rather than a tablist:
the grid below is not a different panel of content, it is the same thirteen
controls filtered, and single-select filtering is what a radio group is for.
The suits are a fixed set written by paint(), not here, so that the one place
that knows what a suit looks like stays CardPicker.SUITS. *@
<div id="cardPickerSuits" role="radiogroup" aria-label="Suit"></div>
@* Which suit's thirteen these are, said once for a screen reader rather than
left to be inferred from the chosen radio two elements up. *@
<span id="cardPickerGridLabel" class="visuallyHidden"></span>
<div id="cardPickerGrid" role="group" aria-labelledby="cardPickerGridLabel"></div>
</div>
</div>
@@ -716,8 +863,14 @@
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
<h2 id="confirmTitle"></h2>
<p id="confirmMessage"></p>
@* The one sheet that lands on a control rather than its heading, and the one
where the APG asks for it by name: this is a question with two answers and
one of them cannot be taken back, so the dealer starts on the answer that
leaves them no worse off. It is short enough to be read at a glance, which is
the case where landing on a control is the right thing. Said out loud here
rather than left to depend on which button happens to be written first. *@
<div class="confirmActions">
<button id="confirmCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmCancelBtn" data-modal-close data-modal-focus class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
</div>
</div>
@@ -726,8 +879,13 @@
<div id="potWizardOverlay" hidden>
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
<div class="potWizardHeader">
<h2 id="potWizardTitle">Resolve the Pot</h2>
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
<h2 id="potWizardTitle" tabindex="-1" data-modal-focus>Resolve the Pot</h2>
@* Named, and named for what leaving does rather than "Close". The glyph is
the whole of this control, so unnamed it announced as "button" — on the
one dialog in the tool that divides money, where the difference between
backing out and confirming is somebody's stack. *@
<button id="closePotWizardBtn" type="button" data-modal-close class="holdemBtn holdemBtnGhost"
aria-label="Close without paying the pot">✕</button>
</div>
<div id="potWizardBody"></div>
@* The buttons live outside the scrolling body on purpose. This is the one
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,15 @@
/* Quick Split is phone-first: sized for one thumb at the table, then allowed to
breathe on a laptop. Palette matches the dealer table so the two read as one tool. */
/* And matches it in the half a stylesheet does not paint, for the same reason: this
page is #0a0a0a and had never told the browser so, which leaves the scrollbars, the
caret, the selection and the autofill wash drawn for a light page. It has no
checkboxes to go white, but a dealer who opens it from the felt should not find a
light scrollbar down the side of the darker of the two pages. */
:root {
color-scheme: dark;
}
body {
background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%);
min-height: 100vh;
@@ -517,20 +526,30 @@ button,
font-weight: 600;
}
/* As wide as the three things in it, and no wider.
This sits in the same wrapping flex row as the chips, where it has never once fitted
on the first line of a phone. `flex: 1 1 auto` then handed it the whole of the second
line: measured on an iPhone, a 358px bar with 252px of nothing in the middle and the
− and + pushed 304px apart — the two ends of a control a thumb is meant to work with
one hand. 136px now, its two ends 121px apart. */
.waysStepper {
display: flex;
align-items: center;
gap: 0.35rem;
flex: 1 1 auto;
min-width: 150px;
flex: 0 0 auto;
background: #191919;
border: 1px solid #333;
border-radius: 11px;
padding: 0.2rem 0.4rem;
}
/* 44 both ways, not 40x44. It had been 40px wide since the steppers were written, and
the sweep never said so because the only two steppers on the site are on this screen
and this screen was only ever audited in its all-in mode, where neither is on display
until a side pot has more than two contenders. The first sweep that opened the even
mode reported `hittable 39x43` on both ends, on every touch viewport. */
.stepBtn {
width: 40px;
width: 44px;
height: 44px;
border: none;
border-radius: 8px;
@@ -542,17 +561,26 @@ button,
cursor: pointer;
}
/* The floor and the ceiling look like the floor and the ceiling. */
.stepBtn:disabled {
opacity: 0.32;
cursor: default;
}
@media (hover: hover) {
.stepBtn:hover {
.stepBtn:not(:disabled):hover {
background: #333;
}
}
/* The count itself, now that no chip beside it is also holding one — so it is painted
the way a selected chip is painted, because that is what it is standing in for. */
.waysReadout {
flex: 1;
min-width: 2.5ch;
text-align: center;
color: #999;
font-size: 0.8rem;
color: #f0d9a8;
font-size: 1rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
@@ -16,8 +16,14 @@ const ActionBar = {
state.actingSeatIndex !== null && state.actingSeatIndex !== undefined
? state.actingSeatIndex
: null;
// watchHeight first, because render() may reach for publishHeight; then publish
// once there is a bar to measure. The ResizeObserver does come round on its own
// when the markup's `hidden` comes off — but it arrives a frame later at best,
// and measured on a cold load with nine seats to lay out it was 875ms behind.
// The felt is drawn inside that gap.
this.watchHeight();
this.render();
this.publishHeight();
},
// The bar is fixed to the bottom of the screen, so everything else has to know how
@@ -31,18 +37,40 @@ const ActionBar = {
if (!host || this.heightWatcher) return;
const publish = () => {
const visible = !host.hidden;
const rail = this.isRail(host);
// A bar nobody has drawn yet is not a bar of zero height. It is a bar whose
// height nobody knows, and saying zero is answering the question wrongly
// rather than declining it: the felt reads this to decide how tall to be,
// and zero tells it the whole window is free.
//
// It is the state this bar starts in — `<div id="actionBar" hidden>` in the
// markup, taken out by the first render() — and watchHeight() used to
// publish from inside that window, because mount() called it before render.
// Measured at 360x640 nine-handed: the table came up 336x504 against a
// published 525px of room, then collapsed to 336x374 sixty milliseconds
// later when the real 151px bar was finally measured. 1280x900 lost 13px
// the same way. A table that is drawn, read, and then redrawn at a
// different size is the layout shift this measurement exists to prevent.
//
// A rail is different, and still publishes: it is a real bar that genuinely
// takes none of the bottom, because it is off to the side.
if (host.hidden && !rail) return;
// On a phone held sideways the bar is a rail down the right-hand side, so it
// takes no height at all — and `--actionBarHeight` is what four other rules
// and the felt measurement all mean by "how much of the bottom is spoken
// for". Publishing the rail's height there would reserve the whole window.
const height = visible && !rail ? host.getBoundingClientRect().height : 0;
const height = rail ? 0 : host.getBoundingClientRect().height;
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
// What is under the felt is the bar in one layout and the two links in the
// other. Either way it is the same question — how much of the bottom is
// already spoken for — so it is the same argument.
this.publishFeltRoom(rail ? this.roomBelowFelt() : height);
//
// Except that the felt is asked a slightly different question from everything
// else that reads --actionBarHeight. The toast and the scroll padding want to
// know what is covering the bottom of the window *right now*; the table wants
// to know how big to be, and being a different size for the few seconds a pad
// is open over it is not an answer, it is a jolt. See restingBarHeight.
this.publishFeltRoom(rail ? this.roomBelowFelt() : this.restingBarHeight(height));
};
// Kept so anything that moves the table without touching the bar can ask for
@@ -58,6 +86,42 @@ const ActionBar = {
publish();
},
// How tall the bar is when it is only a bar.
//
// The raise pad is 57px taller than the row it replaces, and the felt was sized
// against it. Measured with five players nine-handed, pressing Raise:
//
// 1280x900 felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px
// 360x640 felt 283x329 -> 283x279, pucks 44 -> 48, worst seat moved 50px
// 390x844 felt 309x463 -> 309x451, worst seat moved 11px
//
// The whole table jumped, re-ran the ten-candidate width search, redrew at a
// different size — and then undid all of it when the dealer pressed Raise to. Every
// raise, all night, at the one moment they are reading the felt to decide what to
// type. A phone held sideways already did the right thing here and scored 0px: the
// rail is a column down the side, so its height never was the table's business.
//
// A pad over the bar is a transient surface, and the thing a transient surface must
// not do is take the context away from underneath itself. So the felt is sized
// against the bar the thumb is coming back to, and the pad grows up over the bottom
// of the felt for as long as it is open.
//
// Only the felt's question is answered this way. --actionBarHeight stays what it has
// always been — what is actually covering the bottom of the window — because that is
// what the toast has to clear and what the page has to scroll past.
//
// The remembered height goes stale if the window changes size while the pad is open,
// which on a phone means turning it over mid-raise. The next publish after the pad
// closes is a measurement, so it puts itself right; a stale felt for the seconds in
// between is the same trade being made everywhere else here.
restingBarHeight(measured) {
if (!this.raiseOpen) {
this.barHeightAtRest = measured;
return measured;
}
return this.barHeightAtRest === undefined ? measured : this.barHeightAtRest;
},
// Is the bar a rail down the side rather than a bar across the bottom?
//
// Asked of the stylesheet rather than answered again here. The layout is chosen by
@@ -202,9 +266,31 @@ const ActionBar = {
}
this.syncTankTimer();
// After the tank timer, because whether the stopwatch and the clock button are
// on the acting line is what decides how much room the name has left.
this.fitActingLine();
this.fitUndoLabel();
this.showRailEnd(host);
this.announce();
},
// A phone held sideways puts this bar down the right-hand edge as a rail, anchored
// low: the buttons sit at the bottom, where a thumb rests rather than where a hand
// has to reach up. The rail scrolls when what it holds outgrows it, which the raise
// pad does on a 360px-tall screen the moment the dealer has their text turned up —
// measured at 640x360 with 20px of root text, 407px of pad in a 358px rail.
//
// And a scroll container starts at its top. So the overflow came off the bottom,
// which is exactly where the two buttons that end a raise are: Cancel and Raise to
// were half under the edge of the screen with the hand number and the pot — the two
// things a dealer had already read — holding the room above them. Anchored low, the
// rail should open at the end for the same reason it is anchored low at all.
showRailEnd(host) {
if (!this.isRail(host)) return;
if (host.scrollHeight <= host.clientHeight) return;
host.scrollTop = host.scrollHeight;
},
// The empty table. One sentence and one button, in the place every other prompt in
// this tool appears, so a dealer who has never opened it before is looking at the
// next step rather than at ten chairs labelled EMPTY.
@@ -336,20 +422,66 @@ const ActionBar = {
row.appendChild(runout);
}
// The second line of this block, held as a line rather than left to whatever the
// wrapping happens to produce.
//
// The row wraps, and flex decides its line breaks before it decides what to
// shrink — so a name too long for the space left beside the clock and the undo
// pill did not give way, it pushed them onto a third line. That is 22px, and the
// bar is fixed to the bottom, so it came off the felt: measured at 360x640
// mid-street, "Dee · $600 behind" left the table 283x329 and "Christopher ·
// $1,200 behind" made it 283x310, with every seat moving 24px between one player
// and the next. Whose turn it was decided how big the table was.
//
// On its own line the three of them are a row that can shrink instead of a row
// that can wrap, and the block is two lines whoever is acting.
const acting = document.createElement("div");
acting.className = "actionActing";
const index = this.state.actingSeatIndex;
if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) {
const player = this.state.seats[index];
// Two spans rather than one sentence, because only one half of it is
// allowed to give way. This row wraps, and a name long enough to push the
// clock and the undo pill onto a third line takes 22px off the felt — so
// whose turn it is decided how big the table was. Measured at 360x640:
// "Dee · $600 behind" left the bar 151px tall and "Christopher · $1,200
// behind" made it 173, and the felt resized between one player and the next.
//
// So the name shrinks and the number does not. A name cut to "Christophe…"
// is still the player the dealer is looking at; a stack cut to "$1,2…" is a
// figure they would have to go and find somewhere else.
const who = document.createElement("span");
who.className = "actionWho";
who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`;
row.appendChild(who);
const whoName = document.createElement("span");
whoName.className = "actionWhoName";
whoName.textContent = HoldemState.displayName(player, index);
// The whole of it, for the one name on the narrowest phone that still has to
// give up a few characters. Same arrangement the seat pucks have.
whoName.title = whoName.textContent;
who.appendChild(whoName);
const whoStack = document.createElement("span");
whoStack.className = "actionWhoStack";
// No leading space: this is a flex item and its own would be collapsed away
// at the edge. .actionWho holds the two of them apart instead.
whoStack.textContent = `· ${Money.format(Betting.stackOf(this.state, player))} behind`;
who.appendChild(whoStack);
acting.appendChild(who);
// Built every render and shown by the tick, the same way the clock button
// beside it is: a stopwatch reading "0:02" is 50px of the narrowest line in
// the tool spent on a number nobody acts on. Starts hidden so a decision
// that has only just landed on somebody never flashes one up for a frame.
const tank = document.createElement("span");
tank.className = "actionTank";
tank.id = "actionTank";
tank.title = "How long this player has been thinking";
tank.textContent = "0:00";
row.appendChild(tank);
tank.hidden = true;
acting.appendChild(tank);
// Rendered every time and shown by the tick once the wait is long enough
// to be worth it, so a clock never costs a re-render at the moment the
@@ -375,7 +507,7 @@ const ActionBar = {
clock.title = `${this.CLOCK_THINK_S} seconds, then a ${this.CLOCK_COUNTDOWN_S}-second countdown`;
clock.addEventListener("click", () => this.callClock());
}
row.appendChild(clock);
acting.appendChild(clock);
}
// A standing control rather than a toast per action: a dealer taps this bar
@@ -387,16 +519,50 @@ const ActionBar = {
undo.type = "button";
undo.className = "actionUndo";
undo.title = `Undo ${undoLabel}`;
// And said out loud, not only hung off a hover. The visible label is the
// short form and every short form is a substring of this one, which is what
// Label in Name asks: a speech user saying what they can see still hits it,
// and a screen reader gets the qualifier the pill had no room for. Without
// this the name was the visible text — on a phone, "↶ hand killed U".
undo.setAttribute("aria-label", `Undo ${undoLabel}`);
// The label goes in its own element rather than straight onto the button.
// The pill is a fixed width — it names the last action, so its text changes
// on every tap, and a control that resizes under the dealer's thumb reads
// as the interface twitching. Fixed width means long labels have to
// ellipsize, and only a real element can: as a bare text node the label
// as the interface twitching. Fixed width means long labels have to give
// something up, and only a real element can: as a bare text node the label
// truncated *through* the keycap below it, which then sat squashed against
// the cut-off text looking like a rendering fault.
//
// Three elements, because what it gives up matters. Measured on a phone,
// the label has 78px and every label with an actor on it wants more:
// "↶ Mike from work's fold" 128, "↶ Christopher's fold" 109. Widening it is
// not available — the room it would take is the room the acting name beside
// it needs, and that is what fitActingLine is already spending. So the actor
// comes off, whole, and the act stays; see fitUndoLabel, and undoParts for
// what that requires of an act.
const label = document.createElement("span");
label.className = "actionUndoLabel";
label.textContent = `↶ ${undoLabel}`;
const glyph = document.createElement("span");
glyph.className = "actionUndoGlyph";
glyph.textContent = "↶";
// Decoration, and hidden so it stays out of the name above — "left arrow
// over hand killed" is not what this control is called.
glyph.setAttribute("aria-hidden", "true");
label.appendChild(glyph);
const parts = this.undoStep();
if (parts.lead) {
const lead = document.createElement("span");
lead.className = "actionUndoLead";
lead.textContent = parts.lead;
label.appendChild(lead);
}
const act = document.createElement("span");
act.className = "actionUndoAct";
act.textContent = parts.act;
label.appendChild(act);
undo.appendChild(label);
// U undoes, and nothing said so — this control was the only place a dealer
// could have learned it, and it was the one action bar control without its
@@ -408,11 +574,15 @@ const ActionBar = {
// floated above the bar. Here the key belongs inline, after the label.
key.className = "actionUndoKey";
key.textContent = this.KEY_HINTS.actionUndo;
key.setAttribute("aria-hidden", "true");
undo.appendChild(key);
undo.addEventListener("click", () => this.hooks.undoLast());
row.appendChild(undo);
acting.appendChild(undo);
}
// Nothing to act on and nothing to take back — no second line at all.
if (acting.childElementCount) row.appendChild(acting);
return row;
},
@@ -542,12 +712,59 @@ const ActionBar = {
}
},
// Whether this decision has been going on long enough to be worth saying anything
// about — which is the one question behind both the stopwatch and the button beside
// it, so they ask it once and answer it together.
//
// The stopwatch used to be there from the first second of every decision, and for
// the first half-minute it reads "0:07" in grey: a number nobody acts on, holding
// 50px of a line that on a 390px phone has 349 to spend between a name, a stack, a
// clock button and the undo pill. It is not a readout the dealer is watching; it is
// a prompt, and a prompt that is always there is not one. See fitActingLine for
// what the room it gives back is spent on.
tankWorthShowing() {
if (this.clockRunning()) return true;
if (this.tankStartedAt === null) return false;
// Nothing to time from — the action was already here when the page loaded. The
// dash and its explanation are the answer to "why is there no clock on this
// one", so it has to be visible to be asked.
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) return true;
return this.tankSeconds() >= this.NUDGE_AFTER_S;
},
tankSeconds() {
return Math.floor((Date.now() - this.tankStartedAt) / 1000);
},
// Updates only the timer's own text, so the seconds ticking over never rebuilds the
// buttons under the dealer's thumb.
paintTank() {
const el = document.getElementById("actionTank");
if (!el || this.tankStartedAt === null) return;
const nudge = document.getElementById("actionClockBtn");
const wasTank = el.hidden;
const wasNudge = nudge ? nudge.hidden : null;
const worth = this.tankWorthShowing();
el.hidden = !worth;
// The button that starts a clock is offered once somebody has been thinking
// long enough for the table to start asking. Before that it is a control nobody
// needs, and this bar is docked over the seats. While a clock is already running
// the button says "Stop the clock" and renderStatus decides whether it is there
// at all — once the count has expired there is nothing left to stop.
if (nudge && !this.clockRunning()) nudge.hidden = !worth;
if (!this.paintTankText(el)) return;
// Two things just left the line or joined it, so what is left has a different
// amount of room than it did a second ago.
if (el.hidden !== wasTank || (nudge && nudge.hidden !== wasNudge)) this.fitActingLine();
},
// The readout itself. Returns false when it has asked for a full render instead, so
// that nothing downstream measures a bar which is about to be rebuilt.
paintTankText(el) {
// A clock takes the readout over: the number the table is waiting on is no
// longer how long this has taken but how long is left. At zero the bar has to
// change shape — a ruling and the button that carries it out — so that one
@@ -557,39 +774,95 @@ const ActionBar = {
if (left === 0 && !this.clockExpiredShown) {
this.clockExpiredShown = true;
this.render();
return;
return false;
}
el.textContent = left <= this.CLOCK_COUNTDOWN_S ? `${left}` : this.formatTank(left);
el.title = "Time left before the hand is decided";
el.classList.toggle("tankNudge", left > this.CLOCK_COUNTDOWN_S);
el.classList.toggle("tankSlow", left <= this.CLOCK_COUNTDOWN_S);
return;
}
// The button that starts a clock is offered once somebody has been thinking
// long enough for the table to start asking — which is the same threshold the
// stopwatch already marks. Before that it is a control nobody needs, and this
// bar is docked over the seats.
const nudge = document.getElementById("actionClockBtn");
if (nudge && !this.clockRunning()) {
const waited = Math.floor((Date.now() - this.tankStartedAt) / 1000);
nudge.hidden = this.tankUnknownFor === this.tankSeatIndex
? false
: waited < this.NUDGE_AFTER_S;
return true;
}
if (this.tankUnknownFor !== null && this.tankSeatIndex === this.tankUnknownFor) {
el.textContent = "—";
el.title = "The action was already on this seat when the page loaded, so there's nothing to time from yet. The clock starts when the action next moves.";
el.classList.remove("tankNudge", "tankSlow");
return;
return true;
}
const seconds = Math.floor((Date.now() - this.tankStartedAt) / 1000);
const seconds = this.tankSeconds();
el.title = "How long this player has been thinking";
el.textContent = this.formatTank(seconds);
el.classList.toggle("tankNudge", seconds >= this.NUDGE_AFTER_S && seconds < this.SLOW_AFTER_S);
el.classList.toggle("tankSlow", seconds >= this.SLOW_AFTER_S);
return true;
},
// Which half of "Christopher · $1,200 behind" survives when the line cannot hold
// both of them.
//
// The name was set up to be the half that gives way, on the rule the seat pucks
// keep: a number read wrong is a worse failure than a name read short. That rule is
// right and it stays. What was missing under it was a floor. Flex shrinks the only
// item that *can* shrink until the line fits, and with the clock button on the line
// there is no fitting to be had — measured at 360x640 and again at 390x844, both
// "Christopher" and "Alexandra" were drawn 0px wide, so the bar read
//
// · $1,200 behind
//
// a dangling separator and a figure, at the exact moment the dealer is looking at
// the bar to say whose turn it is out loud. The ellipsis never appeared: there were
// no characters left to put one after.
//
// So the stack comes off the line before the name gives up a single character. It is
// not lost — it is on that player's own seat puck, a couple of inches up the same
// screen, which is where a dealer counting somebody down is looking anyway. The name
// is the thing the bar alone is saying.
// The last action in the two halves the pill draws it in. The history is main.js's,
// because undo pops it — remembering the halves here would name the step that was
// just taken back rather than the one now on top. A caller that has not been given
// the halves gets the label whole, with nothing to drop, which is what it had before.
undoStep() {
const label = this.hooks.lastActionLabel();
const parts = this.hooks.lastActionParts && this.hooks.lastActionParts();
return parts || { lead: "", act: label };
},
// Which half of "Christopher's fold" the pill can hold.
//
// The actor comes off whole rather than being cut down: "Christo…'s fold" is a worse
// sentence than "fold", and the question a hand later is what came back rather than
// whose it was — the dealer has the seat in front of them. The whole of it stays on
// the button's title and aria-label, which is what the guidance asks of a control
// that has had to shorten its own name.
fitUndoLabel() {
const label = document.querySelector("#actionBar .actionUndoLabel");
const lead = label && label.querySelector(".actionUndoLead");
const act = label && label.querySelector(".actionUndoAct");
if (!lead || !act) return;
// The act is what gets asked, not the label. The label cannot overflow — the act
// inside it is the item with `overflow: hidden`, so it swallows the excess and
// the row it sits in measures as though everything fitted. Asking the label was
// the first way this was written and it read "↶ dealing the hand" with "the
// hand" quietly cut off inside it, on the one label short enough that the actor
// alone still fitted.
//
// Measured with the actor back on, so the answer is a property of the pill
// rather than of whatever the last render left behind.
lead.hidden = false;
if (act.scrollWidth > act.clientWidth + 1) lead.hidden = true;
},
fitActingLine() {
const name = document.querySelector("#actionBar .actionWhoName");
const stack = document.querySelector("#actionBar .actionWhoStack");
if (!name || !stack) return;
// Always measured with the stack back on, so the answer is a property of the
// line rather than of whatever the last render happened to leave behind.
stack.hidden = false;
// What the name wants against what flex gave it. A cut name reads as an
// overflowing one even when the overflow is the whole word.
if (name.scrollWidth > name.clientWidth + 1) stack.hidden = true;
},
// "0:07", "4:01", and past the hour "1:05:03" rather than "65:03" — a table left
@@ -710,12 +983,48 @@ const ActionBar = {
// would actually take back.
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
this.hooks.beginAction(label);
const step = this.undoParts(label);
this.hooks.beginAction(step.long, step);
mutate();
this.hooks.save(this.state);
this.hooks.rerender();
},
// An action's name in the parts the undo pill needs it in.
//
// The pill is a fixed width — it names the last action, so its text changes on every
// tap, and a control that resizes under a thumb reads as the interface twitching.
// Measured on a phone, that leaves the label 78px, and given the halves it can drop
// one. The actor is the half that goes, because the dealer has the seat in front of
// them and the question a hand later is *what* came back, not who it belonged to;
// and because "Christo…'s fold" is a worse sentence than "fold".
//
// Two rules follow, and both were being broken:
//
// `act` has to name the action on its own, because it is what survives. Four
// labels put a verb in the lead instead of an actor — "dealing" + "the hand",
// "calling" + "the straddle off" — so dropping the lead left the pill reading
// "↶ the hand" after every deal of the night, and "↶ the straddle off", neither
// of which names anything. Those are re-split; the ones with no actor at all
// hand over a single string and have nothing to drop.
//
// `act` has to fit, because the box ellipsises whatever is left. Measured at
// 390x844 with a thumb: "hand killed on the clock" wanted 125px of 78, "check on
// the clock" 99, "waiting for the blind" 103, "posting to come in" 97 — all four
// came out cut mid-phrase. The qualifier moves into `long` and the verb stays on
// the pill.
//
// `long` is the whole sentence, for the title and the accessible name and for the
// toast the bar's own Undo raises. Every short form is a substring of it — that is
// what WCAG's Label in Name asks of a control that has shortened its own name, and
// it is why `long` can be the accessible name rather than only a title.
undoParts(label) {
const lead = typeof label === "string" ? "" : (label.lead || "");
const act = typeof label === "string" ? label : label.act;
const spelt = typeof label === "string" ? "" : (label.long || "");
return { lead, act, long: spelt || (lead ? `${lead} ${act}` : act) };
},
renderActions() {
const index = this.state.actingSeatIndex;
const player = this.state.seats[index];
@@ -744,7 +1053,13 @@ const ActionBar = {
expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`,
"actionEnforceClock",
() => this.commit(
expired.verb === "fold" ? `${expired.who}'s hand killed on the clock` : `${expired.who}'s check on the clock`,
// The ruling is what the sentence is for; the pill keeps the verb.
// "hand killed on the clock" wants 125px of the 78 the pill has and
// came out "hand killed on the cl…"; "check on the clock" wanted 99.
{ lead: `${expired.who}'s`,
act: expired.verb === "fold" ? "hand killed" : "check",
long: `${expired.who}'s ${expired.verb === "fold"
? "hand killed on the clock" : "check on the clock"}` },
() => {
this.stopClock();
if (expired.verb === "fold") Betting.fold(this.state, index);
@@ -756,16 +1071,16 @@ const ActionBar = {
const row = document.createElement("div");
row.className = "actionButtons";
row.appendChild(this.button("Fold", "actionFold", () => this.commit(`${who}'s fold`, () => Betting.fold(this.state, index))));
row.appendChild(this.button("Fold", "actionFold", () => this.commit({ lead: `${who}'s`, act: "fold" }, () => Betting.fold(this.state, index))));
if (owed <= 0) {
row.appendChild(this.button("Check", "actionCheck", () => this.commit(`${who}'s check`, () => Betting.check(this.state, index))));
row.appendChild(this.button("Check", "actionCheck", () => this.commit({ lead: `${who}'s`, act: "check" }, () => Betting.check(this.state, index))));
} else if (owed >= stack) {
// Calling would put them all-in, so say that rather than quoting a number
// they can't cover.
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(`${who}'s all-in`, () => Betting.allIn(this.state, index)), Money.format(stack)));
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit({ lead: `${who}'s`, act: "all-in" }, () => Betting.allIn(this.state, index)), Money.format(stack)));
} else {
row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => Betting.call(this.state, index)), Money.format(owed)));
row.appendChild(this.button("Call", "actionCall", () => this.commit({ lead: `${who}'s`, act: "call" }, () => Betting.call(this.state, index)), Money.format(owed)));
}
if (stack > owed) {
@@ -863,9 +1178,13 @@ const ActionBar = {
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
this.raiseOpen = false;
const who = HoldemState.displayName(player, index);
const label = raising
? `${who}'s raise to ${Money.format(target)}`
: `${who}'s bet of ${Money.format(target)}`;
// The amount stays in the half that survives: "did I raise, and to what"
// is one question. It is the only act long enough to still need an ellipsis
// on the narrowest phone, and only past four figures — "raise to $60" is
// 55px of the 59 the label has once the arrow is drawn, "raise to $1,200" is
// 69. The whole of it is on the button's own title and aria-label either way.
const label = { lead: `${who}'s`,
act: raising ? `raise to ${Money.format(target)}` : `bet of ${Money.format(target)}` };
this.commit(label, () => Betting.raiseTo(this.state, index, target));
}));
@@ -972,12 +1291,12 @@ const ActionBar = {
buttons.push(this.button(
on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`,
"actionStraddle",
() => this.commit(on === 0 ? "the straddle" : "the re-straddle",
() => this.commit({ lead: "the", act: on === 0 ? "straddle" : "re-straddle" },
() => { this.state.straddleCount = on + 1; })));
}
if (on > 0) {
buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
() => this.commit("calling the straddle off",
() => this.commit({ lead: "the straddle", act: "called off" },
() => { this.state.straddleCount = 0; })));
}
return buttons;
@@ -1065,7 +1384,7 @@ const ActionBar = {
aside.appendChild(this.button(
benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`,
"dealPromptDebtBtn",
() => this.commit("dealing everybody in", () => {
() => this.commit({ lead: "everybody", act: "dealt in" }, () => {
benched.forEach(index => {
this.state.seats[index].inHand = true;
this.state.seats[index].folded = false;
@@ -1154,7 +1473,10 @@ const ActionBar = {
posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`,
"dealPromptDebtBtn",
() => this.commit(
posting ? `${who} waiting for the blind` : `${who} posting to come in`,
// Both of these wanted around 100px of 78 and came out mid-phrase.
{ lead: who,
act: posting ? "waiting" : "posting",
long: `${who} ${posting ? "waiting for the blind" : "posting to come in"}` },
() => { this.state.postingIn = posting ? [] : ids; })
));
wrap.appendChild(line);
@@ -1173,7 +1495,10 @@ const ActionBar = {
// 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));
// No actor to drop, so no half of this can go missing: "dealing" was the
// lead, and dropping a lead that is a verb left the pill reading "↶ the
// hand" after every deal of the night.
this.commit({ act: "the deal" }, () => Betting.beginHand(this.state));
}));
wrap.appendChild(row);
@@ -1234,7 +1559,8 @@ const ActionBar = {
this.toShowdown();
return;
}
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state));
const street = Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase();
this.commit({ act: `the ${street}` }, () => Betting.closeStreet(this.state));
// The cards that just came off the deck. Asked for here rather than left
// for the dealer to remember, because the moment they are holding them is
// the only moment they know what they are without turning the board round.
@@ -41,7 +41,17 @@ const AddChips = {
];
},
// Choosing a preset redraws the three of them so the chosen one can light up, which
// meant the button the dealer had just pressed was deleted while it held focus —
// and a deleted element doesn't hand focus on, it drops it on the body. Measured:
// Space on "Buy-in" left focus nowhere, inside a modal, with the amount changed and
// nothing to say so. See Modal.preservingFocus; the key is the amount, because that
// is what makes one of these three buttons itself.
render() {
Modal.preservingFocus(document.getElementById("addChipsSheet"), () => this.paint());
},
paint() {
const player = this.state.seats[this.seatIndex];
document.getElementById("addChipsTitle").textContent =
`Add chips — ${HoldemState.displayName(player, this.seatIndex)}`;
@@ -59,6 +69,7 @@ const AddChips = {
btn.type = "button";
btn.className = "addChipsQuickBtn";
btn.classList.toggle("selected", Number(this.draft) === option.value);
btn.dataset.focusKey = `quick-${option.value}`;
const label = document.createElement("span");
label.className = "addChipsQuickLabel";
@@ -2,18 +2,51 @@
//
// Typing "AH" into a two-character box is fine at a desk and miserable at a table:
// it needs a keyboard, it accepts nonsense like "ZZ", and it happily lets the same
// card land in two players' hands. The picker is one tap per card, greys out cards
// already dealt somewhere else at the table, and stores exactly the same two-character
// string the rest of the tool has always read.
// card land in two players' hands. The picker greys out cards already dealt somewhere
// else at the table and stores exactly the same two-character string the rest of the
// tool has always read.
//
// It used to put all fifty-two on the screen at once, four suits across and thirteen
// ranks down, and that grid is 646px tall. Measured with the board tracked and seven
// cards out:
//
// 640x360 816px of content in a 317px sheet — 40 of the 52 below the fold
// 667x375 816 in 330 — 40 below
// 844x390 774 in 343 — 36 below
// 375x667 816 in 587 — 20 below
// 1280x900 774 in 775 — none
//
// So on every phone the tool is actually used on, entering the flop began with a
// scroll, and a dealer looking for the nine of diamonds was reading down a column.
// Fifty-two 44px targets do not fit a phone in any arrangement: thirteen across is
// 572px of grid before gaps, which is wider than any phone in portrait.
//
// A card is not one choice out of fifty-two. It is two: a suit and a rank. So the sheet
// asks them in that order — four suits on a row, then the thirteen ranks of the one
// chosen — which is seventeen targets instead of fifty-two, all of them on the screen
// at once on every phone, and none of them smaller than they were. The suit stays put
// between picks, so a second card of the same suit is still a single tap.
const CardPicker = {
RANKS: ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"],
SUITS: [
{ code: "S", symbol: "♠", red: false },
{ code: "H", symbol: "♥", red: true },
{ code: "D", symbol: "♦", red: true },
{ code: "C", symbol: "♣", red: false },
{ code: "S", symbol: "♠", name: "Spades", red: false },
{ code: "H", symbol: "♥", name: "Hearts", red: true },
{ code: "D", symbol: "♦", name: "Diamonds", red: true },
{ code: "C", symbol: "♣", name: "Clubs", red: false },
],
// Which card in the grid the arrows are holding, as an index into the thirteen
// ranks on show. Kept across a redraw so the grid doesn't send the dealer back to
// the ace every time they pick something — and kept across a change of suit, which
// is the same rank in a different suit rather than a new place to be.
cursor: 0,
// Which suit's thirteen are on the grid. Deliberately not reset when the sheet
// opens: a dealer entering a flush board, or two suited hole cards, picks the suit
// once. Where the slot being filled already holds a card, that card's suit wins —
// the sheet should open showing what it is about to replace.
suit: "S",
state: null,
hooks: null,
// Which set of slots is being filled: a seat's two hole cards, or the five
@@ -30,6 +63,8 @@ const CardPicker = {
this.target = "seat";
this.seatIndex = seatIndex;
this.cardIndex = cardIndex;
this.cursor = 0;
this.aimSuit();
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
@@ -44,6 +79,8 @@ const CardPicker = {
this.seatIndex = null;
this.boardLimit = Math.max(1, Math.min(5, limit));
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
this.cursor = 0;
this.aimSuit();
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
@@ -138,10 +175,153 @@ const CardPicker = {
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
},
// Every card in the deck, ace down to two, each rank's four suits together. The
// grid shows one suit's worth of this at a time — see gridCards — and the duplicate
// check reads the whole of it, so the order is written once here rather than
// rebuilt by two nested loops that have to agree.
deck() {
const cards = [];
this.RANKS.forEach(rank => {
this.SUITS.forEach(suit => cards.push({ rank, suit, card: `${rank}${suit.code}` }));
});
return cards;
},
suitOf(code) {
return this.SUITS.find(suit => suit.code === code) || this.SUITS[0];
},
// The thirteen on the grid: one suit's worth of deck(), in deck order, so the ranks
// read A down to 2 exactly as they always have.
gridCards() {
return this.deck().filter(entry => entry.suit.code === this.suit);
},
// Open on the card that is already there. A slot holding the nine of diamonds
// should show the diamonds with the nine under the cursor — the dealer came here to
// change that card, and hunting for where it lives is work the sheet can do. An
// empty slot changes nothing, which is what keeps the suit across a flush board.
aimSuit() {
const current = (this.slotValues()[this.cardIndex] || "").toUpperCase();
if (!/^[AKQJT2-9][SHDC]$/.test(current)) return;
this.suit = current.slice(1);
const rank = this.RANKS.indexOf(current.slice(0, 1));
if (rank >= 0) this.cursor = rank;
},
// Changing suit is not moving: the cursor is a rank, and the same rank in another
// suit is the card directly beside the one you were on. Coming back with the cursor
// reset to the ace would undo the only thing the dealer had told the sheet.
setSuit(code, { focus = false } = {}) {
if (this.suit === code) return;
this.suit = code;
this.render();
if (focus) {
const chosen = document.querySelector(`#cardPickerSuits [data-suit="${code}"]`);
if (chosen) chosen.focus({ preventScroll: true });
}
},
// Arrow keys along the suits, the way the APG's radio group asks for: one Tab stop
// for the set, arrows to move within it, and moving is choosing.
onSuitKey(event) {
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
if (step === undefined) return;
event.preventDefault();
const at = this.SUITS.findIndex(suit => suit.code === this.suit);
const to = (at + step + this.SUITS.length) % this.SUITS.length;
this.setSuit(this.SUITS[to].code, { focus: true });
},
// How far Down and Up move, asked of the grid rather than assumed. The row is as
// many ranks as the width holds — five on a 360px phone, nine on a phone held
// sideways — so a constant here would be a lie on most screens, and the last time
// this number disagreed with the layout the cursor slid into a column nobody asked
// for. Outside a browser there is nothing to measure and nothing to move.
gridStride() {
const grid = typeof document !== "undefined" && document.getElementById("cardPickerGrid");
if (!grid || typeof getComputedStyle !== "function") return this.RANKS.length;
const columns = getComputedStyle(grid).gridTemplateColumns.split(/\s+/).filter(Boolean).length;
return columns > 0 ? columns : this.RANKS.length;
},
// Where the arrows can go: one card, in the direction asked for, and nowhere if
// that runs off the end of the grid. Wrapping would put the dealer on a different
// rank without them asking to move rank.
//
// This used to hunt past dealt cards, because a `disabled` button cannot take focus
// — and hunting did the very thing the no-wrap rule was written to prevent. Right
// from A♠ with the ace of hearts dealt landed on A♦, so the column silently became
// the one to its right, and the next press of Down walked K♦ instead of K♥. A dealt
// card is `aria-disabled` now, so it holds focus like every other cell and the grid
// tells the truth about its own shape; the APG asks for exactly that inside a
// composite widget, and pressing one is answered rather than ignored.
nextCursor(from, step) {
const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
const to = from + step;
return to >= 0 && to < count ? to : from;
},
// One Tab stop for fifty-two cards.
//
// Every card used to be its own stop, which made this sheet fifty-six of them:
// reaching the seven of clubs meant something like forty presses of the only key
// that moves at all without a mouse. The APG's answer for a set of controls like
// this is the roving tabindex — one of them is in the Tab order at a time, and the
// arrows move between them — so the grid now costs one press to enter and one to
// leave, and the arrows bring the row into view on the way, which forty Tab presses
// through a scrolling sheet never reliably did.
//
// No new role is claimed for the container. They are buttons, they announce as
// buttons, and the only thing that changed is how far away they are.
aimCursor(index, { focus = false } = {}) {
const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")];
if (!buttons.length) return;
this.cursor = Math.max(0, Math.min(buttons.length - 1, index));
buttons.forEach((btn, i) => {
btn.tabIndex = i === this.cursor ? 0 : -1;
});
if (focus) buttons[this.cursor].focus({ preventScroll: false });
},
onGridKey(event) {
const stride = this.gridStride();
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: stride, ArrowUp: -stride }[event.key];
if (step !== undefined) {
event.preventDefault();
this.aimCursor(this.nextCursor(this.cursor, step), { focus: true });
return;
}
if (event.key !== "Home" && event.key !== "End") return;
event.preventDefault();
const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
this.aimCursor(event.key === "Home" ? this.nextCursor(-1, 1) : this.nextCursor(count, -1), { focus: true });
},
// A redraw is not a reason to lose your place. Every button in here is thrown away
// and rebuilt on each one, so whatever held focus was being deleted out from under
// the dealer — see Modal.preservingFocus. The card just picked is the one key that
// can't come back, because it is a dealt card now: the slot it went into is the
// useful place to be instead.
render() {
const sheet = document.getElementById("cardPickerSheet");
Modal.preservingFocus(sheet, () => this.paint(), () => {
const slot = sheet.querySelector("#cardPickerSlots .pickerSlot.active");
if (slot) slot.focus({ preventScroll: true });
});
},
paint() {
const used = this.cardsInUse();
const values = this.slotValues();
// Whatever the line was answering has been answered. Cleared here rather than
// on a timer, because a paint is exactly the set of things that make the last
// answer stale — a card went in, or the sheet moved to a different slot — and
// nothing else in this sheet redraws.
const note = document.getElementById("cardPickerNote");
if (note) note.textContent = "";
document.getElementById("cardPickerTitle").textContent = this.onBoard()
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
@@ -156,8 +336,17 @@ const CardPicker = {
btn.classList.toggle("active", slot === this.cardIndex);
const value = values[slot] || "";
btn.textContent = value || "—";
if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
// What it is called out loud. On the eye it is a card or an em dash, and an
// em dash is what a screen reader was being given as the whole name of the
// control: "dash, button", twice, above fifty-two more. The slot knows which
// slot it is and what is in it, so it says both.
const which = this.onBoard() ? this.boardSlotLabel(slot) : `Card ${slot + 1}`;
btn.setAttribute("aria-label", value ? `${which}: ${value}` : `${which}: empty`);
if (this.onBoard()) btn.title = which;
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
// The name this control answers to after a redraw. A slot is always the
// same slot — flop 2 stays flop 2 — so its number is the whole of it.
btn.dataset.focusKey = `slot-${slot}`;
btn.addEventListener("click", () => {
this.cardIndex = slot;
this.render();
@@ -165,23 +354,57 @@ const CardPicker = {
slots.appendChild(btn);
});
// The first question. Four of them, always in the same four places, so the one
// the dealer wants is somewhere their thumb already knows.
const suits = document.getElementById("cardPickerSuits");
suits.innerHTML = "";
this.SUITS.forEach(suit => {
const chosen = suit.code === this.suit;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerSuitTab";
btn.classList.toggle("cardRed", suit.red);
btn.classList.toggle("chosen", chosen);
btn.textContent = suit.symbol;
btn.setAttribute("role", "radio");
btn.setAttribute("aria-checked", chosen ? "true" : "false");
// The glyph is the whole of the control on the eye, and "♦, radio button"
// is not a suit out loud. The word is.
btn.setAttribute("aria-label", suit.name);
btn.dataset.suit = suit.code;
// One Tab stop for the four, like the grid below it has one for thirteen.
btn.tabIndex = chosen ? 0 : -1;
btn.dataset.focusKey = `suit-${suit.code}`;
btn.addEventListener("click", () => this.setSuit(suit.code, { focus: true }));
btn.addEventListener("keydown", event => this.onSuitKey(event));
suits.appendChild(btn);
});
const gridLabel = document.getElementById("cardPickerGridLabel");
if (gridLabel) gridLabel.textContent = `${this.suitOf(this.suit).name} — pick a rank`;
const grid = document.getElementById("cardPickerGrid");
grid.innerHTML = "";
this.RANKS.forEach(rank => {
this.SUITS.forEach(suit => {
const card = `${rank}${suit.code}`;
this.gridCards().forEach(({ rank, suit, card }) => {
const takenBy = used.get(card);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red);
btn.disabled = !!takenBy;
if (takenBy) {
btn.title = takenBy === "the board"
? "Already face up on the board"
: `Already dealt to ${takenBy}`;
}
btn.dataset.focusKey = card;
// Named in the sentence rather than left as "this one", because the sentence
// is read above the grid and the card that was pressed can be eight rows
// down it. Short by measurement, not by taste — see .pickerNote.
const label = `${rank}${suit.symbol}`;
Unavailable.set(btn, !!takenBy, takenBy === "the board"
? `${label} — face up on the board`
: `${label} — dealt to ${takenBy}`);
// Still the tooltip, and now also the accessible description — `title` only
// reaches a screen reader on an element it is allowed to land on, which is
// the other half of what `disabled` was costing here.
if (takenBy) btn.title = Unavailable.reason(btn);
else btn.removeAttribute("title");
if ((values[this.cardIndex] || "").toUpperCase() === card) {
btn.classList.add("selected");
}
@@ -196,9 +419,13 @@ const CardPicker = {
btn.appendChild(suitEl);
btn.addEventListener("click", () => this.assign(card));
btn.addEventListener("keydown", event => this.onGridKey(event));
grid.appendChild(btn);
});
});
// Hand the Tab order back exactly one of them. Done after the grid is built,
// because which card can hold the cursor depends on which are dealt already.
this.aimCursor(this.cursor);
},
};
@@ -7,7 +7,20 @@
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
const Toast = {
DEFAULT_DURATION_MS: 8000,
// What the clock is set back to when the dealer lets go of a toast.
//
// Pausing while it is touched or focused is only half the job. Let go at 7.9
// seconds of 8 and the message goes while the thumb is still travelling: the
// timer that matters once somebody has reached for a toast is the time to act
// on it, not what happened to be left of the time to notice it.
RESUME_MS: 2500,
timer: null,
endsAt: 0,
remaining: 0,
// Where focus was standing when this arrived.
returnFocusTo: null,
show(message, options = {}) {
const host = document.getElementById("toastHost");
@@ -17,7 +30,11 @@ const Toast = {
const toast = document.createElement("div");
toast.className = "toast";
toast.setAttribute("role", "status");
// No role on the toast itself. #toastHost is the live region, and it is in the
// markup — which is what makes it observed. A region that arrives already
// carrying its text has not been watched by anything yet, and is routinely
// never spoken at all; one nested inside a region that is watched only buys a
// second reading of the same sentence.
const text = document.createElement("span");
text.className = "toastText";
@@ -29,6 +46,12 @@ const Toast = {
action.type = "button";
action.className = "toastAction";
action.textContent = options.actionLabel;
// Which keyboard shortcut, if any, presses this. Measured on a desktop,
// this button is the 67th of the page's 68 tab stops, and it is gone in
// eight seconds: reaching it by Tab is not a thing anybody can do. Only
// the toasts that say so opt in — "Turn off" and "Reload" are not things
// a stray keypress should be able to reach.
if (options.shortcut) action.dataset.shortcut = options.shortcut;
action.addEventListener("click", () => {
this.dismiss();
options.onAction();
@@ -44,6 +67,27 @@ const Toast = {
close.addEventListener("click", () => this.dismiss());
toast.appendChild(close);
// Read after dismiss() above has had its chance to put focus back, so that a
// toast replacing another remembers where the dealer actually is.
const active = document.activeElement;
this.returnFocusTo = active && active !== document.body ? active : null;
// A message with a button in it is on a timer the reader has to beat, which is
// what WCAG's Timing Adjustable is about. Touching it or tabbing into it stops
// the clock; letting go starts it again.
toast.addEventListener("pointerenter", () => this.hold());
toast.addEventListener("focusin", event => {
// Where focus came *from* beats where it was when this appeared: a dealer
// who moves on and then tabs back into the toast should be put back where
// they had got to, not where they were eight seconds ago.
if (event.relatedTarget && !toast.contains(event.relatedTarget)) {
this.returnFocusTo = event.relatedTarget;
}
this.hold();
});
["pointerleave", "focusout"].forEach(type =>
toast.addEventListener(type, () => this.resume()));
host.appendChild(toast);
// Next frame, so the entrance transition actually runs.
requestAnimationFrame(() => {
@@ -51,10 +95,51 @@ const Toast = {
this.publishHeight();
});
this.timer = setTimeout(
() => this.dismiss(),
options.duration || this.DEFAULT_DURATION_MS
);
this.arm(options.duration || this.DEFAULT_DURATION_MS);
},
arm(ms) {
if (this.timer) clearTimeout(this.timer);
this.endsAt = Date.now() + ms;
this.timer = setTimeout(() => this.dismiss(), ms);
},
// Is the dealer holding this open — a finger on it, or the keyboard inside it?
heldOpen() {
if (typeof document === "undefined") return false;
const host = document.getElementById("toastHost");
const toast = host && host.firstElementChild;
if (!toast) return false;
if (document.activeElement && toast.contains(document.activeElement)) return true;
// :hover rather than a flag of our own, so a pointer that left the window
// without a pointerleave — or one that never sent pointerenter because the
// toast appeared underneath it — is answered by the same question either way.
try {
return typeof toast.matches === "function" && toast.matches(":hover");
} catch (err) {
return false;
}
},
hold() {
if (!this.timer) return;
clearTimeout(this.timer);
this.timer = null;
this.remaining = Math.max(0, this.endsAt - Date.now());
},
resume() {
// `focusout` arrives before the new focus lands, so "is anything in here still
// focused?" has no true answer until the next tick. pointerleave is deferred
// with it rather than against it: leaving with the keyboard still inside must
// not restart the clock.
setTimeout(() => {
if (this.timer || this.heldOpen()) return;
if (typeof document === "undefined") return;
const host = document.getElementById("toastHost");
if (!host || !host.firstElementChild) return;
this.arm(Math.max(this.remaining, this.RESUME_MS));
}, 0);
},
dismiss() {
@@ -62,8 +147,25 @@ const Toast = {
clearTimeout(this.timer);
this.timer = null;
}
this.remaining = 0;
// The same guard publishHeight below already carries, for the same reason: the
// bar dismisses a toast on the way into every action it commits, and that path
// is otherwise plain bookkeeping that answers without a document.
if (typeof document === "undefined") return;
const host = document.getElementById("toastHost");
if (host) host.innerHTML = "";
if (host) {
// Never leave the keyboard standing on nothing. A control removed while it
// has focus hands focus to <body>, and on this page that is 66 tab stops
// back from where the dealer was — so whoever pressed Undo, or ✕, or was
// simply reading when a second toast arrived, goes back where they were.
const active = document.activeElement;
const goBack = host.contains(active) ? this.returnFocusTo : null;
host.innerHTML = "";
if (goBack && goBack.isConnected && typeof goBack.focus === "function") {
goBack.focus();
}
}
this.returnFocusTo = null;
this.publishHeight();
},
@@ -131,6 +233,80 @@ const Dialog = {
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = { Toast, Dialog };
// A control that is on the screen and cannot act, and the reason it can't.
//
// `disabled` is the wrong attribute the moment there is something to say. It takes the
// control out of the tab order, so a screen reader never lands on it and never hears
// the `title` hung off it; and it eats the tap on a touch screen, where there is no
// hover to show a `title` to begin with. Measured on an iPhone with three hands dealt:
// seven of the card picker's fifty-two cards were dead, none of them could hold focus,
// and tapping one changed nothing whatsoever — no toast, no text, not a byte of DOM.
// The tool's answer to "who has the ace of spades?" existed, and was reachable only
// with a mouse.
//
// `aria-disabled` announces the same state and leaves the control reachable, so the
// press arrives and can be answered instead. Nothing enforces it for you — that is the
// whole point — so a press is intercepted once, centrally, in `answerPress` below.
const Unavailable = {
// Which control is being told off, if any. A press can land on the text inside a
// button rather than the button, so the answer is looked for upwards.
from(target) {
if (!target || typeof target.closest !== "function") return null;
return target.closest('[aria-disabled="true"]');
},
reason(el) {
return (el && el.dataset && el.dataset.whyNot) || "";
},
blocked(el) {
return !!el && el.getAttribute("aria-disabled") === "true";
},
// Both directions in one call, because every caller works out the condition anyway
// and the two halves have to stay in step — a control left marked after the reason
// stopped being true is worse than one that was never marked.
set(el, blocked, reason) {
if (!el) return;
if (!blocked) {
el.removeAttribute("aria-disabled");
delete el.dataset.whyNot;
return;
}
el.setAttribute("aria-disabled", "true");
el.dataset.whyNot = reason || "";
},
// Where the answer is written.
//
// The toast is fixed to the bottom of the window, and a sheet that stands 88% of
// the screen tall is sitting on top of it — so a control inside one answers inside
// it instead. A sheet opts in by naming its own line: `data-why-note="someId"`.
// Anything else falls through to the toast, which is where the table says things.
say(el, why) {
const host = el && typeof el.closest === "function" ? el.closest("[data-why-note]") : null;
const note = host ? document.getElementById(host.dataset.whyNote) : null;
if (note) {
note.textContent = why;
return;
}
Toast.show(why);
},
// One listener for the whole table, in the capture phase so it arrives before the
// control's own handler and can stop it. Registered once, by main.js.
listen(root) {
root.addEventListener("click", event => {
const el = this.from(event.target);
const why = this.reason(el);
if (!why) return;
event.preventDefault();
event.stopPropagation();
this.say(el, why);
}, true);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = { Toast, Dialog, Unavailable };
}
+81 -48
View File
@@ -5,6 +5,13 @@ document.addEventListener("DOMContentLoaded", () => {
// `hidden` attribute they all already toggle.
Modal.mount();
// A press on a control that can't act is answered rather than swallowed. One
// listener for the whole table, because the alternative is remembering to guard
// every click handler that might one day be attached to a dimmed control — and the
// one that gets forgotten is a dead tap with no explanation, which is the bug this
// is here to end. See Unavailable in feedback.js.
Unavailable.listen(document);
// ---- A scroll is not an edit ----
//
// A focused `<input type="number">` is a spinbutton, and a browser lets the mouse
@@ -54,6 +61,7 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show(message, {
actionLabel: "Undo",
shortcut: "undo",
onAction: () => {
restoreState(snapshot);
Toast.show("Undone.", { duration: 2500 });
@@ -342,6 +350,11 @@ document.addEventListener("DOMContentLoaded", () => {
// the right panel to have open on a table nobody has touched, and the wrong one to
// have open on the fourth hand of a night.
//
// That was half the answer. The other half was what the panel held while it was
// open — 886px of it, on a 667px phone — and it has since given three switches and
// the paragraphs explaining them to "While you deal". The panel this opens is 529px
// now, and the two figures its own summary glances at are the first two rows of it.
//
// Decided once, here, rather than on every render — a panel that reopened itself
// whenever the table emptied would fight the dealer who had just shut it.
(() => {
@@ -407,13 +420,18 @@ document.addEventListener("DOMContentLoaded", () => {
Render.bringIntoView(panel, { block: "nearest" });
},
beginAction: label => {
actionHistory.push({ label, snapshot: snapshotState() });
// `parts` is the same name split into the actor and the act — see
// ActionBar.undoParts. It rides along with the label rather than replacing it,
// because the toast below wants the sentence and the pill wants the halves.
beginAction: (label, parts) => {
actionHistory.push({ label, parts, snapshot: snapshotState() });
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
},
lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null),
lastActionParts: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].parts : null),
undoLast: () => {
const step = actionHistory.pop();
if (!step) return;
@@ -436,6 +454,24 @@ document.addEventListener("DOMContentLoaded", () => {
return true;
};
// The toast's Undo, pressed the same way the bar's is: by pressing the button that
// is actually on the screen, not by running a second copy of what it does.
//
// It has to be reachable. Every whole-step undo — next hand, resolve pot, a seat
// edited — clears the per-action history on its way past, so the bar has no pill to
// press and this button is the only way back. Measured on a desktop, it is the 67th
// of the page's 68 tab stops and it is gone in eight seconds, so U was the key the
// sidebar legend advertised and nothing answered.
//
// Only a button that named this shortcut, so the wake lock's "Turn off" and the
// other-tab warning's "Reload" are not things a stray U can reach.
const pressToastAction = shortcut => {
const btn = document.querySelector(`#toastHost .toastAction[data-shortcut="${shortcut}"]`);
if (!btn) return false;
btn.click();
return true;
};
const quickRaiseButton = label =>
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
.find(b => b.querySelector(".actionBtnLabel").textContent === label);
@@ -456,7 +492,10 @@ document.addEventListener("DOMContentLoaded", () => {
// Dealing is an advance too — Enter should carry a dealer through setup, deal,
// streets and showdown without reaching for the trackpad.
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"),
undo: () => pressInBar(".actionUndo"),
// The toast first, because it is the more recent of the two. They can both be
// up at once — a seat edited mid-hand leaves the bar's pill alone — and the one
// the dealer just caused is the one on the screen saying what it will take back.
undo: () => pressToastAction("undo") || pressInBar(".actionUndo"),
cancel: () => pressInBar(".raiseCancel"),
});
@@ -476,6 +515,7 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show(message, {
actionLabel: "Undo",
shortcut: "undo",
onAction: () => {
restoreState(snapshot);
Toast.show("Undone.", { duration: 2500 });
@@ -523,7 +563,7 @@ document.addEventListener("DOMContentLoaded", () => {
});
document.getElementById("basePotInput").addEventListener("input", e => {
state.basePotAmount = e.target.value === "" ? null : Number(e.target.value);
state.basePotAmount = Money.readAmount(e.target.value);
hooks.save(state);
Render.renderAll();
});
@@ -624,6 +664,10 @@ document.addEventListener("DOMContentLoaded", () => {
// middle took the table from 800 chips to 792, and the toast said nothing
// about it. Chips only ever leave this tool because a dealer meant them to.
const unresolved = HoldemState.unresolvedContributions(state);
// Asked before the mutation, because after it there is never a hand out. This is
// the difference between the two things this one button does: end the hand that
// is running, or pass the button along on a hand nobody played.
const wasLive = !!state.bettingOpen;
if (unresolved > Betting.EPSILON_CHIPS) {
const ok = await Dialog.confirm({
@@ -652,7 +696,17 @@ document.addEventListener("DOMContentLoaded", () => {
? ` ${Money.format(unresolved)} taken off the table.`
: "";
runUndoable(`Next hand — ${where}. Seat the table, then deal.${discarded}`, () => {
// The toast said "Next hand — ... Seat the table, then deal." every time, including
// the press that followed the pot wizard's own "Start Next Hand" — two lines that
// read as a confirmation of the thing the dealer had already done, when what had
// actually happened was a second rotation. It now reports which of the two it was,
// and the second line is gone: the bar is already holding a green "Post blinds &
// deal", so the toast was spending a line telling a dealer to press it.
const done = wasLive
? `Next hand — ${where}.${discarded}`
: `Button moved on, no hand dealt — ${where}.${discarded}`;
runUndoable(done, () => {
// Written down as well as announced. The toast is a moment; the bank check
// is asked at cash-out, and until now it saw only that the chips were gone
// and reported them as missing.
@@ -774,7 +828,10 @@ document.addEventListener("DOMContentLoaded", () => {
["defaultStackInput", "defaultStack"],
].forEach(([id, key]) => {
document.getElementById(id).addEventListener("input", e => {
state[key] = e.target.value === "" ? null : Number(e.target.value);
// Money.readAmount rather than Number(): these boxes are type="text" now, so
// a half-typed "0." or a stray letter reaches here instead of being silently
// eaten by the control, and NaN must not become the blind.
state[key] = Money.readAmount(e.target.value);
hooks.save(state);
Render.renderAll();
});
@@ -874,56 +931,32 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// An index across the top of the rulings sheet, one chip per ruling.
// The sheet opens on twelve shut rulings, and it opens that way every time.
//
// This is opened mid-argument with players waiting, and it was nine rulings of
// prose with no way in: reaching the last one meant scrolling 1.6 screens on a
// desktop, 2.1 on a phone and 3.5 on a phone held sideways. A dealer looking for
// the odd-chip rule had to read past seven others to find it.
//
// Built from the sections rather than written out beside them, so a ruling added
// later turns up in the index without anyone having to remember to add it twice.
const buildRulingsIndex = () => {
const index = document.getElementById("rulingsIndex");
if (!index || index.childElementCount > 0) return;
[...rulingsOverlay.querySelectorAll(".ruling")].forEach((section, i) => {
const heading = section.querySelector("h3");
if (!heading) return;
if (!section.id) section.id = `ruling${i + 1}`;
const jump = document.createElement("button");
jump.type = "button";
jump.className = "rulingsIndexBtn";
// A short label where the ruling gives one. The headings are sentences —
// "A short all-in doesn't reopen the betting" — and nine of those stacked
// one per row made the index 342px tall on a phone, which is most of the
// sheet before the first ruling. "Short all-in" says the same thing to
// somebody who is looking for it, and three fit on a line.
jump.textContent = section.dataset.short || heading.textContent;
// The full heading is still what it announces, so the short label never
// costs a screen reader the sentence.
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
jump.addEventListener("click", () => {
Render.bringIntoView(section, { block: "start" });
// The heading is what the dealer is now reading, so that is where the
// keyboard and a screen reader should be too.
heading.setAttribute("tabindex", "-1");
heading.focus({ preventScroll: true });
});
index.appendChild(jump);
});
// There was an index of chips here that this built from the headings; the headings
// are the index now, so there is nothing to build. What is left is the other half
// of the job: a dealer who read the odd-chip rule an hour ago should not find it
// still hanging open under the one they came back for. <details> keeps its state,
// so closing the sheet is what forgets it.
const openRulings = () => {
rulingsOverlay.querySelectorAll(".ruling[open]").forEach(r => { r.open = false; });
rulingsOverlay.hidden = false;
};
document.getElementById("openRulingsBtn").addEventListener("click", () => {
buildRulingsIndex();
rulingsOverlay.hidden = false;
});
document.getElementById("openRulingsBtn").addEventListener("click", openRulings);
document.getElementById("closeRulingsBtn").addEventListener("click", closeRulings);
rulingsOverlay.addEventListener("click", e => {
if (e.target === rulingsOverlay) closeRulings();
});
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
document.getElementById("seatMenuCloseBtn").addEventListener("click", () => SeatMenu.close());
// The button at the thumb end is the sheet's Back while the sheet has an inside to
// go back to. Opening the move picker used to be one-way: it replaced the action
// list, and the only control left threw the whole menu away.
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => {
if (SeatMenu.mode === "move") SeatMenu.closeMovePicker();
else SeatMenu.close();
});
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
if (e.target.id === "seatMenuOverlay") SeatMenu.close();
});
+76 -12
View File
@@ -14,12 +14,24 @@
const Modal = {
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
// must not be a Tab stop.
//
// `details > summary` is here because a summary is focusable without anyone giving
// it a tabindex, and leaving it out was not a small omission. The rulings sheet is
// twelve summaries and one Done button; this list saw one control in it, so the Tab
// trap below decided Done was both the first stop and the last and bounced every
// press straight back to it. Measured: eighty Tab presses, one element ever
// reached. Twelve rulings, none of them openable without a mouse.
//
// `:not([tabindex="-1"])` belongs on every line, not only the last. A control taken
// out of the Tab order still matches `button` or `input` — and a roving tabindex,
// which is what the card grid runs on now, is fifty-one of them at a time.
FOCUSABLE: [
"a[href]",
"button:not([disabled])",
'input:not([disabled]):not([type="hidden"])',
"select:not([disabled])",
"textarea:not([disabled])",
'a[href]:not([tabindex="-1"])',
'button:not([disabled]):not([tabindex="-1"])',
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
'select:not([disabled]):not([tabindex="-1"])',
'textarea:not([disabled]):not([tabindex="-1"])',
'details > summary:not([tabindex="-1"])',
'[tabindex]:not([tabindex="-1"])',
].join(","),
@@ -63,11 +75,22 @@ const Modal = {
this.open.push({ overlay, returnFocus: document.activeElement });
// Focus the first real control. A dialog that leaves focus on the button
// behind it reads, to a screen reader, as though nothing happened at all.
// Content-only sheets get focus on the sheet so the title is announced.
// Where the sheet puts the dealer when it opens.
//
// This used to be "the first control", full stop — one answer to a question six
// dialogs answer differently, and on the card picker the first control is Clear.
// The sheet for writing a card down opened on the button that rubs one out. The
// APG's rule is the opposite of that: where an action is hard to take back, land
// on the least destructive thing in the dialog.
//
// So a sheet names its own landing spot with `data-modal-focus`, and on all five
// that name one it is the heading. That is Roselli's advice for a dialog with
// more in it than a sentence — give the reader a chance to orient — and here it
// has a second use, because these headings say *which*: which seat, which card,
// which player's chips. A sheet that names nothing still gets the first control,
// and a sheet with no controls at all still gets focus on itself.
const focusable = this.focusableIn(overlay);
const target = focusable[0];
const target = overlay.querySelector("[data-modal-focus]") || focusable[0];
if (target) {
target.focus({ preventScroll: true });
} else if (sheet) {
@@ -93,6 +116,39 @@ const Modal = {
}
},
// Rebuild part of a sheet without losing the dealer's place in it.
//
// Emptying the element that currently holds focus does not move focus — it destroys
// it, and the browser drops the remains on `<body>`. Measured, on three sheets that
// redraw themselves after every tap: choosing a buy-in preset, picking a card,
// switching which slot is being filled. All three left focus on the document body,
// which inside a modal is nowhere: the next Tab had to be rescued by the wrap above.
//
// Controls that get rebuilt say who they are with `data-focus-key`, and whoever held
// focus is handed back the control with the same name. When that name is gone the
// caller is asked instead, because only it knows where the dealer should be next —
// the card just picked is a dealt card now, and dealt cards are disabled.
preservingFocus(root, rebuild, whenGone) {
const active = document.activeElement;
const inside = !!(active && root.contains(active));
const key = inside ? active.dataset.focusKey : undefined;
rebuild();
// Nothing was taken from under the dealer: either they weren't in this sheet, or
// the control they were standing on is still standing, and still has them. Only
// a rebuild that destroyed the focused element has anything to put right, which
// is why Clear — a button in the header that no redraw touches — keeps focus
// through a redraw it asked for.
if (!inside || active.isConnected) return;
const again = key === undefined ? null
: [...root.querySelectorAll("[data-focus-key]")]
.find(el => el.dataset.focusKey === key && !el.disabled);
if (again) again.focus({ preventScroll: true });
else if (whenGone) whenGone();
},
topmost() {
return this.open.length ? this.open[this.open.length - 1] : null;
},
@@ -130,12 +186,20 @@ const Modal = {
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
// Where the dealer stands *in the ring*, which is a different question from
// where they are on the screen. Three things inside these sheets are on purpose
// not in the ring: the heading a sheet lands on, every card in the grid bar the
// one the arrows are holding, and — for a moment after a rebuild — nothing at
// all, because focus has been dropped on the body. -1 covers all three, and the
// wrap has to work from there or the first Shift+Tab off a heading walks out of
// the dialog it was supposed to be held inside.
const at = focusable.indexOf(document.activeElement);
if (event.shiftKey && at <= 0) {
event.preventDefault();
last.focus({ preventScroll: true });
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
} else if (!event.shiftKey && (at === -1 || at === focusable.length - 1)) {
event.preventDefault();
first.focus({ preventScroll: true });
}
@@ -17,6 +17,31 @@ const Money = {
return Math.round((Number(amount) || 0) * 100);
},
// What a typed-in box is worth, or null if it isn't worth anything yet.
//
// The numeric fields are type="text" now, which is the whole point — the browser
// hands over exactly what was typed instead of silently dropping the characters it
// dislikes. The cost of that is that what was typed might not be a number, and
// `Number("abc")` is NaN, which would go into state, get saved, and come back as
// null on reload having taken the blind with it.
//
// So the reading is done in one place: empty is null, anything non-finite is null,
// and a real number comes back rounded to the cent the rest of the tool works in.
// Null rather than 0 because the fields it reads distinguish them — an unset
// starting stack falls back to the table default, a zero one does not.
//
// Deliberately not clamped to >= 0 here. A negative blind is a different mistake
// from a mistyped one, and the callers that care already clamp (the rake to 0..100,
// the seat count to 2..24); folding it in would silently turn a typed "-5" into
// something the dealer did not ask for.
readAmount(text) {
if (text == null) return null;
const trimmed = String(text).trim();
if (trimmed === "") return null;
const value = Number(trimmed);
return Number.isFinite(value) ? this.round(value) : null;
},
fromCents(cents) {
return cents / 100;
},
@@ -149,11 +174,20 @@ const Money = {
//
// Only number boxes. A name or a note is a thing you edit, and selecting all of
// it on focus would be the same mistake pointing the other way.
//
// What makes a box a number box is now the inputmode rather than the type: nothing
// in the tool is type="number" any more (see the comment above the blinds in
// TexasHoldem.cshtml). Both inputmodes count. "numeric" is the seat count and the
// chip count — whole-number boxes that open pre-filled exactly like the money ones,
// and which this used to catch through their old type. Reading only "decimal" would
// have quietly dropped them, so it reads both.
selectsOnFocus(field) {
if (!field || field.tagName !== "INPUT") return false;
const type = String(field.type || "").toLowerCase();
if (type === "number") return true;
return type === "text" && String(field.inputMode || "").toLowerCase() === "decimal";
if (type !== "text") return false;
const mode = String(field.inputMode || "").toLowerCase();
return mode === "decimal" || mode === "numeric";
},
// Would a wheel over this field change what it says?
@@ -166,6 +200,12 @@ const Money = {
//
// 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.
//
// As of the move off type="number" this answers false for every field in the tool,
// and that is the point rather than a bug: a text box with an inputmode is not a
// spinbutton, so a wheel over it cannot change it and there is nothing left to
// defend. Kept, tests and all, because it is the guard that makes that safe to rely
// on — reintroduce a type="number" anywhere and this starts firing again on its own.
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,
@@ -24,6 +24,32 @@ const PotCalculator = {
return entries.filter(e => !e.folded && e.remaining > this.EPSILON);
},
// The most this player can be handed if they win the round outright.
//
// resolveRound caps the pool at the winner's own remaining contribution and then
// takes min(remaining, cap) from every entry, dead money included — so the ceiling
// is that sum. It is not the player's own contribution, which is what the wizard
// used to print beside each name under the words "up to": with three players in
// for $250 each the rows all read "up to $250" while the prompt above them said
// $765 was in play, and $765 is what the winner was actually paid. A dealer who
// trusted the row would have short-paid by two thirds.
ceilingFor(entries, id) {
const winner = entries.find(e => e.id === id);
if (!winner || winner.remaining <= this.EPSILON) return 0;
return Money.round(entries.reduce(
(sum, e) => sum + Math.min(e.remaining, winner.remaining), 0));
},
// The same number, but only when it is worth saying: a ceiling equal to everything
// on the table is the prompt's own figure repeated once per row, which is ink that
// carries no data. It appears exactly when somebody is in for less than the rest —
// which is the one time a dealer needs to be told, because it is the hand where
// picking that winner leaves a side pot still to award.
bindingCeilingFor(entries, id) {
const ceiling = this.ceilingFor(entries, id);
return ceiling < this.totalPot(entries) - this.EPSILON ? ceiling : null;
},
// Splits the money on the table into the main pot and any side pots, with who can
// win each. Read-only — this is what a dealer says out loud before the showdown
// ("main pot four-eighty, side pot ninety between seats 3 and 5"), which is the
@@ -108,7 +108,25 @@ const PotWizard = {
requestAnimationFrame(update);
},
// Confirming a round replaces the whole step, buttons and all, and the button it
// replaces first is the Confirm the dealer just pressed — so on a table with side
// pots, paying the main pot dropped focus on the body and the next Tab had to start
// over. There is no keeping them where they were here: the control is gone and the
// question has changed. The new question is where they should be, so focus lands on
// the sentence asking it — "that side pot still leaves $240 unassigned, who's next
// best?" — rather than on the button that answers it.
renderStepInto() {
const modal = document.getElementById("potWizardModal");
Modal.preservingFocus(modal, () => this.paintStep(), () => {
const asking = modal.querySelector("#potWizardBody .potWizardPrompt")
|| document.getElementById("potWizardTitle");
if (!asking) return;
asking.tabIndex = -1;
asking.focus({ preventScroll: true });
});
},
paintStep() {
// Auto-resolve any round where only one player is left standing — nothing to ask.
let contenders = PotCalculator.getContenders(this.entries);
while (contenders.length === 1) {
@@ -218,10 +236,18 @@ const PotWizard = {
}
label.appendChild(text);
// What this row costs if it is the one ticked — but only on the rows where
// that is less than the figure in the prompt above. On a pot everybody
// covered it was the same number three times, and it was the wrong number:
// each player's own stake rather than what winning pays them. See
// PotCalculator.bindingCeilingFor.
const ceiling = PotCalculator.bindingCeilingFor(this.entries, c.id);
if (ceiling !== null) {
const upTo = document.createElement("span");
upTo.className = "potWizardChoiceUpTo";
upTo.textContent = `up to ${Money.format(c.remaining)}`;
upTo.textContent = `up to ${Money.format(ceiling)}`;
label.appendChild(upTo);
}
// Cards sit apart from the name rather than inside it, so a dealer reading
// a row across the table sees the hand, not a sentence.
@@ -21,6 +21,27 @@ const QuickSplit = {
mode: "even",
entries: [],
// The first count the preset chips don't cover. Below it the chips are the control;
// at it and above, one chip opens a stepper and the stepper is the control. The
// number is never stated by two of them at once — it used to be stated by three,
// with chip "2" lit gold, "2 ways" in a bar under it, and "Each of 2 players gets"
// in the panel above.
overflowFrom() {
return this.WAY_CHOICES[this.WAY_CHOICES.length - 1] + 1;
},
clampWays(count) {
return Math.max(this.WAY_CHOICES[0], Math.min(this.MAX_WAYS, count));
},
// A pot is chopped between the players who can win it, and there is no such thing
// as chopping it between more of them than there are. This used to read `Math.max`,
// which let a side pot only two players were eligible for be split ten ways at $12
// each — an answer to a question nobody at the table could have asked.
chopCeiling(contenderCount) {
return Math.max(2, Math.min(contenderCount, this.MAX_WAYS));
},
mount() {
this.potInput = document.getElementById("potInput");
this.clearPotBtn = document.getElementById("clearPotBtn");
@@ -86,10 +107,11 @@ const QuickSplit = {
renderWayChoices() {
const row = document.getElementById("waysChoices");
row.innerHTML = "";
const beyond = this.ways >= this.overflowFrom();
this.WAY_CHOICES.forEach(count => {
row.appendChild(
this.makeChoiceButton(String(count), this.ways === count, () => {
this.makeChoiceButton(String(count), !beyond && this.ways === count, () => {
this.ways = count;
this.renderWayChoices();
this.update();
@@ -97,34 +119,65 @@ const QuickSplit = {
);
});
// Beyond a five-way chop it's rare enough to hide behind a stepper rather than
// spend a row of buttons on it.
const more = document.createElement("div");
more.className = "waysStepper";
// Beyond a five-way chop it's rare enough not to be worth a chip each. One more
// chip is the door to the rest; it reads "6+" whatever the count behind it is,
// because the stepper is where the count is written. Tapping it while it is
// already open does nothing — it is the fifth option of a group, not a toggle.
const door = this.makeChoiceButton(`${this.overflowFrom()}+`, beyond, () => {
if (beyond) return;
this.ways = this.overflowFrom();
this.renderWayChoices();
this.update();
});
door.setAttribute("aria-label", `${this.overflowFrom()} or more`);
row.appendChild(door);
const minus = this.makeStepButton("−", () => this.stepWays(-1));
const plus = this.makeStepButton("+", () => this.stepWays(1));
const readout = document.createElement("span");
readout.className = "waysReadout";
readout.textContent = `${this.ways} ways`;
more.appendChild(minus);
more.appendChild(readout);
more.appendChild(plus);
row.appendChild(more);
if (beyond) row.appendChild(this.buildWaysStepper());
},
makeStepButton(label, onPick) {
// Six to twelve, which is exactly what the door promised. The floor is 6 rather
// than 2 because the chips below it are one tap away and already on screen — and
// because stepping down out of the stepper would pull the stepper out from under
// the finger that was pressing it.
buildWaysStepper() {
const stepper = document.createElement("div");
stepper.className = "waysStepper";
stepper.setAttribute("role", "group");
stepper.setAttribute("aria-label", "How many ways");
stepper.appendChild(this.makeStepButton("−", () => this.stepWays(-1), {
label: "One fewer way",
disabled: this.ways <= this.overflowFrom(),
}));
const readout = document.createElement("span");
readout.className = "waysReadout";
readout.textContent = String(this.ways);
stepper.appendChild(readout);
stepper.appendChild(this.makeStepButton("+", () => this.stepWays(1), {
label: "One more way",
disabled: this.ways >= this.MAX_WAYS,
}));
return stepper;
},
makeStepButton(label, onPick, options) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "stepBtn";
btn.textContent = label;
if (options && options.label) btn.setAttribute("aria-label", options.label);
// A step that can't happen says so. Both ends of both steppers on this screen
// used to look exactly like the middle and swallow the tap in silence.
btn.disabled = !!(options && options.disabled);
btn.addEventListener("click", onPick);
return btn;
},
stepWays(delta) {
this.ways = Math.max(2, Math.min(this.MAX_WAYS, this.ways + delta));
this.ways = this.clampWays(this.ways + delta);
this.renderWayChoices();
this.update();
},
@@ -205,7 +258,8 @@ const QuickSplit = {
// The per-pot ways live on the row rather than in `this.ways`, so opening a chop on
// the main pot doesn't change what the side pot below it is saying.
addChopControl(row, amount, contenderCount) {
let ways = contenderCount;
const ceiling = this.chopCeiling(contenderCount);
let ways = ceiling;
let open = false;
const toggle = document.createElement("button");
@@ -225,19 +279,24 @@ const QuickSplit = {
const result = SplitCalculator.split(amount, ways, this.chip);
detail.innerHTML = "";
// Two eligible players is two ways and nothing else, so the stepper is left
// out rather than shown with both ends dead — which is the common shape, a
// side pot usually coming down to the two who could still call.
if (ceiling > 2) {
const stepper = document.createElement("div");
stepper.className = "unevenChopWays";
stepper.appendChild(this.makeStepButton("−", () => {
if (ways > 2) { ways -= 1; draw(); }
}));
}, { label: "One fewer way", disabled: ways <= 2 }));
const label = document.createElement("span");
label.className = "unevenChopWaysLabel";
label.textContent = `${ways} ways`;
stepper.appendChild(label);
stepper.appendChild(this.makeStepButton("+", () => {
if (ways < Math.max(contenderCount, this.MAX_WAYS)) { ways += 1; draw(); }
}));
if (ways < ceiling) { ways += 1; draw(); }
}, { label: "One more way", disabled: ways >= ceiling }));
detail.appendChild(stepper);
}
const each = document.createElement("p");
each.className = "unevenChopEach";
@@ -368,4 +427,10 @@ const QuickSplit = {
},
};
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", () => QuickSplit.mount());
}
if (typeof module !== "undefined" && module.exports) {
module.exports = QuickSplit;
}
+492 -125
View File
@@ -21,6 +21,7 @@ const Render = {
this.lastHistorySignature = null;
this.handTimeCache.clear();
this.watchSeatSheetHeight();
this.watchFieldExits();
this.renderAll();
},
@@ -113,21 +114,79 @@ const Render = {
});
},
// ---- Writing a value into a field somebody might be standing in ----
//
// A render says what the state is. It is not entitled to say what somebody is
// half-way through typing, and when it did, it ate the decimal point.
//
// The six setup fields are wired `input` -> save -> renderAll, and renderAll comes
// back round through renderSetupFields and assigns `.value`. Type "0.25" into the
// small blind and the fourth keystroke is not the one that breaks it; the "." is:
//
// "0" -> reads "0" -> state 0 -> written back "0" (no change, fine)
// "0." -> reads "0" -> state 0 -> written back "0" <- the "." is gone
// then -> caret is at 0, because assigning .value to a number input parks it
// at the start (number inputs do not expose selectionStart at all)
// "2" -> "20"
// "5" -> "250"
//
// A dealer setting $0.25/$0.50 blinds got a table playing 250/500, silently, with
// every pot for the rest of the night computed off it. Measured on all six:
// 0.25->250, 0.50->500, 12.50->5012, 2.5->52 (a 52% rake), 7.50->507, 0.75->750.
//
// So: never write into the control that has focus. It is the one place where the
// person knows better than the state does, and the state is a keystroke behind them
// anyway. Everything they type still lands in state through the `input` handler, so
// skipping the write-back costs nothing and is not a deferral of it.
//
// This guards every control rather than the six that were caught, because the next
// field wired to a re-render is the next one to eat a decimal point, and there is
// nothing about it at the call site to warn whoever adds it.
setField(el, value) {
if (!el) return;
if (typeof document !== "undefined" && el === document.activeElement) return;
const next = value == null ? "" : String(value);
// Assigning an identical value still moves the caret in some engines, so the
// compare is part of the guard rather than an optimisation.
if (el.value !== next) el.value = next;
},
setToggle(el, on) {
if (!el) return;
if (typeof document !== "undefined" && el === document.activeElement) return;
if (el.checked !== !!on) el.checked = !!on;
},
// The other half of the rule above: a field that was skipped because somebody was
// standing in it has to catch up when they step out, or it keeps showing what they
// typed after the state has moved on (a blind that got clamped, say). Bound once,
// on the document, because the controls it covers are rebuilt from templates.
watchFieldExits() {
if (this.fieldExitWatcher || typeof document === "undefined") return;
this.fieldExitWatcher = event => {
const el = event.target;
if (!el || !el.matches || !el.matches("input, select, textarea")) return;
// After the blur has settled, so activeElement is no longer this field.
setTimeout(() => this.renderSetupFields(), 0);
};
document.addEventListener("focusout", this.fieldExitWatcher);
},
renderSetupFields() {
document.getElementById("seatCountInput").value = this.state.seatCount;
document.getElementById("directionSelect").value = this.state.direction;
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 ?? "";
this.setField(document.getElementById("seatCountInput"), this.state.seatCount);
this.setField(document.getElementById("directionSelect"), this.state.direction);
this.setField(document.getElementById("basePotInput"), this.state.basePotAmount ?? "");
this.setToggle(document.getElementById("dedicatedDealerToggle"), this.state.dedicatedDealer);
this.setToggle(document.getElementById("trackBoardToggle"), this.state.trackBoard);
this.setToggle(document.getElementById("keepScreenAwakeToggle"), this.state.keepScreenAwake !== false);
this.setField(document.getElementById("smallBlindInput"), this.state.smallBlind ?? "");
this.setField(document.getElementById("bigBlindInput"), this.state.bigBlind ?? "");
this.setField(document.getElementById("defaultStackInput"), this.state.defaultStack ?? "");
const rake = this.state.rake || {};
document.getElementById("rakePercentInput").value = rake.percent || "";
document.getElementById("rakeCapInput").value = rake.cap ?? "";
document.getElementById("rakeNoFlopInput").checked = rake.noFlopNoDrop !== false;
this.setField(document.getElementById("rakePercentInput"), rake.percent || "");
this.setField(document.getElementById("rakeCapInput"), rake.cap ?? "");
this.setToggle(document.getElementById("rakeNoFlopInput"), rake.noFlopNoDrop !== false);
// What the house has actually taken tonight, said where the rake is set rather
// than only in the bank check — a dealer who changes the percentage mid-session
@@ -161,8 +220,8 @@ const Render = {
// on and stays away otherwise — nobody waiting, no notes, no hands yet — because a
// row reading "Nobody waiting" all night is the clutter this is here to remove, and
// eight rows that always say something are eight rows that say nothing again.
PANEL_GLANCES: ["glanceTableSetup", "glanceStandings", "glanceWaiting",
"glanceHands", "glanceChips", "glanceNotes"],
PANEL_GLANCES: ["glanceTableSetup", "glanceWhileYouDeal", "glanceStandings",
"glanceWaiting", "glanceHands", "glanceChips", "glanceNotes"],
// Pure, so the wording can be tested without a page to hang it on. Every value is
// read off the state the panel below it renders from, never off the panel's own
@@ -204,8 +263,24 @@ const Render = {
// over an empty table would be answering for a count that was never taken.
const nothingBanked = bank.cashIn === 0 && bank.chipsInPlay === 0;
// The three switches behind "While you deal", which is shut unless somebody
// opens it: a switch nobody can see is a switch nobody remembers turning on, and
// a dealer wondering why a card sheet keeps appearing should be told where it
// lives without having to go looking.
//
// One of them, not all three. This row is 44px of a 320px column with a label
// already on it, and the three joined run to 49 characters — which ellipsizes
// into "House dealer · Asking for c…", a row that has told nobody anything. So
// it names the one most likely to be the surprise, and opening the panel shows
// the rest. Keeping the screen on is the only one that ships on, so it is its
// absence that gets said.
const dealing = state.trackBoard ? "Asking for cards"
: state.dedicatedDealer ? "House dealer"
: state.keepScreenAwake === false ? "Screen may sleep" : "";
return {
glanceTableSetup: glance(`${state.seatCount} seats${blinds}`),
glanceWhileYouDeal: glance(dealing),
glanceStandings: nothingBanked ? glance("")
: bank.balanced ? glance("Balanced", "ok")
: glance(`Off by ${Money.format(Math.abs(bank.difference))}`, "warn"),
@@ -246,6 +321,30 @@ const Render = {
if (!misdeal) return;
misdeal.hidden = !this.state.bettingOpen;
// "Next hand" names three different things in this tool: the bar's green button
// deals one, the pot wizard finishes on "Start Next Hand", and this header
// shortcut — the only one of the three that does not start a hand. Between hands
// it moves the button a second time, so the seat that just paid the big blind
// pays it again, and it said so in a toast that opens with the same two words
// the wizard just used. Measured: wizard → hand 6, button seat 2; press this →
// still hand 6, button seat 3, no confirmation asked.
//
// The mechanism is right — a dealer does pass the button along on a hand nobody
// played — so what changes is the name. A command is named for the state the
// system moves into (NN/g, Button States: "Show Tab Bar" / "Hide Tab Bar"), and
// between hands the state it moves into is a moved button, not a new hand.
const nextHand = document.getElementById("nextHandBtn");
if (nextHand) {
const live = !!this.state.bettingOpen;
nextHand.textContent = live ? "Next Hand ⟳" : "Move Button ⟳";
// Written every render, ahead of the borrow-and-hand-back below: that cache
// is filled once from the markup, and a title that depends on the hand would
// freeze on whichever one happened to be true at load.
nextHand.dataset.titleWhenUsable = live
? "End this hand and move the button, small blind and big blind to the next occupied seat"
: "Pass the button on again without dealing — for a hand nobody played";
}
// On a table nobody is sitting at, Next Hand has no button to move and Resolve
// Pot has no pot, so both are visibly not-yet rather than sitting there looking
// ready.
@@ -260,12 +359,18 @@ const Render = {
// here is only the part about what a control can do — which is still worth
// saying, because a shortcut that cannot work yet should not look ready.
//
// Disabled rather than hidden: hiding them would move the header the moment
// the first player is named, and a control that vanishes teaches nothing about
// what it was for.
// Dimmed rather than hidden: hiding them would move the header the moment the
// first player is named, and a control that vanishes teaches nothing about what
// it was for.
//
// Dimmed rather than `disabled`, because there is a reason and it was only ever
// readable with a mouse. `disabled` took both of these out of the tab order, so
// the sentence hung off them never reached a screen reader, and it swallowed the
// tap on the device this tool is built for — leaving a dealer's first screen
// holding two grey buttons that answered nothing when pressed. See Unavailable.
const nothingToRunYet = !this.state.seats.some(seat => HoldemState.seatInPlay(seat));
[["resolvePotBtn", "Nothing to resolve — nobody is at the table yet"],
["nextHandBtn", "Nothing to move on from — nobody is at the table yet"]]
[["resolvePotBtn", "Nothing to resolve yet — seat a player first."],
["nextHandBtn", "Nothing to move on from — seat a player first."]]
.forEach(([id, why]) => {
const button = document.getElementById(id);
if (!button) return;
@@ -274,7 +379,7 @@ const Render = {
if (button.dataset.titleWhenUsable === undefined) {
button.dataset.titleWhenUsable = button.getAttribute("title") || "";
}
button.disabled = nothingToRunYet;
Unavailable.set(button, nothingToRunYet, why);
const title = nothingToRunYet ? why : button.dataset.titleWhenUsable;
if (title) button.setAttribute("title", title);
else button.removeAttribute("title");
@@ -631,10 +736,14 @@ const Render = {
SEAT_CLEARANCE_PX: 4,
// And between a puck's inner edge and the chip sitting in front of it.
BET_CLEARANCE_PX: 5,
// How far a blind badge hangs off the puck it belongs to — the `top: -9px` and
// `bottom: -9px` in holdem.css. It points away from the table at both ends, so it
// takes no room between two seats; what it must not do is reach *sideways*, which is
// what seatFootprint.test.js holds the stylesheet to.
// How far a marker hangs off the puck it belongs to — the `top` and `bottom` on
// .blindBadge and on .dealerChip in holdem.css, which seatFootprint.test.js holds this
// to. It points away from the table at both ends; what it must not do is reach
// *sideways*, which is the other thing that test checks.
//
// Charged once per seat, not once per marker, and the two markers are why that is
// still right: they share one edge and sit at opposite ends of it, so a seat wearing
// both is no taller than a seat wearing either.
SEAT_BADGE_OVERHANG_PX: 5,
// How much space two neighbouring seats need between their centres.
@@ -792,29 +901,36 @@ const Render = {
return widths;
},
// What the table has changed about the shape of its pucks. Anything that can make a
// name wrap to a second line belongs here, because the height that comes back from
// measuring is what the ring is sized against.
// What the table has changed about the shape of its pucks — which is now almost
// nothing, and that is the point.
//
// The hole-card line and the amount pill belong here for the same reason, now that
// an empty one collapses instead of holding its space: a puck with either of them
// filled is about 15px taller than the same puck without, and a ring still sized
// from the measurement taken before the first chips went in is sized for a puck
// that no longer exists.
// This used to name every player's name, the dealer's seat, whether anybody had hole
// cards and whether anybody had chips out, because each of those could change how
// tall a puck was and the ring is sized against the tallest puck on it. Four things,
// three of which flip during an ordinary hand, and every flip is a ten-candidate
// re-search: ten widths applied and read back, ten forced layouts, on a phone, while
// somebody is waiting to know whether the bet is called.
//
// Booleans over the whole table rather than a list per seat, because that is what
// the measurement is — measuredSeatBox takes the tallest puck on the ring, so what
// matters is whether *anybody* has one. A boolean also flips about twice a hand
// rather than on every action, and each flip costs ten forced layouts, which is the
// expense this cache exists to avoid.
// A puck's height no longer depends on any of them. The name gets one line whatever
// it says, the amount pill holds its row whether or not there is an amount in it, and
// the button's chip changes a padding rather than a line count — see the row-height
// tokens in holdem.css. What is left is the hole-card row, which still collapses when
// the table isn't tracking cards, because that is a decision a table makes once and
// not a thing that changes under a dealer's thumb.
//
// A boolean over the whole table rather than a list per seat, because that is what
// the measurement is: measuredSeatBox takes the tallest puck on the ring, so what
// matters is whether *anybody* has one.
//
// Being wrong here is a stale ring rather than a wrong one, and it would not stay
// wrong: renderSeats measures the finished pucks against the box the search settled
// on and asks again if they no longer match. That check is what catches a fifth thing
// nobody thought of, and it is what catches the first render of all — where the pucks
// are built empty and the search that used to run against them was measuring nine
// blank boxes and caching the answer under this table's key.
seatShapeSignature() {
if (!this.state) return "";
const seats = this.state.seats;
const anyCards = seats.some(p => (p.cards || []).some(Boolean));
const anyAmount = seats.some(p => Betting.handTotal(p) > 0 ||
HoldemState.computeContribution(p, this.state.chipLegend, this.state.basePotAmount) > 0);
return `${seats.map(p => p.name || "").join("")}|${this.state.dealerSeatIndex}` +
`|${anyCards ? "c" : ""}${anyAmount ? "a" : ""}`;
return this.state.seats.some(p => (p.cards || []).some(Boolean)) ? "c" : "";
},
// Which width the pucks get, and how wide a ring they sit on.
@@ -871,14 +987,19 @@ const Render = {
this.applySeatWidth(layer, width);
const puck = this.measuredSeatBox();
if (!puck) {
// Nothing laid out yet — the very first render. Take the widest and let
// the resize that follows do this properly.
const radii = this.seatRingRadii();
if (key) this.seatLayoutCache = { key, width, radii };
return { width, radii };
// Nothing on the felt to measure at all. Take the widest and say nothing
// about it — no `puck`, so renderSeats' second pass knows this answer was
// not measured and asks again once there is something to measure.
//
// It used to cache this one, on the reasoning that "the resize that
// follows" would put it right. Nothing resizes a phone. A table opened
// upright kept a ring measured before it had any seats on it until the
// dealer turned the phone sideways and back.
return { width, radii: this.seatRingRadii() };
}
candidates.push({
width,
puck,
contained: this.seatRingRadii(puck),
footprint: this.seatFootprint(puck),
tooTall: this.tooTallForTheTable(),
@@ -896,7 +1017,10 @@ const Render = {
rx: candidate.contained.rx + (this.RING_MAX_RX - candidate.contained.rx) * t,
ry: candidate.contained.ry + (this.RING_MAX_RY - candidate.contained.ry) * t,
};
if (fits(candidate, radii)) { chosen = { width: candidate.width, radii }; break; }
if (fits(candidate, radii)) {
chosen = { width: candidate.width, radii, puck: candidate.puck };
break;
}
}
}
@@ -905,7 +1029,11 @@ const Render = {
// so out loud rather than the layout quietly papering over it.
if (!chosen) {
const last = candidates[candidates.length - 1];
chosen = { width: last.width, radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY } };
chosen = {
width: last.width,
radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY },
puck: last.puck,
};
}
this.applySeatWidth(layer, chosen.width);
@@ -1408,7 +1536,7 @@ const Render = {
// these read the layout back, so they are taken once, here, before the loop
// starts writing to it: asked per seat they would force the browser to
// recompute the layout on every iteration.
const { radii } = this.resolveSeatLayout(count);
let { radii, puck } = this.resolveSeatLayout(count);
const aspect = this.feltAspect();
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
// A button on an empty chair normally means the table has drifted and wants
@@ -1429,12 +1557,10 @@ const Render = {
const dealerChip = node.querySelector(".dealerChip");
const hasButton = index === dealerIndex;
dealerChip.classList.toggle("active", hasButton);
// Marked on the seat as well as the chip, so the puck's own text can keep
// out from under it. A name is centred across the whole puck and the chip
// sits in the corner on a layer above: measured, "Christopher" on the
// button lost 20px of its 64px box to the chip and showed no ellipsis,
// because the text believed it had the room it was painted out of.
node.classList.toggle("seatHasButton", hasButton);
// The seat used to be marked too — `seatHasButton` — so the puck's text could
// keep out from under a chip that sat in its corner. The chip is on the rim
// with the blind badges now and there is nothing to keep out of, so the seat
// no longer needs to know which of nine chairs the chip is on.
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
// Every one of these announced itself as "D", so a screen reader heard a row
// of nine identical buttons and couldn't tell which seat held the real one.
@@ -1475,8 +1601,34 @@ const Render = {
});
this.fitSeatNames(layer);
// The names have just changed how tall some of these pucks are, so this comes
// after them: a two-line name shortened to one line is 15px of height back.
this.fitAmounts(this.seatAmountCells(layer));
// The pucks are only now what they will be drawn as, and the ring was sized
// before any of it went in. Usually those agree — a puck's height is a stack of
// rows the stylesheet holds open, and none of the writing above changes it.
//
// Twice they don't. The first render of all builds nine empty pucks and then
// fills them, so a search run against that measured blank boxes; and any
// assumption in seatShapeSignature that turns out to be wrong lands here too.
// Both used to be permanent, because the answer was cached under a key naming a
// table the measurement had never seen: a phone that never resizes kept a ring
// sized for empty seats for the whole session.
//
// So the finished pucks are measured against the ones the ring was built from,
// and if they have moved the search is asked again with the real thing in front
// of it. Once. The second answer is cached and the render after this one reads it
// back, which is what stops this being a loop.
if (this.seatBoxMoved(puck)) {
this.seatLayoutCache = null;
({ radii, puck } = this.resolveSeatLayout(count));
for (let index = 0; index < layer.childElementCount; index++) {
this.placeSeat(layer.children[index], index, aspect, radii);
}
this.fitSeatNames(layer);
// The puck is a different width than the one these were fitted to.
this.fitAmounts(this.seatAmountCells(layer));
}
const settled = this.settleSeatsOnTable(layer, aspect, radii);
// Kept so the board can ask for the chips to be laid out again if placing it
@@ -1485,6 +1637,23 @@ const Render = {
this.renderBets(aspect, settled);
},
// Are the pucks on the felt still the puck the ring was measured for?
//
// A pixel of slack, the same as settleSeatsOnTable's, because the two measurements
// are taken at different moments and a sub-pixel difference between them is a
// rounding artefact rather than a table that has changed shape. Without it this
// would ask the search to run again on every render for the rest of the session.
//
// No box at all means the search never measured one — the first render, before the
// pucks were filled in. That is exactly the case worth asking again about.
seatBoxMoved(puck) {
if (!puck) return true;
const now = this.measuredSeatBox();
if (!now) return false;
return Math.abs(now.width - puck.width) > this.SEAT_SETTLE_SLACK_PX ||
Math.abs(now.height - puck.height) > this.SEAT_SETTLE_SLACK_PX;
},
// One seat, onto the ring.
placeSeat(node, index, aspect, radii) {
const pos = this.seatPosition(index, this.state.seatCount, aspect, radii);
@@ -1572,13 +1741,20 @@ const Render = {
//
// So a name that overflows drops whole trailing words until it fits: "Mike from
// work" becomes "Mike from", then "Mike", and stops there with a real word rather
// than a fragment. A single word that still doesn't fit is left to the ellipsis it
// always had — there is no honest way to shorten "Christopher" that a dealer
// glancing across a table would read faster than "Christo…".
// than a fragment.
//
// Written as three passes over all the seats rather than a loop per seat: each pass
// writes to every puck and then reads back from every puck, so the browser lays the
// felt out three times whatever the table's size. Interleaved, nine seats would
// What is left when there is no word to drop is one word too long for its puck, and
// the ellipsis used to be the end of the story: "Christo…", which is a name only
// until a Christine sits down. The amounts on the same puck have never accepted that
// trade — see fitAmount, which steps a tournament stack's type down rather than
// rounding it off — and a misread name is the same kind of failure as a misread
// stack. So the last resort before the ellipsis is one step of type. Every name that
// has one, not only the ones with a spare word, because "Christopher" is exactly the
// case this is for.
//
// Written as passes over all the seats rather than a loop per seat: each pass writes
// to every puck and then reads back from every puck, so the browser lays the felt out
// a fixed number of times whatever the table's size. Interleaved, nine seats would
// have cost nine layouts, and this runs on every render.
fitSeatNames(layer) {
if (!layer) return;
@@ -1589,21 +1765,37 @@ const Render = {
if (!el || !el.textContent) return;
const full = (player.name || "").trim();
// The tooltip is the whole name whether or not this one gets shortened: a
// label that wraps to two clamped lines was already hiding some of it.
// label clamped to its one line was already hiding some of it.
el.title = full;
const words = full.split(/\s+/).filter(Boolean);
if (words.length > 1) labels.push({ el, words, kept: words.length });
// Put back whatever a previous run of this took off, so what gets measured
// below is the name against the puck it has now. Normally a no-op —
// updatePuckContent has just written the full name — and not a no-op on the
// second pass of a render that had to resolve the ring twice, which is the
// one time a puck's width changes underneath a name already fitted to it.
if (el.textContent !== full) el.textContent = full;
// Cleared before anything is measured, so a name that fitted last render at
// a smaller size is measured at the size it is meant to be read at. Left on,
// it would be a ratchet: once a puck went small it could never come back.
el.classList.remove("puckNameTight");
labels.push({ el, words: full.split(/\s+/).filter(Boolean), kept: 0 });
});
if (!labels.length) return;
labels.forEach(label => { label.kept = label.words.length; });
for (let pass = 0; pass < this.SEAT_NAME_PASSES; pass++) {
const shorten = labels.filter(label => label.kept > 1 && this.overflowing(label.el));
if (!shorten.length) return;
if (!shorten.length) break;
shorten.forEach(label => {
label.kept = this.dropAWord(label.words, label.kept);
label.el.textContent = label.words.slice(0, label.kept).join(" ");
});
}
// One read for the whole table, then one write, in that order — the same reason
// the loop above is written in passes.
labels
.filter(label => this.overflowing(label.el))
.forEach(label => label.el.classList.add("puckNameTight"));
},
// Words a name can't end on. Dropping "work" off "Mike from work" leaves "Mike
@@ -1632,9 +1824,9 @@ const Render = {
// four plain words reaches its first in three.
SEAT_NAME_PASSES: 3,
// Past the box in either direction. Both matter: the label clamps to two lines, so
// a name too long runs out of height, while a single word too wide for the puck
// runs out of width without ever reaching the second line.
// Past the box in either direction. Width is the one that answers now that a name
// cannot wrap — see .seatPuckName — and height is kept because it costs nothing and
// is the honest question for anything else that ever asks this.
overflowing(el) {
return el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
},
@@ -1750,11 +1942,27 @@ const Render = {
if (onStrip) {
if (strip && row.parentElement !== strip) strip.appendChild(row);
if (strip) strip.hidden = false;
if (centre) { centre.style.maxWidth = ""; centre.style.maxHeight = ""; }
if (centre) { centre.style.width = ""; centre.style.maxWidth = ""; centre.style.maxHeight = ""; }
} else {
if (centre && row.parentElement !== centre) centre.insertBefore(row, centre.firstChild);
if (strip) strip.hidden = true;
if (centre) centre.style.maxWidth = `${Math.round(width)}px`;
if (centre) {
// Both, and the width is the half that was missing.
//
// `max-width` caps a box; it does not claim anything. The centre is a
// flex column that sizes to its content, so with 463px of measured room
// written into its max-width it went on being 136px wide — and the board
// row inside it is `width: 100%` of that, which is a loop that comes to
// rest on the cards' own `min-width`. Measured with the board on: the
// middle had 294px on an iPad, 463px on a desktop and 542px on a wide
// one, and in all three the five community cards drew 24px across. On
// the strip, where the row simply gets the width of the page, the same
// cards draw 44. They are buttons — a tap on one opens the picker for
// that card — so 24px was a little over half the floor this sweep holds
// every other control on the felt to.
centre.style.width = `${Math.round(width)}px`;
centre.style.maxWidth = `${Math.round(width)}px`;
}
}
// The chips were laid out against whatever was in the middle of the felt a
@@ -1945,7 +2153,11 @@ const Render = {
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
const totalEl = node.querySelector(".seatPuckTotal");
totalEl.textContent = total > 0 ? Money.format(total) : "";
this.fitAmount(totalEl);
// The figure as it is meant to be read, kept beside the one that gets drawn.
// fitAmounts may take the separators out of what is on screen, and the pass
// after it has to start from the grouped figure again or the puck can only ever
// get further from it.
totalEl.dataset.figure = totalEl.textContent;
// A player with nothing behind used to leave this line blank, so the felt drew
// an all-in exactly like a seat whose stack nobody had entered. On a table with
@@ -1956,7 +2168,7 @@ const Render = {
const allIn = Betting.isAllIn(this.state, player);
stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : "";
stackEl.classList.toggle("seatPuckAllIn", allIn);
this.fitAmount(stackEl);
stackEl.dataset.figure = stackEl.textContent;
node.classList.toggle("seatAllIn", allIn);
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
@@ -1966,35 +2178,90 @@ const Render = {
node.classList.toggle("seatLive", player.inHand && !player.folded);
},
// A seat puck is 64px of usable width. A tournament stack doesn't fit in that and,
// because the number has no spaces to break on, it doesn't wrap or ellipsise
// either — it just draws straight out over the neighbouring seat. Stepping the
// font down keeps every digit, which matters: a dealer misreading a stack is a
// worse outcome than a small one, so this shrinks rather than abbreviating.
fitAmount(el) {
const formatted = el.textContent || "";
// The separators go before the type size does. "$1,234,567" is ten characters,
// which used to drop the puck to 7px — and 7px is not a size anybody reads a
// stack off a felt at, so the shrink-rather-than-abbreviate rule above was
// buying precision the dealer then couldn't see. Without its commas the same
// number is eight characters, and eight fits at 10px with every digit intact.
// Grouping is a reading aid for a column of figures; this is one number on a
// 64px puck, and the aid costs more than it gives.
const text = formatted.length >= 10 ? formatted.replace(/,/g, "") : formatted;
if (text !== formatted) el.textContent = text;
// The two steps of type an amount can come down, in order, once taking the
// separators out has not been enough.
AMOUNT_STEPS: ["puckAmountLong", "puckAmountHuge"],
const length = text.length;
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
// Only a stack of $100,000,000 or more still lands here. It is an overflow
// guard rather than a size anybody is expected to read.
el.classList.toggle("puckAmountHuge", length >= 10);
// Puts one amount back to the figure and the size it is meant to be read at, and
// hands back the grouped figure. Everything below measures from here, so a puck that
// once went small can come back — left as it was, this would be a ratchet.
resetAmount(el) {
const figure = el.dataset.figure === undefined ? el.textContent : el.dataset.figure;
if (el.textContent !== figure) el.textContent = figure;
this.AMOUNT_STEPS.forEach(step => el.classList.remove(step));
return figure;
},
// A seat puck is 64px of usable width, and 34 on the tightest ring in the sweep. A
// tournament stack doesn't fit in that and, because a number has no spaces to break
// on, it doesn't wrap or ellipsise either — it draws straight out over the seat next
// to it. Stepping the type down keeps every digit, which matters: a dealer misreading
// a stack is a worse outcome than a small one, so this shrinks rather than
// abbreviating.
//
// Measured, and it used to be counted. The old rule was eight characters for one
// step and ten for the other, which is a guess at how wide a figure is that happens
// to hold at one text size and one puck width. Turn a phone's text up — 20px of root
// text is what an Android slider gives you at 125%, and every size in this
// stylesheet is in `rem` on purpose — and it stops holding: measured at 360x640
// twelve-handed, "$1,200" is six characters, took no step at all, and wanted 38px of
// a 34px puck. The sweep caught it as a stack figure painted over the seat next door
// at every phone in the list. What the puck can hold is a question about the puck,
// so it is asked of the puck.
//
// Written as passes over every amount on the ring rather than a loop per seat: each
// pass writes to all of them and then reads back from all of them, so the browser
// lays the felt out four times whatever the table's size. Interleaved, twelve seats
// would have cost twenty-four layouts, and this runs on every render.
fitAmounts(cells) {
const live = cells.filter(el => el && (el.dataset.figure || el.textContent));
if (!live.length) return;
const figures = new Map(live.map(el => [el, this.resetAmount(el)]));
// The separators go before the type size does. "$1,234,567" is ten characters,
// which used to drop the puck's figures to 7px — and 7px is not a size anybody
// reads a stack off a felt at, so the shrink-rather-than-abbreviate rule above
// was buying a precision the dealer then couldn't see. Without its commas the
// same number is eight characters and fits two steps higher up this ladder.
// Grouping is a reading aid for a column of figures; this is one number on a
// 44px puck, and there the aid costs more than it gives.
const ungroup = live.filter(el => figures.get(el).includes(",") && this.overflowing(el));
ungroup.forEach(el => { el.textContent = figures.get(el).replace(/,/g, ""); });
let carried = null;
this.AMOUNT_STEPS.forEach(step => {
const over = live.filter(el => this.overflowing(el));
const previous = carried;
over.forEach(el => {
if (previous) el.classList.remove(previous);
el.classList.add(step);
});
carried = step;
});
},
// One puck's worth — for refreshPuck, which redraws a single seat.
fitAmount(el) {
this.fitAmounts([el]);
},
// Every amount on the ring, so fitAmounts can do the whole table in four layouts.
seatAmountCells(layer) {
const cells = [];
for (let index = 0; index < layer.childElementCount; index++) {
const node = layer.children[index];
cells.push(node.querySelector(".seatPuckTotal"), node.querySelector(".seatPuckStack"));
}
return cells;
},
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
// (which live in a separate panel) never lose focus.
refreshPuck(index) {
const node = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (node) this.updatePuckContent(node, this.state.seats[index], index);
if (!node) return;
this.updatePuckContent(node, this.state.seats[index], index);
this.fitAmounts([node.querySelector(".seatPuckTotal"), node.querySelector(".seatPuckStack")]);
},
// ---- Seat gestures ----
@@ -2156,7 +2423,9 @@ const Render = {
const index = this.selectedSeatIndex;
const player = this.state.seats[index];
title.textContent = this.puckLabel(player, index);
// Named and numbered. The chair number came off the puck's face this round —
// see HoldemState.seatHeading — and this is one of the two places it went.
title.textContent = HoldemState.seatHeading(player, index);
body.innerHTML = "";
const template = document.getElementById("seatEditorTemplate");
@@ -2253,6 +2522,65 @@ const Render = {
this.renderPanelGlances();
},
// The three things a seat can be, in the order a hand goes through them. `folded`
// is only meaningful while `inHand` is set, which is why these are one choice and
// not two: the pair (out, folded) has no meaning, and the old two-checkbox control
// could be asked for it.
SEAT_STATES: [
{ key: "out", label: "Sitting out", inHand: false, folded: false },
{ key: "in", label: "In the hand", inHand: true, folded: false },
{ key: "folded", label: "Folded", inHand: true, folded: true },
],
seatStateOf(player) {
if (!player.inHand) return "out";
return player.folded ? "folded" : "in";
},
// Draw the current answer onto the three buttons. Called on every paint and again
// whenever something other than a press changes the seat — typing a name into an
// empty chair seats somebody, and the control has to say so.
paintSeatState(form, player) {
const current = this.seatStateOf(player);
form.querySelectorAll(".seatStateOption").forEach(btn => {
const chosen = btn.dataset.seatState === current;
btn.setAttribute("aria-checked", chosen ? "true" : "false");
// One Tab stop for the three, the way the APG asks: Tab reaches the group,
// the arrows move inside it.
btn.tabIndex = chosen ? 0 : -1;
});
},
wireSeatState(form, player, index) {
const options = [...form.querySelectorAll(".seatStateOption")];
const choose = (key, { focus = false } = {}) => {
const state = this.SEAT_STATES.find(entry => entry.key === key);
if (!state) return;
player.inHand = state.inHand;
player.folded = state.folded;
this.paintSeatState(form, player);
if (focus) {
const chosen = options.find(btn => btn.dataset.seatState === key);
if (chosen) chosen.focus({ preventScroll: true });
}
this.commitSeatChange(form, player, index);
};
options.forEach(btn => {
btn.addEventListener("click", () => choose(btn.dataset.seatState));
btn.addEventListener("keydown", event => {
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
if (step === undefined) return;
event.preventDefault();
const at = this.SEAT_STATES.findIndex(entry => entry.key === this.seatStateOf(player));
const to = (at + step + this.SEAT_STATES.length) % this.SEAT_STATES.length;
choose(this.SEAT_STATES[to].key, { focus: true });
});
});
this.paintSeatState(form, player);
},
wireSeatFields(form, player, index) {
const nameInput = form.querySelector(".seatNameInput");
nameInput.value = player.name;
@@ -2271,32 +2599,13 @@ const Render = {
if (wasEmpty && player.name.trim()) {
player.inHand = true;
player.folded = false;
const inHandBox = form.querySelector(".inHandCheckbox");
if (inHandBox) inHandBox.checked = true;
const foldBox = form.querySelector(".foldCheckbox");
if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
this.paintSeatState(form, player);
}
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
this.commitSeatChange(form, player, index);
});
const inHandCheckbox = form.querySelector(".inHandCheckbox");
inHandCheckbox.checked = player.inHand;
inHandCheckbox.addEventListener("change", () => {
player.inHand = inHandCheckbox.checked;
if (!player.inHand) player.folded = false;
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
this.commitSeatChange(form, player, index);
});
const foldCheckboxRef = form.querySelector(".foldCheckbox");
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
foldCheckboxRef.addEventListener("change", () => {
player.folded = foldCheckboxRef.checked;
this.commitSeatChange(form, player, index);
});
this.wireSeatState(form, player, index);
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
slots.forEach((slot, cardIdx) => {
@@ -2321,7 +2630,11 @@ const Render = {
const stackInput = form.querySelector(".stackInput");
stackInput.value = player.stack ?? "";
stackInput.addEventListener("input", () => {
player.stack = stackInput.value === "" ? null : Number(stackInput.value);
// Null for empty *and* for anything that isn't a number — the box is
// type="text" now, so a stray letter arrives here rather than being eaten
// by the control, and a seat holding NaN chips is worse than one holding
// none. See Money.readAmount.
player.stack = Money.readAmount(stackInput.value);
this.commitSeatChange(form, player, index);
});
@@ -2376,8 +2689,21 @@ const Render = {
const valueInput = row.querySelector(".customFieldValue");
labelInput.value = field.label;
valueInput.value = field.value;
// Same as the chip legend's remove button, and for the same reason: "×" is
// the whole of this control, so unnamed it announces as a button and nothing
// more, in a column of others exactly like it. The label the dealer typed is
// what tells them apart, and it changes as it is typed.
const removeBtn = row.querySelector(".removeFieldBtn");
const nameRemove = () => {
const typed = labelInput.value.trim();
removeBtn.setAttribute("aria-label", typed ? `Remove "${typed}"` : "Remove this note");
};
nameRemove();
labelInput.addEventListener("input", () => {
field.label = labelInput.value;
nameRemove();
this.hooks.save(this.state);
this.renderSeatDisclosures(form, player);
});
@@ -2386,7 +2712,7 @@ const Render = {
this.hooks.save(this.state);
this.renderSeatDisclosures(form, player);
});
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
removeBtn.addEventListener("click", () => {
player.customFields.splice(fieldIdx, 1);
this.renderCustomFields(form, player, index);
this.hooks.save(this.state);
@@ -2398,7 +2724,34 @@ const Render = {
// ---- Chip legend ----
// The legend itself: what each colour is worth, read left to right.
//
// The panel has always been named for this and has never shown it — what it showed
// was the form for setting it, five rows deep, with the values in number boxes. A
// dealer wanting to know what the green one is worth had to read it out of an input.
// This is the same five facts in two lines, and it sits above the form now.
renderChipLegendStrip() {
const strip = document.getElementById("chipLegendStrip");
if (!strip) return;
strip.innerHTML = "";
(this.state.chipLegend || []).forEach(chip => {
const item = document.createElement("li");
item.className = "chipLegendChip";
const dot = document.createElement("span");
dot.className = "chipSwatch";
dot.style.background = chip.color;
const worth = document.createElement("span");
worth.textContent = Money.format(Number(chip.value) || 0);
item.appendChild(dot);
item.appendChild(worth);
strip.appendChild(item);
});
},
renderChipLegend() {
this.renderChipLegendStrip();
const list = document.getElementById("chipLegendList");
list.innerHTML = "";
const template = document.getElementById("chipLegendRowTemplate");
@@ -2427,10 +2780,18 @@ const Render = {
colorInput.addEventListener("input", () => {
chip.color = colorInput.value;
this.hooks.save(this.state);
// The strip above is the same fact this field is editing, so it keeps up
// with it. Only the strip, still — but for cost now rather than for
// safety: setField refuses to write into a focused control, so the
// mid-keystroke clobber this was dodging can no longer happen from any
// direction. Naming the two renders that matter is simply cheaper than
// laying the felt out again on every keystroke.
this.renderChipLegendStrip();
});
valueInput.addEventListener("input", () => {
chip.value = Number(valueInput.value) || 0;
nameRemove();
this.renderChipLegendStrip();
this.hooks.save(this.state);
this.renderSeats();
this.renderSeatEditor();
@@ -2439,8 +2800,8 @@ const Render = {
// legend's own summary is on it: shut, the panel reads "5 colours ·
// $1–$500", and re-pricing the top chip is exactly the edit that
// changes it. Called here for the same reason as the rest of the list:
// renderAll would write this field's own value back into it mid-
// keystroke.
// it is what this edit actually moves, and renderAll would lay the whole
// felt out again on every keystroke to find that out.
//
// Only that one. The bank check is not downstream of this, which is
// worth saying because it looks as though it should be — a seat's chip
@@ -3040,7 +3401,13 @@ const Render = {
const emptyHint = document.getElementById("handHistoryEmptyHint");
const history = this.state.handHistory || [];
emptyHint.hidden = history.length > 0;
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
// This one was greyed out with no explanation anywhere — not even a tooltip —
// so on every device it was a button that did nothing and said nothing about it.
Unavailable.set(
document.getElementById("exportHistoryBtn"),
history.length === 0,
"No hands to export yet — the file fills in as the night is played."
);
const query = this.historyFilter || "";
const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
@@ -1,6 +1,26 @@
// The long-press menu on a seat. Everything a dealer does to one player mid-hand,
// one press and one tap away, without opening the full editor and without hunting
// for whose turn it is in the action bar.
//
// It grew to eleven of those, all drawn the same: eleven full-width bars filling 85%
// of every screen it was measured on, scrolling on all of them, with Cancel below the
// fold on all of them too — 252px of it hidden on a 360px phone, 448px in landscape.
// Apple's action-sheet guidance puts the comfortable number at three to five and says
// not to let one scroll at all, and a dealer folding a player mid-hand is reading
// eleven choices to find the one they came for.
//
// The eleven have not been cut — every one of them is the only route to something, and
// "Blinds follow the button again" is the only route in the whole tool to clearing an
// override. They are sorted by *when* instead. A seat action is either something you
// do while the cards are in the air, or housekeeping between hands, and those two want
// different targets: the first wants a bar you can hit without looking, the second
// wants to be findable and out of the way. So the hand keeps the full-width bars and
// everything else goes to a two-column grid underneath it.
//
// The captions are not decoration. A half-width tile reading "Small blind" is a label,
// and the puck already wears one of those; under "Give this seat" it is a verb again.
// That is what lets three of the longest labels here lose ten characters each and
// still fit a half tile on one line, which is the height the caption pays back.
const SeatMenu = {
state: null,
hooks: null,
@@ -8,6 +28,14 @@ const SeatMenu = {
mode: null,
// The two groups that sit under the hand, in the order they are drawn. `hand` has
// no caption because it needs none — it is the top of the sheet, and its actions
// are the two the sheet is coloured for.
GROUPS: [
{ name: "player", caption: "This player" },
{ name: "position", caption: "Give this seat" },
],
open(state, hooks, seatIndex) {
this.state = state;
this.hooks = hooks;
@@ -26,50 +54,68 @@ const SeatMenu = {
// Only the actions that make sense for this seat right now — a folded player has
// nothing to go all-in with, and an empty chair has nothing to fold.
//
// `group` is when you do it, not what it touches: "hand" is what a dealer reaches
// for with cards on the table, and it is the only group drawn at full width.
actionsFor(player, index) {
const actions = [];
const dealtIn = player.inHand;
const live = dealtIn && !player.folded;
if (live) {
actions.push({ label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
actions.push({ label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
actions.push({ group: "hand", label: "Fold", className: "menuDanger", run: () => this.hooks.fold(index) });
actions.push({ group: "hand", label: "All-in", className: "menuAccent", run: () => this.hooks.allIn(index) });
} else if (dealtIn && player.folded) {
actions.push({ label: "Un-fold", run: () => this.hooks.unfold(index) });
actions.push({ group: "hand", label: "Un-fold", run: () => this.hooks.unfold(index) });
}
// The most common interruption in a cash game, and until now it meant opening
// the editor and retyping a stack from memory.
actions.push({ label: "Add chips…", run: () => this.hooks.addChips(index) });
actions.push({
label: dealtIn ? "Sit out" : "Deal in",
run: () => this.hooks.toggleDealtIn(index),
});
// An empty chair with people waiting: the dealer taps the chair somebody is
// about to sit in, which is what their hand is doing anyway. Only the front of
// the queue is offered, for the same reason the panel only offers them — a list
// that isn't kept in order is just a pile of names.
//
// In the hand group rather than with the housekeeping: the queue moves when a
// seat opens, which is between two hands with people watching, not later.
const waiting = (this.state.waitingList || [])[0];
const emptyChair = !HoldemState.seatInPlay(player);
if (waiting && emptyChair) {
actions.push({
group: "hand",
label: `Seat ${waiting.name}`,
className: "menuAccent",
run: () => this.hooks.seatWaiting(waiting.id, index),
});
}
// The most common interruption in a cash game, and until now it meant opening
// the editor and retyping a stack from memory.
actions.push({ group: "player", label: "Add chips…", run: () => this.hooks.addChips(index) });
actions.push({
group: "player",
label: dealtIn ? "Sit out" : "Deal in",
run: () => this.hooks.toggleDealtIn(index),
});
// Leaving for the night, which is a different thing from sitting out a hand:
// the chips go back over the rail and the chair opens. Offered only for a seat
// that has somebody in it, since an empty chair has nobody to pay.
if (HoldemState.seatInPlay(player)) {
actions.push({ label: "Cash out…", run: () => this.hooks.cashOut(index) });
actions.push({ group: "player", label: "Cash out…", run: () => this.hooks.cashOut(index) });
}
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
// generated from a touch screen — so on the device this tool is mostly used on,
// the "drag to swap seats" the hint advertised did nothing at all. This is the
// same move, by the route a thumb can actually take.
if (this.state.seats.length > 1) {
actions.push({ group: "player", label: "Move to…", run: () => this.openMovePicker() });
}
actions.push({ group: "player", label: "Edit seat…", run: () => this.hooks.editSeat(index) });
if (!this.state.dedicatedDealer && index !== this.state.dealerSeatIndex) {
actions.push({ label: "Give the button", run: () => this.hooks.makeDealer(index) });
actions.push({ group: "position", label: "The button", run: () => this.hooks.makeDealer(index) });
}
// Naming a blind by hand. Only offered while nothing but the blinds are in —
@@ -80,25 +126,25 @@ const SeatMenu = {
if (dealtIn && Betting.onlyBlindsIn(this.state)) {
const blinds = HoldemState.getBlindSeats(this.state);
if (blinds.sbIndex !== index) {
actions.push({ label: "Make small blind", run: () => this.hooks.assignBlind(index, "sb") });
actions.push({ group: "position", label: "Small blind", run: () => this.hooks.assignBlind(index, "sb") });
}
if (blinds.bbIndex !== index) {
actions.push({ label: "Make big blind", run: () => this.hooks.assignBlind(index, "bb") });
actions.push({ group: "position", label: "Big blind", run: () => this.hooks.assignBlind(index, "bb") });
}
// Not about this seat at all — it puts the whole table back on automatic —
// so it spans the grid instead of sitting in it as a fourth peer. It is
// still here because this menu is the only place in the tool that offers
// it, and it belongs beside the two overrides that caused it.
if (this.state.blindOverride) {
actions.push({ label: "Blinds follow the button again", run: () => this.hooks.clearBlindOverride() });
actions.push({
group: "position",
label: "Blinds follow the button again",
wide: true,
run: () => this.hooks.clearBlindOverride(),
});
}
}
// Changing chairs was drag-and-drop only, and HTML5 drag events are never
// generated from a touch screen — so on the device this tool is mostly used on,
// the "drag to swap seats" the hint advertised did nothing at all. This is the
// same move, by the route a thumb can actually take.
if (this.state.seats.length > 1) {
actions.push({ label: "Move to…", run: () => this.openMovePicker() });
}
actions.push({ label: "Edit seat…", run: () => this.hooks.editSeat(index) });
return actions;
},
@@ -116,48 +162,117 @@ const SeatMenu = {
this.render();
},
closeMovePicker() {
this.mode = null;
this.render();
},
button(label, className) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = className;
btn.textContent = label;
return btn;
},
// A row of tiles. Two columns on a phone: a seat action's label is a verb and a
// short one, and stacking nine of them full width was most of the height the sheet
// was spending.
grid(extra) {
const el = document.createElement("div");
el.className = `seatMenuGrid ${extra || ""}`.trim();
return el;
},
// The two tiers, as boxes rather than as a run of siblings.
//
// Upright they are what they always were: one column, in the order they are
// written. The wrappers exist for the phone on its side, where the sheet has 306px
// of height and 616 of width and the tiers stop being two halves of a column and
// become two columns — see the `max-height: 560px` block in holdem.css. That is
// only expressible if each tier is one box the layout can address; as five loose
// children they can only ever stack.
tier(name) {
const el = document.createElement("div");
el.className = name;
return el;
},
caption(text) {
const el = document.createElement("p");
el.className = "seatMenuCaption";
el.textContent = text;
return el;
},
// Stepping into the move picker throws the whole list away, and the first thing it
// throws away is the "Move this seat" the dealer just pressed — which left focus on
// the body, inside a sheet that had just changed into a different sheet. The
// heading is where to be: in move mode it has stopped saying who this is and
// started saying what is being asked — "Move Alexandra to…".
render() {
Modal.preservingFocus(document.getElementById("seatMenuOverlay"), () => this.paint(), () => {
const title = document.getElementById("seatMenuTitle");
if (title) title.focus({ preventScroll: true });
});
},
paint() {
const player = this.state.seats[this.seatIndex];
const title = document.getElementById("seatMenuTitle");
const subtitle = document.getElementById("seatMenuSubtitle");
const list = document.getElementById("seatMenuActions");
const cancel = document.getElementById("seatMenuCancelBtn");
list.innerHTML = "";
if (this.mode === "move") {
title.textContent = `Move ${HoldemState.displayName(player, this.seatIndex)} to…`;
subtitle.textContent = "Their chips, cards and bet go with them. The button and the blinds stay on the chair.";
// Opening the picker used to be a one-way door: it replaced the sheet, and
// the only button left closed the whole menu. The sheet's own back is the
// button already at the thumb end, so it says Back while there is somewhere
// to go back to.
cancel.textContent = "Back";
const keeping = this.tier("seatMenuKeeping");
// Chairs, not verbs: "Seat 3 — Alexandra" sets 116px where "Sit out" sets
// about 50, so this grid asks for a wider column than the tiles do.
const grid = this.grid("seatMenuGridNames");
this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "seatMenuBtn";
// displayName falls back to "Seat 5" for a chair with nobody in it,
// which reads as "Seat 5 — Seat 5". An empty chair says it's empty.
const named = (other.name || "").trim();
btn.textContent = named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`;
const btn = this.button(
named ? `Seat ${seat + 1} — ${named}` : `Seat ${seat + 1} — empty`,
"seatMenuBtn menuTile");
btn.addEventListener("click", () => {
const from = this.seatIndex;
this.close();
this.hooks.swapSeats(from, seat);
});
list.appendChild(btn);
grid.appendChild(btn);
});
keeping.appendChild(grid);
list.appendChild(keeping);
return;
}
cancel.textContent = "Cancel";
title.textContent = HoldemState.displayName(player, this.seatIndex);
const stack = Betting.stackOf(this.state, player);
const owed = Betting.toCall(this.state, player);
const bits = [];
// Which chair this is leads the line. It came off the puck's face this round —
// see HoldemState.seatHeading — and this is the other place it went. It goes in
// the subtitle rather than the heading because the heading is a name and names
// are long: this header is stuck to the top of the sheet that scrolls, and a
// second line of h2 is height every action below it pays for.
const bits = [`Seat ${this.seatIndex + 1}`];
if (stack > 0) bits.push(`${Money.format(stack)} behind`);
if (owed > 0) bits.push(`${Money.format(owed)} to call`);
subtitle.textContent = bits.join(" · ");
this.actionsFor(player, this.seatIndex).forEach(action => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `seatMenuBtn ${action.className || ""}`;
btn.textContent = action.label;
const actions = this.actionsFor(player, this.seatIndex);
const wire = (btn, action) => {
btn.addEventListener("click", () => {
// "Move to…" stays on the sheet and swaps it for the seat list; every
// other action is the end of the interaction.
@@ -168,8 +283,37 @@ const SeatMenu = {
this.close();
action.run();
});
list.appendChild(btn);
};
const hand = this.tier("seatMenuHand");
actions.filter(a => a.group === "hand").forEach(action => {
const btn = this.button(action.label, `seatMenuBtn ${action.className || ""}`.trim());
wire(btn, action);
hand.appendChild(btn);
});
// An empty tier is left out rather than drawn empty — a folded player in a game
// with nobody waiting has nothing in the air to do, and the sideways layout
// reads the tier's absence to know it has one column rather than two.
if (hand.childElementCount) list.appendChild(hand);
const keeping = this.tier("seatMenuKeeping");
this.GROUPS.forEach(({ name, caption }) => {
const inGroup = actions.filter(a => a.group === name);
// A caption for nothing is worse than no caption. A group empties often —
// past the blinds there is nothing to give a seat but the button.
if (inGroup.length === 0) return;
keeping.appendChild(this.caption(caption));
const grid = this.grid();
inGroup.forEach(action => {
const btn = this.button(action.label,
`seatMenuBtn menuTile ${action.wide ? "menuTileWide" : ""} ${action.className || ""}`
.replace(/\s+/g, " ").trim());
wire(btn, action);
grid.appendChild(btn);
});
keeping.appendChild(grid);
});
if (keeping.childElementCount) list.appendChild(keeping);
},
};
@@ -30,7 +30,9 @@ const Shortcuts = {
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
{ keys: ["A"], action: "allIn", what: "Put this player all-in" },
{ keys: ["U"], action: "undo", what: "Take back the last action" },
// Both undos, because there are two and a dealer has no reason to know that:
// the bar's pill takes back a bet, and the toast's takes back a whole step.
{ keys: ["U"], action: "undo", what: "Take back the last thing you did" },
{ keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
],
@@ -476,6 +476,23 @@ const HoldemState = {
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
},
// The same seat, named and numbered, for the two sheets that are about one seat.
//
// The chair number used to be a grey digit in the corner of the puck, and the corner
// of the puck is where the blind badge lands and where a long name reaches: measured
// across 348 seats on 36 tables, 117 of them had it printed under something else —
// every seat posting a blind, and one in seven with it sitting on the first letter of
// the player's own name. The felt has no free corner to move it to, so it stopped
// being on the felt. These two headings are where a dealer goes when the question is
// about one seat, and they answer it now.
//
// An unnamed seat is already called "Seat 5" by displayName, so it doesn't say it
// twice.
seatHeading(player, seatIndex) {
const name = player && player.name ? player.name.trim() : "";
return name ? `${name} · Seat ${seatIndex + 1}` : `Seat ${seatIndex + 1}`;
},
// Contribution the table calculator will treat this player as having put in.
//
// Chips already pushed into the middle count whether or not the player is still
+47
View File
@@ -1017,6 +1017,53 @@ lose. Chromium and Safari answer silently on their own engagement heuristics; Fi
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.
## The sweep had never once been run at a size anybody sets
Every length in `holdem.css` that isn't a structural clearance is in `rem`. That is the
right way round: it means the whole page follows the browser's font size, which is what
a phone's text-size slider changes. It also means the page had a whole axis of behaviour
that 320 audits had never touched, because every one of them ran at the default 16px.
A third pass now runs the same surfaces on the eight phone-shaped viewports with the
root at **20px** — the 125% an Android accessibility slider gives you. It found four
things on its first run, and every check that caught them was already written:
- **The page slid sideways.** `#holdemHeaderActions` was `flex-wrap: nowrap` with three
`white-space: nowrap` labels inside it, so when they stopped fitting they had nowhere
to go: "Resolve Pot" ended 23px past the right edge of a 360px window and put a
horizontal scrollbar under the whole document. At 24px it was 60px past the edge of a
390px one. `page never scrolls sideways` had been in the audit for months and had never
had anything to say.
- **Figures painted over the seat next door.** Which of the two type steps an amount got
was decided by *counting characters* — eight for one, ten for the other — which is a
guess at how wide a figure is that happens to hold at one text size and one puck width.
At 20px, "$1,200" is six characters, took no step at all, and wanted 38px of a 34px
puck. `Render.fitAmounts` measures now, in the same batched passes `fitSeatNames` uses,
and takes the separators out before it takes a step of type.
- **Pucks grew and the ring could not answer.** The row heights are `px` tokens so that
an amount stepping its type down shortens its digits and not its row — but a token is
a floor, not a ceiling, and text bigger than its row makes the row taller. The puck
went from 55px to 65 and twelve of them came down on each other, because at the 44px
floor there is nowhere narrower for the search to go. The two sizes a puck is written
at are capped at exactly the sizes they have always been drawn at, so nothing changes
at an ordinary text size; the felt is a diagram, and the rule that already governed a
puck's width now governs its height. `.seatsRoomy` is exempt — those pucks have room.
- **A check that was crying wolf.** `no control is parked where scrolling can't reach it`
flagged the raise pad's Cancel and Raise-to in the landscape rail. The rail is
`overflow-y: auto` on purpose and they were one flick away, so the message was untrue;
it now asks whether a scrollable ancestor can bring the control back. What was true is
that the rail opened at its *top*, so the overflow came off the bottom — where the two
buttons that end a raise are. It opens at the end now, which is the same reason it is
anchored low in the first place.
One shape it is not held to, with the arithmetic in `runUiAudit.js`: twelve players
all-in on a 360x640 phone. The action bar is at its tallest there — 283px of the 640 at
20px — and with the header that leaves the felt 290x163 where it had 296x262. Twelve
pucks 44px wide (the floor a thumb needs) and 62px tall do not fit around an ellipse
163px deep. That is a geometry with no answer rather than a fault with a fix, so it is
reported as a note every run instead of a failure every run — a sweep that is always red
stops being read.
## 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
+92 -12
View File
@@ -51,11 +51,13 @@ const valuesIn = (properties, source = body) => {
describe("the spacing scale", () => {
// Hairlines and structural clearances, each one deliberate:
// 0px/1px/3px/4px/22px — borders, hairline offsets, and the 22px of room a seat
// puck's name gives the dealer button beside it.
// 0px/1px/3px/4px/25px — borders, hairline offsets, and the 25px of room a seat
// puck's name gives the dealer button beside it: the chip's
// own 22px plus the 3px it is inset by, which theRail's
// "as wide as the chip it stands in for" holds it to.
// 5rem/9rem — how far the page has to scroll past the fixed action
// bar. Not rhythm; clearance for a thing of a known size.
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "22px", "5rem", "9rem"]);
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "25px", "5rem", "9rem"]);
test("every gap, margin and padding comes off the scale", () => {
const offenders = valuesIn(
@@ -77,12 +79,26 @@ describe("the spacing scale", () => {
describe("the type scale", () => {
// 16px — the size below which iOS zooms the page when a field is focused, so
// every text input states it. Not a typographic choice.
// 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for
// a six-figure stack on a 58px seat puck.
const ALLOWED = new Set(["16px", "1.05em", "0.44rem"]);
// 1.05em — a relative bump inside a component.
// 9px — the floor under the seat puck's shrink-to-fit applied to a name. It is a
// limit rather than a size: past it the word stops being legible from
// across a table and an honest ellipsis is the better answer.
// 10px, 12px — the ceilings on a seat puck's two sizes, which are the one place in
// this file where type does not follow the browser's font size. Each is
// exactly the size that row has always been drawn at, so a cap only ever
// engages when a phone's text is turned up; see the #seatsLayer token block
// for why a ring whose pucks are already at the 44px floor cannot answer a
// taller row any other way. Limits rather than sizes.
const ALLOWED = new Set(["16px", "1.05em", "9px", "10px", "12px"]);
// The seat puck's two sizes are custom properties that feed `font-size` through
// `var()`, so the sweep above walks straight past them — which is exactly where a
// raw number would end up hiding. They are held to the same scale.
const TYPE_TOKENS = ["--seatNameSize", "--seatAmountSize"];
test("every font-size comes off the scale", () => {
const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value));
const offenders = valuesIn(["font-size", ...TYPE_TOKENS])
.filter(v => !ALLOWED.has(v.value));
assert.deepEqual(offenders, [],
"use var(--fs-2xs … --fs-2xl), or add the value to ALLOWED with a reason");
});
@@ -147,18 +163,82 @@ describe("numbers that change while the dealer watches", () => {
});
});
describe("what an open chair is allowed to weigh", () => {
// An empty seat used to be drawn as a whole puck of near-black on the green: the same
// box a seated player gets, at the same weight, saying nothing. Five players
// nine-handed is four of them, and they read as holes punched in the felt rather than
// as chairs nobody is in — the eye had to sort the loud empty boxes from the loud full
// ones before it could find who was in the hand.
//
// The wash the box carried was there for one reason: the chair number's contrast
// cannot depend on whether its chair happens to sit over the brown rim, the dark green
// or the light, which is about Lc 5 across one ring. So the wash moved off the chair
// and under the number, which is the only thing an open chair has to say.
test("the chair is quieter than the player who could sit in it", () => {
const empty = css.match(/\.seat\.seatEmpty \{([^}]*)\}/);
const seat = css.match(/\n\.seat \{([^}]*)\}/);
assert.ok(empty && seat);
assert.match(seat[1], /background:\s*linear-gradient/,
"a taken seat is a puck with a ground of its own");
const alpha = empty[1].match(/background:\s*rgba\([^)]*?([0-9.]+)\s*\)/);
assert.ok(alpha, "an open chair should still be washed, only faintly");
assert.ok(Number(alpha[1]) <= 0.35,
`an open chair at ${alpha[1]} reads as a hole in the felt, not a chair`);
});
test("and it still says it is open", () => {
const empty = css.match(/\.seat\.seatEmpty \{([^}]*)\}/)[1];
assert.match(empty, /border-style:\s*dashed/,
"the dashes are what carry the meaning; they always were");
});
test("the number keeps a ground of its own, because it is the part that is read", () => {
const rule = css.match(/\.seatEmpty \.seatChairNumber \{([^}]*)\}/);
assert.ok(rule, "no ground under the one thing an open chair says");
assert.match(rule[1], /background:\s*rgba/);
assert.match(rule[1], /border-radius:\s*var\(--r-pill\)/);
});
});
describe("controls that must not resize under a thumb", () => {
test("the undo pill has a fixed width, not a content width", () => {
// It names the last action, so its label changes on every tap. Measured across
// one hand before this: 148, 110, 109, 108, 109, 110, 120.
const rule = css.match(/\.actionUndo\s*\{[^}]*\}/);
assert.ok(rule, ".actionUndo rule not found");
assert.match(rule[0], /width:\s*[0-9.]+rem/);
//
// A length at every breakpoint, never a percentage: `min(10rem, 34%)` was tried
// and the percentage resolves against the row it sits in, which is sized to its
// own contents — the two chase each other, and the sweep caught the pill drawn
// 38px wide at ten viewports. A phone gets a narrower one through a media query
// instead, which is still one width at a time.
const rules = [...css.matchAll(/\.actionUndo\s*\{([^}]*)\}/g)].map(m => m[1]);
assert.ok(rules.length, ".actionUndo rule not found");
rules.filter(body => /(?<![\w-])width:/.test(body)).forEach(body =>
assert.match(body, /(?<![\w-])width:\s*([0-9.]+rem|100%)/,
"a content width would let the pill resize under the dealer's thumb"));
assert.ok(rules.some(body => /(?<![\w-])width:\s*[0-9.]+rem/.test(body)),
"the pill should still state a fixed width somewhere");
});
test("and its label is the part that ellipsizes", () => {
test("and its label is the part that gives, never the keycap", () => {
const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/);
assert.ok(rule, ".actionUndoLabel rule not found");
assert.match(rule[0], /text-overflow:\s*ellipsis/);
assert.match(rule[0], /overflow:\s*hidden/);
assert.match(css.match(/\.actionUndoKey\s*\{[^}]*\}/)[0], /flex:\s*none/);
});
test("and within the label, the act outlives the actor", () => {
// Three items, not one string: the arrow, who it was, and what they did. Cut as
// one string, "↶ Mike from work's fold" reads "↶ Mike from wor…" on a phone —
// everything except the thing you would be taking back. The actor is dropped
// whole instead, so the act is what survives, and only the act may ellipsize.
const lead = css.match(/\.actionUndoGlyph,\s*\n\.actionUndoLead\s*\{([^}]*)\}/);
assert.ok(lead, "the arrow and the actor should be fixed items");
assert.match(lead[1], /flex:\s*none/);
assert.match(css, /\.actionUndoLead\[hidden\]\s*\{[^}]*display:\s*none/);
const act = css.match(/\.actionUndoAct\s*\{([^}]*)\}/);
assert.ok(act, ".actionUndoAct rule not found");
assert.match(act[1], /text-overflow:\s*ellipsis/);
assert.match(act[1], /min-width:\s*0/);
});
});
+12 -1
View File
@@ -140,7 +140,18 @@ describe("what stops the oval", () => {
// The oval is a preference and the room is a fact. Whatever shape is asked for
// above, the felt gives way to the measurement rather than the other way round,
// because the thing on the other side of the cap is a seat behind the action bar.
assert.equal(phoneRule, "var(--feltRoomPx, 100vh)");
assert.match(phoneRule, /^var\(--feltRoomPx,/,
"something other than the measured room decides how tall the felt may be");
});
test("and what it falls back on is a near guess rather than the whole window", () => {
// The fallback is only ever used for the frame before actionBar.js has measured
// anything — but that frame is one a dealer sees, because the scripts run at the
// end of the body. `100vh` was what stood here, and it says the whole window is
// free: measured at 360x640 nine-handed, the table came up 336x504 and dropped to
// 336x374 as soon as the bar was measured. See whatTheFeltIsToldFirst.
assert.doesNotMatch(phoneRule, /var\(--feltRoomPx,\s*100vh\)/);
assert.match(phoneRule, /var\(--actionBarHeight, 9rem\)/);
});
test("and it is not floored, however tempting that is", () => {
+30
View File
@@ -43,6 +43,31 @@ const CardPicker = load("cardPicker");
// navigator.wakeLock — see wakeLock.test.js.
const WakeLock = load("wakeLock");
// feedback.js exports two namespaces and a third. Two of them answer here: Unavailable,
// which is nearly all logic (see theUnansweredPress), and Toast, whose timer and focus
// bookkeeping ask little enough of a document that a stand-in can answer (see
// theWayBack). All three are also set as globals, the way the browser hands them to
// render.js and cardPicker.js.
const { Toast, Dialog, Unavailable } = require(path.join(SRC, "feedback.js"));
globalThis.Toast = Toast;
globalThis.Dialog = Dialog;
globalThis.Unavailable = Unavailable;
// Modal is DOM behaviour through and through, but two pieces of it answer here: the
// list of what counts as a Tab stop, which is a string, and preservingFocus, which asks
// so little of a document that a stand-in can answer it. See theKeyboardInASheet.
const Modal = load("modal");
// Quick Split draws itself entirely from the DOM, but the arithmetic that decides what
// its two steppers are allowed to reach — and how many ways a pot may be chopped — is
// plain and answers here. See theWaysControl.
const QuickSplit = load("quickSplit");
// The seat menu's list — which actions a seat is offered and which group each one
// lands in — is decided before anything is drawn, so actionsFor() answers here. Only
// render() and open() touch the DOM.
const SeatMenu = load("seatMenu");
// ---- Table builders ----------------------------------------------------------
// A table with `seatCount` chairs where `seated` (a list of seat indexes) are dealt
@@ -99,6 +124,11 @@ module.exports = {
ActionBar,
CardPicker,
WakeLock,
SeatMenu,
Modal,
QuickSplit,
Toast,
Unavailable,
makeTable,
chipsOnTable,
contributions,
+132
View File
@@ -0,0 +1,132 @@
// The half of the interface the stylesheet does not draw.
//
// The browser draws the checkboxes, the menu a <select> opens, the scrollbars, the
// caret, the spellcheck underline and the autofill wash, and it draws all of them for a
// light page unless the page says otherwise. This one never did: measured,
// `color-scheme: normal` on a #141414 ground, with three checkboxes in the Table Setup
// panel rendering as bright white squares — the loudest objects on the screen, brighter
// than the gold — and two more in the seat sheet doing the same over the felt.
//
// The comment on the pot wizard's hand-painted checkbox called it "the last raw browser
// control in the tool". Six others were still the platform's, and three of those were
// still 13x13 against a 24px floor this stylesheet had already written down once and
// applied to exactly one box.
//
// Painting is the expensive way to fix that, and the pot wizard's box shows the bill:
// a tick drawn as a `background` on a pseudo-element is not a colour forced-colors mode
// knows how to replace, so it simply disappears — no visible checked state at all, on
// the dialog that pays the table. Declaring the scheme fixes six controls natively and
// costs nothing; the seventh stays painted on purpose and pays its own way.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// Every block of a given at-rule, braces balanced, joined.
//
// This used to take the first one and stop, which held for exactly as long as there was
// one of them. A second forced-colors block was added nearer the top of the file for a
// control that dropped out of the OS palette, and the bill-paying test below started
// reading that block instead and reporting the pot wizard's answers missing — a false
// failure about a rule nobody had touched. There is no reason a stylesheet should
// answer a media query in one place, so this asks about all of them.
const atRule = query => {
const open = `@media ${query} {`;
const blocks = [];
for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
let depth = 0;
for (let i = CSS.indexOf("{", at); i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) { blocks.push(CSS.slice(at, i + 1)); break; }
}
assert.ok(blocks.length && depth === 0, `\`${open}\` never closes`);
}
assert.ok(blocks.length, `there is no \`@media ${query}\` block`);
return blocks.join("\n");
};
// ---------------------------------------------------------------------------
describe("the page says what colour it is", () => {
const root = ruleFor(":root");
test("dark, to the browser and not only to the eye", () => {
assert.match(root, /color-scheme:\s*dark/);
});
// The checked colour is the green this tool already uses for yes, so the one
// painted checkbox and the six native ones agree rather than nearly agree.
test("and the checked colour is the tool's own green, not the platform's blue", () => {
assert.match(root, /--accent-on:\s*#1e8449/);
assert.match(root, /accent-color:\s*var\(--accent-on\)/);
assert.match(ruleFor(".potWizardChoice input:checked"), /background:\s*#1e8449/);
});
});
describe("a checkbox is one size", () => {
test("every one of them, not the one that was noticed", () => {
assert.match(ruleFor('input[type="checkbox"]'), /width:\s*24px/);
assert.match(ruleFor('input[type="checkbox"]'), /height:\s*24px/);
});
test("and no box carries a size of its own any more", () => {
assert.ok(!/#dedicatedDealerToggle\s*\{/.test(CSS),
"one checkbox is still sized by id, which is how five got left behind");
assert.ok(!/width:\s*22px/.test(ruleFor(".potWizardChoice input")),
"the painted box restates its size instead of taking the one above");
});
// A stated width is only a suggestion to a flex item. The coarse-pointer block had
// already been bitten by this and said so; the mouse case had not.
test("a swatch in a crowded row keeps the width it was given", () => {
assert.match(ruleFor(".chipColorInput"), /flex:\s*none/);
});
});
describe("what painting a control costs, and who pays it", () => {
// If a future round paints another one, this is the bill it has to settle too.
test("nothing gives up the platform's drawing without answering forced colours", () => {
const forced = atRule("(forced-colors: active)");
const painted = [...CSS.matchAll(/\n([^\n{}]+)\s*\{[^}]*appearance:\s*none/g)]
.map(m => m[1].trim());
assert.ok(painted.length > 0, "nothing is painted, so this test is measuring nothing");
painted.forEach(selector => {
assert.ok(forced.includes(selector),
`\`${selector}\` drops the platform's control but has no forced-colors answer`);
});
});
// A tick drawn as a background is gone in forced-colors mode; a system keyword is
// the only colour guaranteed to survive, because it *is* the palette.
test("the tick that is drawn rather than typed survives the OS repainting it", () => {
const forced = atRule("(forced-colors: active)");
assert.match(forced, /\.potWizardChoice input::before\s*\{[^}]*background:\s*CanvasText/);
});
test("and so does the row's own chosen state, which is a gradient", () => {
const forced = atRule("(forced-colors: active)");
assert.match(forced, /\.potWizardChoice\.chosen\s*\{[^}]*outline:\s*2px solid Highlight/);
});
});
describe("the other page of the same tool", () => {
const QUICKSPLIT = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "quicksplit.css"), "utf8");
// It is the darker of the two pages and is opened from the felt mid-hand. It has no
// checkboxes to go white, but scrollbars, caret and selection were still drawn for
// a light page.
test("Quick Split says what colour it is too", () => {
assert.match(QUICKSPLIT, /:root\s*\{[^}]*color-scheme:\s*dark/);
});
});
+2 -2
View File
@@ -175,8 +175,8 @@ describe("the rows that stay quiet until they have news", () => {
});
describe("what the row is allowed to be", () => {
const ROWS = ["glanceTableSetup", "glanceStandings", "glanceWaiting",
"glanceHands", "glanceChips", "glanceNotes"];
const ROWS = ["glanceTableSetup", "glanceWhileYouDeal", "glanceStandings",
"glanceWaiting", "glanceHands", "glanceChips", "glanceNotes"];
test("every glance the sidebar renders is one this knows about", () => {
// The list drives the DOM writes, so a glance computed and never listed is a
+154
View File
@@ -0,0 +1,154 @@
// The number the pot wizard prints beside a name under the words "up to".
//
// It was the player's own remaining contribution. That is not what winning pays them,
// and on the screen that moves the money the gap was large and in the wrong direction:
// three players in for $250 each showed "up to $250" on all three rows while the prompt
// directly above said "$765 is in play" — and $765 is what the winner was actually
// handed. A dealer who trusted the row would have short-paid by two thirds. With a real
// side pot it was worse in a more confusing way: the breakdown box at the top of the
// same dialog announced "Main pot $765 · all 3", and Bob's row three lines under it
// said "up to $250".
//
// The ceiling is now what resolveRound will actually pay, and the test that matters is
// that it stays that: every case below checks the label against the function that moves
// the chips rather than against a number written out by hand.
//
// It is also printed only where it binds. A ceiling equal to everything on the table is
// the prompt's own figure repeated once per row — redundant data-ink, in Tufte's sense
// — and the rows it does appear on are exactly the hands where picking that winner
// leaves a side pot still to award, which is the one time a dealer needs telling.
const { test, describe } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("fs");
const path = require("path");
const { PotCalculator, Money } = require("./load");
const p = (id, contribution, folded = false) => ({ id, contribution, folded });
const entriesOf = (...players) => PotCalculator.buildEntries(players);
// What the wizard would hand this player if their row were the one ticked.
const paysOut = (entries, id) => PotCalculator.resolveRound(entries, [id]).payoutPool;
// ---------------------------------------------------------------------------
describe("what a row promises is what the round pays", () => {
test("a pot everybody covered pays the whole thing, dead blinds included", () => {
// Three in for $250, two blinds folded before the raise.
const entries = entriesOf(p("chris", 250), p("bob", 250), p("dee", 250),
p("sb", 5, true), p("bb", 10, true));
for (const id of ["chris", "bob", "dee"]) {
assert.equal(PotCalculator.ceilingFor(entries, id), 765);
assert.equal(PotCalculator.ceilingFor(entries, id), paysOut(entries, id));
}
});
test("the short all-in's ceiling is the main pot, not their own stake", () => {
// Bob is all in for 250; Christopher and Dee go on to 600.
const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600),
p("sb", 5, true), p("bb", 10, true));
assert.equal(PotCalculator.ceilingFor(entries, "bob"), 765);
assert.equal(PotCalculator.ceilingFor(entries, "chris"), 1465);
assert.equal(PotCalculator.ceilingFor(entries, "dee"), 1465);
});
// The property, over every shape the wizard can reach: a ceiling that disagreed
// with the payout would be the same defect again under a different number.
test("across every shape of pot, the ceiling is the payout", () => {
const shapes = [
[p("a", 10), p("b", 10)],
[p("a", 10), p("b", 25), p("c", 25)],
[p("a", 5), p("b", 50), p("c", 120), p("d", 120)],
[p("a", 33.33), p("b", 66.67), p("c", 100)],
[p("a", 20), p("b", 20), p("folder", 20, true)],
[p("a", 1), p("b", 2), p("c", 3), p("d", 4), p("e", 5)],
[p("short", 0.05), p("deep", 1000)],
];
shapes.forEach((shape, n) => {
const entries = entriesOf(...shape);
PotCalculator.getContenders(entries).forEach(c => {
assert.equal(PotCalculator.ceilingFor(entries, c.id), paysOut(entries, c.id),
`shape ${n}: ${c.id}`);
});
});
});
test("a second round asks about what is left, not what was there", () => {
const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600));
const after = PotCalculator.resolveRound(entries, ["bob"]).updatedEntries;
// Bob took $750 of the $1,450; $700 is left between the two deep stacks.
assert.equal(PotCalculator.totalPot(after), 700);
assert.equal(PotCalculator.ceilingFor(after, "chris"), 700);
assert.equal(PotCalculator.ceilingFor(after, "dee"), 700);
});
test("somebody already paid out has nothing left to be owed", () => {
const entries = entriesOf(p("a", 100), p("b", 100));
const after = PotCalculator.resolveRound(entries, ["a"]).updatedEntries;
assert.equal(PotCalculator.ceilingFor(after, "a"), 0);
});
test("a name nobody put chips in for is worth nothing rather than an error", () => {
assert.equal(PotCalculator.ceilingFor(entriesOf(p("a", 10)), "ghost"), 0);
});
});
describe("and it is only said where it binds", () => {
test("a pot everybody covered says its figure once, in the prompt", () => {
const entries = entriesOf(p("chris", 250), p("bob", 250), p("dee", 250),
p("sb", 5, true), p("bb", 10, true));
["chris", "bob", "dee"].forEach(id =>
assert.equal(PotCalculator.bindingCeilingFor(entries, id), null));
});
test("only the player who is in for less carries a number", () => {
const entries = entriesOf(p("chris", 600), p("bob", 250), p("dee", 600),
p("sb", 5, true), p("bb", 10, true));
assert.equal(PotCalculator.bindingCeilingFor(entries, "bob"), 765);
assert.equal(PotCalculator.bindingCeilingFor(entries, "chris"), null);
assert.equal(PotCalculator.bindingCeilingFor(entries, "dee"), null);
});
// Two short stacks at different heights: each is capped by the one above, so both
// rows have something to say and they do not say the same thing.
test("two all-ins at different heights each carry their own", () => {
const entries = entriesOf(p("small", 50), p("middle", 120), p("big", 400));
assert.equal(PotCalculator.bindingCeilingFor(entries, "small"), 150);
assert.equal(PotCalculator.bindingCeilingFor(entries, "middle"), 290);
assert.equal(PotCalculator.bindingCeilingFor(entries, "big"), null);
});
test("the deepest player is never the one capped", () => {
const entries = entriesOf(p("a", 10), p("b", 50), p("c", 900));
assert.equal(PotCalculator.bindingCeilingFor(entries, "c"), null);
assert.equal(PotCalculator.ceilingFor(entries, "c"), PotCalculator.totalPot(entries));
});
// Dead money is owed to whoever wins the layer it fell in, so it lifts every
// ceiling — including the short stack's, which is the whole reason Bob's row reads
// $765 rather than $750.
test("a folded player's chips lift the ceilings rather than sitting outside them", () => {
const live = entriesOf(p("a", 100), p("b", 100));
const withDead = entriesOf(p("a", 100), p("b", 100), p("folder", 40, true));
assert.equal(PotCalculator.ceilingFor(live, "a"), 200);
assert.equal(PotCalculator.ceilingFor(withDead, "a"), 240);
});
test("money in cents does not drift", () => {
const entries = entriesOf(p("a", 0.05), p("b", 0.05), p("c", 0.05));
assert.equal(PotCalculator.ceilingFor(entries, "a"), Money.round(0.15));
});
});
describe("the wizard asks the calculator rather than reaching for the stake", () => {
const SRC = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "potWizard.js"), "utf8");
test("the row's number comes from bindingCeilingFor", () => {
assert.match(SRC, /PotCalculator\.bindingCeilingFor\(this\.entries, c\.id\)/);
});
test("and nothing prints a contender's own stake under those words any more", () => {
assert.ok(!/up to \$\{Money\.format\(c\.remaining\)\}/.test(SRC),
"the wizard still prints the player's own contribution as their ceiling");
});
});
+139 -26
View File
@@ -28,7 +28,25 @@ const VIEWPORTS = [
const OVERLAYS = [
["rulings", () => document.getElementById("openRulingsBtn").click()],
["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)],
["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }],
// Opened the way a dealer opens it, with cards already out. This used to un-hide the
// overlay and nothing else, so the grid inside it was whatever `paint()` had last
// drawn — which on a freshly loaded page is nothing at all. For the whole life of
// this sweep the card picker was audited as an empty sheet: no slots, no fifty-two
// targets, no dealt cards, none of the three things it is made of.
["card picker", () => {
const state = HoldemState.load();
const playing = state.seats.filter(seat => seat.inHand);
if (playing[0]) playing[0].cards = ["AS", "KH"];
if (playing[1]) playing[1].cards = ["QD", "QC"];
state.board = ["7C", "2H", "TS", "", ""];
const empty = playing[2] || playing[0];
CardPicker.open(state, { onPick: () => {}, onDone: () => {} }, state.seats.indexOf(empty), 0);
// And with its answer showing. The line that says where a dealt card went is the
// one piece of this sheet that is empty until something is pressed, so pressing
// one is the only way the sweep ever sees it holding text.
const out = document.querySelector('#cardPickerGrid .pickerCard[aria-disabled="true"]');
if (out) out.click();
}],
["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
["pot wizard", () => document.getElementById("resolvePotBtn").click()],
];
@@ -110,23 +128,88 @@ async function main() {
const warnings = [];
let checks = 0;
// Twice over: once with a mouse, once with a thumb. The 44px rule stands itself
// down where `hover: hover` says there's a pointer, and a headless desktop browser
// is a pointer — so a single pass here has never once measured a touch target,
// however many viewports it visited. The phone-sized ones are exactly the pass that
// needs it.
for (const touch of [false, true]) {
// Three passes over the same surfaces.
//
// A mouse and a thumb, because the 44px rule stands itself down where `hover: hover`
// says there's a pointer, and a headless desktop browser is a pointer — so a single
// pass has never once measured a touch target, however many viewports it visited.
//
// And a thumb on a phone whose owner has turned the text up. Every length in this
// stylesheet that isn't a structural clearance is in `rem`, which is the right way
// round and means the whole page follows the browser's font size — including out of
// the side of the window, which is what it was doing: at 20px, which is the 125%
// an Android text-size slider gives you, "Resolve Pot" ran 23px off the right of a
// 360px phone and took the document's scroll width with it. Every check in the
// audit already knew how to catch that; not one of them had ever been run at a size
// anybody actually sets.
//
// One shape it is not held to, and the arithmetic for why. Twelve players all-in on
// a 360x640 phone is the tallest the action bar ever gets — the status block and the
// pot breakdown both grow with the text — and at 20px it stands 283px of the 640,
// which with the header leaves the felt 290x163 where it had 296x262. Twelve pucks
// 44px wide (the floor a thumb needs; there is nothing narrower to go to) and 62px
// tall do not fit around an ellipse 163px deep: two per side is all the height there
// is, and the sweep reports it as seats 9, 10 and 11 written across each other.
//
// That is a geometry with no answer rather than a fault with a fix, so it is
// reported as a note every run instead of a failure every run. A sweep that is
// always red stops being read — the same reasoning as the 30% furniture warning.
//
// The packed table is the same arithmetic and was only ever passing by luck.
// Measured on the same 360x640 phone at 20px, with nobody all-in: the felt is
// 330x294, the pucks are 44x63, and five of them sit down each side — 315px of puck
// into 294px of felt, with the pitch between neighbours at 35px and 51px against a
// puck 63px tall. They overlap by construction, worst case 12px, and they always
// have. What kept the sweep green was the hit-test probes landing in the gaps
// between the glyphs; a change anywhere that moves the ring by three pixels moves
// the probes onto the overlap and the run goes red for something nothing in the
// change touched. Twice this round, from a stylesheet reset and from a sidebar
// panel getting shorter.
//
// So both twelve-handed shapes are notes at this text size rather than failures,
// for the same stated reason. Not an exemption for the felt in general: at 16px the
// same table is held to the same standard as everything else, and the two passes
// above it are unchanged.
const CROWDED_BEYOND_HELP = ["all in", "packed"];
const LARGE_TEXT_PX = 20;
const PHONES = VIEWPORTS.filter(([w, h]) => Math.min(w, h) <= 430);
const PASSES = [
{ hand: "mouse", touch: false, viewports: VIEWPORTS },
{ hand: "touch", touch: true, viewports: VIEWPORTS },
{ hand: "large text", touch: true, viewports: PHONES, rootPx: LARGE_TEXT_PX, soft: CROWDED_BEYOND_HELP },
];
for (const { hand, touch, viewports, rootPx, soft } of PASSES) {
const context = await browser.newContext(touch
? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 }
: {});
// Before any of the page's own scripts, so its listener is registered first and
// the size is in place before main.js measures anything. A stylesheet rather
// than an inline style because that is what a browser font-size setting is: the
// root's used size, from first paint, not something applied afterwards.
if (rootPx) {
await context.addInitScript((px) => {
document.addEventListener("DOMContentLoaded", () => {
const style = document.createElement("style");
style.textContent = `html { font-size: ${px}px }`;
document.head.appendChild(style);
}, { once: true });
}, rootPx);
}
const page = await context.newPage();
const hand = touch ? "touch" : "mouse";
for (const [width, height, rawLabel] of VIEWPORTS) {
for (const [width, height, rawLabel] of viewports) {
await page.setViewportSize({ width, height });
for (const [shape, seats, players, board, shoves] of TABLES) {
const label = `${rawLabel} · ${shape} · ${hand}`;
// Measured either way; where the shape has no answer at this text size,
// said in the notes rather than counted against the run.
const record = (where, offenders) => {
if (!offenders.length) return;
if (soft && soft.includes(shape)) warnings.push(`${where}: ${offenders.join("; ")}`);
else failures.push({ where, offenders });
};
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable, [seats, players, board]);
await page.goto(`${BASE}/Casino/TexasHoldem`);
@@ -201,7 +284,7 @@ async function main() {
const table = await page.evaluate(() => LayoutAudit.runAll());
checks++;
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
record(`table · ${label} ${width}x${height}`, table.failed.flatMap(f => f.offenders));
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
// The raise pad, which is part of the bar rather than an overlay and so
@@ -224,12 +307,8 @@ async function main() {
const pad = await page.evaluate(() => LayoutAudit.runAll({
occlusionSelector: "#actionBar button, #actionBar input",
}));
if (!pad.clean) {
failures.push({
where: `raise pad · ${label} ${width}x${height}`,
offenders: pad.failed.flatMap(f => f.offenders),
});
}
record(`raise pad · ${label} ${width}x${height}`,
pad.failed.flatMap(f => f.offenders));
await page.evaluate(() => {
const cancel = [...document.querySelectorAll("#actionBar button")]
.find(b => /^Cancel/.test(b.textContent.trim()));
@@ -243,34 +322,67 @@ async function main() {
// spent already.
if (shape !== TABLES[0][0]) continue;
// The whole audit, not the one check.
//
// This ran `occlusion()` and nothing else — one rule of twenty-five — so
// for the sweep's entire life the five sheets a dealer spends half the
// night in had never been measured for touch targets, contrast, control
// names, text spill or anything else. The three rules that made that a
// reasonable thing to do are modal-aware now: two of them ask about the
// fixed bar, which is not a question while a scrim is covering it, and
// the third called every card below the fold of a sheet that scrolls an
// unreachable button.
for (const [name, open] of OVERLAYS) {
await page.evaluate(open);
await page.waitForTimeout(180);
checks++;
const result = await page.evaluate(() => {
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
if (!overlay) return { offenders: [] };
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
const sheet = LayoutAudit.runAll({
occlusionSelector: overlay
? `#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`
: undefined,
});
return { offenders: sheet.failed.flatMap(f => f.offenders), warnings: sheet.warnings };
});
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
record(`${name} · ${label} ${width}x${height}`, result.offenders);
result.warnings.forEach(w => warnings.push(`${name} · ${label}: ${w}`));
}
}
}
for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
for (const [width, height, rawLabel] of viewports.filter((_, i) => i % 4 === 0)) {
const label = `${rawLabel} · ${hand}`;
await page.setViewportSize({ width, height });
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
await page.evaluate(() => {
await page.addScriptTag({ path: AUDIT });
// Both halves of the screen. The sweep used to switch straight to the all-in
// mode and audit only that, so the mode the screen opens in — the common one,
// a pot and a number of ways — was never looked at once, which is how a bar
// with 252px of nothing in it lasted this long. The ways stepper only exists
// past five, so the even pass opens it before measuring.
const modes = [
["even", () => {
const beyond = [...document.querySelectorAll("#waysChoices .choiceChip")]
.find(b => /\+$/.test(b.textContent.trim()));
if (beyond) beyond.click();
}],
["all-in", () => {
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
if (button) button.click();
});
await page.addScriptTag({ path: AUDIT });
}],
];
for (const [mode, arrange] of modes) {
await page.evaluate(arrange);
await page.waitForTimeout(120);
checks++;
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
result.warnings.forEach(w => warnings.push(`quick split · ${label}: ${w}`));
if (!result.clean) failures.push({ where: `quick split · ${mode} · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
result.warnings.forEach(w => warnings.push(`quick split · ${mode} · ${label}: ${w}`));
}
}
await context.close();
@@ -278,7 +390,8 @@ async function main() {
await browser.close();
console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`);
console.log(`${checks} audits across ${VIEWPORTS.length} viewports — with a mouse, with a thumb, ` +
`and with a thumb at ${LARGE_TEXT_PX}px of root text on ${PHONES.length} phones`);
warnings.forEach(w => console.log(` note: ${w}`));
if (failures.length === 0) {
console.log("clean");
+30
View File
@@ -204,6 +204,36 @@ describe("the blind badge counts", () => {
"the same distance at both ends, or one arc is charged for the other's badge");
});
test("and the dealer chip shares that edge on exactly the same terms", () => {
// It moved out of the puck's top-right corner and onto the rim beside the badges,
// which is only free while it hangs off by the same 5px they do. A pixel more and
// the ring is charging every chair for a distance it isn't measuring.
const top = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
const bottom = CSS.match(/\.seat\.seatBelowCentre \.dealerChip \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
assert.ok(top && bottom, "the chip should hang off both arcs, like the badge");
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX);
});
test("and it stays inside the puck's own width, where the badges are", () => {
// Sideways is the direction that lands on the seat next door. This chip has been
// there: at -10px it took the centre of seat 1's taps and gave the button to seat
// 9. `right` is a positive inset for the same reason `left: 2px` is.
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(-?[\d.]+)px/);
assert.ok(inset, "the chip should be inset from the right edge");
assert.ok(Number(inset[1]) >= 0, "a negative inset hangs it over the neighbour");
});
test("and it is short enough to clear the name it sits above", () => {
// The tight ring is the close one: 1px of border, 3px of padding and 4px of
// margin put the name's top 8px below the puck's, and the chip reaches
// height - overhang below it.
const height = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])height:\s*([\d.]+)px/);
assert.ok(height, "the chip should have a height to check");
assert.ok(Number(height[1]) - Render.SEAT_BADGE_OVERHANG_PX <= 8,
"the chip dips into the name row on the tight ring");
});
test("and it is counted once, not once per seat", () => {
// seatsCollide is given one box and compares centres against it, so the
// separation it asks for is one puck plus one badge. That is exactly right for
+268
View File
@@ -0,0 +1,268 @@
// What the long-press menu offers, and in what shape.
//
// It had grown to eleven actions drawn as eleven identical full-width bars. Measured
// on the fullest menu the tool can build — a live seat that isn't the dealer, blinds
// still the only money in, an override already set — the sheet came to 796px and took
// 85% of every viewport in the sweep. It scrolled on all four, and Cancel was off the
// bottom of the screen on all four: 79px past it on a 390px phone, 252px on a 360px
// one, 448px in landscape. Apple puts the comfortable number for an action sheet at
// three to five and says not to let one scroll at all.
//
// Nothing was cut — every one of the eleven is the only route to something, and
// "Blinds follow the button again" is the only route in the whole tool to clearing an
// override. They were sorted by *when* instead: what you tap with cards in the air
// keeps the full-width bars, and the housekeeping goes to a two-column grid under a
// caption. Same fullest menu afterwards: 650px, 77%, no scroll, and the way out is in
// a header that can't scroll away.
//
// Two of these are answered by running the code — which actions a seat is offered,
// and which group each lands in. The rest are layout decisions, checked as text
// against the markup and the stylesheet.
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, SeatMenu, 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 sheetMarkup = () => {
const at = PAGE.indexOf(`<div id="seatMenuOverlay"`);
assert.ok(at >= 0, "there is no seat menu overlay");
return PAGE.slice(at, PAGE.indexOf("</div>\n\n", at));
};
// The rule block for a selector, so a test asks about one rule rather than about
// whether a string happens to appear anywhere in 6000 lines.
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// The fullest menu the tool can build, aimed at seat 3.
function fullestMenu() {
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 500, dealerSeatIndex: 0 });
Betting.beginHand(state);
state.blindOverride = { sbIndex: 1, bbIndex: 2 };
state.waitingList = [{ id: "w1", name: "Nina" }];
SeatMenu.state = state;
SeatMenu.hooks = {};
return { state, actions: SeatMenu.actionsFor(state.seats[3], 3) };
}
const labels = actions => actions.map(a => a.label);
const inGroup = (actions, group) => labels(actions.filter(a => a.group === group));
// ---------------------------------------------------------------------------
describe("everything the menu used to offer, it still offers", () => {
test("all eleven are there on the fullest menu", () => {
const { actions } = fullestMenu();
assert.deepEqual(labels(actions).sort(), [
"Add chips…",
"All-in",
"Big blind",
"Blinds follow the button again",
"Cash out…",
"Edit seat…",
"Fold",
"Move to…",
"Sit out",
"Small blind",
"The button",
]);
});
// The three that lost words lost them to the caption above them, not to brevity
// for its own sake: "Give this seat" is what makes "Small blind" a verb, and a
// verb that short is what fits a half-width tile on one line.
test("the three that were shortened are the three the caption speaks for", () => {
const { actions } = fullestMenu();
assert.deepEqual(inGroup(actions, "position"), [
"The button", "Small blind", "Big blind", "Blinds follow the button again",
]);
});
// This menu is the only place in the tool that offers it. If it ever stops being
// offered here, a dealer who names a blind by hand can never take it back.
test("the only way out of a blind override is still here", () => {
const { actions } = fullestMenu();
assert.ok(labels(actions).includes("Blinds follow the button again"));
});
});
describe("a group is when you do it, not what it touches", () => {
test("the hand keeps only what a dealer reaches for with cards on the table", () => {
const { actions } = fullestMenu();
assert.deepEqual(inGroup(actions, "hand"), ["Fold", "All-in"]);
});
test("a folded player's one move is in the hand too", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
Betting.beginHand(state);
state.seats[2].folded = true;
SeatMenu.state = state;
SeatMenu.hooks = {};
const actions = SeatMenu.actionsFor(state.seats[2], 2);
assert.deepEqual(inGroup(actions, "hand"), ["Un-fold"]);
});
// Seating the front of the queue is not housekeeping. A chair opens between two
// hands with people watching it, and the dealer fills it then or not at all.
test("seating the next player off the rail is a hand action", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
// makeTable names every chair; an open one has nobody on it.
state.seats[4].name = "";
state.waitingList = [{ id: "w1", name: "Nina" }];
SeatMenu.state = state;
SeatMenu.hooks = {};
const actions = SeatMenu.actionsFor(state.seats[4], 4);
assert.deepEqual(inGroup(actions, "hand"), ["Seat Nina"]);
});
test("every action lands in a group the sheet knows how to draw", () => {
const { actions } = fullestMenu();
const known = ["hand", ...SeatMenu.GROUPS.map(g => g.name)];
actions.forEach(action => {
assert.ok(known.includes(action.group),
`"${action.label}" is in group "${action.group}", which nothing draws`);
});
});
// A caption for nothing is worse than no caption, and this group empties often:
// past the blinds there is nothing to give a seat but the button.
test("a group that has nothing to say is empty rather than thin", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 });
Betting.beginHand(state);
Betting.call(state, 3);
SeatMenu.state = state;
SeatMenu.hooks = {};
const actions = SeatMenu.actionsFor(state.seats[3], 3);
assert.deepEqual(inGroup(actions, "position"), ["The button"]);
});
});
describe("the one action here that is not about this seat", () => {
// It puts the whole table's blinds back on automatic. Spanning the grid is what
// stops it reading as a fourth position to give.
test("the override reset spans the grid and nothing else does", () => {
const { actions } = fullestMenu();
assert.deepEqual(labels(actions.filter(a => a.wide)),
["Blinds follow the button again"]);
});
test("and it is drawn lighter than the Cancel underneath it", () => {
const rule = ruleFor(".seatMenuBtn.menuTileWide");
assert.match(rule, /grid-column:\s*1 \/ -1/);
assert.match(rule, /border-style:\s*dashed/);
// Lighter, not smaller. A thumb does not care how rare an action is, so it
// keeps the 44px every other tile has rather than shrinking out of reach.
assert.ok(!/min-height/.test(rule),
"the wide row sets its own height, which takes it under the touch floor");
});
});
describe("the sheet a dealer reads it in", () => {
test("the header is stuck to the top, so whose menu this is cannot scroll away", () => {
const rule = ruleFor(".seatMenuHeader");
assert.match(rule, /position:\s*sticky/);
assert.match(rule, /top:\s*0/);
// A sticky header inside a padded scroller shows the list sliding through the
// gap either side of it unless it reaches the sheet's own edges.
assert.match(rule, /margin:\s*0 calc\(var\(--sp-4\) \* -1\)/);
assert.match(rule, /background:/);
});
test("and the sheet keeps no padding above it to slide through", () => {
assert.match(ruleFor("#seatMenuSheet"), /padding:\s*0 var\(--sp-4\)/);
});
// NN/g is explicit that a grab handle is not a close affordance. This sheet is the
// one that can outgrow the screen, so it is also the one that most needs a way out
// in the part that cannot leave.
test("there is a real close button, not just a grip", () => {
const markup = sheetMarkup();
assert.match(markup, /id="seatMenuCloseBtn"/);
assert.match(markup, /aria-label="Close this menu"/);
});
// Modal focuses a dialog's first control, and the first control used to be Fold.
// The APG says to keep initial focus off the destructive choice; a close button at
// the top of the markup is what puts it there without a special case.
test("the first control in the sheet is the one that does nothing", () => {
const markup = sheetMarkup();
assert.ok(markup.indexOf("seatMenuCloseBtn") < markup.indexOf("seatMenuActions"),
"the actions come before the close button, so focus opens on Fold again");
});
// Escape finds the first [data-modal-close] in the overlay. That has to be the one
// that shuts the sheet, not the one at the foot that steps back inside it.
test("Escape closes the sheet rather than stepping back inside it", () => {
const markup = sheetMarkup();
const closer = markup.indexOf("data-modal-close");
assert.ok(closer >= 0 && closer < markup.indexOf("seatMenuActions"),
"Escape does not reach a closer above the action list");
assert.ok(!/id="seatMenuCancelBtn"[^>]*data-modal-close/.test(markup),
"the button at the foot still answers Escape, so Escape only goes back");
});
});
describe("two columns, and what they are for", () => {
test("the housekeeping half is a grid", () => {
assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
});
// Half the width and a step quieter, but not below the floor a fingertip needs.
test("a tile is quieter than a bar without being smaller than a thumb", () => {
const rule = ruleFor(".seatMenuBtn.menuTile");
assert.match(rule, /min-height:\s*44px/);
assert.match(rule, /font-size:\s*var\(--fs-base\)/);
});
test("a bar is still a bar", () => {
assert.match(ruleFor(".seatMenuBtn"), /min-height:\s*52px/);
});
// The dashed row and the Cancel under it are both full width. Two rows of the same
// size touching, one of which changes the table and one of which changes nothing,
// is a mistake waiting at the end of a scroll.
test("the last action and the way out are not two rows touching", () => {
assert.match(ruleFor("#seatMenuCancelBtn"), /margin-top:\s*var\(--sp-3\)/);
});
});
describe("the move picker is no longer a one-way door", () => {
const MAIN = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
// Opening it used to replace the whole sheet, and the only control left closed the
// menu outright — a dealer who opened the seat list by mistake lost their place.
test("the button at the foot goes back while there is an inside to go back to", () => {
const wiring = MAIN.slice(MAIN.indexOf('getElementById("seatMenuCancelBtn")'));
const body = wiring.slice(0, wiring.indexOf("});"));
assert.match(body, /SeatMenu\.mode === "move"/);
assert.match(body, /SeatMenu\.closeMovePicker\(\)/);
assert.match(body, /SeatMenu\.close\(\)/);
});
test("and the menu knows how to come back", () => {
assert.equal(typeof SeatMenu.closeMovePicker, "function");
SeatMenu.mode = "move";
SeatMenu.state = { seats: [] };
SeatMenu.seatIndex = 0;
// render() needs a document, so the mode change is checked where it is made.
assert.match(SeatMenu.closeMovePicker.toString(), /this\.mode = null/);
});
test("every other chair is a destination, including the empty ones", () => {
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
SeatMenu.state = state;
const seats = SeatMenu.destinationsFor(2).map(d => d.seat);
assert.deepEqual(seats, [0, 1, 3, 4, 5]);
});
});
+298 -66
View File
@@ -1,22 +1,28 @@
// What the ring is allowed to be sized from.
//
// Two rows in every seat puck are usually empty — the hole-card line, and the pill that
// shows what a player has put in. Both used to hold their space anyway, and the pill,
// which has a background and a pill radius and side padding, painted an 8x11px green
// lozenge when it had nothing to say: measured on seven of nine seats at an ordinary
// table, a coloured mark on almost every seat standing for nothing.
// resolveSeatLayout buys its answer with ten forced layouts — ten candidate widths, each
// applied to the felt and read back — and caches it against this signature. So the
// signature has to move whenever a puck's height can, and must not move otherwise: one
// that changed on every action would throw the cache away on every tap, which is the
// whole cost it exists to avoid.
//
// The space was not free either. seatRingRadii sizes the ring from the tallest puck on
// it, so two rows nobody had filled were setting how far apart the seats had to sit. On
// an iPhone 14 nine-handed the puck measured 78x88 with 25px of that reserved for
// nothing; collapsed, the width search settles on 78x63. Twelve-handed on a Galaxy S8
// the seats overlapped by 1556px² and now overlap by 87px².
// It used to name four things: every player's name, the dealer's seat, whether anybody
// had hole cards, and whether anybody had chips out. Three of those flip during an
// ordinary hand. A dealer calling a bet paid ten forced layouts for it, and so did the
// next street closing, and so did editing a name.
//
// The collapse is only safe if the layout re-measures when one of those rows fills,
// because resolveSeatLayout caches its ten forced layouts against this signature. That
// is what is checked here: the cache key has to move when the puck's shape can, and
// stay still when it cannot — a signature that changed on every action would throw the
// cache away on every tap, which is the cost it exists to avoid.
// None of them can change a puck's height any more. The stylesheet holds three rows open
// — name, amount, stack — at a height that does not depend on what is written in them:
// the name gets one line whatever it says, the amount pill keeps its row when it is
// empty, and the dealer's chip changes a padding rather than a line count. What is left
// is the hole-card row, which still collapses, because whether a table tracks hole cards
// is something it decides once rather than something that changes under a thumb.
//
// The other half of that bargain is in renderSeats: it measures the finished pucks
// against the box the search settled on and asks again if they disagree. That is what
// makes a wrong assumption here a stale frame rather than a stale session — and it is
// what catches the first render of all, where the pucks are built empty and the search
// that used to run against them was measuring nine blank boxes.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
@@ -32,37 +38,39 @@ const signatureOf = state => {
const table = () => makeTable({ seatCount: 6, stack: 200 });
describe("the signature the ring layout is cached against", () => {
test("moves when the first chips go in", () => {
// Before the blinds every pill is empty and the pucks are short; after them two
// seats are ~15px taller. A ring still sized from the first measurement is sized
// for a puck that no longer exists.
const state = table();
const empty = signatureOf(state);
state.seats[1].committed = 10;
assert.notEqual(signatureOf(state), empty,
"the ring would still be sized for pucks with no amount on them");
});
test("and when a hole card is entered", () => {
test("moves when a hole card is entered", () => {
// The one row still allowed to collapse, and the one thing still worth
// re-measuring for: a puck with cards on it is about 15px taller.
const state = table();
const none = signatureOf(state);
state.seats[0].cards = ["AS", "KC"];
assert.notEqual(signatureOf(state), none);
});
test("but not again for the second player to put chips in", () => {
// The whole point of a boolean over the table. The ring is sized from the
// tallest puck on it, so once anybody has an amount the answer cannot change —
// and re-measuring here would mean ten forced layouts on every single action.
test("but not again for the second player to be dealt one", () => {
// The point of a boolean over the table. The ring is sized from the tallest puck
// on it, so once anybody has cards the answer cannot change.
const state = table();
state.seats[1].committed = 10;
state.seats[0].cards = ["AS", "KC"];
const first = signatureOf(state);
state.seats[2].committed = 10;
assert.equal(signatureOf(state), first);
state.seats[3].committed = 250;
state.seats[1].cards = ["2H", "7D"];
assert.equal(signatureOf(state), first);
});
test("and it stands still while the chips move", () => {
// The expensive one. Somebody has money out for most of every hand and which
// seats it is changes on every action, so this used to be a ten-layout re-search
// twice a hand — and the pucks it was re-measuring for are the same height now
// whether there is an amount in them or not.
const state = table();
const quiet = signatureOf(state);
state.seats[1].committed = 10;
assert.equal(signatureOf(state), quiet,
"the amount pill holds its row whether or not it has an amount in it");
state.seats[2].committed = 250;
assert.equal(signatureOf(state), quiet);
});
test("nor when an amount merely changes size", () => {
const state = table();
state.seats[1].committed = 10;
@@ -72,54 +80,278 @@ describe("the signature the ring layout is cached against", () => {
"a wider number is the amount fitter's problem, not the ring's");
});
test("and it still moves for the things it always moved for", () => {
// Renaming can wrap a name onto a second line, and the button's chip floats
// into the name's first line, so both change how tall a puck measures.
test("and it stands still for a rename", () => {
// Renaming used to be able to wrap a name onto a second line. A name gets one
// line now, and one that does not fit drops a word or steps its type down — see
// fitSeatNames. Neither moves the row it sits in.
const state = table();
const base = signatureOf(state);
state.seats[0].name = "Bartholomew Fairweather";
const renamed = signatureOf(state);
assert.notEqual(renamed, base);
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
assert.notEqual(signatureOf(state), renamed);
assert.equal(signatureOf(state), base);
});
test("and two tables that differ only in where a name is split stay different", () => {
// The names are joined on a control character no name can contain. Joined on
// nothing, "ann" + "abel" and "anna" + "bel" are both "annabel" and one of those
// tables would be laid out with the other's measurements — different names, so
// different wrapping, so different puck heights.
//
// The first version of this used "Ann"/"Abel" against "Anna"/"Bel", which can
// never collide: the capital letters land in different places, so it passed with
// the separator taken out and proved nothing at all. Lower case is what makes it
// a real test.
const a = table();
const b = table();
a.seats[0].name = "ann"; a.seats[1].name = "abel";
b.seats[0].name = "anna"; b.seats[1].name = "bel";
assert.notEqual(signatureOf(a), signatureOf(b));
test("and for the button moving round the table", () => {
// The chip in the puck's corner takes 22px off the name's line. That is a
// narrower name, not a taller puck, so the ring it sits on is the same ring.
const state = table();
const base = signatureOf(state);
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
assert.equal(signatureOf(state), base);
});
});
describe("a table nobody has sat down at", () => {
test("has no amounts and no cards on it", () => {
test("carries no flag at all", () => {
// The empty-table case is the one the collapse is most visible on, and it is
// also the one where a stray default could quietly keep both rows open forever.
// also the one where a stray default could quietly keep the row open forever.
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9);
assert.match(signatureOf(state), /\|$/,
"an untouched table should carry neither flag");
assert.equal(signatureOf(state), "",
"an untouched table should carry nothing");
});
test("but a table with an ante on it does have an amount", () => {
// computeContribution answers for a seat that has never been given a stack —
// the table's base pot stands in for it — so a table dealt with "everyone antes
// 5" has amounts on its pucks before anybody acts, and the ring has to know.
test("and neither does one with an ante on it", () => {
// computeContribution answers for a seat that has never been given a stack — the
// table's base pot stands in for it — so a table dealt with "everyone antes 5"
// has amounts on its pucks before anybody acts. That used to have to be in here.
// The pucks are the same height with it as without, so it does not any more.
const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 6);
state.basePotAmount = 5;
state.seats.forEach(p => { p.inHand = true; });
assert.match(signatureOf(state), /a$/);
assert.equal(signatureOf(state), "");
});
});
// ---------------------------------------------------------------------------
// The other half: the stylesheet has to actually hold those rows open, or the signature
// above is a promise about a puck that doesn't exist.
//
// Read as text, the way the seat sheet's rules and the reduced-motion rules are. What
// height a row is, is a decision that lives in the file — the browser is where it is
// proved, and runUiAudit.js is what proves it there.
const fs = require("fs");
const path = require("path");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
const ruleFor = selector => {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const found = CSS.match(new RegExp(`(^|[},])\\s*${escaped}\\s*\\{([^}]*)\\}`, "m"));
return found ? found[2] : null;
};
describe("every puck on the ring is the same puck", () => {
test("each of the three rows is a row high whether or not it says anything", () => {
// A blank label had no height at all, so the one puck with nobody in it was the
// one puck a different size from the rest — and a seat that had put chips in was
// a different size again from one that hadn't.
["name", "total", "stack"].forEach(row => {
const selector = `.seatPuck${row[0].toUpperCase()}${row.slice(1)}`;
const body = ruleFor(selector) || "";
assert.match(body, /min-height:\s*var\(--seatRow(Name|Money)\)/,
`${selector} does not hold its row open`);
});
});
test("and the row heights are tokens, not ems", () => {
// fitAmount steps a tournament stack's type down so every digit stays on the
// puck, and .puckNameTight does the same to a name with no word to drop. In ems
// each of those would have taken its row down with it, and the puck with it.
["--seatRowName", "--seatRowMoney"].forEach(token =>
assert.match(CSS, new RegExp(`\\${token}:\\s*\\d+px`), `${token} is not a fixed height`));
});
test("the amount pill hides its paint rather than giving up its place", () => {
// Collapsing it is what made a puck grow 17px when its player called and shrink
// again when the street closed. Painting an empty pill is what collapsing it was
// introduced to stop — an 8x11px green lozenge on seven of nine seats, standing
// for nothing. Neither, now: the row stays and the pill is not drawn.
assert.match(ruleFor(".seatPuckTotal:empty") || "", /visibility:\s*hidden/);
assert.doesNotMatch(ruleFor(".seatPuckTotal:empty") || "", /display:\s*none/);
});
test("but the hole-card row still collapses, because a table decides that once", () => {
// Holding 15px open on nine seats all night for a row most tables never fill is
// the crowding this is meant to avoid, and it is the one thing left that the
// signature above still watches for.
assert.match(ruleFor(".seatPuckCards:empty") || "", /display:\s*none/);
});
test("an empty pill sits on its top edge, not on a baseline it hasn't got", () => {
// An inline-block sits on its last line's baseline, and an empty one has no last
// line — so it sits on its bottom edge instead and the line box comes out 2px
// shorter than the same pill with a figure in it. That put the seats with money
// out 2px below everyone else: the one difference this row exists to remove,
// reappearing as its own mirror image.
assert.match(ruleFor(".seatPuckTotal") || "", /vertical-align:\s*top/);
});
});
// ---------------------------------------------------------------------------
describe("and the ring checks its own measurement afterwards", () => {
// The signature above is an assumption about what can change a puck. This is what
// happens when the assumption is wrong, and it is also what happens on the first
// render of all: buildSeatNodes puts nine empty pucks on the felt and the search runs
// before anything is written into them, so it was measuring nine blank boxes and
// caching the answer under a key naming a table it had never seen.
//
// That used to be permanent. The comment where it was cached said "let the resize
// that follows do this properly" — and nothing resizes a phone. A table opened
// upright kept a ring measured for empty seats until the dealer turned the phone
// sideways and back, which is the only thing that ever changed the key.
const withBox = (box, run) => {
const before = Render.measuredSeatBox;
Render.measuredSeatBox = () => box;
try { return run(); } finally { Render.measuredSeatBox = before; }
};
test("a layout that measured nothing is always asked again", () => {
// No box on the answer means the search never had a puck in front of it.
assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(null)), true);
assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(undefined)), true);
});
test("one that measured the puck that is now on the felt is not", () => {
assert.equal(
withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
false);
});
test("and a pixel of difference is a rounding artefact, not a table that moved", () => {
// The two measurements are taken at different moments. Without the slack this
// would ask the search to run again on every render for the rest of the session,
// which is ten forced layouts a tap — the exact cost the cache exists to avoid.
assert.equal(
withBox({ width: 72.4, height: 56.4 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
false);
assert.equal(
withBox({ width: 72, height: 58 }, () => Render.seatBoxMoved({ width: 72, height: 56 })),
true, "two pixels taller is a puck that has changed shape");
});
test("nothing on the felt to measure is not a reason to ask again", () => {
// Between a table being resized and the new pucks being built there is a moment
// with no seats at all. Asking again there would clear a good answer and replace
// it with one measured from nothing.
assert.equal(withBox(null, () => Render.seatBoxMoved({ width: 72, height: 56 })), false);
});
});
// ---------------------------------------------------------------------------
describe("how much room a figure has on the puck it is written on", () => {
// The two steps of type an amount comes down used to be chosen by counting
// characters: eight for the first, ten for the second. That is a guess at how wide a
// figure is, and it happens to hold at one text size and one puck width.
//
// Turn a phone's text up — 20px of root text is what an Android slider gives you at
// 125%, and every size in the stylesheet is in `rem` on purpose — and it stops
// holding. Measured at 360x640 twelve-handed: the puck is at its 44px floor, which
// is 34px of usable width, and "$1,200" is six characters, took no step at all, and
// wanted 38. The sweep caught it as a stack figure painted over the seat next door
// at every phone in the list, and as "ALL IN" doing the same on the all-in table.
//
// So it is measured. A cell is a stand-in for the real one: what it wants against
// what it has, and a class list to write the answer into.
const cell = (figure, wants, has) => ({
dataset: { figure },
textContent: figure,
classes: new Set(),
classList: {
add(name) { this.owner.classes.add(name); },
remove(name) { this.owner.classes.delete(name); },
},
// What the text wants is proportional to how many characters are in it and how
// far down the ladder it has come — which is what a browser would tell us.
get scrollWidth() {
const step = this.classes.has("puckAmountHuge") ? 0.7
: this.classes.has("puckAmountLong") ? 0.85 : 1;
return Math.round((wants * this.textContent.length / figure.length) * step);
},
clientWidth: has,
});
const make = (...args) => {
const c = cell(...args);
c.classList.owner = c;
return c;
};
const fit = cells => {
const before = Render.overflowing;
Render.overflowing = el => el.scrollWidth > el.clientWidth + 1;
try { Render.fitAmounts(cells); } finally { Render.overflowing = before; }
};
test("a figure that fits is left at the size it is meant to be read at", () => {
const c = make("$600", 30, 34);
fit([c]);
assert.equal(c.textContent, "$600");
assert.deepEqual([...c.classes], []);
});
test("one that doesn't gives up its separators first", () => {
// Grouping is a reading aid for a column of figures. This is one number on a
// 44px puck, and a step of type costs more than the commas do.
const c = make("$1,200", 38, 34);
fit([c]);
assert.equal(c.textContent, "$1200");
assert.deepEqual([...c.classes], [], "no type step needed once the commas are out");
});
test("then a step of type, and then another", () => {
const c = make("$1,234,567", 90, 34);
fit([c]);
assert.equal(c.textContent, "$1234567");
assert.deepEqual([...c.classes], ["puckAmountHuge"]);
});
test("and only ever one step at a time is worn", () => {
// The two rules have the same specificity, so a puck wearing both would be sized
// by whichever the stylesheet happens to mention last.
const c = make("$1,234,567", 45, 34);
fit([c]);
assert.equal(c.classes.has("puckAmountHuge") && c.classes.has("puckAmountLong"), false);
});
test("a figure with no separators to give goes straight to the type", () => {
// "ALL IN" is the one thing on this row that isn't a number, and it is the widest
// — six characters and a space, at 700 weight with its letters spread apart.
const c = make("ALL IN", 39, 34);
fit([c]);
assert.equal(c.textContent, "ALL IN");
assert.deepEqual([...c.classes], ["puckAmountLong"]);
});
test("a puck that went small can come back", () => {
// Everything is measured from the figure and the size it is meant to be read at,
// not from whatever the last render left behind. Otherwise this is a ratchet: one
// crowded frame and the seat is small for the rest of the night.
const c = make("$600", 30, 34);
c.classes.add("puckAmountHuge");
c.textContent = "$600";
fit([c]);
assert.deepEqual([...c.classes], []);
});
test("and the grouped figure is what it comes back to", () => {
const c = make("$1,200", 38, 34);
fit([c]);
assert.equal(c.textContent, "$1200");
c.clientWidth = 96;
fit([c]);
assert.equal(c.textContent, "$1,200", "a wider puck should get its separators back");
});
test("an empty row is not an amount", () => {
// The pill keeps its place on a seat with nothing in the pot — that is what makes
// every puck on the ring the same puck — and there is nothing there to fit.
const c = make("", 0, 34);
assert.doesNotThrow(() => fit([c]));
assert.deepEqual([...c.classes], []);
});
});
+2 -2
View File
@@ -41,9 +41,9 @@ describe("what the sheet puts in front of a dealer at rest", () => {
// <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", () => {
test("the flat part is the name, what the seat is doing and the stack, and nothing else", () => {
const flat = alwaysVisible();
["seatNameInput", "inHandCheckbox", "foldCheckbox", "stackInput", "seatBuyIns"]
["seatNameInput", "seatState", "seatStateOption", "stackInput", "seatBuyIns"]
.forEach(cls => assert.ok(flat.includes(cls),
`${cls} is what a dealer opens a seat for — it cannot be behind a disclosure`));
+209
View File
@@ -0,0 +1,209 @@
// The five cards in the middle of the felt, and the sheet of glass over them.
//
// Two faults, one on top of the other, and the sweep could see neither.
//
// #seatsLayer is a transparent `inset: 0` sibling of the table that holds the seat
// pucks. The pucks ring the felt; the middle of it is empty layer — and an element with
// no background still hit-tests. So every tap aimed at the community cards landed on the
// layer instead. Measured at 1280x900 with the board on and five cards out: all five
// hit-tested to `div#seatsLayer`, on every screen wide enough to keep the board in the
// middle rather than on the strip. Each one is a button that opens the picker for that
// card, and not one of them could be pressed. #betsLayer, the other sheet over the felt,
// has been `pointer-events: none` since it was written.
//
// Underneath that, the cards were a little over half the size they were meant to be.
// `placeBoard` measures how much middle the felt has and writes it to the centre's
// `max-width` — but a max-width caps a box, it does not claim anything, and the centre
// is a flex column that sizes to its content. So with 463px of measured room it went on
// being 136px wide, the board row inside it is `width: 100%` of that, and five cards
// sharing 136px come to rest on their own `min-width`: 24px, against a 44px floor this
// sweep holds every other control on the felt to. On the strip, where the row simply
// gets the width of the page, the same cards have always drawn 44.
//
// Neither was visible to a scripted probe — `.click()` on a node skips hit-testing
// entirely — and the sweep's occlusion check had only ever been shown three selectors'
// worth of the page: the action bar, the header, and the seats.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
const src = name => fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// ---------------------------------------------------------------------------
describe("the sheet over the felt", () => {
test("it does not take taps meant for what is under it", () => {
assert.match(ruleFor("#seatsLayer"), /pointer-events:\s*none/);
});
// Which is only safe because the thing in it that *is* a control says so itself.
test("but a seat still does", () => {
assert.match(ruleFor(".seat"), /pointer-events:\s*auto/);
});
// The two chips that want to stay untouchable set their own value and are inside a
// seat, so they override what they inherit rather than depending on the layer.
test("and the two pucks that are markers rather than controls still are", () => {
assert.match(ruleFor(".dealerChip:not(.active)"), /pointer-events:\s*none/);
assert.match(ruleFor(".dealerChip.active"), /pointer-events:\s*none/);
});
test("the other sheet is unchanged, and was always right", () => {
assert.match(ruleFor("#betsLayer"), /pointer-events:\s*none/);
});
test("what it measured is written beside the rule", () => {
const at = CSS.indexOf("/* A transparent sheet over the whole felt");
assert.ok(at >= 0, "the rule carries no note saying why");
const note = CSS.slice(at, CSS.indexOf("*/", at));
assert.match(note, /1280x900/);
assert.match(note, /seatsLayer/);
});
});
describe("how wide the middle actually is", () => {
test("the room it measured is claimed, not only capped", () => {
const render = src("render");
const at = render.indexOf("placeBoard(row, strip) {");
assert.ok(at >= 0, "there is no placeBoard");
const body = render.slice(at, render.indexOf("\n },", at));
assert.match(body, /centre\.style\.width = `\$\{Math\.round\(width\)\}px`/,
"the centre is still given a ceiling and no floor, so it shrink-wraps");
assert.match(body, /centre\.style\.maxWidth = `\$\{Math\.round\(width\)\}px`/);
});
// Both come off together when the board leaves the felt, or the centre would keep a
// width measured for a board that is no longer in it.
test("and both are given back when the board moves to the strip", () => {
const render = src("render");
const at = render.indexOf("placeBoard(row, strip) {");
const body = render.slice(at, render.indexOf("} else {", at));
assert.match(body, /centre\.style\.width = ""/);
assert.match(body, /centre\.style\.maxWidth = ""/);
});
// The floor is what it always said it was; what changed is that the cards can now
// reach past it to the ceiling.
test("a card is still allowed to grow to a thumb", () => {
const rule = ruleFor(".boardCard");
assert.match(rule, /max-width:\s*44px/);
assert.match(rule, /min-height:\s*44px/);
assert.match(rule, /flex:\s*1 1 0/);
});
test("what it measured is written beside the code", () => {
const render = src("render");
const at = render.indexOf("// Both, and the width is the half that was missing.");
assert.ok(at >= 0, "the fix carries no note saying why");
const note = render.slice(at, at + 1400);
assert.match(note, /463px/);
assert.match(note, /24px/);
});
});
describe("why the sweep could not see either of them", () => {
// Three selectors: the bar, the header and the seats. Nothing else on the page had
// ever been asked whether something was sitting on top of it.
test("the occlusion check looks at every control now, not three groups", () => {
const at = AUDIT.indexOf("occlusion(selector = null) {");
assert.ok(at >= 0, "occlusion still hard-codes its own list");
assert.match(AUDIT.slice(at, at + 200), /selector = selector \|\| this\.CONTROL_SELECTOR/);
});
// Widening it put three kinds of non-fault in front of the rule for the first time.
test("a control that opted out of the pointer is not reported as covered", () => {
const at = AUDIT.indexOf("occlusion(selector = null) {");
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
assert.match(body, /getComputedStyle\(el\)\.pointerEvents === "none"/);
});
test("nor is one inside a disclosure nobody has opened", () => {
const at = AUDIT.indexOf("occlusion(selector = null) {");
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
assert.match(body, /el\.checkVisibility/,
"a shut <details> still hands out a real rect, and a size check lets it through");
});
test("and flow content under a fixed layer is said rather than failed", () => {
const at = AUDIT.indexOf("occlusion(selector = null) {");
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
assert.match(body, /scrolls clear of it/);
});
});
describe("the rest of what the sweep had never run", () => {
// It opened five sheets and asked one question of them.
test("an overlay gets the whole audit, not the occlusion check alone", () => {
const at = DRIVER.indexOf("for (const [name, open] of OVERLAYS) {");
assert.ok(at >= 0);
const body = DRIVER.slice(at, DRIVER.indexOf("record(`${name}", at));
assert.match(body, /LayoutAudit\.runAll\(/);
assert.ok(!/return LayoutAudit\.occlusion\(/.test(body),
"the overlay pass still runs one rule");
});
// Two of the rules that made running only one of them reasonable ask about the
// fixed bar, which is not a question while a scrim is covering the page.
test("the two questions about the fixed bar stand down while a sheet is open", () => {
["focusHasRoom() {", "furnitureBudget() {"].forEach(signature => {
const at = AUDIT.indexOf(signature);
assert.ok(at >= 0, `there is no \`${signature}\``);
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
assert.match(body, /if \(this\.modalScope\(\)\) \{/,
`${signature} still reads a full-screen scrim as furniture`);
});
});
// And the third called the bottom four rows of a sheet that scrolls unreachable.
test("a dialog's buttons below the fold are only a fault if nothing scrolls to them", () => {
const at = AUDIT.indexOf("dialogActionsReachable() {");
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
assert.match(body, /if \(this\.reachableByScrolling\(button\)\) return;/);
});
// The helper all three of those lean on had a blind spot of its own: it asked
// whether the element had left the box entirely, which is true of something well
// below the fold and false of something hanging eight pixels over the edge.
test("and reachability counts a control that is only part-way out", () => {
const at = AUDIT.indexOf("reachableByScrolling(el) {");
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
assert.match(body, /const below = rect\.bottom - box\.bottom/);
assert.match(body, /const above = box\.top - rect\.top/);
// The comment above the fix quotes the old test, so the code is read on its own.
const code = body.split("\n").filter(line => !/^\s*\/\//.test(line)).join("\n");
assert.ok(!/rect\.top >= box\.bottom/.test(code),
"it still asks whether the element has left the box entirely");
});
});
describe("two controls that were a glyph and nothing else", () => {
test("the pot wizard's close is named for what leaving does", () => {
const markup = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const at = markup.indexOf('id="closePotWizardBtn"');
assert.ok(at >= 0);
assert.match(markup.slice(at, markup.indexOf(">", at)), /aria-label="Close without paying the pot"/);
});
// Named from the label the dealer typed, and kept up with it — the same shape the
// chip legend's remove button already used, in a column of identical crosses.
test("and a custom field's remove says which field", () => {
const render = src("render");
const at = render.indexOf("renderCustomFields(form, player, index) {");
const body = render.slice(at, render.indexOf("\n },", at));
assert.match(body, /removeBtn\.setAttribute\("aria-label"/);
assert.match(body, /labelInput\.value\.trim\(\)/);
assert.match(body, /nameRemove\(\);/);
});
});
@@ -0,0 +1,184 @@
// The one control in the header that is named for a hand and never starts one.
//
// "Next hand" names three different actions in this tool. The action bar's green button
// deals one. The pot wizard finishes on "Start Next Hand" and hands back a table ready
// to deal. And the header shortcut — which, between hands, deals nothing: it moves the
// button on a second time, so the seat that was about to pay the big blind is skipped
// and the next one pays it instead.
//
// Measured before this changed: settle a pot through the wizard, and the table is on
// hand 6 with the button on seat 2 and the bar offering "Post blinds & deal". Press the
// header's "Next Hand ⟳" — no confirmation, because there is nothing in the middle to
// warn about — and the table is still on hand 6, with the button on seat 3. The toast
// then opened with the same two words the wizard's button had just used, which reads as
// a confirmation of the thing that was already done rather than a report of a second
// rotation.
//
// The mechanism is right and stays: a dealer does pass the button along on a hand nobody
// played, and state.startNextHand says so in as many words. What was wrong was the name.
// A command is named for the state the system moves into — NN/g's Button States, "Show
// Tab Bar" against "Hide Tab Bar" — and between hands the state this moves into is a
// moved button, not a new hand.
//
// So the label is read off the hand: "Next Hand ⟳" while one is out, "Move Button ⟳"
// when none is. Both claims are checked here, along with the trap underneath them — the
// title is cached once from the markup for the dimmed-reason machinery to borrow and
// hand back, and a title that depends on the hand has to be written past that cache on
// every render or it freezes on whichever state happened to be true at load.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, HoldemState, Betting, makeTable } = require("./load");
// Stand-ins for the three header buttons. Enough of an element for Unavailable and for
// renderHeaderActions: attributes, a dataset, text, and a hidden flag.
const element = title => {
const attributes = title ? { title } : {};
return {
hidden: false,
textContent: "",
dataset: {},
getAttribute: name => (name in attributes ? attributes[name] : null),
setAttribute: (name, value) => { attributes[name] = String(value); },
removeAttribute: name => { delete attributes[name]; },
};
};
// The header as the page ships it, with the markup's own title on the shortcut.
const header = () => {
const buttons = {
misdealBtn: element("Take this hand back"),
nextHandBtn: element("Pass the button on again without dealing — for a hand nobody played"),
resolvePotBtn: element(""),
};
global.document = { getElementById: id => buttons[id] || null };
return buttons;
};
const renderOn = state => {
const before = Render.state;
Render.state = state;
try { Render.renderHeaderActions(); } finally { Render.state = before; }
};
const table = () => makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 5, bigBlind: 10 });
test.afterEach(() => { delete global.document; });
describe("what the shortcut calls itself", () => {
test("between hands it offers to move the button, because that is all it does", () => {
const buttons = header();
renderOn(table());
assert.equal(buttons.nextHandBtn.textContent, "Move Button ⟳");
assert.match(buttons.nextHandBtn.getAttribute("title"), /without dealing/,
"the title has to say the thing the label can't fit");
});
test("and with a hand out it offers the next one, because that is what it moves to", () => {
const buttons = header();
const state = table();
Betting.beginHand(state);
state.bettingOpen = true;
renderOn(state);
assert.equal(buttons.nextHandBtn.textContent, "Next Hand ⟳");
assert.match(buttons.nextHandBtn.getAttribute("title"), /End this hand/);
});
test("the name follows the table rather than sticking where it started", () => {
// The failure this guards is a one-way trip: the label is right on the state the
// page loaded in and wrong for the rest of the night.
const buttons = header();
const state = table();
renderOn(state);
const betweenHands = buttons.nextHandBtn.textContent;
Betting.beginHand(state);
state.bettingOpen = true;
renderOn(state);
const midHand = buttons.nextHandBtn.textContent;
HoldemState.startNextHand(state);
renderOn(state);
assert.notEqual(betweenHands, midHand, "the label never moved off the loaded state");
assert.equal(buttons.nextHandBtn.textContent, betweenHands, "and it never came back");
});
test("including the title, which the dimmed-reason cache would otherwise freeze", () => {
// renderHeaderActions fills dataset.titleWhenUsable once, from the markup, so the
// sentence hung off a dimmed control can be handed back when it is usable again.
// A state-dependent title has to be written into that slot on every render — this
// is the assertion that the write happens before the borrow, not after it.
const buttons = header();
const state = table();
renderOn(state);
const betweenHands = buttons.nextHandBtn.getAttribute("title");
Betting.beginHand(state);
state.bettingOpen = true;
renderOn(state);
assert.notEqual(buttons.nextHandBtn.getAttribute("title"), betweenHands);
assert.match(buttons.nextHandBtn.getAttribute("title"), /End this hand/);
});
test("and an empty table still gets the reason it can't be pressed", () => {
// Naming the two states must not cost the third one its sentence: with nobody
// seated there is no button to move, and that is what the title says.
const buttons = header();
// A table as the tool first opens it: chairs, and nobody named in any of them.
renderOn(HoldemState.createDefault());
assert.match(buttons.nextHandBtn.getAttribute("title"), /seat a player first/);
// And seating the table hands the state's own sentence back.
renderOn(table());
assert.match(buttons.nextHandBtn.getAttribute("title"), /without dealing/);
});
test("Misdeal still comes and goes with the hand", () => {
const buttons = header();
const state = table();
renderOn(state);
assert.equal(buttons.misdealBtn.hidden, true, "there is nothing to take back yet");
Betting.beginHand(state);
state.bettingOpen = true;
renderOn(state);
assert.equal(buttons.misdealBtn.hidden, false);
});
});
describe("what it does when there is no hand to end", () => {
test("the button moves on and the hand number doesn't", () => {
// This is the behaviour the label now names, pinned so the name stays true.
const state = table();
state.handNumber = 6;
state.dealerSeatIndex = 1;
HoldemState.startNextHand(state);
assert.equal(state.dealerSeatIndex, 2, "the button passed to the next occupied seat");
assert.equal(state.handNumber, 6, "no hand was dealt, so none was counted");
assert.equal(state.bettingOpen, false);
assert.equal(state.postedBlinds, null, "nothing is posted until the dealer says so");
});
test("so pressing it twice skips a seat's big blind", () => {
// Not a bug — a dealer passing the button along twice means it — but it is the
// reason the control cannot be called "Next Hand" while it is doing it.
const state = table();
const first = HoldemState.getBlindSeats({ ...state });
HoldemState.startNextHand(state);
const second = HoldemState.getBlindSeats(state);
HoldemState.startNextHand(state);
const third = HoldemState.getBlindSeats(state);
assert.notEqual(second.bbIndex, first.bbIndex);
assert.notEqual(third.bbIndex, second.bbIndex,
"the seat that was about to pay the big blind never did");
});
});
+129
View File
@@ -0,0 +1,129 @@
// The one mark on a puck that isn't about this hand.
//
// A seat's chair number was a grey digit in the top-left corner, and the top-left corner
// is where the blind badge drops off the rim and where a long centred name reaches.
// Measured across 348 seats on 36 tables: 117 of them had the number printed under
// something else. All 66 seats posting a blind had it covered outright — the badge is
// 22px wide over a 6px digit, at z-index 4 — and 51 more had it sitting on the first
// letter of the player's own name, the whole glyph in the worst case, on a 54px puck.
//
// There is nowhere on the felt to move it to. The button's chip owns the right-hand end
// of the top rim. Bets land on the rim between a seat and the pot, which is the bottom
// edge for the seats above centre and the top edge for the ones below. The middle of the
// puck is three rows — name, bet, stack — and all three are about this hand. So the mark
// that isn't gave way, and the two sheets that are about one seat say which seat.
//
// The sweep had never mentioned any of it, because the check that asks whether a puck
// covers itself up only knew about two of the three things a puck wears, and only ever
// tested them against writing.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { HoldemState, makeTable } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
const src = name => fs.readFileSync(
path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// ---------------------------------------------------------------------------
describe("a chair with somebody in it", () => {
test("does not draw its number on the felt at all", () => {
assert.match(ruleFor(".seat:not(.seatEmpty) .seatChairNumber"), /display:\s*none/);
});
// Where the number is the thing being looked for rather than a footnote on somebody
// else's name — which chair is open — it is still large and centred and washed.
test("and an open one still wears its number, large and in the middle", () => {
const rule = ruleFor(".seatEmpty .seatChairNumber");
assert.match(rule, /top:\s*50%/);
assert.match(rule, /left:\s*50%/);
assert.match(rule, /background:/);
});
// Removing it from the felt is only defensible because the felt is not the only
// place it appears. A seat nobody has named says "Seat 5" as its name already.
test("a seat with no name typed in still says which seat it is", () => {
const state = makeTable({ seatCount: 6 });
state.seats[4].name = "";
assert.equal(HoldemState.displayName(state.seats[4], 4), "Seat 5");
});
});
describe("the two sheets that are about one seat", () => {
test("name the chair as well as the player", () => {
assert.equal(HoldemState.seatHeading({ name: "Alexandra" }, 2), "Alexandra · Seat 3");
});
test("and don't say it twice when the name is already the number", () => {
assert.equal(HoldemState.seatHeading({ name: "" }, 4), "Seat 5");
assert.equal(HoldemState.seatHeading({ name: " " }, 4), "Seat 5");
assert.equal(HoldemState.seatHeading({}, 0), "Seat 1");
assert.equal(HoldemState.seatHeading(null, 0), "Seat 1");
});
test("a typed name keeps its own spacing off the ends", () => {
assert.equal(HoldemState.seatHeading({ name: " Mike from work " }, 1), "Mike from work · Seat 2");
});
test("the seat editor's heading is the one that says it", () => {
const render = src("render");
const at = render.indexOf("renderSeatEditor() {");
assert.ok(at >= 0);
const body = render.slice(at, render.indexOf("\n },", at));
assert.match(body, /title\.textContent = HoldemState\.seatHeading\(player, index\)/);
});
// Not the heading here: this header is stuck to the top of the sheet that scrolls,
// and a name long enough to wrap would cost every action below it a line.
test("the seat menu says it on its first line instead, and says it first", () => {
const menu = src("seatMenu");
const at = menu.indexOf("const stack = Betting.stackOf(this.state, player);");
assert.ok(at >= 0);
const body = menu.slice(at, menu.indexOf("subtitle.textContent", at));
assert.match(body, /const bits = \[`Seat \$\{this\.seatIndex \+ 1\}`\]/);
});
});
describe("the sweep can see it now", () => {
const check = () => {
const at = AUDIT.indexOf(" seatLabelsClear() {");
assert.ok(at >= 0, "the check that asks whether a puck covers itself up is gone");
return AUDIT.slice(at, AUDIT.indexOf("\n },", at));
};
// The mark that was sitting on names was neither of the two this check knew about.
test("the chair number counts as something a puck wears", () => {
assert.match(check(), /const MARKS = "\.dealerChip, \.blindBadge, \.seatChairNumber"/);
});
// And a badge painted over the number is a puck covering itself up, which is the
// whole subject of this check, and was invisible to it.
test("and the marks are held against each other, not only against the writing", () => {
const body = check();
assert.match(body, /marks\.forEach\(\(a, i\) => marks\.slice\(i \+ 1\)/);
assert.match(body, /px across/);
});
test("a mark that isn't drawn isn't an offender", () => {
const body = check();
assert.match(body, /style\.display === "none"/);
assert.match(body, /parseFloat\(style\.opacity\) < 0\.05/);
// An empty badge slot has a box and no text; three of them sit on every puck.
assert.match(body, /if \(!\(mark\.textContent \|\| ""\)\.trim\(\)\) return null;/);
});
test("and the check says what it now covers", () => {
assert.match(AUDIT, /name: "nothing a seat wears covers its name or another mark"/);
});
});
+167
View File
@@ -0,0 +1,167 @@
// The panel named after a legend that never showed one.
//
// Measured on an iPhone: 793px, of which 420 were the colour form — five rows of
// swatch, value box and a filled red delete block, then an add button and a paragraph
// of instructions. Under all of it sat "Stacks in chips", which is the thing a dealer
// opens this panel during a game to read: the fewest chips that make each stack, for
// colouring up or counting one out. Setting the colours is something you do once, on
// the first night, and it was sitting on top of the thing you do at every colour-up.
//
// So the order is the other way round. The legend itself — five colours and five
// amounts, which is what the panel is named for and what it had never actually
// displayed, the values being locked inside number inputs — leads, in two lines. The
// colour-up list follows. The form goes behind a disclosure, which is the shape Table
// Setup already uses twice. Panel: 793px to 441px.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const RENDER = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "render.js"), "utf8");
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
const panel = () => {
const at = PAGE.indexOf('<summary><h2>Chip Legend</h2>');
assert.ok(at >= 0, "there is no Chip Legend panel");
return PAGE.slice(at, PAGE.indexOf('<details class="sidebarPanel" id="keyboardPanel">', at));
};
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// ---------------------------------------------------------------------------
describe("what the panel leads with", () => {
test("the legend, then the stacks, then the form", () => {
const markup = panel();
const strip = markup.indexOf('id="chipLegendStrip"');
const stacks = markup.indexOf('id="colourUp"');
const form = markup.indexOf('id="chipLegendList"');
assert.ok(strip >= 0, "the panel still doesn't show the legend it is named for");
assert.ok(strip < stacks, "the legend is below the colour-up list");
assert.ok(stacks < form, "the form a dealer fills in once is above the list they read all night");
});
test("and the form is behind a disclosure, shut", () => {
const markup = panel();
const at = markup.indexOf('<details class="subPanel">');
assert.ok(at >= 0, "the colour form is not behind a disclosure");
assert.ok(!/<details class="subPanel"[^>]*\bopen\b/.test(markup));
assert.ok(at < markup.indexOf('id="chipLegendList"'), "the disclosure doesn't contain the form");
assert.match(markup.slice(at, at + 200), /<summary>Colours and values<\/summary>/);
});
// The add button and the instructions belong to the form, not to the panel.
test("the instructions went with it", () => {
const markup = panel();
const sub = markup.indexOf('<details class="subPanel">');
assert.ok(markup.indexOf('id="addChipColorBtn"') > sub);
assert.ok(markup.indexOf("Give each chip color a value") > sub);
});
});
describe("the legend line", () => {
test("is built from the same list the form edits", () => {
const at = RENDER.indexOf("renderChipLegendStrip() {");
assert.ok(at >= 0, "nothing draws the legend");
const body = RENDER.slice(at, RENDER.indexOf("\n },", at));
assert.match(body, /this\.state\.chipLegend/);
assert.match(body, /Money\.format/);
assert.match(body, /chipSwatch/);
});
test("and is drawn whenever the list is", () => {
const at = RENDER.indexOf("renderChipLegend() {");
assert.ok(at >= 0);
assert.match(RENDER.slice(at, at + 160), /this\.renderChipLegendStrip\(\);/);
});
// Typing in the form is the one moment the line above it goes stale. Only the
// strip is redrawn — redrawing the list would write the field's own value back
// into it mid-keystroke, which is why everything else here is called one at a time.
test("it keeps up with the field being typed into, and nothing else does", () => {
// Anchored inside renderChipLegend: the seat editor's custom fields have
// listeners spelled the same way, earlier in the file.
const from = RENDER.indexOf("renderChipLegend() {");
assert.ok(from >= 0);
["colorInput.addEventListener(\"input\"", "valueInput.addEventListener(\"input\""].forEach(hook => {
const at = RENDER.indexOf(hook, from);
assert.ok(at >= 0, `there is no ${hook}`);
const body = RENDER.slice(at, RENDER.indexOf("});", at));
assert.match(body, /this\.renderChipLegendStrip\(\);/, `${hook} leaves the legend line stale`);
assert.ok(!/this\.renderChipLegend\(\)/.test(body),
"redrawing the whole list would rewrite the field being typed in");
});
});
test("it wraps rather than running off the side of a 320px sidebar", () => {
const rule = ruleFor(".chipLegendStrip");
assert.match(rule, /display:\s*flex/);
assert.match(rule, /flex-wrap:\s*wrap/);
assert.match(rule, /list-style:\s*none/);
});
});
describe("five ways to delete something", () => {
// The target stays the size the touch rules give it. Only the paint changes: height
// is not what makes a destructive control safe, and these were the loudest thing in
// the panel — one filled red block per row, brighter than the values beside them.
test("the delete buttons are quiet without being small", () => {
const rule = ruleFor("#chipLegendList .removeChipColorBtn");
assert.match(rule, /background:\s*none/);
assert.ok(!/min-height/.test(rule), "de-emphasis is a colour change, never a height change");
assert.ok(!/width/.test(rule));
});
// The dialog that asks "reset the table?" is also holdemBtnDanger, and it is meant
// to be loud.
test("and the confirm dialog's own danger button is untouched", () => {
const at = CSS.indexOf("\n.holdemBtnDanger {");
assert.ok(at >= 0);
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /background:\s*linear-gradient/);
});
test("the red comes back on the way to pressing one", () => {
assert.match(CSS, /#chipLegendList \.removeChipColorBtn:hover \{[^}]*color:\s*#ffd6d6/);
});
});
describe("the list that runs into its own footnote", () => {
test("the colour-up note is spaced off the last stack", () => {
assert.match(ruleFor("#colourUp .colourUpList + .fieldHint"), /margin-top:\s*var\(--sp-\d\)/);
});
});
describe("what the sweep is allowed to shrug at", () => {
// Both twelve-handed shapes, for one stated reason, and only at 20px text.
test("both crowded shapes are notes, not failures, at large text", () => {
const at = DRIVER.indexOf("const CROWDED_BEYOND_HELP");
assert.ok(at >= 0);
assert.match(DRIVER.slice(at, DRIVER.indexOf(";", at)), /\["all in", "packed"\]/);
assert.match(DRIVER, /soft && soft\.includes\(shape\)/);
});
// The arithmetic has to be written down, or the next round reads this as the sweep
// being softened to make something pass.
test("and the arithmetic for why is written down beside it", () => {
const at = DRIVER.indexOf("// The packed table is the same arithmetic");
assert.ok(at >= 0, "the packed shape is exempt with no reason given");
const why = DRIVER.slice(at, DRIVER.indexOf("const CROWDED_BEYOND_HELP", at));
assert.match(why, /315px of puck[\s/]*\/\/?\s*into 294px of felt/);
assert.match(why, /44x63/);
});
// Only at 20px. The same table at the browser's normal size is held to the same
// standard as everything else on the felt.
test("the other two passes are not softened", () => {
assert.match(DRIVER, /\{ hand: "mouse", touch: false, viewports: VIEWPORTS \}/);
assert.match(DRIVER, /\{ hand: "touch", touch: true, viewports: VIEWPORTS \}/);
});
});
+272
View File
@@ -0,0 +1,272 @@
// Typing 0.25 into a box and getting 0.25.
//
// The six setup fields are wired `input` -> save -> Render.renderAll(), and renderAll
// comes back round through renderSetupFields and assigns `.value` to the very box being
// typed in. The decimal point is where that turns into a wrong number rather than a
// wasted render, because a `type="number"` reports a half-typed "0." as "0" — so the
// write-back erased the point, and assigning `.value` to a number input parks the caret
// at position 0, so every digit after it landed at the front.
//
// Measured in Chrome against the page as it stood, keystroke by keystroke:
//
// small blind "0.25" 0 -> 0 -> 20 -> 250 a table playing 250/500
// big blind "0.50" 0 -> 0 -> 50 -> 500
// start stack "12.50" 1 -> 12 -> 12 -> 512 -> 5012
// rake percent "2.5" 2 -> 2 -> 52 a 52% rake
// rake cap "7.50" 7 -> 7 -> 57 -> 507
// base pot "0.75" 0 -> 0 -> 70 -> 750
//
// Nothing warned: every one of those is a valid number, so no validation could have
// caught it, and the dealer's own table is the only place it shows.
//
// Two rules hold it shut, and they are separate on purpose. One stops a render writing
// over somebody's half-finished typing at all. The other takes the fields off
// `type="number"`, which is what made a half-finished number unreadable in the first
// place — and is what GOV.UK's design system concluded after user testing, for this and
// four other reasons.
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, Money } = require("./load");
const MARKUP = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
// A stand-in for one form control, and a document that knows which one has focus.
const field = (value = "") => ({ value, checked: false });
const withFocus = focused => {
global.document = { activeElement: focused || null };
};
test.afterEach(() => { delete global.document; });
// ---------------------------------------------------------------------------
describe("a render does not type for you", () => {
test("the box somebody is standing in is left exactly as they left it", () => {
// The whole fix in one assertion. "0." is not a number and state has read it as
// 0; writing that 0 back is what ate the point.
const box = field("0.");
withFocus(box);
Render.setField(box, 0);
assert.equal(box.value, "0.",
"the render wrote over a half-typed number, which is how 0.25 became 250");
});
test("and the box nobody is in is brought up to date", () => {
const box = field("2");
withFocus(null);
Render.setField(box, 5);
assert.equal(box.value, "5");
});
test("a value that has not changed is not written at all", () => {
// Assigning an identical value still moves the caret in some engines, so this
// is part of the guard rather than a saving.
const box = field("5");
let writes = 0;
Object.defineProperty(box, "value", {
get: () => "5",
set: () => { writes += 1; },
});
withFocus(null);
Render.setField(box, 5);
assert.equal(writes, 0, "an unchanged value was assigned anyway");
});
test("null and undefined clear the box rather than spelling themselves out", () => {
withFocus(null);
const a = field("9");
const b = field("9");
Render.setField(a, null);
Render.setField(b, undefined);
assert.equal(a.value, "");
assert.equal(b.value, "", "an unset blind would have read the word 'undefined'");
});
test("and a box that isn't there is not an error", () => {
withFocus(null);
assert.doesNotThrow(() => Render.setField(null, 5));
assert.doesNotThrow(() => Render.setToggle(null, true));
});
});
describe("the same rule for a switch", () => {
test("a checkbox somebody is on is not flipped under them", () => {
const box = field();
box.checked = true;
withFocus(box);
Render.setToggle(box, false);
assert.equal(box.checked, true,
"a render flipped a switch the dealer had their finger on");
});
test("and one nobody is on keeps up with the state", () => {
const box = field();
box.checked = true;
withFocus(null);
Render.setToggle(box, false);
assert.equal(box.checked, false);
});
});
describe("what a typed-in box is worth", () => {
// The fields are type="text" now, so what arrives is what was typed — including
// things that are not numbers. Number("abc") is NaN, and NaN must not become a blind.
test("a number is worth that number", () => {
assert.equal(Money.readAmount("0.25"), 0.25);
assert.equal(Money.readAmount("12.50"), 12.5);
assert.equal(Money.readAmount("200"), 200);
});
test("nothing typed yet is not zero", () => {
// Null and 0 mean different things to the fields that use this: an unset
// starting stack falls back to the table default, a zero one does not.
assert.equal(Money.readAmount(""), null);
assert.equal(Money.readAmount(" "), null);
assert.equal(Money.readAmount(null), null);
assert.equal(Money.readAmount(undefined), null);
});
test("and neither is something that is not a number", () => {
["abc", "1.2.3", "$5", "--", "1,5"].forEach(text => {
assert.equal(Money.readAmount(text), null,
`"${text}" came back as something other than null`);
});
});
test("infinity is not a stack", () => {
assert.equal(Money.readAmount("Infinity"), null);
assert.equal(Money.readAmount("1e999"), null);
});
test("a half-typed number is read as what it is so far", () => {
// "0." arrives here now instead of being flattened to "0" by the control. It is
// worth 0 at this instant, and the next keystroke will say otherwise — what
// matters is that the box still reads "0." when it does.
assert.equal(Money.readAmount("0."), 0);
assert.equal(Money.readAmount(".5"), 0.5);
});
test("and it comes back rounded to the cent the tool works in", () => {
assert.equal(Money.readAmount("0.005"), 0.01);
assert.equal(Money.readAmount("1.004"), 1);
});
test("a negative is left alone rather than quietly clamped", () => {
// A typed "-5" is a different mistake from a mistyped one, and the callers that
// care clamp for themselves. Folding it in here would change what the dealer
// asked for without saying so.
assert.equal(Money.readAmount("-5"), -5);
});
});
describe("the boxes themselves", () => {
const inputs = MARKUP.match(/<input\b[^>]*>/g) || [];
const attr = (tag, name) => {
const m = tag.match(new RegExp(`${name}="([^"]*)"`));
return m ? m[1] : null;
};
const named = tag => attr(tag, "id") || attr(tag, "class") || tag.slice(0, 40);
test("none of them is a type=number any more", () => {
const spinners = inputs.filter(t => attr(t, "type") === "number");
assert.deepEqual(spinners.map(named), [],
"a type=number is back: it reports a half-typed '0.' as '0', which is the " +
"read that let a render write the decimal point away");
});
test("every box that takes digits says which keypad it wants", () => {
// The digits fields are the ones this round converted. Identified by name rather
// than by type, because their type is exactly what no longer distinguishes them.
const DIGITS = [
"seatCountInput", "smallBlindInput", "bigBlindInput", "defaultStackInput",
"rakePercentInput", "rakeCapInput", "basePotInput",
"stackInput", "amountOverrideInput", "chipValueInput", "chipCountInput",
"addChipsInput",
];
DIGITS.forEach(name => {
const tag = inputs.find(t => named(t) === name);
assert.ok(tag, `${name} is gone from the markup`);
const mode = attr(tag, "inputmode");
assert.ok(mode === "decimal" || mode === "numeric",
`${name} has inputmode="${mode}" — without one a phone offers a ` +
`full keyboard for a number, which is the slowest thing on the page`);
});
});
test("the money ones ask for the separator, the counting ones do not", () => {
// "decimal" carries the reader's decimal separator; "numeric" is bare digits.
// A blind needs the point. A seat count would only be a way to type a wrong one.
const modeOf = name => attr(inputs.find(t => named(t) === name), "inputmode");
["smallBlindInput", "bigBlindInput", "defaultStackInput", "rakeCapInput",
"basePotInput", "stackInput", "chipValueInput"].forEach(name => {
assert.equal(modeOf(name), "decimal", `${name} cannot be given cents`);
});
["seatCountInput", "chipCountInput"].forEach(name => {
assert.equal(modeOf(name), "numeric", `${name} counts things, not money`);
});
});
test("and none of them invites the browser to fill it in", () => {
// A saved address offering itself as a small blind is the browser being helpful
// about the one field on the page it cannot know anything about.
["smallBlindInput", "bigBlindInput", "defaultStackInput", "seatCountInput",
"rakePercentInput", "rakeCapInput", "basePotInput"].forEach(name => {
const tag = inputs.find(t => named(t) === name);
assert.equal(attr(tag, "autocomplete"), "off", `${name} may be autofilled`);
});
});
});
describe("what still counts as a number box", () => {
// Money.selectsOnFocus decides whether tapping a pre-filled amount box selects what
// is in it. It used to recognise those boxes by type="number"; there are none left.
test("a text box with a decimal keypad is one", () => {
assert.equal(Money.selectsOnFocus(
{ tagName: "INPUT", type: "text", inputMode: "decimal" }), true);
});
test("and so is one with a numeric keypad", () => {
// The seat count and the chip count. Both open pre-filled, both were caught by
// the old type check, and reading only "decimal" would have dropped them.
assert.equal(Money.selectsOnFocus(
{ tagName: "INPUT", type: "text", inputMode: "numeric" }), true);
});
test("a plain text box is not", () => {
assert.equal(Money.selectsOnFocus(
{ tagName: "INPUT", type: "text", inputMode: "" }), false,
"a player's name would be wiped by the first keystroke after a tap");
});
});
describe("a wheel has nothing left to spin", () => {
test("no field in the tool is a spinbutton any more", () => {
// wheelWouldEdit answers false for every field now, which is the point rather
// than a regression: a text box is not a spinbutton, so a wheel over it cannot
// change it. The guard stays because it is what makes that safe to rely on.
assert.equal(Money.wheelWouldEdit(
{ tagName: "INPUT", type: "text", inputMode: "decimal" },
{ tagName: "INPUT", type: "text", inputMode: "decimal" }), false);
});
test("but it would still catch one if somebody added it back", () => {
const spinner = { tagName: "INPUT", type: "number" };
assert.equal(Money.wheelWouldEdit(spinner, spinner), true);
});
});
+245
View File
@@ -0,0 +1,245 @@
// Fifty-two cards on a phone.
//
// The picker put the whole deck on the screen at once — four suits across, thirteen
// ranks down — and that grid is 646px tall. Measured with the board tracked and seven
// cards already out:
//
// 640x360 816px of content in a 317px sheet, 40 of the 52 below the fold
// 667x375 816 in 330, 40 below
// 844x390 774 in 343, 36 below
// 375x667 816 in 587, 20 below
// 1280x900 774 in 775, none
//
// So on every phone the tool is actually used on, entering the flop began with a
// scroll, and a dealer looking for the nine of diamonds read down a column to find it.
// There is no arrangement that fixes that: fifty-two targets at the 44px this sweep
// holds everything else to need 572px of width before gaps if they are thirteen across,
// which is wider than any phone in portrait, and thirteen rows deep if they are four.
//
// A card is not one choice out of fifty-two. It is two — a suit and a rank — and that
// is how a dealer says it out loud. So the sheet asks them in that order: four suits on
// a row, then the thirteen ranks of the one chosen. Seventeen targets instead of
// fifty-two, none of them smaller than they were, and measured afterwards at eight
// viewports from 360x640 to 1280x900: nothing below the fold, nothing scrolling, every
// button still at least 44x44 and still hit-testing as itself.
//
// The second tap is not a second cost in the usual case. The suit stays where it was
// put, so a second card of the same suit is one tap — and picking one card used to be
// one tap plus a scroll through thirteen rows.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { CardPicker } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const SRC = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "cardPicker.js"), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
const methodBody = name => {
const at = SRC.indexOf(`\n ${name}`);
assert.ok(at >= 0, `cardPicker has no ${name}`);
return SRC.slice(at, SRC.indexOf("\n },", at));
};
// The picker reaches for the global document in two places. Neither is exercised here —
// these tests ask what it decides, not what it draws — but aimSuit reads the slot it is
// about to fill, so it needs the state and nothing else.
function aimedAt(board, cardIndex, { suit = "S", cursor = 0 } = {}) {
CardPicker.state = { board: board.slice(), seats: [] };
CardPicker.target = "board";
CardPicker.boardLimit = board.length;
CardPicker.cardIndex = cardIndex;
CardPicker.suit = suit;
CardPicker.cursor = cursor;
CardPicker.aimSuit();
return { suit: CardPicker.suit, cursor: CardPicker.cursor };
}
// ---------------------------------------------------------------------------
describe("the deck is still the deck", () => {
test("all fifty-two of it, ace down to two", () => {
const deck = CardPicker.deck();
assert.equal(deck.length, 52);
assert.equal(new Set(deck.map(entry => entry.card)).size, 52);
assert.equal(deck[0].card, "AS");
assert.equal(deck[51].card, "2C");
});
// Which is what the duplicate check reads, and why splitting the *grid* by suit
// cannot let the same card be dealt twice.
test("and a card already out is still a card already out, in any suit", () => {
CardPicker.state = {
seats: [{ name: "Mike", cards: ["AS", "KH"] }],
board: ["7D", "", "", "", ""],
};
CardPicker.target = "board";
CardPicker.boardLimit = 5;
CardPicker.cardIndex = 1;
const used = CardPicker.cardsInUse();
assert.equal(used.get("AS"), "Mike");
assert.equal(used.get("KH"), "Mike");
assert.equal(used.get("7D"), "the board");
assert.equal(used.size, 3);
});
});
describe("one suit at a time", () => {
test("the grid is thirteen ranks of the chosen suit, in deck order", () => {
CardPicker.suit = "D";
const cards = CardPicker.gridCards();
assert.equal(cards.length, 13);
assert.deepEqual(cards.map(entry => entry.card).slice(0, 3), ["AD", "KD", "QD"]);
assert.equal(cards[12].card, "2D");
assert.ok(cards.every(entry => entry.suit.code === "D"));
});
test("every suit gets the same thirteen", () => {
CardPicker.SUITS.forEach(suit => {
CardPicker.suit = suit.code;
assert.deepEqual(CardPicker.gridCards().map(entry => entry.rank), CardPicker.RANKS);
});
});
// The glyph is the whole of the control on the eye, and "♦, radio button" is not a
// suit out loud.
test("and a suit has a name to be called by", () => {
assert.deepEqual(CardPicker.SUITS.map(suit => suit.name),
["Spades", "Hearts", "Diamonds", "Clubs"]);
assert.match(methodBody("paint()"), /aria-label", suit\.name/);
});
test("an unknown suit code falls back rather than emptying the grid", () => {
assert.equal(CardPicker.suitOf("Z").code, "S");
assert.equal(CardPicker.suitOf("H").symbol, "♥");
});
});
describe("where the sheet opens", () => {
// The dealer came here to change that card. Hunting for where it lives is work the
// sheet can do.
test("on the suit of the card already in the slot, with it under the cursor", () => {
assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 0), { suit: "H", cursor: 7 });
assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 1), { suit: "D", cursor: 12 });
assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 2), { suit: "S", cursor: 4 });
});
// Which is what makes a flush board, or two suited hole cards, one tap each after
// the first.
test("and an empty slot leaves the suit where the dealer put it", () => {
assert.deepEqual(aimedAt(["7H", "", "", "", ""], 1, { suit: "H", cursor: 7 }),
{ suit: "H", cursor: 7 });
});
test("junk in a slot is left alone rather than read as a suit", () => {
assert.deepEqual(aimedAt(["ZZ", "", "", "", ""], 0, { suit: "C", cursor: 3 }),
{ suit: "C", cursor: 3 });
assert.deepEqual(aimedAt(["", "", "", "", ""], 0, { suit: "C", cursor: 3 }),
{ suit: "C", cursor: 3 });
});
test("both ways in ask the question before they draw", () => {
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
.forEach(signature => {
const at = SRC.indexOf(signature);
assert.ok(at >= 0, `there is no \`${signature}\``);
assert.match(SRC.slice(at, SRC.indexOf("this.render();", at)), /this\.aimSuit\(\);/);
});
});
});
describe("changing suit is not moving", () => {
// The cursor is a rank. The same rank in another suit is the card beside the one you
// were on, so coming back to the ace would undo the only thing the dealer had said.
test("the rank under the cursor survives the change", () => {
const body = methodBody("setSuit(code, { focus = false } = {}) {");
assert.ok(!/cursor/.test(body), "setSuit still has an opinion about where the cursor is");
});
test("and asking for the suit already showing redraws nothing", () => {
const body = methodBody("setSuit(code, { focus = false } = {}) {");
assert.match(body, /if \(this\.suit === code\) return;/);
});
// The APG's radio group: one Tab stop for the set, arrows to move within it, and
// moving is choosing. Wrapping is right here and wrong in the grid below — four
// suits are a ring with no ends, thirteen ranks have two.
test("the arrows run round the four suits", () => {
const body = methodBody("onSuitKey(event)");
assert.match(body, /ArrowRight: 1.*ArrowDown: 1.*ArrowLeft: -1.*ArrowUp: -1/s);
assert.match(body, /% this\.SUITS\.length/);
assert.match(body, /event\.preventDefault\(\)/);
});
});
describe("what the markup and the stylesheet have to agree about", () => {
// Not a tablist: the grid below is not another panel of content, it is the same
// thirteen controls filtered, and single-select filtering is what a radio group is.
test("the suits are a radio group with a name", () => {
const at = PAGE.indexOf('id="cardPickerSuits"');
assert.ok(at >= 0, "there is no suit row");
const tag = PAGE.slice(PAGE.lastIndexOf("<", at), PAGE.indexOf(">", at));
assert.match(tag, /role="radiogroup"/);
assert.match(tag, /aria-label="Suit"/);
});
test("each suit says whether it is the chosen one, and only one is a Tab stop", () => {
const body = methodBody("paint()");
assert.match(body, /setAttribute\("role", "radio"\)/);
assert.match(body, /aria-checked", chosen \? "true" : "false"/);
assert.match(body, /tabIndex = chosen \? 0 : -1/);
});
test("and the grid says which suit's thirteen these are", () => {
const at = PAGE.indexOf('id="cardPickerGrid"');
assert.ok(at >= 0);
assert.match(PAGE.slice(at, PAGE.indexOf(">", at)), /aria-labelledby="cardPickerGridLabel"/);
assert.match(methodBody("paint()"), /gridLabel\.textContent = `\$\{this\.suitOf\(this\.suit\)\.name\}/);
});
// A row of four, in the same four places every time, so the one the dealer wants is
// somewhere their thumb already knows.
test("the suit row is four fixed columns, not as many as fit", () => {
assert.match(ruleFor("#cardPickerSuits"), /grid-template-columns:\s*repeat\(4, minmax\(0, 1fr\)\)/);
});
test("a suit is a target before it is a glyph", () => {
assert.match(ruleFor(".pickerSuitTab"), /min-height:\s*48px/);
});
// Drawing the three unchosen suits in one grey made a dealer reaching for the hearts
// read four shapes to find them, which is the search this sheet exists to remove.
test("red is red whether or not it is the one chosen", () => {
assert.match(ruleFor(".pickerSuitTab.cardRed"), /color:\s*#e07b7b/);
assert.match(ruleFor(".pickerSuitTab.chosen"), /background:\s*#fdfdfd/);
assert.match(ruleFor(".pickerSuitTab.cardRed.chosen"), /color:\s*#b32020/);
});
// Five across on a 360px phone, seven at 460, more on a phone held sideways.
test("the ranks take as many columns as the row holds", () => {
assert.match(ruleFor("#cardPickerGrid"),
/grid-template-columns:\s*repeat\(auto-fill, minmax\(3\.25rem, 1fr\)\)/);
});
// Which is why the arrows cannot assume a number any more.
test("so the arrows read the column count back off the layout", () => {
assert.match(methodBody("onGridKey(event)"), /const stride = this\.gridStride\(\)/);
assert.match(methodBody("gridStride()"), /getComputedStyle\(grid\)\.gridTemplateColumns/);
});
test("what was measured is written beside the code", () => {
const note = SRC.slice(0, SRC.indexOf("const CardPicker = {"));
assert.match(note, /646px/);
assert.match(note, /40 of the 52/);
assert.match(note, /572px/);
});
});
+111
View File
@@ -0,0 +1,111 @@
// Forty-four pixels, meaning forty-four pixels.
//
// Every input and select a dealer touches has a rule asking for `min-height: 44px`, and
// not one of them was 44px tall. Form controls are content-box by default, so 44 was
// being read as the height of the text inside and 8px of padding top and bottom plus a
// 1px border each side went on the outside of it. Measured on an iPhone: all fifteen
// settings fields in the sidebar stood at 62px, and the raise pad's amount box at 66px
// with the text turned up.
//
// Nothing looked wrong, which is why it lasted eleven rounds. 62px clears a 44px floor,
// so the sweep — which asks whether a target is big enough, never whether it is the size
// it was asked to be — had nothing to say about it. The cost is only visible in the
// aggregate: fifteen fields at 18px over is 180px of sidebar, and the raise field's 10px
// is 10px of felt, because the action bar's height is published into --feltRoomPx and
// the table is laid out from what is left.
//
// This is the third time this stylesheet has been caught by content-box — the sidebar's
// panel headers and the rulings sheet's summaries are the other two, and both carry a
// note saying so. The note is in the stylesheet; the list of what it covers is here.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const CSS = fs.readFileSync(path.join(
__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// The rule, the selectors it is written against, and the note above it saying why.
const boxSizing = () => {
const note = CSS.indexOf("/* A field is as tall as it says it is.");
assert.ok(note >= 0, "nothing in the stylesheet asks a field to be the height it states");
const at = CSS.indexOf("box-sizing: border-box;", note);
assert.ok(at >= 0);
return CSS.slice(note, CSS.indexOf("}", at));
};
// Every selector anywhere in the stylesheet that gives a typing field a 44px floor,
// wherever the media query that holds it happens to live. Checkboxes and colour swatches
// are left out: they are sized by an explicit width and height rather than by a floor
// their padding can push past, which is the whole of the bug this guards.
const touchSized = () => {
const found = [];
let from = 0;
for (;;) {
const hit = CSS.indexOf("min-height: 44px", from);
if (hit === -1) break;
from = hit + 1;
const opens = CSS.lastIndexOf("{", hit);
const prior = Math.max(CSS.lastIndexOf("}", opens), CSS.lastIndexOf("{", opens - 1),
CSS.lastIndexOf("*/", opens));
CSS.slice(prior + 1, opens).split(",")
.map(line => line.trim())
.filter(Boolean)
.forEach(selector => found.push(selector));
}
return found.filter(selector =>
/\b(input|select|textarea)\b|\.raiseInput/.test(selector) &&
!/\[type="(checkbox|color)"\]/.test(selector));
};
// ---------------------------------------------------------------------------
describe("a field is the height it says it is", () => {
const rule = boxSizing();
// Every field, not a list of the surfaces fields sit on. The list version of this
// rule was written first and had already missed one — see the note.
test("said about the elements, so nothing can sit outside the list", () => {
assert.match(rule, /(^|\n)input,\s*\n\s*select,\s*\n\s*textarea \{/);
});
// The one field whose height is not only its own: it sets the action bar's height,
// the bar's height is published into --feltRoomPx, and the felt is laid out from
// what is left. Taking 10px off the bar gave them to the table.
test("the felt's stake in it is written down", () => {
assert.match(rule, /--feltRoomPx/);
assert.match(rule, /248px to[\s*]*238/);
});
test("and so is the measurement that found it", () => {
assert.match(rule, /62px/);
assert.match(rule, /fifteen settings fields/);
});
// Two other rules in this file already carry a content-box note; this is the third
// place it has bitten, and the note is what stops it being a fourth.
test("and what it is about, so the next one is recognised", () => {
assert.match(rule, /content-box/);
assert.match(rule, /padding/);
});
});
describe("nothing asks for a height it will not get", () => {
// A durable guard rather than a snapshot. If a later round gives a field a 44px
// floor, that floor is only the height the author meant while this rule reaches the
// field — and nothing else in the suite would notice if it stopped.
test("every field given a touch height is measured the way that height assumes", () => {
const fields = touchSized();
assert.ok(fields.length >= 4, `only ${fields.length} field rules found — the scrape has drifted`);
// The rule names the element types themselves, so the only way a field escapes
// is a later round narrowing it back to a list of places.
const rule = boxSizing();
fields.forEach(selector => {
const kind = (selector.match(/\b(input|select|textarea)\b/) || [])[1]
|| (/\.raiseInput/.test(selector) ? "input" : null);
assert.ok(kind && new RegExp(`(^|\\n|,\\s*)${kind}\\b`).test(rule),
`\`${selector}\` is given a touch height that box-sizing no longer reaches`);
});
});
});
+210
View File
@@ -0,0 +1,210 @@
// What Table Setup holds while it is open.
//
// It is the one panel the tool opens by itself, on a table nobody has set up yet — so
// it is most of what a dealer sees the first time they arrive. Measured on a fresh
// table, before this: the panel stood 886px on a 667px phone, and the whole page 2076.
//
// The order was the problem more than the length. The two figures the panel's own
// summary glances at — "10 seats · $1/$2" — were the seat count at the top and the
// blinds 218px further down, under three switches and three paragraphs explaining
// them: a dedicated dealer, a card sheet at every street, and whether the phone is
// allowed to sleep. A dealer setting up a $1/$2 game read all of that on the way to
// the two boxes they came for.
//
// The three have gone into "While you deal", with the paragraphs that explain them.
// That is not a new idea in this panel — House take and Without live betting were
// already nested disclosures — it is the same idea applied to the things that were not
// about money. Afterwards: the panel is 529px, the page 1719 on the same phone and
// 1275 on a 1280x900 desktop, and the first two rows are the seats and the blinds.
//
// A switch nobody can see is a switch nobody remembers turning on, so the disclosure
// carries a glance of its own.
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 ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const MAIN = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
// The Table Setup panel, from its opening tag to the panel after it.
const setupPanel = () => {
const at = PAGE.indexOf('<details class="sidebarPanel" id="tableSetupPanel">');
assert.ok(at >= 0, "there is no Table Setup panel");
const end = PAGE.indexOf('<details class="sidebarPanel"', at + 1);
assert.ok(end > at, "the panel after Table Setup has gone");
return PAGE.slice(at, end);
};
// What the panel shows without anything being opened: everything before the first
// nested disclosure.
const alwaysShowing = () => {
const panel = setupPanel();
const firstSub = panel.indexOf('<details class="subPanel">');
assert.ok(firstSub >= 0, "the panel has no nested disclosures at all");
return panel.slice(0, firstSub);
};
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
const glance = state => (Render.panelGlances(state).glanceWhileYouDeal || {}).text;
const table = () => makeTable({ seatCount: 9, seated: [], stack: 0 });
// ---------------------------------------------------------------------------
describe("what the open panel leads with", () => {
// The two the summary already glances at. They were first and seventh.
test("the seats and the blinds, in that order, above everything else", () => {
const body = alwaysShowing();
const seats = body.indexOf('id="seatCountInput"');
const small = body.indexOf('id="smallBlindInput"');
const big = body.indexOf('id="bigBlindInput"');
const stack = body.indexOf('id="defaultStackInput"');
assert.ok(seats >= 0 && small >= 0 && big >= 0 && stack >= 0,
"one of the four numbers is no longer in the part of the panel that shows");
assert.ok(seats < small, "the seat count is below the blinds");
assert.ok(small < big && big < stack, "the blinds and the stack have swapped");
});
test("and nothing in it is a switch about how the tool behaves", () => {
const body = alwaysShowing();
["dedicatedDealerToggle", "trackBoardToggle", "keepScreenAwakeToggle"].forEach(id => {
assert.ok(!body.includes(id), `${id} is still on the panel's first screen`);
});
});
// One line, and it is about the boxes directly above it.
test("one paragraph survives, the short one", () => {
const hints = alwaysShowing().match(/<p class="fieldHint">/g) || [];
assert.equal(hints.length, 1, "the panel's first screen is back to being prose");
assert.match(alwaysShowing(), /Blinds post themselves each hand/);
});
});
describe("where the three switches went", () => {
const dealing = () => {
const panel = setupPanel();
const at = panel.indexOf("<summary>While you deal");
assert.ok(at >= 0, 'there is no "While you deal" disclosure');
return panel.slice(panel.lastIndexOf("<details", at), panel.indexOf("</details>", at));
};
test("all three of them, with the paragraphs that explain them", () => {
const body = dealing();
["dedicatedDealerToggle", "trackBoardToggle", "keepScreenAwakeToggle"].forEach(id => {
assert.ok(body.includes(id), `${id} did not come with the rest`);
});
assert.match(body, /no seat shows the dealer button/);
assert.match(body, /a sheet asks which cards came off the deck/);
assert.match(body, /Stops the phone dimming and locking/);
});
// The same shape House take and Without live betting already had. A disclosure that
// ships open is a disclosure that has saved nobody anything.
test("it is shut, like the two money ones beside it", () => {
const panel = setupPanel();
assert.ok(!/<details class="subPanel"[^>]*\bopen\b/.test(panel),
"a nested disclosure in this panel ships open");
});
test("and it sits above them, because it is not about money", () => {
const panel = setupPanel();
assert.ok(panel.indexOf("While you deal") < panel.indexOf("<summary>House take"));
assert.ok(panel.indexOf("<summary>House take") < panel.indexOf("<summary>Without live betting"));
});
});
describe("the glance on a shut disclosure", () => {
test("silence when all three are where they ship", () => {
const state = table();
assert.equal(glance(state), "");
});
test("and a name when one of them is not", () => {
const state = table();
state.trackBoard = true;
assert.equal(glance(state), "Asking for cards");
const other = table();
other.dedicatedDealer = true;
assert.equal(glance(other), "House dealer");
});
// The only one that ships on, so it is its absence that is worth saying.
test("the screen setting speaks by going quiet, not by being on", () => {
const on = table();
on.keepScreenAwake = true;
assert.equal(glance(on), "");
const off = table();
off.keepScreenAwake = false;
assert.equal(glance(off), "Screen may sleep");
});
// Joined, the three run to 49 characters, and this row is 44px of a 320px column
// with a label already on it. "House dealer · Asking for c…" has told nobody
// anything; the one most likely to be the surprise has.
test("one of them, never a list of three", () => {
const state = table();
state.trackBoard = true;
state.dedicatedDealer = true;
state.keepScreenAwake = false;
assert.equal(glance(state), "Asking for cards");
assert.ok(glance(state).length <= 24);
});
test("it is a row this sidebar knows how to write", () => {
assert.ok(Render.PANEL_GLANCES.includes("glanceWhileYouDeal"),
"the glance is computed and never written, so the row keeps its old text all night");
const panel = setupPanel();
const at = panel.indexOf('id="glanceWhileYouDeal"');
assert.ok(at >= 0, "there is nowhere to write it");
assert.match(panel.slice(panel.lastIndexOf("<span", at), at + 40), /class="panelGlance subPanelGlance"/);
assert.match(panel.slice(at, panel.indexOf(">", at)), /hidden/);
});
});
describe("what had to be true for it to hang there", () => {
// Without this the span anchors to whichever ancestor is positioned, which is the
// sidebar — the glance would appear at the top right of the whole column.
test("the summary it hangs off is a positioned box", () => {
assert.match(ruleFor(".subPanel > summary"), /position:\s*relative/);
});
// A footnote on a line that is itself a footnote. At the weight of "Off by $40" the
// eye stops on a row saying nothing has gone wrong.
test("and it is drawn a step quieter than the panel headings' own", () => {
const sub = ruleFor(".subPanelGlance");
assert.match(sub, /font-size:\s*var\(--fs-xs\)/);
assert.match(sub, /color:\s*var\(--text-dim\)/);
assert.match(ruleFor(".panelGlance"), /font-size:\s*var\(--fs-sm\)/);
});
});
describe("the panel still opens itself only when it should", () => {
test("on a table nobody has touched, and not on the fourth hand", () => {
const at = MAIN.indexOf('document.getElementById("tableSetupPanel")');
assert.ok(at >= 0, "nothing decides whether the panel starts open");
const block = MAIN.slice(at, at + 400);
assert.match(block, /Number\(state\.handNumber\) \|\| 1\) === 1/);
assert.match(block, /!state\.seats\.some\(seat => HoldemState\.seatInPlay\(seat\)\)/);
assert.match(block, /panel\.open = fresh/);
});
test("what was measured is written beside the decision", () => {
const at = MAIN.indexOf("Table Setup used to ship open");
assert.ok(at >= 0);
const note = MAIN.slice(at, at + 1200);
assert.match(note, /886px/);
assert.match(note, /529px/);
});
});
+3 -3
View File
@@ -97,9 +97,9 @@ describe("the header is one row on a phone", () => {
});
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.
// `flex: 1 1 0` would divide the row into equal shares: at 360px the row is 269
// wide, so mid-hand that is 87px each and "Next Hand ⟳" needs 89 — 73 of text and
// 16 of padding. 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/);
+413
View File
@@ -0,0 +1,413 @@
// Where a sheet puts the dealer, and whether they can get anywhere from there.
//
// Modal exists because six overlays were each missing the same three things, and it
// fixed all six at once by answering one question the same way everywhere: focus the
// first control, and hold Tab between the first and the last. Measured this round, that
// single answer had gone wrong in three separate directions.
//
// It could not see a <summary>. The rulings sheet is twelve of them and one Done
// button, so the trap decided Done was both ends of the ring and bounced every press
// back to it: eighty Tab presses, one element ever reached, twelve rulings that could
// not be opened without a mouse.
//
// "The first control" was the card picker's Clear. The sheet a dealer opens to write a
// card down opened on the button that rubs one out — the one thing the APG says to keep
// a dialog off. And "the first control" is not an answer six dialogs share: Roselli's
// rule is that a dialog long enough to scroll should land on its heading so the reader
// can orient, and a short question with one irreversible answer should land on the
// answer that leaves them no worse off.
//
// And a sheet that redraws itself was deleting the control that held focus. Choosing a
// buy-in, picking a card, switching slots, confirming a side pot: all four left focus on
// the document body, which inside a modal is nowhere at all.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Modal, CardPicker } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const src = name => fs.readFileSync(
path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
// The markup of one overlay, from its opening div to the next one's.
const OVERLAY_ORDER = ["addChipsOverlay", "rulingsOverlay", "seatMenuOverlay",
"cardPickerOverlay", "confirmOverlay", "potWizardOverlay"];
const overlay = id => {
const at = PAGE.indexOf(`<div id="${id}"`);
assert.ok(at >= 0, `there is no ${id}`);
const ends = OVERLAY_ORDER
.map(other => PAGE.indexOf(`<div id="${other}"`, at + 1))
.filter(i => i > at);
return PAGE.slice(at, ends.length ? Math.min(...ends) : PAGE.length);
};
// A document small enough to answer the handful of questions these functions ask of
// one, and honest about the two that matter: a detached node knows it is detached, and
// a disabled button refuses focus.
function fakeSheet(specs) {
const doc = { activeElement: null };
const nodes = specs.map(spec => ({
dataset: spec.key === undefined ? {} : { focusKey: spec.key },
disabled: !!spec.disabled,
isConnected: true,
tabIndex: 0,
label: spec.key || spec.label,
focus() {
if (this.disabled) return;
doc.activeElement = this;
},
}));
const root = {
nodes,
contains: el => nodes.includes(el),
querySelectorAll: () => nodes.filter(n => n.dataset.focusKey !== undefined),
};
return { doc, root, nodes };
}
// preservingFocus and the picker's cursor both reach for the global document. They are
// given one for the length of a test and it is taken away again afterwards.
function withDocument(doc, body) {
const had = Object.prototype.hasOwnProperty.call(globalThis, "document");
const before = globalThis.document;
globalThis.document = doc;
try {
return body();
} finally {
if (had) globalThis.document = before;
else delete globalThis.document;
}
}
// ---------------------------------------------------------------------------
describe("what counts as somewhere you can Tab to", () => {
const selectors = Modal.FOCUSABLE.split(",");
// The whole of the rulings sheet hung on this one omission.
test("a summary is a control, because the browser already treats it as one", () => {
assert.ok(selectors.some(s => /details\s*>\s*summary/.test(s)),
"a <details> summary is focusable without a tabindex, and this list can't see it");
});
// Left off, a card taken out of the Tab order still matches `button`, and the grid
// goes back to being fifty-two stops with a roving tabindex painted on top.
test("every line excludes what has been taken out of the Tab order, not just the last", () => {
selectors.forEach(selector => {
assert.ok(/:not\(\[tabindex="-1"\]\)/.test(selector) || /^\s*\[tabindex\]/.test(selector),
`\`${selector.trim()}\` still matches controls that are not Tab stops`);
});
});
test("and the sheet itself is still off the list", () => {
assert.ok(/\[tabindex\]:not\(\[tabindex="-1"\]\)/.test(Modal.FOCUSABLE));
});
});
describe("where each sheet lands the dealer", () => {
const LANDS_ON_ITS_HEADING = [
["cardPickerOverlay", "cardPickerTitle"],
["addChipsOverlay", "addChipsTitle"],
["rulingsOverlay", "rulingsTitle"],
["potWizardOverlay", "potWizardTitle"],
["seatMenuOverlay", "seatMenuTitle"],
];
LANDS_ON_ITS_HEADING.forEach(([id, heading]) => {
test(`${id} opens on the line that says which`, () => {
const markup = overlay(id);
const tag = markup.match(new RegExp(`<h2 id="${heading}"[^>]*>`));
assert.ok(tag, `${heading} is not an h2 in ${id}`);
assert.match(tag[0], /data-modal-focus/);
assert.match(tag[0], /tabindex="-1"/,
"a heading that is a Tab stop is a Tab stop the dealer has to press past");
});
});
// The APG's own example of when to land on a control rather than a heading, and the
// reason: one of the two answers cannot be taken back.
test("the confirm dialog is the exception, and it lands on Cancel", () => {
const markup = overlay("confirmOverlay");
const cancel = markup.match(/<button id="confirmCancelBtn"[^>]*>/);
assert.ok(cancel);
assert.match(cancel[0], /data-modal-focus/);
assert.ok(!/<button id="confirmAcceptBtn"[^>]*data-modal-focus/.test(markup),
"the dialog lands on the answer that cannot be taken back");
});
test("no sheet names two landing spots, which would be a coin toss", () => {
OVERLAY_ORDER.forEach(id => {
const found = (overlay(id).match(/data-modal-focus/g) || []).length;
assert.equal(found, 1, `${id} names ${found} landing spots`);
});
});
test("the card picker no longer opens on Clear", () => {
const markup = overlay("cardPickerOverlay");
assert.ok(markup.indexOf('id="cardPickerTitle"') < markup.indexOf('id="cardPickerClearBtn"'),
"the heading has moved below the button it was there to keep focus off");
assert.ok(!/id="cardPickerClearBtn"[^>]*data-modal-focus/.test(markup));
});
// The landing spot is deliberately outside the ring, and a wrap that only knows
// "is this the first one" walks straight out of the dialog on the first Shift+Tab.
test("the trap wraps from outside the ring as well as from its ends", () => {
const modal = src("modal");
const at = modal.indexOf("const at = focusable.indexOf(");
assert.ok(at >= 0, "the Tab trap no longer asks where the dealer is in the ring");
const body = modal.slice(at, modal.indexOf("\n },", at));
assert.match(body, /shiftKey && at <= 0/);
assert.match(body, /at === -1/);
});
});
describe("a redraw that does not drop the dealer", () => {
test("focus comes back to the control with the same name", () => {
const { doc, root, nodes } = fakeSheet([
{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
doc.activeElement = nodes[1];
withDocument(doc, () => Modal.preservingFocus(root, () => {
// What a rebuild does: the old buttons go, identical new ones arrive.
nodes.forEach(n => { n.isConnected = false; });
root.nodes = root.querySelectorAll = null;
const rebuilt = fakeSheet([{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
rebuilt.nodes.forEach(n => { n.focus = () => { doc.activeElement = n; }; });
root.querySelectorAll = () => rebuilt.nodes;
root.contains = el => rebuilt.nodes.includes(el);
}));
assert.equal(doc.activeElement.dataset.focusKey, "quick-1000");
});
// The card just picked is a dealt card now, and a dealt card is a disabled button.
// There is no coming back to it, so the caller is asked where to go instead.
test("a name that comes back disabled hands the decision to the caller", () => {
const { doc, root, nodes } = fakeSheet([{ key: "AS" }, { key: "KH" }]);
doc.activeElement = nodes[0];
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {
nodes[0].isConnected = false;
nodes[0].disabled = true;
}, () => { asked = true; }));
assert.ok(asked, "focus was left on the body of a modal");
});
test("and a name that is simply gone asks too", () => {
const { doc, root, nodes } = fakeSheet([{ key: "confirm" }]);
doc.activeElement = nodes[0];
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {
nodes[0].isConnected = false;
root.querySelectorAll = () => [];
}, () => { asked = true; }));
assert.ok(asked);
});
// Clear is in the header. Nothing rebuilds it, so nothing should move it — a redraw
// the dealer asked for is not a reason to take them somewhere else.
test("a control that survived the redraw is left exactly where it was", () => {
const { doc, root, nodes } = fakeSheet([{ label: "Clear" }, { key: "AS" }]);
doc.activeElement = nodes[0];
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {
nodes[1].isConnected = false;
}, () => { asked = true; }));
assert.equal(doc.activeElement, nodes[0]);
assert.ok(!asked);
});
test("and a dealer who was never in this sheet is not dragged into it", () => {
const { doc, root } = fakeSheet([{ key: "AS" }]);
const elsewhere = { dataset: {}, isConnected: true };
doc.activeElement = elsewhere;
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {}, () => { asked = true; }));
assert.equal(doc.activeElement, elsewhere);
assert.ok(!asked);
});
// Four sheets redraw themselves on a tap. All four go through this now, and each
// one hands over the piece of itself it is about to throw away.
[["addChips", "render()", "addChipsSheet"],
["cardPicker", "render()", "cardPickerSheet"],
["seatMenu", "render()", "seatMenuOverlay"],
["potWizard", "renderStepInto()", "potWizardModal"]].forEach(([module, method, root]) => {
test(`${module}'s ${method} redraws through it`, () => {
const text = src(module);
const at = text.indexOf(`\n ${method} {`);
assert.ok(at >= 0, `${module} has no ${method}`);
const body = text.slice(at, text.indexOf("\n },", at));
assert.match(body, /Modal\.preservingFocus\(/,
`${module} rebuilds its controls without saying what to do about focus`);
assert.ok(body.includes(`"${root}"`),
`${module} hands preservingFocus something other than ${root}`);
});
});
});
describe("thirteen cards, one Tab stop", () => {
test("the deck is written once, ace down to two", () => {
const deck = CardPicker.deck();
assert.equal(deck.length, 52);
assert.equal(new Set(deck.map(c => c.card)).size, 52);
assert.deepEqual(deck.slice(0, 4).map(c => c.card), ["AS", "AH", "AD", "AC"]);
assert.equal(deck[4].card, "KS");
assert.equal(deck[51].card, "2C");
});
// A dealt card is `aria-disabled` rather than `disabled`, so it holds focus like any
// other cell — which is what lets the arrows tell the truth about the grid, and what
// lets pressing one be answered instead of ignored. See theUnansweredPress.
const grid = keys => keys.map(key => ({
dataset: { focusKey: key.replace("!", "") },
getAttribute: name => (name === "aria-disabled" && key.includes("!") ? "true" : null),
tabIndex: 0,
focus() {},
}));
const withGrid = (cards, body) => {
const nodes = grid(cards);
return withDocument({ querySelectorAll: () => nodes }, () => body(nodes));
};
test("a step is one card, whether or not that card is already out", () => {
// The seven is dealt. Stepping along from the eight lands on it and says so,
// rather than quietly arriving at the six.
withGrid(["8C", "7C!", "6C"], () => {
assert.equal(CardPicker.nextCursor(0, 1), 1);
});
});
// The whole reason the skipping had to go. Hunting for the next card that could take
// focus did the exact thing the no-wrap rule below was written to prevent: it moved
// the dealer sideways without being asked. Right from the ace over a dealt king
// landed on the queen, so the column silently became the one to its right, and the
// next press of Down walked a rank nobody had asked for.
test("and it never slides the dealer into a column they didn't ask for", () => {
withGrid(["AS", "KS!", "QS", "JS", "TS", "9S", "8S", "7S"], () => {
const afterRight = CardPicker.nextCursor(0, 1);
assert.equal(afterRight, 1, "a dealt king is still the cell to the right of the ace");
assert.equal(CardPicker.nextCursor(afterRight, 4), 5,
"and down a row of four from it is the nine, four ranks along");
});
});
// Wrapping would answer "move me down a row" by moving the dealer to the other end
// of the grid, which is not what they asked and not where they would look.
test("the edge of the grid is the edge, not a way round to the other side", () => {
withGrid(["AS", "KS", "QS", "JS"], () => {
assert.equal(CardPicker.nextCursor(3, 1), 3);
assert.equal(CardPicker.nextCursor(0, -1), 0);
});
});
test("only one card is in the Tab order at a time", () => {
withGrid(["AS", "KS", "QS", "JS"], nodes => {
CardPicker.aimCursor(2);
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, 0, -1]);
});
});
// Which happens after every pick: the cursor is sitting on a card that has just
// become a dealt card. It stays there, because that cell is still a cell and its
// neighbours are still where they were a moment ago.
test("a cursor left on a dealt card stays on it", () => {
withGrid(["AS!", "KS", "QS", "JS"], nodes => {
CardPicker.aimCursor(0);
assert.equal(CardPicker.cursor, 0);
assert.deepEqual(nodes.map(n => n.tabIndex), [0, -1, -1, -1]);
});
});
test("and a grid with nothing left in it still has somewhere to put the cursor", () => {
withGrid(["5S!", "4S!", "3S!", "2S!"], nodes => {
CardPicker.aimCursor(3);
assert.equal(CardPicker.cursor, 3);
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, -1, 0]);
});
});
// An index from outside the grid is clamped rather than wrapped, which is what
// Home and End lean on: they ask for one card past either end and take what they
// are given.
test("Home and End ask past the ends and land on them", () => {
withGrid(["AS", "KS", "QS", "JS"], () => {
assert.equal(CardPicker.nextCursor(-1, 1), 0);
assert.equal(CardPicker.nextCursor(4, -1), 3);
});
});
// It used to step by `SUITS.length`, because the grid was four suits wide at every
// size. The grid is thirteen ranks of one suit now and takes as many columns as the
// width holds — five on a 360px phone, seven at 460 — so a constant would be wrong
// on most screens, which is the thing that slid the cursor into the wrong column
// the last time these two disagreed. It asks the layout instead.
test("the arrows step by the row the grid actually has, not by a guess", () => {
const picker = src("cardPicker");
const at = picker.indexOf("onGridKey(event)");
assert.ok(at >= 0, "the grid has no arrow keys");
const body = picker.slice(at, picker.indexOf("\n },", at));
assert.match(body, /const stride = this\.gridStride\(\)/);
assert.match(body, /ArrowRight: 1.*ArrowLeft: -1.*ArrowDown: stride.*ArrowUp: -stride/s);
assert.match(body, /"Home"/);
assert.match(body, /"End"/);
});
test("and the row it actually has is read off the grid", () => {
const picker = src("cardPicker");
const at = picker.indexOf("gridStride() {");
assert.ok(at >= 0, "there is no gridStride");
const body = picker.slice(at, picker.indexOf("\n },", at));
assert.match(body, /getComputedStyle\(grid\)\.gridTemplateColumns/);
// Outside a browser there is nothing to measure, and thirteen ranks in one row
// is the shape that makes Up and Down do nothing rather than something wrong.
assert.match(body, /return this\.RANKS\.length/);
});
test("a fresh sheet starts at the top of the deck", () => {
const picker = src("cardPicker");
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
.forEach(signature => {
const at = picker.indexOf(signature);
assert.ok(at >= 0, `there is no \`${signature}\``);
assert.match(picker.slice(at, picker.indexOf("this.render();", at)), /this\.cursor = 0;/);
});
});
});
describe("seeing where you are", () => {
// Until a summary could be Tabbed to at all, there was no way to notice that
// twenty of them had no ring.
test("a summary gets the tool's own focus ring, not the browser's", () => {
const at = CSS.indexOf("---------- Keyboard focus ----------");
assert.ok(at >= 0);
const block = CSS.slice(at, CSS.indexOf("outline: 2px solid #7fc4ff", at));
assert.match(block, /(^|\n)summary:focus-visible,/);
});
// Only script ever focuses these, so `:focus` would ring the title of every sheet
// on a phone that has no keyboard to ring it for.
test("the landing heading rings for a keyboard and stays quiet for a thumb", () => {
const at = CSS.indexOf('h2[data-modal-focus]:focus-visible {');
assert.ok(at >= 0, "the heading a sheet lands on shows nothing when it takes focus");
const rule = CSS.slice(at, CSS.indexOf("}", at));
assert.match(rule, /outline: 2px solid #7fc4ff/);
// These headings run to the edge of their sheet; an outward ring is clipped.
assert.match(rule, /outline-offset: -2px/);
assert.ok(!/\[data-modal-focus\]:focus\b(?!-)/.test(CSS),
"a plain :focus rule would ring the title on every tap");
});
});
+533 -41
View File
@@ -15,6 +15,8 @@ const { ActionBar, Render } = require("./load");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
const CSS_JS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"), "utf8");
// ---------------------------------------------------------------------------
@@ -115,53 +117,506 @@ describe("the bar lines up with the table", () => {
// ---------------------------------------------------------------------------
describe("the button seat's name", () => {
// The dealer button's chip sits in the puck's top-right corner. Keeping the name out
// from under it with `padding-right` takes the chip's width off *every* line of the
// name, and the chip is on one of them: measured at 1280x900, the name box is 78px,
// "Christopher" is 66 and fits, and on the seat holding the button it was cut to 56
// and broke as "Christop / her". The button moves every hand, so that is a player
// whose name reads properly for four hands and comes apart on the fifth.
const roomy = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
const float = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName::before \{([^}]*)\}/);
const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
// The button seat's name is the same name in the same box as every other seat's.
//
// Three attempts said otherwise, in this order: a float, a float with a first-line
// indent, and `padding-right: 25px` — the dealer chip's width plus its inset, taken
// off the name because the chip sat inside the puck's top-right corner. All three
// were answers to a chip that had no business being in the writing, and the price
// was a quarter of the name's line on whichever seat currently held the button.
// Measured with the sweep's twelve names: 7 of 12 fitted on the button seat at
// 9-handed on an iPhone against 11 anywhere else, and 1 of 12 against 7 at
// 12-handed on a Galaxy S8.
const name = CSS.match(/\n\.seatPuckName \{([^}]*)\}/);
test("a ring with room for two lines indents only the line beside the chip", () => {
assert.ok(roomy && float, "the roomy regime should use a float");
assert.match(float[1], /float:\s*right/);
assert.match(roomy[1], /padding-right:\s*0/);
test("nothing reserves a gutter inside the name any more", () => {
assert.equal(/\.seat\.seatHasButton \.seatPuckName/.test(CSS), false,
"the button seat's name should be the same box as every other seat's");
});
test("the float is exactly one line tall", () => {
// Tall enough to cover the chip's reach into the name, and no taller: a float
// that spills into the second line indents that one too, which puts the whole
// problem back.
assert.match(float[1], /height:\s*1\.2em/);
test("the float that used to do this is gone, not merely unused", () => {
// Left in place with a one-line name it would clip the name to nothing: the word
// cannot fit beside the float, so it moves to a second line, and there is no
// second line. Measured — "Christopher" drew as an empty label.
assert.equal(/\.seatPuckName::before/.test(CSS), false,
"a float and one line of name cannot both be true");
});
test("break-word stands down where the float is doing the work", () => {
// It is what stops the float working — it breaks a word to fit the short line
// rather than letting it move past the float to the long one.
assert.match(roomy[1], /overflow-wrap:\s*normal/);
test("and no rule anywhere gives a puck's name a second line", () => {
// This is what every puck being the same height rests on. A name is a label, and
// the ring is sized against the tallest puck on it, so one name wrapping is one
// seat setting how far apart all of them have to sit.
assert.ok(name, "the name should have a base rule");
assert.match(name[1], /white-space:\s*nowrap/);
});
test("a crowded ring keeps the padding, because it has only one line", () => {
// `.seatsTight .seatPuckName` is `line-clamp: 1` on purpose, and one line has
// nowhere to move to — the only way to keep it off the chip is to shorten it.
// Trying the float on both cost a whole sweep to find out: the name dropped onto
// a second line that the chip's lower half was still sitting on, and the audit
// called it at every viewport in the list.
assert.ok(base, "the base rule should still reserve the chip's width");
assert.match(base[1], /padding-right:\s*22px/);
assert.match(CSS, /\.seatsTight \.seatPuckName \{[^}]*line-clamp:\s*1/);
test("and it is one line by not wrapping, not by hiding what wrapped", () => {
// A `-webkit-box` clamped to one line hides the lines past the first; it does not
// hide the part of the second one that the row's own min-height leaves uncovered.
// The row is 15px and .puckNameTight's line box is 12.24, so 2.76px of the next
// line showed — "Christ…" with the ascenders of "opher" underneath it.
assert.equal(/(?<![\w-])line-clamp:/.test(CSS), false,
"a clamp and a fixed row height only agree while the type never moves");
assert.match(name[1], /text-overflow:\s*ellipsis/);
});
test("the float is as wide as the chip it stands in for", () => {
const chip = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])width:\s*(\d+)px/);
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(\d+)px/);
const floatW = float[1].match(/width:\s*(\d+)px/);
assert.ok(chip && inset && floatW);
assert.equal(Number(floatW[1]), Number(chip[1]) + Number(inset[1]),
"the float should cover the chip and the gap it is inset by");
test("the box a name is measured in is the box it is drawn in", () => {
// What the clamp cost besides the stray line: a name that wrapped reported
// scrollWidth == clientWidth, so fitSeatNames() could only ever see the overflow
// as height. `nowrap` puts the whole name on one line whether it fits or not,
// which is what makes the width worth reading.
assert.match(name[1], /overflow:\s*hidden/);
assert.equal(/overflow-wrap:\s*break-word/.test(name[1]), false,
"nothing to break a word onto");
});
});
// ---------------------------------------------------------------------------
describe("the table is sized against the bar, not against a pad over it", () => {
// The raise pad is 57px taller than the row it replaces, and the felt was sized
// against it. Measured with five players nine-handed, pressing Raise:
//
// 1280x900 felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px
// 360x640 felt 283x329 -> 283x279, pucks 44 -> 48, worst seat moved 50px
//
// The whole table jumped, re-ran the ten-candidate width search, and undid all of it
// when the dealer pressed Raise to. Every raise, all night, at the one moment they
// are reading the felt to decide what to type.
//
// A phone held sideways already scored 0px, because the rail is a column down the
// side and its height never was the table's business. This is the upright layout
// being held to what the sideways one already does.
test.afterEach(() => { ActionBar.raiseOpen = false; delete ActionBar.barHeightAtRest; });
test("a closed bar is measured, and remembered", () => {
ActionBar.raiseOpen = false;
assert.equal(ActionBar.restingBarHeight(151), 151);
assert.equal(ActionBar.barHeightAtRest, 151);
});
test("and an open pad gets the height the bar had before it opened", () => {
ActionBar.raiseOpen = false;
ActionBar.restingBarHeight(151);
ActionBar.raiseOpen = true;
assert.equal(ActionBar.restingBarHeight(208), 151,
"the felt would resize under the dealer's thumb mid-raise");
});
test("and gets it back the moment the pad closes", () => {
ActionBar.raiseOpen = false;
ActionBar.restingBarHeight(151);
ActionBar.raiseOpen = true;
ActionBar.restingBarHeight(208);
ActionBar.raiseOpen = false;
assert.equal(ActionBar.restingBarHeight(172), 172,
"a bar that really did grow — a side pot to show — still takes its room");
});
test("a pad open before anything was ever measured takes what it can see", () => {
// Nothing has been remembered yet, so the measurement is the only answer there
// is. Better a felt sized for the pad than one sized for undefined.
ActionBar.raiseOpen = true;
delete ActionBar.barHeightAtRest;
assert.equal(ActionBar.restingBarHeight(208), 208);
});
test("the bar that is actually covering the window is still reported honestly", () => {
// Only the felt's question is answered from the resting height. The toast has to
// clear whatever is really there, and so does the page's scroll padding, so
// --actionBarHeight stays a measurement.
const publish = CSS_JS.slice(CSS_JS.indexOf("const publish = () => {"));
const body = publish.slice(0, publish.indexOf("};"));
assert.match(body, /--actionBarHeight[^\n]*Math\.round\(height\)/,
"--actionBarHeight should still be the measured height");
assert.match(body, /publishFeltRoom\(.*restingBarHeight\(height\)/,
"the felt should be the one asking for the resting height");
});
});
// ---------------------------------------------------------------------------
describe("the status block is the same number of lines whoever is acting", () => {
// The bar is fixed to the bottom, so a line added to this block comes straight off
// the felt. The block wraps, and flex settles its line breaks before it decides what
// to shrink — so a name too long for the space left beside the clock and the undo
// pill did not give way, it pushed them onto a third line.
//
// Measured at 360x640 mid-street: "Dee · $600 behind" left the table 283x329 and
// "Christopher · $1,200 behind" made it 283x310, with every seat moving 24px between
// one player and the next. Whose turn it was decided how big the table was.
const acting = CSS.match(/\.actionActing \{([^}]*)\}/);
const who = CSS.match(/\n\.actionWho \{([^}]*)\}/);
const name = CSS.match(/\.actionWhoName \{([^}]*)\}/);
const stack = CSS.match(/\.actionWhoStack \{([^}]*)\}/);
test("the acting row takes a line of its own, once there is somebody on it", () => {
assert.ok(acting, ".actionActing rule not found");
assert.match(acting[1], /flex:\s*0 1 auto/,
"the pill on its own should not claim a line");
const withActor = CSS.match(/\.actionActing:has\(\.actionWho\) \{([^}]*)\}/);
assert.ok(withActor, "nothing gives the acting row its own line");
assert.match(withActor[1], /flex:\s*1 1 100%/,
"without a full basis it shares whatever line the wrap left it");
});
test("and only then — a run-out has nobody to act and cannot spare the line", () => {
// While a board is running out there is nobody to act, so this row is the undo
// pill alone. Claiming a line for it is 22px off the felt on the twelve-handed
// 360x640 table where the pucks are already at their 44px floor, and the sweep
// caught that as a seat's "ALL IN" going back under the blind badge next door.
assert.doesNotMatch(acting[1], /flex:\s*1 1 100%/);
});
test("and it shrinks rather than wrapping", () => {
// Left wrapping, this row reproduced the fault one level down: the undo pill is
// a fixed width, so a long name pushed it onto a line of its own and the block
// was 84px for some players and 66 for others.
assert.match(acting[1], /flex-wrap:\s*nowrap/);
});
test("the name is what gives way", () => {
// `min-width` on a flex item defaults to `auto` — "never smaller than your own
// content" — which is why the ellipsis this was written with never once appeared.
assert.ok(who && name, "the actor should be a name and an amount, not one string");
assert.match(who[1], /min-width:\s*0/);
assert.match(name[1], /min-width:\s*0/);
assert.match(name[1], /text-overflow:\s*ellipsis/);
});
test("and the stack is what does not", () => {
// The same rule the seat pucks keep: a number read wrong is a worse failure than
// a name read short, so the stack keeps every digit.
assert.ok(stack, ".actionWhoStack rule not found");
assert.match(stack[1], /flex:\s*none/);
});
test("except in the rail, which is a column and has no line to shrink into", () => {
// 190px wide: every one of these readings is on its own line there regardless,
// and squeezing a name into a fifth of the column would be shrinking to fit a
// row that isn't there.
const rules = [...CSS.matchAll(/\.actionActing \{([^}]*)\}/g)].map(m => m[1]);
assert.equal(rules.length, 2,
"one rule for the bar and one for the rail — no more, no fewer");
assert.ok(rules.some(body => /flex-wrap:\s*wrap\s*;/.test(body)),
"the rail should say what it wants instead");
});
});
// ---------------------------------------------------------------------------
describe("and the name is the half that is still there at the end of it", () => {
// Shrinking the name had no floor under it. Flex takes the only item that can give
// way and takes it all the way, so with the clock button on the line there was
// nothing left to give: measured with five players nine-handed, mid-street, forcing
// the clock button on —
//
// 360x640 "Christopher" wanted 62px and was drawn 0. "Alexandra" 54 -> 0.
// 390x844 "Christopher" wanted 62px and was drawn 0. "Alexandra" 54 -> 0.
// 430x932 "Christopher" 62 -> 31.
//
// What the bar read at the first two was "· $1,200 behind" — the separator, the
// figure, and no player. The ellipsis the rule was written around never appeared,
// because there were no characters left to put one after.
//
// The rule itself is kept: a number read wrong is worse than a name read short, so
// the stack is never truncated. It is taken off the line whole instead. Measured
// again afterwards, with the same clock button on the same line:
//
// 360x640 "Christopher" 62 -> 44 ("Christo…"), "Alexandra" 54 -> 44
// 390x844 both whole
// 430x932 both whole
//
// and 0px of seat movement across the thirty-second mark at every one of them.
const withLine = (name, stack, run) => {
const el = (extra) => Object.assign({
hidden: false, scrollWidth: 0, clientWidth: 0,
}, extra);
const nameEl = el(name);
const stackEl = el(stack);
global.document = {
querySelector: sel => (sel.endsWith(".actionWhoName") ? nameEl
: sel.endsWith(".actionWhoStack") ? stackEl : null),
};
try { run(nameEl, stackEl); } finally { delete global.document; }
};
test("a name that fits keeps the stack beside it", () => {
withLine({ scrollWidth: 54, clientWidth: 54 }, {}, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, false);
});
});
test("a name being cut takes the stack off the line first", () => {
withLine({ scrollWidth: 62, clientWidth: 0 }, {}, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, true,
"the stack is on that player's own seat puck; the name is only here");
});
});
test("and a stack already off it is put back before anything is measured", () => {
// Otherwise the answer is a property of whatever the last render left behind
// rather than of the line: a name that fits only *because* the stack is missing
// would keep it missing for the rest of the night.
withLine({ scrollWidth: 54, clientWidth: 54 }, { hidden: true }, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, false);
});
});
test("a hair of rounding is not a cut", () => {
withLine({ scrollWidth: 55, clientWidth: 54 }, {}, (nameEl, stackEl) => {
ActionBar.fitActingLine();
assert.equal(stackEl.hidden, false);
});
});
test("and a bar with nobody acting on it is left alone", () => {
global.document = { querySelector: () => null };
try { assert.doesNotThrow(() => ActionBar.fitActingLine()); }
finally { delete global.document; }
});
test("the stylesheet can actually take it off the line", () => {
// `hidden` is a UA-stylesheet default, and .actionWhoStack sits inside a flex
// container. Said out loud here so a later `display:` on the class can't quietly
// put the figure back.
assert.match(CSS, /\.actionWhoStack\[hidden\]\s*\{[^}]*display:\s*none/);
assert.match(CSS, /\.actionTank\[hidden\]\s*\{[^}]*display:\s*none/);
});
test("the space before the separator is in the stylesheet, not in the string", () => {
// These are two flex items, and a flex item's own leading whitespace is collapsed
// at its edge — so ` · $1,200 behind` drew as `Alexandra· $1,500 behind` from the
// day the sentence was split in two.
const who = CSS.match(/\n\.actionWho \{([^}]*)\}/);
assert.match(who[1], /gap:\s*var\(--sp-1\)/);
assert.match(CSS_JS, /whoStack\.textContent = `· \$\{/,
"a leading space here is invisible and would double the gap if it weren't");
});
test("and the fit is taken after the timer has decided whether it is on the line", () => {
const render = CSS_JS.slice(CSS_JS.indexOf(" render() {"));
const body = render.slice(0, render.indexOf("\n },"));
const tank = body.indexOf("this.syncTankTimer()");
const fit = body.indexOf("this.fitActingLine()");
assert.ok(tank >= 0 && fit >= 0, "render() should do both");
assert.ok(fit > tank,
"measured before the stopwatch is hidden, the name is measured against 50px it has");
});
});
// ---------------------------------------------------------------------------
describe("what the undo pill says it would take back", () => {
// The pill is a fixed width — it names the last action, so its text changes on every
// tap, and a control that resizes under a thumb reads as the interface twitching —
// and on a phone that width leaves the label 73px. Measured across one hand, four of
// the five labels a hand produces wanted more:
//
// ↶ dealing the hand 104 ↶ Christopher's fold 109
// ↶ Mike from work's fold 128 ↶ Alexandra's fold 100
// ↶ Dee's fold 69 (the only one that fitted)
//
// So the safety net spent most of a night reading "↶ dealing …", which does not say
// what it is a net for. Cut as one string it says the wrong half: "↶ Mike from wor…"
// names everybody except the action.
//
// Two halves, and the actor is the one that goes — whole, not shortened. Measured
// again afterwards, nothing is cut at any viewport in the sweep: a phone reads
// "↶ the hand", "↶ Dee's fold", "↶ raise to $20", "↶ call"; a desktop and the
// landscape rail keep the actor wherever it fits.
test("a label handed over in halves knows which is which", () => {
const step = ActionBar.undoParts({ lead: "Christopher's", act: "fold" });
assert.equal(step.lead, "Christopher's");
assert.equal(step.act, "fold");
assert.equal(step.long, "Christopher's fold", "the sentence the toast and the tooltip use");
});
test("one with no actor is all act", () => {
// "the straddle" belongs to the table rather than to a player.
const step = ActionBar.undoParts({ lead: "", act: "the straddle" });
assert.equal(step.lead, "");
assert.equal(step.long, "the straddle", "no stray space in front of it");
});
test("and a plain string still works, with nothing to drop", () => {
// Every caller passes halves today. This is what keeps a caller that doesn't
// from drawing "[object Object]" on the one control a dealer reaches for when
// they have made a mistake.
const step = ActionBar.undoParts("something nobody split");
assert.equal(step.lead, "");
assert.equal(step.act, "something nobody split");
assert.equal(step.long, "something nobody split");
});
test("the sentence is what goes into the history, and the halves ride with it", () => {
const seen = [];
const before = ActionBar.hooks;
ActionBar.hooks = {
beginAction: (label, parts) => seen.push({ label, parts }),
save: () => {}, rerender: () => {},
};
try {
ActionBar.commit({ lead: "Dee's", act: "call" }, () => {});
} finally { ActionBar.hooks = before; }
assert.equal(seen.length, 1);
assert.equal(seen[0].label, "Dee's call", "the toast says the whole thing");
assert.deepEqual(
{ lead: seen[0].parts.lead, act: seen[0].parts.act },
{ lead: "Dee's", act: "call" });
});
test("and the halves are read back from the history, not remembered here", () => {
// Undo pops the history. A copy kept on this object would name the step that was
// just taken back rather than the one now on top of the pile.
const before = ActionBar.hooks;
ActionBar.hooks = {
lastActionLabel: () => "Dee's call",
lastActionParts: () => ({ lead: "Dee's", act: "call" }),
};
try {
assert.deepEqual(ActionBar.undoStep(), { lead: "Dee's", act: "call" });
} finally { ActionBar.hooks = before; }
});
test("a caller that offers no halves gets the label whole", () => {
const before = ActionBar.hooks;
ActionBar.hooks = { lastActionLabel: () => "dealing the hand" };
try {
assert.deepEqual(ActionBar.undoStep(), { lead: "", act: "dealing the hand" });
} finally { ActionBar.hooks = before; }
});
const withPill = (act, run) => {
const lead = { hidden: false, className: "actionUndoLead" };
const label = {
className: "actionUndoLabel",
querySelector: sel => (sel === ".actionUndoLead" ? lead
: sel === ".actionUndoAct" ? act : null),
};
global.document = {
querySelector: sel => (sel.endsWith(".actionUndoLabel") ? label : null),
};
try { run(lead); } finally { delete global.document; }
};
test("an act that fits keeps the actor beside it", () => {
withPill({ scrollWidth: 19, clientWidth: 40 }, lead => {
ActionBar.fitUndoLabel();
assert.equal(lead.hidden, false);
});
});
test("an act being cut drops the actor whole", () => {
withPill({ scrollWidth: 63, clientWidth: 40 }, lead => {
ActionBar.fitUndoLabel();
assert.equal(lead.hidden, true,
"\"Christo…'s fold\" is a worse sentence than \"fold\"");
});
});
test("and it is the act that is asked, never the label", () => {
// The label cannot overflow: the act inside it is the item with `overflow:
// hidden`, so it swallows the excess and the row measures as though everything
// fitted. Asking the label was the first way this was written, and it drew
// "↶ dealing the hand" with "the hand" quietly cut off inside it.
const fit = CSS_JS.slice(CSS_JS.indexOf(" fitUndoLabel() {"));
const body = fit.slice(0, fit.indexOf("\n },"));
assert.match(body, /act\.scrollWidth > act\.clientWidth/);
assert.doesNotMatch(body, /label\.scrollWidth/);
});
test("an actor already dropped is put back before anything is measured", () => {
withPill({ scrollWidth: 19, clientWidth: 40 }, lead => {
lead.hidden = true;
ActionBar.fitUndoLabel();
assert.equal(lead.hidden, false);
});
});
test("and a bar with nothing to take back is left alone", () => {
global.document = { querySelector: () => null };
try { assert.doesNotThrow(() => ActionBar.fitUndoLabel()); }
finally { delete global.document; }
});
test("the keycap goes where there is no keyboard to press it", () => {
// Every other control on the bar had dropped its cap on a phone since the rule
// was written; this one is a different class and had been left out of it, so the
// narrowest label on the bar was the only one still spending 22px on a shortcut
// nothing could press.
const rule = CSS.match(/@media \(hover: none\) \{\s*\.actionKey,\s*\.actionUndoKey \{([^}]*)\}/);
assert.ok(rule, ".actionUndoKey should be hidden alongside .actionKey on touch");
assert.match(rule[1], /display:\s*none/);
});
});
// ---------------------------------------------------------------------------
describe("a stopwatch that is only there when it is saying something", () => {
// 50px of a 349px line, from the first second of every decision, reading "0:07" in
// grey. It is a prompt rather than a readout — nothing happens when it grows — and a
// prompt that is always on screen is not a prompt. It arrives when the button that
// acts on it arrives, which is the same threshold, and the room goes to the name.
const withClock = ({ seat = 0, started = null, unknown = null, clockAt = null }, run) => {
const saved = {
state: ActionBar.state, tankSeatIndex: ActionBar.tankSeatIndex,
tankStartedAt: ActionBar.tankStartedAt, tankUnknownFor: ActionBar.tankUnknownFor,
clockSeatIndex: ActionBar.clockSeatIndex, clockStartedAt: ActionBar.clockStartedAt,
};
ActionBar.state = { actingSeatIndex: seat };
ActionBar.tankSeatIndex = seat;
ActionBar.tankStartedAt = started;
ActionBar.tankUnknownFor = unknown;
ActionBar.clockSeatIndex = clockAt === null ? null : seat;
ActionBar.clockStartedAt = clockAt;
try { run(); } finally { Object.assign(ActionBar, saved); }
};
test("a decision that has only just landed on somebody shows neither", () => {
withClock({ started: Date.now() - 3000 }, () =>
assert.equal(ActionBar.tankWorthShowing(), false));
});
test("and both arrive together, at the threshold the stopwatch already marked", () => {
withClock({ started: Date.now() - (ActionBar.NUDGE_AFTER_S * 1000) }, () =>
assert.equal(ActionBar.tankWorthShowing(), true));
});
test("a clock already running is always worth watching", () => {
// The readout is a countdown by then, and the button beside it says "Stop the
// clock" — there is no second at which either of those is not the point.
withClock({ started: Date.now(), clockAt: Date.now() }, () =>
assert.equal(ActionBar.tankWorthShowing(), true));
});
test("so is the seat whose wait cannot be known", () => {
// The action was already on them when the page loaded. The dash and the sentence
// behind it are the answer to "why is there no time on this one", so the answer
// has to be visible for the question to get asked.
withClock({ started: Date.now(), unknown: 0 }, () =>
assert.equal(ActionBar.tankWorthShowing(), true));
});
test("and with nothing to time from at all, nothing is shown", () => {
withClock({ started: null }, () =>
assert.equal(ActionBar.tankWorthShowing(), false));
});
test("it is built hidden, so a fresh decision never flashes one up", () => {
assert.match(CSS_JS, /tank\.hidden = true;/);
});
test("and the tick re-fits the line only when one of them actually moved", () => {
// The line is measured by forcing a layout, and this runs once a second all
// night. Nothing has changed on the seconds in between.
const paint = CSS_JS.slice(CSS_JS.indexOf(" paintTank() {"));
const body = paint.slice(0, paint.indexOf("\n },"));
assert.match(body, /if \(el\.hidden !== wasTank \|\| \(nudge && nudge\.hidden !== wasNudge\)\) this\.fitActingLine\(\)/);
});
});
@@ -234,9 +689,17 @@ describe("a name that will not fit", () => {
// ladder — which words go, in what order, and when it stops — and the browser's part
// is one boolean per pass.
const label = (text, fits) => {
const classes = new Set();
const el = {
textContent: text,
title: "",
// The step of type a name takes when it has no word left to drop, held as
// the same set of strings the real element's classList is.
classList: {
add: name => classes.add(name),
remove: name => classes.delete(name),
contains: name => classes.has(name),
},
// The browser's answer, standing in for a real one: this name fits if
// `fits` says the version currently written to it does.
get scrollWidth() { return fits(this.textContent) ? 10 : 100; },
@@ -309,13 +772,16 @@ describe("a name that will not fit", () => {
assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop");
});
test("a single word is left to the ellipsis it always had", () => {
// There is no honest way to shorten "Christopher" that a dealer glancing across
// a table would read faster than "Christo…".
test("a single word steps its type down rather than being cut", () => {
// There is no honest way to shorten "Christopher" at a word, and "Christo…" is a
// name only until a Christine sits down. The amounts on the same puck have never
// taken that trade — fitAmount shrinks a tournament stack rather than rounding it
// — and a misread name is the same kind of failure as a misread stack.
const el = label("Christopher", () => false);
seatsNamed("Christopher");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Christopher", "never cut mid-word here");
assert.ok(el.classList.contains("puckNameTight"), "and never left overflowing either");
});
test("a name that fits is not touched", () => {
@@ -323,6 +789,32 @@ describe("a name that will not fit", () => {
seatsNamed("Mike from work");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Mike from work");
assert.ok(!el.classList.contains("puckNameTight"), "nothing to shrink for");
});
test("a name shortened until it fits is not also shrunk", () => {
// The step of type is the last resort, not a second helping. "Mike" fits, so it
// is read at the size everything else on the felt is.
const el = label("Mike from work", text => text === "Mike");
seatsNamed("Mike from work");
Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Mike");
assert.ok(!el.classList.contains("puckNameTight"));
});
test("the step comes off again when the puck it was for is gone", () => {
// Otherwise it is a ratchet: a name that had to shrink on a nine-handed phone
// would still be shrunk on the same table six-handed, or on a desktop, because
// nothing ever measured it at the size it was supposed to be read at.
const el = label("Christopher", () => false);
seatsNamed("Christopher");
Render.fitSeatNames(layer([el]));
assert.ok(el.classList.contains("puckNameTight"));
const roomy = label("Christopher", () => true);
roomy.classList.add("puckNameTight");
Render.fitSeatNames(layer([roomy]));
assert.ok(!roomy.classList.contains("puckNameTight"));
});
test("the whole name is on the tooltip whether it was shortened or not", () => {
+274
View File
@@ -0,0 +1,274 @@
// The sheet a dealer opens mid-argument.
//
// It held twelve rulings with all their prose showing — 2,480px of it on a 390px
// phone, of which 1,754 was below the fold — and a row of twelve chips across the top
// to jump between them. The chips were an index of the twelve headings printed directly
// above the same twelve headings: the same list twice, 172px of navigation before the
// first word, and the dealer who used one landed in the middle of a scroll with eleven
// rulings they hadn't asked for still wrapped around them.
//
// The headings are the index now. NN/g's rule for this shape — a longer list of
// independent topics somebody opens one of — is an accordion, shut by default, and
// half of these headings answer the question without being opened at all: "A short
// all-in doesn't reopen the betting" is the ruling, not the topic. Measured after:
// 29px below the fold on an iPhone with all twelve rows on screen at once, 51px on a
// desktop. Opening the longest of them leaves 244px.
//
// These are layout and markup decisions, so they are checked as text — except the one
// about what the sheet does when it is opened again, which is behaviour and lives in
// main.js.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const MAIN = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
const sheet = () => {
const at = PAGE.indexOf('<div id="rulingsOverlay"');
assert.ok(at >= 0, "there is no rulings overlay");
return PAGE.slice(at, PAGE.indexOf('<div id="seatMenuOverlay"', at));
};
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// Every @media block written at exactly this condition, concatenated — the same helper
// theHeader.test.js uses, and for the same reason: this condition is opened more than
// once in the file, and a helper that stops at the first finds a block with none of
// these rules in it and reports 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);
assert.ok(found, `there is no \`${selector}\` rule in that block`);
return found[2];
};
const SIDEWAYS = () => mediaBlock("(orientation: landscape) and (max-height: 560px)");
// Each ruling as its own slice of markup.
const rulings = () => {
const parts = sheet().split('<details class="ruling"');
return parts.slice(1).map(p => p.slice(0, p.indexOf("</details>")));
};
// ---------------------------------------------------------------------------
describe("twelve rulings, shut", () => {
test("every one of them is a disclosure, and there are still twelve", () => {
assert.equal(rulings().length, 12);
});
// NN/g is explicit that opening the first one by default costs the reader the
// glance at the whole list, which on this sheet is the entire point.
test("none of them is open in the markup", () => {
assert.ok(!/<details class="ruling"[^>]*\bopen\b/.test(sheet()),
"a ruling starts open, so the sheet no longer opens on its own contents");
});
// Native exclusive accordions close the open one when another is opened, which
// shifts everything below it under the finger that is reaching. That is the
// disorientation the accordion is here to remove, not add.
test("opening one does not close another", () => {
assert.ok(!/<details class="ruling"[^>]*\bname=/.test(sheet()),
"the rulings are an exclusive accordion, so content moves when one opens");
});
test("the name of a ruling is the control that opens it", () => {
rulings().forEach((r, i) => {
assert.match(r, /<summary><h3>.+<\/h3><\/summary>/,
`ruling ${i + 1} does not carry its heading in its summary`);
});
});
});
describe("the index that was above them is gone, not merely hidden", () => {
test("nothing builds a chip row any more", () => {
assert.ok(!/rulingsIndex/.test(PAGE), "the index nav is still in the markup");
assert.ok(!/buildRulingsIndex|rulingsIndexBtn/.test(MAIN), "main.js still builds the index");
assert.ok(!/rulingsIndexBtn/.test(CSS), "the index chips still have styles");
});
// The short labels existed only to fit three chips on a line. With the full
// headings doing the work there is nothing left to read them.
test("and the short labels it read are gone with it", () => {
assert.ok(!/data-short=/.test(PAGE));
});
});
describe("what a summary has to be to work", () => {
// A <summary> is announced as a button and not as a heading, so without this the
// twelve names — the whole of this sheet's navigation — are invisible to anybody
// moving through it by heading. Same fix as the sidebar's panel names.
test("the heading survives being a control", () => {
assert.match(ruleFor(".ruling > summary > h3"), /display:\s*inline/);
assert.match(ruleFor(".ruling > summary > h3"), /font:\s*inherit/);
});
// A <summary> is content-box, so a min-height is *added* to its padding — the trap
// the sidebar's headers were in, one stylesheet section over.
test("a thumb gets its 44px, counted the way the box actually counts", () => {
const rule = ruleFor(".ruling > summary");
assert.match(rule, /box-sizing:\s*border-box/);
assert.match(rule, /min-height:\s*44px/);
});
test("the accent belongs to the ruling being read, not to all twelve", () => {
assert.match(ruleFor(".ruling > summary"), /color:\s*var\(--text\)/);
assert.match(ruleFor(".ruling[open] > summary"), /color:\s*var\(--gold\)/);
});
test("and so does the gold bar down the side", () => {
assert.match(ruleFor(".ruling[open]"), /border-left:\s*2px solid/);
assert.ok(!/border-left/.test(ruleFor(".ruling")),
"every ruling wears the open one's marker");
});
});
describe("reading one of them", () => {
// Five of the twelve are two paragraphs and they ran into each other: the sections
// were spaced from one another, but nothing separated the halves of one ruling.
test("two paragraphs of a ruling are two paragraphs", () => {
assert.match(ruleFor(".ruling p + p"), /margin-top:\s*var\(--sp-\d\)/);
});
// <details> keeps its state, so without this a dealer who read the odd-chip rule an
// hour ago finds it still hanging open under the one they came back for.
test("the sheet opens shut, however it was left", () => {
const at = MAIN.indexOf("const openRulings");
assert.ok(at >= 0, "nothing owns what the sheet looks like when it opens");
const body = MAIN.slice(at, MAIN.indexOf("};", at));
assert.match(body, /\.ruling\[open\]/);
assert.match(body, /open = false/);
});
test("and that is what the button calls", () => {
assert.match(MAIN, /getElementById\("openRulingsBtn"\)\.addEventListener\("click", openRulings\)/);
});
});
// The dealer who has read the answer has to be able to leave without scrolling back
// past it. Measured before this: on a 360x640 phone the twelve shut rulings are 791px of
// a 549px sheet, so at the bottom of that scroll Done is 221px above the sheet's top
// edge; on a 390x844 phone with one ruling opened — the only reason anybody is in here —
// it is 262px. The seat menu's sheet had already been given the fix and this one, the
// longer of the two, had not.
describe("the way out does not scroll away", () => {
test("the header is stuck to the top of the sheet's own scrollport", () => {
const rule = ruleFor(".rulingsHeader");
assert.match(rule, /position:\s*sticky/);
assert.match(rule, /top:\s*0/);
// Out to the sheet's edges and back, or the list shows through either side of it.
assert.match(rule, /margin:\s*0 calc\(var\(--sp-4\) \* -1\)/);
assert.match(rule, /background:\s*var\(--surface-2\)/);
});
test("and nothing is left above it for the list to scroll out through", () => {
assert.match(ruleFor("#rulingsSheet"), /padding:\s*0 var\(--sp-4\)/,
"the sheet keeps a top padding, which is a gap above a sticky header");
// The grip was the one thing up there. It belongs inside the header now.
const header = sheet().match(/<div class="rulingsHeader">[\s\S]*?<\/div>\s*<\/div>/);
assert.ok(header, "the header is not in the markup where it was");
assert.match(header[0], /seatSheetGrip/,
"the grip is still outside the header, so it scrolls away above it");
assert.ok(ruleFor(".rulingsHeader .seatSheetGrip"),
"the grip has no rule placing it inside a two-column header");
});
test("the scroll inside the sheet stays inside it", () => {
assert.match(ruleFor("#rulingsSheet"), /overscroll-behavior:\s*contain/);
});
});
// A phone held sideways is 844x390: no height and swimming in width. Measured before
// this, the bottom sheet was 460px wide in the middle of that window — 384px of it
// unused either side — holding 754px of rulings in a 334px scrollport, 111px of which
// went on a title and a caveat that stand still. Four of the twelve names on screen.
//
// Material's rule for a bottom sheet in landscape is to stop being one, and this tool
// already does exactly that under this query for the action bar. So the sheet takes the
// window and the width pays for the height: two columns of six, all twelve names on
// screen at 844x390 with nothing to scroll at all.
describe("a phone on its side gets the whole window", () => {
test("the twelve are six and six in the markup", () => {
const columns = sheet().split('<div class="rulingsColumn">').slice(1);
assert.equal(columns.length, 2, "there are not exactly two columns to stand side by side");
columns.forEach((c, i) => {
const count = (c.slice(0, c.indexOf("\n </div>")).match(/<details class="ruling"/g) || []).length;
assert.equal(count, 6, `column ${i + 1} holds ${count} rulings rather than six`);
});
});
test("but everywhere else they are still one column", () => {
// `display: contents` hands the rulings straight to the list, so every layout
// that isn't landscape sees exactly the markup it saw before the wrappers.
assert.match(ruleFor(".rulingsColumn"), /display:\s*contents/);
assert.match(ruleIn(SIDEWAYS(), ".rulingsColumn"), /display:\s*block/);
});
test("sideways the sheet stops being a sheet and becomes the screen", () => {
const block = SIDEWAYS();
assert.match(ruleIn(block, "#rulingsOverlay"), /align-items:\s*stretch/,
"the sheet is still anchored to the bottom edge, so it is still short");
const sheetRule = ruleIn(block, "#rulingsSheet");
assert.match(sheetRule, /height:\s*100%/);
assert.match(sheetRule, /max-height:\s*none/);
// Capped rather than unbounded: this query also matches a short desktop window,
// and two columns of a 1400px one would be 680px lines of body text.
assert.match(sheetRule, /max-width:\s*62rem/);
assert.match(sheetRule, /overflow:\s*hidden/,
"the sheet scrolls as well as the list, which is two scrollbars for one list");
});
test("the title, the caveat and the way out share one line", () => {
// That line is the whole of the 111px this screen was spending on them, and the
// sticky positioning goes with the box being dropped: nothing above the list
// scrolls here, so nothing has to stick.
const block = SIDEWAYS();
assert.match(ruleIn(block, ".rulingsHeader"), /display:\s*contents/);
assert.match(ruleIn(block, ".rulingsHeader .seatSheetGrip"), /display:\s*none/,
"a grab handle on a sheet that is not a sheet any more");
assert.match(ruleIn(block, "#rulingsSheet"), /grid-template-areas:\s*\n?\s*"title intro done"/);
});
test("the two columns are two flows, not one flow wrapped", () => {
// A grid with twelve children of its own pairs them into six shared rows, and
// opening the third ruling on the left pushes the ninth down the right-hand side
// to keep the row square. Two wrappers means a column only ever moves itself.
const rule = ruleIn(SIDEWAYS(), ".rulingsList");
assert.match(rule, /grid-template-columns:\s*1fr 1fr/);
assert.match(rule, /overflow-y:\s*auto/);
assert.match(rule, /min-height:\s*0/,
"a grid item's floor is its content, so the list would push the sheet open");
});
test("and the gutter is reserved before it is needed", () => {
// It arrives the moment a ruling is opened. Without the reservation it takes 8px
// off the tracks and slides the right-hand column sideways under the eye that is
// reading it.
assert.match(ruleIn(SIDEWAYS(), ".rulingsList"), /scrollbar-gutter:\s*stable/);
});
});
+263
View File
@@ -0,0 +1,263 @@
// What a seat is doing, asked once.
//
// The seat editor had two checkboxes, "In hand" and "Fold". A seat is really in one of
// three states — sitting out, in the hand, folded — and "folded" means nothing while
// the seat is out, so two boxes offered a fourth corner that has no meaning. The code
// that stopped a dealer reaching it `disabled` the Fold box whenever In hand was clear.
//
// Measured: that left the label at opacity 1 and colour rgb(170,170,170) — identical to
// a live one — with a `title` of "Folded" on it. So pressing Fold on a seat that was
// sitting out gave no movement and no reason, which is the exact failure round 16 went
// through the whole tool to remove. It hid from the sweep rule that finds those because
// the title sat on the <label> rather than on the control the rule inspects.
//
// Three named states cannot contradict each other and none of them is ever unavailable,
// so there is nothing left to dim.
//
// The second half of this round is where that form appears at all. The phone block's
// own comment has always said why the editor becomes a bottom sheet — "the seat editor
// lives far enough down the page that tapping a seat looks like it did nothing" — but
// the sheet was scoped to 700px while #holdemLayout goes to one column at 900. The two
// tiers in between had precisely the fault the sheet was written to fix. Measured,
// tapping a seat:
//
// 844x390 the editor drew in the flow at y=369 in a 390px window: 21px of 401 on
// screen, 5% of it, at scrollY 0 with nothing scrolling it into view.
// 768x1024 31% visible, the rest below the fold, and again no scroll.
//
// Afterwards every viewport from 360x640 to 900x500 shows 97-99% of the sheet, and
// above 900 the editor is a static block at the top of a sidebar that is beside the
// table rather than below it — 100% visible there too.
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 ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const seatForm = () => {
const at = PAGE.indexOf('<template id="seatEditorTemplate">');
assert.ok(at >= 0, "there is no seat editor template");
return PAGE.slice(at, PAGE.indexOf("</template>", at));
};
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// Everything inside every block with this query, braces balanced and joined. Every one
// of them: this stylesheet opens `@media (hover: none)` twice, and a helper that stops
// at the first has twice caught a test asserting about the wrong half of the file.
function blockAt(query) {
const found = [];
for (let at = CSS.indexOf(query); at >= 0; at = CSS.indexOf(query, at + 1)) {
let depth = 0;
for (let i = at + query.length - 1; i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(at, i + 1)); break; }
}
}
assert.ok(found.length, `there is no \`${query}\` block`);
return found.join("\n");
}
// The three buttons, and enough of a form to hand them back.
function fakeForm() {
const options = Render.SEAT_STATES.map(state => ({
dataset: { seatState: state.key },
attrs: {},
tabIndex: 0,
handlers: {},
setAttribute(name, value) { this.attrs[name] = value; },
getAttribute(name) { return this.attrs[name]; },
addEventListener(type, fn) { this.handlers[type] = fn; },
focus() { options.focused = this.dataset.seatState; },
}));
return {
options,
querySelectorAll: selector => {
assert.equal(selector, ".seatStateOption");
return options;
},
};
}
const checked = form => form.options.filter(o => o.getAttribute("aria-checked") === "true")
.map(o => o.dataset.seatState);
// ---------------------------------------------------------------------------
describe("three states, not two booleans", () => {
test("the markup asks one question with three answers", () => {
const form = seatForm();
const at = form.indexOf('class="seatState"');
assert.ok(at >= 0, "the seat form has no state control");
const group = form.slice(form.lastIndexOf("<div", at), form.indexOf("</div>", at));
assert.match(group, /role="radiogroup"/);
assert.match(group, /aria-label="This seat"/);
["out", "in", "folded"].forEach(key => {
assert.match(group, new RegExp(`data-seat-state="${key}"`), `there is no "${key}" answer`);
});
assert.equal((group.match(/role="radio"/g) || []).length, 3);
});
test("and the two checkboxes are gone from the page and the stylesheet", () => {
["inHandCheckbox", "foldCheckbox", "inHandToggle", "foldToggle", "seatCardToggles"]
.forEach(name => {
assert.ok(!PAGE.includes(name), `${name} is still in the markup`);
assert.ok(!CSS.includes(name), `${name} is still in the stylesheet`);
});
});
// The pair (out, folded) is the one the old control could be asked for.
test("every state names both halves, and none of them is the meaningless pair", () => {
assert.deepEqual(Render.SEAT_STATES.map(s => s.key), ["out", "in", "folded"]);
Render.SEAT_STATES.forEach(state => {
assert.equal(typeof state.inHand, "boolean");
assert.equal(typeof state.folded, "boolean");
assert.ok(!(state.folded && !state.inHand),
`"${state.key}" is folded and not in the hand, which means nothing`);
});
});
test("and a seat reads back as the state it was put in", () => {
Render.SEAT_STATES.forEach(state => {
assert.equal(Render.seatStateOf({ inHand: state.inHand, folded: state.folded }), state.key);
});
// Including a seat saved before any of this, where folded was left set on a
// chair that had sat out.
assert.equal(Render.seatStateOf({ inHand: false, folded: true }), "out");
});
});
describe("what the control draws", () => {
test("exactly one answer is marked, and it is the seat's", () => {
const form = fakeForm();
Render.paintSeatState(form, { inHand: true, folded: true });
assert.deepEqual(checked(form), ["folded"]);
Render.paintSeatState(form, { inHand: false, folded: false });
assert.deepEqual(checked(form), ["out"]);
Render.paintSeatState(form, { inHand: true, folded: false });
assert.deepEqual(checked(form), ["in"]);
});
// One stop for the group, arrows inside it — the APG's radio group, and the same
// shape the card picker's suits use.
test("and exactly one of the three is a Tab stop", () => {
const form = fakeForm();
Render.paintSeatState(form, { inHand: true, folded: false });
assert.deepEqual(form.options.map(o => o.tabIndex), [-1, 0, -1]);
});
});
describe("what pressing one does", () => {
const wired = player => {
const form = fakeForm();
const commits = [];
const realCommit = Render.commitSeatChange;
Render.commitSeatChange = () => commits.push(true);
try {
Render.wireSeatState(form, player, 0);
} finally {
Render.commitSeatChange = realCommit;
}
return { form, commits, press: key => {
const btn = form.options.find(o => o.dataset.seatState === key);
const again = Render.commitSeatChange;
Render.commitSeatChange = () => commits.push(true);
try { btn.handlers.click(); } finally { Render.commitSeatChange = again; }
} };
};
test("it writes both halves of the seat, not one", () => {
const player = { inHand: true, folded: false };
const { press } = wired(player);
press("folded");
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: true });
press("out");
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: false, folded: false });
press("in");
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: false });
});
test("and says so, so the table redraws", () => {
const player = { inHand: true, folded: false };
const { commits, press } = wired(player);
const before = commits.length;
press("out");
assert.ok(commits.length > before, "nothing was committed, so the felt still shows the old state");
});
// Typing a name into an empty chair seats somebody. The control has to say so —
// it is the only thing on the form that shows it.
test("naming an empty chair repaints the control rather than leaving it stale", () => {
const src = fs.readFileSync(
path.join(ROOT, "wwwroot", "js", "casino", "holdem", "render.js"), "utf8");
const at = src.indexOf("const wasEmpty = !player.name.trim()");
assert.ok(at >= 0);
assert.match(src.slice(at, at + 400), /this\.paintSeatState\(form, player\);/);
});
});
describe("how it is drawn", () => {
// The answer moves between the three as the hand goes on, and a segment that
// resizes under the thumb reads as the control flinching.
test("three equal columns, whatever the words in them", () => {
assert.match(ruleFor(".seatState"), /grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/);
});
test("the chosen one is the one that looks pressed", () => {
assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /background:\s*var\(--surface-3\)/);
assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /color:\s*var\(--text-bright\)/);
});
// The same red the seat menu's own Fold wears, and only when it is the answer: a red
// segment sitting unpressed beside two grey ones reads as a warning about the seat.
test("folded is red, and only once it is chosen", () => {
const rule = ruleFor('.seatStateOption[data-seat-state="folded"][aria-checked="true"]');
assert.match(rule, /#3d1f1f/);
assert.ok(!/\n\.seatStateOption\[data-seat-state="folded"\] \{/.test(CSS),
"an unchosen Folded segment is painted red");
});
test("and a thumb gets its 44px wherever there is no pointer", () => {
assert.match(blockAt("@media (hover: none) {"), /\.seatStateOption \{\s*min-height: 44px;/);
});
});
describe("where the form appears at all", () => {
// The sheet exists because a stacked sidebar puts the editor far below the felt.
// That is true at every width where the layout is one column.
test("the sheet is scoped to the breakpoint that stacks the layout", () => {
const oneColumn = blockAt("@media (max-width: 900px) {");
assert.match(oneColumn, /#holdemLayout \{\s*grid-template-columns: 1fr;/,
"900px is no longer where the sidebar stops being a column beside the table");
const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
const query = sheet.slice(sheet.indexOf("@media"), sheet.indexOf("{", sheet.indexOf("@media")) + 1);
assert.equal(query.trim(), "@media (max-width: 900px) {");
});
test("and it is a sheet, fixed to the bottom, not a block in the flow", () => {
const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
const at = sheet.indexOf(" #seatEditorPanel {");
assert.ok(at >= 0, "the sheet block no longer positions the panel");
const rule = sheet.slice(at, sheet.indexOf("}", at));
assert.match(rule, /position:\s*fixed/);
assert.match(rule, /bottom:\s*0/);
assert.match(rule, /max-height:\s*74vh/);
});
test("what was measured is written beside the query", () => {
const note = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"),
CSS.indexOf("@media (max-width: 900px) {", CSS.indexOf("---- Seat editor as a bottom sheet ----")));
assert.match(note, /844x390/);
assert.match(note, /768x1024/);
assert.match(note, /5% of it/);
});
});
+277
View File
@@ -0,0 +1,277 @@
// The seat menu on a phone held sideways.
//
// The tool learned two rounds ago that a landscape phone is short of height and
// swimming in width: the action bar stops being a bar across the bottom and becomes a
// rail down the right. The sheets were never told. Measured on the fullest menu the
// tool can build — twelve seated, somebody on the waiting list, blinds still the only
// money in:
//
// 640x360 545px of content in a 306px sheet. Cash out…, Move to…, Edit seat…,
// Small blind, Big blind and Cancel all below the fold, Cancel by 224px.
// 667x375 545 in 319, Cancel 211px under.
// 844x390 529 in 332, Cancel 183px under.
//
// And on all three the sheet was 420px wide inside a window 640 to 844 wide. Two
// hundred and forty-seven pixels of width held empty on the one screen shape whose
// whole problem is height, while the dealer scrolled a menu to find the way out of it.
//
// The two tiers this menu has been sorted into since round 7 — what you press with
// cards in the air, and housekeeping between hands — stop being the top and bottom of
// a column and become the right and left of a row. Fold and All-in land under the same
// thumb the rail puts Fold, Call and Raise under, with Cancel beneath them. Measured
// after: 297px in a 298px sheet at 640x360 and at 667x375, 281 in 282 at 844x390.
// Nothing below the fold on any of them, and the move picker's twelve chairs and its
// Back went from four rows and a scroll to three rows and none.
//
// Upright nothing moved at all: 545px before, 545px after, the same two columns.
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, SeatMenu, makeTable } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// Everything inside one @media block, braces balanced, so a test asks what that block
// says rather than whether a string appears anywhere in six thousand lines.
const SHORT = "@media (max-height: 560px) and (min-width: 600px) {";
function shortViewportBlock() {
const at = CSS.indexOf(SHORT);
assert.ok(at >= 0, "there is no short-viewport block for the sheets");
let depth = 0;
for (let i = at + SHORT.length - 1; i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) return CSS.slice(at, i + 1);
}
assert.fail("the short-viewport block is never closed");
}
// A document small enough to answer what paint() asks of one. It builds elements,
// appends them and reads their labels back; nothing here lays anything out, which is
// the stylesheet's half of the job and is checked as text below.
function fakeDom() {
const element = tag => ({
tag,
className: "",
type: "",
textContent: "",
children: [],
appendChild(child) { this.children.push(child); return child; },
addEventListener() {},
setAttribute() {},
focus() {},
get childElementCount() { return this.children.length; },
set innerHTML(value) { assert.equal(value, "", "only clearing is modelled"); this.children = []; },
get innerHTML() { return ""; },
});
const byId = {};
["seatMenuTitle", "seatMenuSubtitle", "seatMenuActions", "seatMenuCancelBtn",
"seatMenuOverlay"].forEach(id => { byId[id] = element("div"); });
return {
byId,
doc: {
createElement: element,
getElementById: id => byId[id] || null,
},
};
}
function withDocument(doc, body) {
const had = Object.prototype.hasOwnProperty.call(globalThis, "document");
const before = globalThis.document;
globalThis.document = doc;
try { return body(); } finally {
if (had) globalThis.document = before; else delete globalThis.document;
}
}
// The fullest menu the tool can build, aimed at a live seat that isn't the dealer with
// the blinds still the only money in and an override already set.
function painted(seatIndex = 3, { move = false, seated = [0, 1, 2, 3, 4], waiting = true } = {}) {
const state = makeTable({ seatCount: 9, seated, stack: 500, dealerSeatIndex: 0 });
Betting.beginHand(state);
state.blindOverride = { sbIndex: 1, bbIndex: 2 };
if (waiting) state.waitingList = [{ id: "w1", name: "Nina" }];
const { doc, byId } = fakeDom();
return withDocument(doc, () => {
SeatMenu.state = state;
SeatMenu.hooks = {};
SeatMenu.seatIndex = seatIndex;
SeatMenu.mode = move ? "move" : null;
SeatMenu.paint();
return { list: byId.seatMenuActions, cancel: byId.seatMenuCancelBtn, state };
});
}
const classes = el => el.className.split(/\s+/).filter(Boolean);
const labels = el => el.children.map(child => child.textContent);
// ---------------------------------------------------------------------------
describe("the two tiers are boxes, not a run of siblings", () => {
// Five loose children can only ever stack. Two boxes can be placed.
test("the sheet's actions are exactly the hand and the housekeeping", () => {
const { list } = painted();
assert.deepEqual(list.children.map(child => child.className),
["seatMenuHand", "seatMenuKeeping"]);
});
test("the hand tier holds the bars a dealer presses with cards in the air", () => {
const { list } = painted();
const hand = list.children[0];
assert.deepEqual(labels(hand), ["Fold", "All-in"]);
hand.children.forEach(btn => {
assert.ok(classes(btn).includes("seatMenuBtn"));
assert.ok(!classes(btn).includes("menuTile"),
"a bar is not a tile — the whole point of the tier is that it is a bar");
});
});
test("and the housekeeping tier holds its captions and grids in order", () => {
const { list } = painted();
const keeping = list.children[1];
assert.deepEqual(keeping.children.map(child => child.className),
["seatMenuCaption", "seatMenuGrid", "seatMenuCaption", "seatMenuGrid"]);
assert.deepEqual(keeping.children.map(child => child.tag), ["p", "div", "p", "div"]);
});
// The sideways layout reads the tier's absence to know it has one column rather
// than two, so an empty tier has to be absent rather than empty.
test("a tier with nothing in it is left out rather than drawn empty", () => {
// A chair nobody is sitting in, with nobody waiting to take it: there is
// nothing at all to do to it while the cards are in the air.
const { list } = painted(7, { seated: [0, 1, 2], waiting: false });
assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"],
"an empty chair still drew a hand tier with nothing in it");
});
});
describe("the move picker", () => {
test("its chairs go in the housekeeping tier too, so the sheet has one column", () => {
const { list } = painted(3, { move: true });
assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"]);
});
// "Seat 3 — Alexandra" sets 116px against "Sit out"'s 50, so this grid asks for a
// different column from the one the tiles use.
test("and they ask for a column of their own width", () => {
const { list } = painted(3, { move: true });
const grid = list.children[0].children[0];
assert.deepEqual(classes(grid), ["seatMenuGrid", "seatMenuGridNames"]);
});
test("the way back out is still the sheet's own foot button", () => {
const { cancel } = painted(3, { move: true });
assert.equal(cancel.textContent, "Back");
assert.equal(painted(3).cancel.textContent, "Cancel");
});
});
describe("upright, nothing moved", () => {
// The wrappers gap exactly as the loose children used to, so the sheet a dealer
// holding a phone the usual way sees is the one it has been since round 7.
test("both tiers are the same column the sheet already was", () => {
const rule = ruleFor(".seatMenuHand,\n.seatMenuKeeping");
assert.match(rule, /flex-direction:\s*column/);
assert.match(rule, /gap:\s*var\(--sp-2\)/);
assert.match(rule, /min-width:\s*0/);
});
test("and the grid is still two columns wherever the screen is not short", () => {
assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
});
});
describe("what the short screen does instead", () => {
const block = shortViewportBlock();
// Height and width, not orientation: a short desktop window has the same shortage
// and the same answer, and a narrow one has neither the width to spend nor a second
// column to spend it on.
test("it is scoped on the shortage rather than on which way up the phone is", () => {
assert.ok(!/orientation/.test(SHORT), "orientation says nothing about how much height there is");
});
test("the sheet takes the width it has been leaving empty", () => {
assert.match(block, /max-width:\s*min\(100% - var\(--sp-5\), 46rem\)/);
});
test("the tiers become the two columns, with the way out under the thumb", () => {
assert.match(block, /"header\s+header"/);
assert.match(block, /"keeping hand"/);
assert.match(block, /"keeping cancel"/);
});
test("which means the box that held them stands aside", () => {
const at = block.indexOf("#seatMenuActions {");
assert.ok(at >= 0, "#seatMenuActions is not addressed here");
assert.match(block.slice(at, block.indexOf("}", at)), /display:\s*contents/);
});
// A column reserved for nothing would narrow the names for no reason.
test("a sheet with no hand tier is one column, not two", () => {
const at = block.indexOf("#seatMenuSheet:not(:has(.seatMenuHand))");
assert.ok(at >= 0, "the move picker still reserves a column for a tier it hasn't got");
const rule = block.slice(at, block.indexOf("}", at));
assert.match(rule, /grid-template-columns:\s*minmax\(0, 1fr\)/);
assert.match(rule, /"keeping"/);
});
test("the tiles spread into as many columns as the width holds", () => {
assert.match(block, /repeat\(auto-fill, minmax\(var\(--tileMin\), 1fr\)\)/);
});
// auto-fit throws away the tracks nothing sits in, so a group of two stretched to
// half the sheet each while the group above it was a third — two grids in a column
// lining up with nothing.
test("and they line up with each other, group to group", () => {
// The comment above the rule says the word it is here to rule out, so the code
// is read with the comments stripped out of it.
const code = block.replace(/\/\*[\s\S]*?\*\//g, "");
assert.ok(!/auto-fit/.test(code),
"auto-fit collapses the empty tracks and the grids stop sharing column edges");
});
// The left column is the taller of the two, and stretching left Cancel a 74px
// button on one phone and a 48px one on the next.
test("nothing grows into the slack the taller column leaves", () => {
assert.match(block, /align-items:\s*start/);
});
});
describe("the numbers the columns are chosen from", () => {
// The floor is what the longest of these labels needs, not a round figure that
// happened to work on the phone it was measured on.
test("a tile's narrowest is a token the grid of names can raise", () => {
assert.match(ruleFor(".seatMenuGrid"), /--tileMin:\s*7\.5rem/);
const block = shortViewportBlock();
const at = block.indexOf(".seatMenuGrid.seatMenuGridNames");
assert.ok(at >= 0, "the chairs never ask for more room than a verb");
assert.match(block.slice(at, block.indexOf("}", at)), /--tileMin:\s*8\.25rem/);
});
// Two words. Every pixel over that comes out of the left column, where it is a
// whole extra column of tiles.
test("the hand column is sized for Fold and All-in and no wider", () => {
const block = shortViewportBlock();
assert.match(block, /grid-template-columns:\s*minmax\(0, 1fr\) minmax\(8rem, 10rem\)/);
});
test("what was measured is written beside the rules", () => {
const block = shortViewportBlock();
const note = CSS.slice(CSS.indexOf("A phone on its side: the seat menu goes beside itself"),
CSS.indexOf(SHORT));
assert.match(note, /640x360/);
assert.match(note, /545px/);
assert.match(note, /224px/);
assert.match(block, /10rem it\s+gets 397 and fits three/);
});
});
+279
View File
@@ -0,0 +1,279 @@
// A control that can't act, pressed on the device this tool is for.
//
// Measured on an iPhone, three hands dealt, the card picker open on an empty seat:
// seven of the fifty-two cards were greyed out, none of them could take focus, and
// tapping one changed nothing whatsoever — no toast, no text, not a byte of DOM. The
// tool did have an answer to "who has the ace of spades?"; it was a `title`, and a
// `title` needs a mouse. The same held in the header, where a new dealer's very first
// screen is two dimmed buttons, and on Export CSV, which was greyed out with no
// explanation written anywhere at all.
//
// `disabled` is the wrong attribute whenever there is something to say. It takes the
// control out of the tab order, so a screen reader never lands on it and never hears
// the description; and it eats the press on a touch screen, where the hover that would
// have shown the `title` does not exist. NN/g's rule for the case is explicit — on a
// touch interface, a press on a disabled control should raise the explanation — and
// WCAG 3.3.1 wants the instruction reachable at all. `aria-disabled` announces the same
// state and leaves the control reachable, so the press arrives and is answered.
//
// It also fixed something nobody had noticed. The picker's arrow keys hunted past dealt
// cards, because a `disabled` button cannot hold focus — and hunting did the exact thing
// the grid's no-wrap rule was written to prevent: Right from the ace of spades over a
// dealt ace of hearts landed on the ace of diamonds, so the column silently shifted and
// the next press of Down walked a different suit. A cell that can hold focus needs no
// hunting, so that code is gone and the grid tells the truth about its own shape.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Unavailable } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const MARKUP = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const src = name => fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// Enough of an element for a module that only ever reads three attributes off one.
const element = (attrs = {}) => ({
dataset: {},
attrs: { ...attrs },
setAttribute(name, value) { this.attrs[name] = String(value); },
getAttribute(name) { return name in this.attrs ? this.attrs[name] : null; },
removeAttribute(name) { delete this.attrs[name]; },
});
// ---------------------------------------------------------------------------
describe("marking a control that cannot act", () => {
test("it says so in the tree, and carries the reason with it", () => {
const btn = element();
Unavailable.set(btn, true, "Nothing to resolve yet — seat a player first.");
assert.equal(btn.getAttribute("aria-disabled"), "true");
assert.equal(Unavailable.reason(btn), "Nothing to resolve yet — seat a player first.");
assert.ok(Unavailable.blocked(btn));
});
// The state and the reason are set and cleared in one call on purpose. A control
// left marked after the reason stopped being true is worse than one never marked:
// it is a live control that refuses to work and explains why in the past tense.
test("and both halves come off together", () => {
const btn = element();
Unavailable.set(btn, true, "Nothing to resolve yet.");
Unavailable.set(btn, false, "Nothing to resolve yet.");
assert.equal(btn.getAttribute("aria-disabled"), null);
assert.equal(Unavailable.reason(btn), "");
assert.ok(!Unavailable.blocked(btn));
});
test("a control nobody marked is not blocked and has nothing to say", () => {
assert.ok(!Unavailable.blocked(element()));
assert.equal(Unavailable.reason(element()), "");
assert.equal(Unavailable.reason(null), "");
assert.ok(!Unavailable.blocked(null));
});
// `aria-disabled="false"` is a real thing to write and it means enabled. Reading the
// attribute's presence rather than its value would have got that backwards.
test("and one marked false is enabled, which is what the attribute means", () => {
const btn = element({ "aria-disabled": "false" });
assert.ok(!Unavailable.blocked(btn));
});
});
describe("what happens when it is pressed", () => {
// The press can land on a span inside the button rather than the button, which is
// most of what the picker's cards are made of.
test("the answer is looked for upwards from whatever was hit", () => {
const btn = element();
Unavailable.set(btn, true, "A♠ — dealt to Maya");
const rank = { closest: selector => (selector === '[aria-disabled="true"]' ? btn : null) };
assert.equal(Unavailable.reason(Unavailable.from(rank)), "A♠ — dealt to Maya");
});
test("a press on nothing in particular finds nothing", () => {
assert.equal(Unavailable.from(null), null);
assert.equal(Unavailable.from({}), null);
});
// Nothing about `aria-disabled` stops a click reaching its own handler — that is the
// trade for staying reachable. One listener does the stopping, in the capture phase,
// so a control's own handler never runs and never has to remember to check.
test("the press is stopped before the control's own handler sees it", () => {
const listener = src("feedback").slice(src("feedback").indexOf("listen(root) {"));
assert.match(listener, /addEventListener\("click"[\s\S]*?\}, true\)/,
"the listener is not in the capture phase, so it cannot stop anything");
assert.match(listener, /event\.preventDefault\(\)/);
assert.match(listener, /event\.stopPropagation\(\)/);
});
test("and it is wired once, for the whole table", () => {
const main = src("main");
assert.match(main, /Unavailable\.listen\(document\)/);
assert.equal((main.match(/Unavailable\.listen\(/g) || []).length, 1);
});
// A control with nothing to say is not a control this is about — the seat sheet's
// own greyed-out fields, say. Swallowing its press would be a new dead end.
test("a marked control with no reason is left entirely alone", () => {
const btn = element();
Unavailable.set(btn, true, "");
assert.equal(Unavailable.reason(btn), "");
});
});
describe("where the answer is written", () => {
// The toast is fixed to the bottom of the window at z-index 80; the card picker's
// scrim is 95 and its sheet stands 88vh tall. A toast raised for a control inside
// that sheet would be behind it, or over the cards if it were not.
test("a sheet that covers the toast says it itself", () => {
assert.match(MARKUP, /id="cardPickerSheet"[^>]*data-why-note="cardPickerNote"/);
assert.match(MARKUP, /id="cardPickerNote"[^>]*role="status"/);
});
test("and everything else falls through to the toast", () => {
const say = src("feedback").slice(src("feedback").indexOf("say(el, why) {"));
assert.match(say, /closest\("\[data-why-note\]"\)/);
assert.match(say, /Toast\.show\(why\)/);
});
// Cleared on the next paint rather than on a timer: a paint is exactly the set of
// things that make the last answer stale, and nothing else in the sheet redraws.
test("the line is cleared by the next thing that changes the sheet", () => {
const picker = src("cardPicker");
const at = picker.indexOf("paint() {");
assert.ok(at >= 0);
const opening = picker.slice(at, picker.indexOf("cardPickerTitle", at));
assert.match(opening, /getElementById\("cardPickerNote"\)/);
assert.match(opening, /note\.textContent = ""/);
});
});
describe("the line holds its place", () => {
// Filling it must not move the grid. It sits between the slots and the cards, so a
// line that appeared from nothing would shove fifty-two targets down the screen
// under a thumb already travelling towards one of them.
test("it is one line tall whether or not it is saying anything", () => {
const rule = ruleFor(".pickerNote");
assert.match(rule, /min-height:\s*1\.35em/);
assert.match(rule, /line-height:\s*1\.35/);
});
// Measured: the note's box is 318px on a Galaxy S8 at 20px of root text, and a name
// is whatever somebody typed. Twenty-four capital Ws draw 434px in it. No wording
// is safe, so the box is what holds.
test("and it cannot become two, whatever the name is", () => {
const rule = ruleFor(".pickerNote");
assert.match(rule, /white-space:\s*nowrap/);
assert.match(rule, /overflow:\s*hidden/);
assert.match(rule, /text-overflow:\s*ellipsis/);
});
test("what it measured is written beside the rule", () => {
const at = CSS.indexOf("/* Which card, and who has it.");
assert.ok(at >= 0, "the rule carries no note saying why");
const note = CSS.slice(at, CSS.indexOf("*/", at));
assert.match(note, /318px/);
assert.match(note, /434px/);
});
// Which is the half that survives the clipping, and the half that does not.
test("the card and where it went come before the name", () => {
const picker = src("cardPicker");
assert.match(picker, /\$\{label\} — face up on the board/);
assert.match(picker, /\$\{label\} — dealt to \$\{takenBy\}/);
});
});
describe("dimmed looks dimmed, however it was said", () => {
test("a button marked either way is painted the same", () => {
const at = CSS.indexOf("\n.holdemBtn:disabled,");
assert.ok(at >= 0, "the two states are painted by separate rules, so they can drift apart");
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /\.holdemBtn\[aria-disabled="true"\]/);
});
test("and so is a card", () => {
const at = CSS.indexOf("\n.pickerCard:disabled,");
assert.ok(at >= 0);
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /\.pickerCard\[aria-disabled="true"\]/);
});
// A press animation on a control that will not act is a lie told in 40ms.
test("neither one presses down under a finger that can't use it", () => {
[".holdemBtn:active", ".pickerCard:active", ".pickerCard:hover"].forEach(base => {
const found = CSS.includes(`${base}:not(:disabled):not([aria-disabled="true"])`);
assert.ok(found, `\`${base}\` still animates for a control marked aria-disabled`);
});
});
// `disabled` is a state the OS palette knows and draws in GrayText by itself.
// `aria-disabled` is a word in the accessibility tree and nothing else, so in
// forced-colors mode a dealt card came out looking exactly like a live one.
test("and the OS is told about it too, since it only knows the other one", () => {
// Whichever of the file's forced-colors blocks it landed in — there is more than
// one of them, and the stylesheet is free to answer a query wherever it likes.
const open = "@media (forced-colors: active) {";
const blocks = [];
for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
blocks.push(CSS.slice(at, CSS.indexOf("\n}", at)));
}
assert.ok(blocks.length, "nothing in the file answers forced-colors at all");
const answering = blocks.filter(b => /\[aria-disabled="true"\]/.test(b));
assert.equal(answering.length, 1, "the dimmed state has no forced-colors answer");
assert.match(answering[0], /\.pickerCard\[aria-disabled="true"\]/);
assert.match(answering[0], /\.holdemBtn\[aria-disabled="true"\]/);
assert.match(answering[0], /color:\s*GrayText/);
});
});
describe("the three controls that were dead ends", () => {
test("the header's two say why, and are reachable enough to say it", () => {
const render = src("render");
const at = render.indexOf("renderHeaderActions() {");
const body = render.slice(at, render.indexOf("\n },", at));
assert.match(body, /Unavailable\.set\(button, nothingToRunYet, why\)/);
assert.ok(!/button\.disabled = /.test(body), "one of them is still natively disabled");
assert.match(body, /Nothing to resolve yet/);
assert.match(body, /Nothing to move on from/);
});
test("Export CSV has a reason at all, which it never did", () => {
const render = src("render");
const at = render.indexOf('getElementById("exportHistoryBtn")');
assert.ok(at >= 0);
const around = render.slice(at - 200, at + 260);
assert.match(around, /Unavailable\.set\(/);
assert.match(around, /No hands to export yet/);
});
// Found the same afternoon, in the same sheet, by running the whole audit over an
// open overlay for the first time: the two slot buttons above the grid are drawn as
// a card or an em dash, and the em dash was the entire name a screen reader was
// given. "Dash, button", twice, on the sheet where a dealer writes a card down.
test("and the slots above it are called something", () => {
const picker = src("cardPicker");
const at = picker.indexOf("btn.className = \"pickerSlot\"");
const body = picker.slice(at, picker.indexOf("slots.appendChild(btn)", at));
assert.match(body, /this\.boardSlotLabel\(slot\) : `Card \$\{slot \+ 1\}`/,
"a seat's slots still have no name of their own");
assert.match(body, /setAttribute\("aria-label", value \? .* : .*empty.*\)/,
"an empty slot does not say that it is empty");
});
test("and a dealt card names itself rather than saying \"this one\"", () => {
const picker = src("cardPicker");
const at = picker.indexOf("const takenBy = used.get(card)");
const body = picker.slice(at, picker.indexOf("grid.appendChild(btn)", at));
assert.match(body, /Unavailable\.set\(btn, !!takenBy/);
assert.ok(!/btn\.disabled = /.test(body), "the cards are still natively disabled");
// The `title` is the same sentence, and now reaches a screen reader because the
// element it hangs off can be landed on.
assert.match(body, /btn\.title = Unavailable\.reason\(btn\)/);
});
});
+333
View File
@@ -0,0 +1,333 @@
// Undo, and whether a dealer can actually get to it.
//
// This tool prefers undo to confirmation — NN/g's own preference, and `runUndoable`
// says so in as many words: "it now just happens, and the toast offers the way back for
// the few seconds anyone would notice a misclick." That makes the toast's Undo the
// safety net under the biggest things the tool does. Three ways it wasn't one:
//
// * Every whole-step undo clears the per-action history on its way past, so the bar
// has no pill left and this button is the only way back. Measured on a desktop it
// is the 67th of the page's 68 tab stops, and it is gone in eight seconds. `U` —
// the key the sidebar legend advertises as taking back the last action — found
// nothing at all and did nothing.
// * The eight seconds ran whatever the dealer was doing. A cursor resting on the
// toast did not stop it; nor did tabbing onto the button itself. Measured: focus
// the Undo, wait 8.6 seconds, and the toast is gone and focus has been handed to
// <body> — the offer withdrawn and the dealer's place in the page with it.
// * Each toast carried its own role="status" inside #toastHost, which is already a
// live region in the markup. A region nested in a region, announced twice.
//
// The timing rules here are the usual ones: three to four seconds for a plain
// confirmation, six to ten for anything carrying a button, pause on hover and on
// keyboard focus, and always a manual way out — which is what WCAG 2.2.1 asks of
// content that disappears on a clock.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Toast } = require("./load");
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
const MAIN = fs.readFileSync(path.join(SRC, "main.js"), "utf8");
const FEEDBACK = fs.readFileSync(path.join(SRC, "feedback.js"), "utf8");
const SHORTCUTS = fs.readFileSync(path.join(SRC, "shortcuts.js"), "utf8");
const PAGE = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
// ---------------------------------------------------------------------------
// Enough of a document for a toast: a host it can be put into, elements that can hold
// listeners and focus, and a `:hover` that a test can turn on.
let active = null;
const node = (tag, className) => {
const self = {
tagName: String(tag).toUpperCase(),
className: className || "",
textContent: "",
dataset: {},
children: [],
parent: null,
isConnected: true,
hovered: false,
listeners: {},
attrs: {},
classList: { add() {} },
appendChild(child) { child.parent = self; self.children.push(child); return child; },
addEventListener(type, fn) { (self.listeners[type] = self.listeners[type] || []).push(fn); },
setAttribute(name, value) { self.attrs[name] = String(value); },
getAttribute(name) { return name in self.attrs ? self.attrs[name] : null; },
contains(other) {
for (let n = other; n; n = n.parent) if (n === self) return true;
return false;
},
matches(selector) { return selector === ":hover" ? self.hovered : false; },
focus() { active = self; },
getBoundingClientRect: () => ({ height: 44 }),
// Fire one of the listeners the toast hung on itself.
fire(type, event) { (self.listeners[type] || []).forEach(fn => fn(event || {})); },
find(className) {
if (self.className === className) return self;
for (const child of self.children) {
const hit = child.find(className);
if (hit) return hit;
}
return null;
},
};
Object.defineProperty(self, "firstElementChild", { get: () => self.children[0] || null });
Object.defineProperty(self, "innerHTML", {
get: () => "",
set(value) {
if (value !== "") return;
self.children.forEach(child => { child.parent = null; child.isConnected = false; });
self.children.length = 0;
},
});
return self;
};
let host;
const openDocument = () => {
host = node("div", "toastHost");
host.id = "toastHost";
active = null;
global.document = {
getElementById: id => (id === "toastHost" ? host : null),
createElement: tag => node(tag),
documentElement: { style: { setProperty() {} } },
};
Object.defineProperty(global.document, "activeElement", { get: () => active });
global.requestAnimationFrame = fn => fn();
};
const toastOnScreen = () => host.firstElementChild;
const undoButton = () => toastOnScreen() && toastOnScreen().find("toastAction");
// The toast's own timers are the thing under test, so they are driven rather than
// waited on. `resume` defers to the next tick on purpose — see feedback.js — so every
// release is followed by a tick of 1 to let that land.
const settle = t => t.mock.timers.tick(1);
const show = (options = {}) => Toast.show(options.message || "Button moved on.", options);
test.beforeEach(t => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
openDocument();
});
test.afterEach(() => {
Toast.dismiss();
delete global.document;
delete global.requestAnimationFrame;
});
// ---------------------------------------------------------------------------
describe("a toast nobody touches", () => {
test("stays for the time it was given, and then goes", t => {
show({ duration: 5000 });
t.mock.timers.tick(4900);
assert.ok(toastOnScreen(), "it left early");
t.mock.timers.tick(200);
assert.equal(toastOnScreen(), null, "it outstayed its duration");
});
test("and the default is the one a message with a button in it gets", () => {
// Three to four seconds for a plain confirmation, six to ten for anything
// carrying a button. Every plain one here names its own shorter duration; this
// default is what the undoable steps and the wake lock fall back to.
assert.ok(Toast.DEFAULT_DURATION_MS >= 6000 && Toast.DEFAULT_DURATION_MS <= 10000,
`${Toast.DEFAULT_DURATION_MS}ms is outside the range for a toast with an action in it`);
});
});
describe("a toast the dealer has reached for", () => {
test("a finger on it stops the clock", t => {
show({ duration: 8000 });
t.mock.timers.tick(1000);
const toast = toastOnScreen();
toast.hovered = true;
toast.fire("pointerenter");
t.mock.timers.tick(30000);
assert.ok(toastOnScreen(), "eight seconds ran out under a finger that was holding it");
});
test("and so does tabbing onto the button", t => {
show({ duration: 8000, actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
undoButton().focus();
toast.fire("focusin", { relatedTarget: null });
t.mock.timers.tick(30000);
assert.ok(toastOnScreen(), "the offer was withdrawn while the keyboard was standing on it");
assert.equal(active, undoButton(), "and focus went with it");
});
test("letting go starts it again", t => {
show({ duration: 8000 });
const toast = toastOnScreen();
toast.hovered = true;
toast.fire("pointerenter");
t.mock.timers.tick(30000);
toast.hovered = false;
toast.fire("pointerleave");
settle(t);
t.mock.timers.tick(7000);
assert.ok(toastOnScreen(), "it left before the time it still had");
t.mock.timers.tick(1200);
assert.equal(toastOnScreen(), null, "letting go did not restart the clock at all");
});
test("and letting go late still leaves time to act", t => {
// The half of this that pausing alone doesn't fix: touch it at 7.9 seconds of
// 8, let go, and what is left of the clock to *notice* the message is 100ms —
// gone while the thumb is still travelling to Undo.
show({ duration: 8000 });
const toast = toastOnScreen();
t.mock.timers.tick(7900);
toast.hovered = true;
toast.fire("pointerenter");
t.mock.timers.tick(3000);
toast.hovered = false;
toast.fire("pointerleave");
settle(t);
t.mock.timers.tick(Toast.RESUME_MS - 500);
assert.ok(toastOnScreen(), "it went in the 100ms that was left rather than the time to act");
t.mock.timers.tick(700);
assert.equal(toastOnScreen(), null, "and then it never went at all");
});
test("a cursor leaving while the keyboard is still inside does not restart it", t => {
// Two ways to hold it open, one timer. The pointer leaving is not an answer to
// "is anybody still using this?"
show({ duration: 8000, actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
toast.hovered = true;
toast.fire("pointerenter");
undoButton().focus();
toast.fire("focusin", { relatedTarget: null });
toast.hovered = false;
toast.fire("pointerleave");
settle(t);
t.mock.timers.tick(30000);
assert.ok(toastOnScreen(), "the clock restarted under a focused button");
});
});
describe("what happens to focus when it goes", () => {
test("the dealer is put back where they had got to", t => {
const elsewhere = node("button");
show({ actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
elsewhere.focus();
undoButton().focus();
toast.fire("focusin", { relatedTarget: elsewhere });
Toast.dismiss();
settle(t);
assert.equal(active, elsewhere,
"focus was left on a button that no longer exists, which hands it to <body>");
});
test("and not somewhere they left eight seconds ago", t => {
// Captured when focus crosses into the toast, not when the toast appeared: a
// dealer who carries on working and then tabs back should land where they got
// to, not where they were when the message arrived.
const whenItArrived = node("button");
const whereTheyGotTo = node("button");
whenItArrived.focus();
show({ actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
whereTheyGotTo.focus();
undoButton().focus();
toast.fire("focusin", { relatedTarget: whereTheyGotTo });
Toast.dismiss();
settle(t);
assert.equal(active, whereTheyGotTo);
});
test("focus is left alone when it was never in the toast", () => {
const elsewhere = node("button");
show({ actionLabel: "Undo", onAction: () => {} });
elsewhere.focus();
Toast.dismiss();
assert.equal(active, elsewhere, "dismissing a toast nobody was in moved the dealer");
});
test("and a control that has gone since is not reached for", () => {
const gone = node("button");
show({ actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
gone.focus();
undoButton().focus();
toast.fire("focusin", { relatedTarget: gone });
// A re-render between the toast going up and coming down. This happens on every
// undo: restoreState rebuilds the table.
gone.isConnected = false;
assert.doesNotThrow(() => Toast.dismiss());
});
});
describe("the key that reaches it", () => {
test("a toast says which shortcut presses its button", () => {
show({ actionLabel: "Undo", shortcut: "undo", onAction: () => {} });
assert.equal(undoButton().dataset.shortcut, "undo");
});
test("and one that named none is not reachable by a stray keypress", () => {
// "Turn off" on the wake lock and "Reload" on the other-tab warning both live
// in this same strip. Neither is a thing U should be able to do.
show({ actionLabel: "Turn off", onAction: () => {} });
assert.equal(undoButton().dataset.shortcut, undefined);
});
test("both of the undoable paths opt in", () => {
// runUndoable, for whole steps, and seatAction, for a gesture on a seat.
const offers = MAIN.split('actionLabel: "Undo"').length - 1;
const named = MAIN.split('shortcut: "undo"').length - 1;
assert.equal(offers, 2, "the number of Undo toasts has changed");
assert.equal(named, offers, "an Undo the keyboard cannot reach");
});
test("the key presses the button rather than running its own copy of it", () => {
// The house rule for every other shortcut in this file: press what is on the
// screen, so there is no second code path to drift out of step with it.
assert.match(MAIN, /#toastHost \.toastAction\[data-shortcut=/);
assert.match(MAIN, /undo: \(\) => pressToastAction\("undo"\) \|\| pressInBar\("\.actionUndo"\)/,
"the toast is the more recent of the two and has to be asked first");
});
test("and the legend covers both undos, because a dealer has no reason to know there are two", () => {
const line = SHORTCUTS.match(/keys: \["U"\][^}]*what: "([^"]+)"/);
assert.ok(line, "the U shortcut has no legend line");
assert.doesNotMatch(line[1], /last action$/,
"the legend still promises only the bar's pill");
});
});
describe("one live region, and it is in the markup", () => {
test("the host is the region", () => {
assert.match(PAGE, /id="toastHost"[^>]*aria-live="polite"/);
});
test("and a toast does not bring a second one with it", () => {
// A region that arrives already carrying its text has not been watched by
// anything yet, which is the documented reason such a message is never spoken.
// The host is in the markup precisely so that it has been.
assert.ok(!/toast\.setAttribute\("role"/.test(FEEDBACK),
"each toast is its own live region again, nested inside the one that works");
show({});
assert.equal(toastOnScreen().getAttribute("role"), null);
});
});
+224
View File
@@ -0,0 +1,224 @@
// One number, one control, and a ceiling that matches the table.
//
// "Split between" on Quick Split was two controls for one value sitting on top of each
// other: a row of chips 2 3 4 5, and under it a bar reading "2 ways" with a − and a +.
// Measured on an iPhone: 134px with its label, against 74px for "Smallest chip in play"
// right below it, which offers five choices in one row. And the number was written down
// three times at once — chip "2" lit gold, "2 ways" in the bar, and "Each of 2 players
// gets" in the result panel above. Two of those three were controls.
//
// The bar was never meant to be a bar. `.waysStepper` asked for `min-width: 150px` in a
// wrapping flex row, so on any phone it wrapped to a line of its own, and `flex: 1 1
// auto` then gave it all 358px of that line: 252px of empty readout with the − and the
// + pushed 304px apart. One-handed, on a screen 390px wide, they cannot both be reached.
//
// So the chips get a fifth chip. It reads "6+" whatever is behind it, because behind it
// is where the count is written, and the stepper only exists once you are through the
// door. Under six ways — every chop a home game will ever run — the field is one row.
//
// Two more things went with it. Neither stepper on this screen ever disabled an end, so
// − at the floor and + at the ceiling looked live and swallowed the tap; NN/G's stepper
// guidelines ask for exactly the opposite. And the per-pot chop capped its + with
// `Math.max(contenderCount, MAX_WAYS)`, which is always 12: a side pot that only P1 and
// P2 were eligible for would happily be chopped ten ways at $12 each.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { QuickSplit } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "quicksplit.css"), "utf8");
const SRC = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "quickSplit.js"), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// ---------------------------------------------------------------------------
describe("where the chips stop and the stepper starts", () => {
test("the door is the first count the chips don't cover", () => {
assert.deepEqual(QuickSplit.WAY_CHOICES, [2, 3, 4, 5]);
assert.equal(QuickSplit.overflowFrom(), 6);
});
// Derived rather than written down twice: adding a chip has to move the door.
test("and it moves if a chip is added or taken away", () => {
const was = QuickSplit.WAY_CHOICES;
try {
QuickSplit.WAY_CHOICES = [2, 3];
assert.equal(QuickSplit.overflowFrom(), 4);
} finally {
QuickSplit.WAY_CHOICES = was;
}
});
test("the stepper's own range is what the door promised", () => {
assert.equal(QuickSplit.clampWays(QuickSplit.overflowFrom() - 1), 5);
assert.equal(QuickSplit.clampWays(QuickSplit.MAX_WAYS + 4), QuickSplit.MAX_WAYS);
assert.equal(QuickSplit.clampWays(0), QuickSplit.WAY_CHOICES[0]);
assert.equal(QuickSplit.clampWays(-3), 2);
});
test("the door is drawn once, from the same number", () => {
const at = SRC.indexOf("renderWayChoices() {");
const body = SRC.slice(at, SRC.indexOf("\n },", at));
assert.match(body, /`\$\{this\.overflowFrom\(\)\}\+`/,
"the chip's label is typed in rather than taken from where the chips stop");
assert.match(body, /aria-label/, "\"6+\" is not a name a screen reader can read out");
});
// The chips and the stepper are one group: when the count is past the chips, no
// chip is lit, and the door is.
test("nothing is lit in two places at once", () => {
const at = SRC.indexOf("renderWayChoices() {");
const body = SRC.slice(at, SRC.indexOf("\n },", at));
assert.match(body, /const beyond = this\.ways >= this\.overflowFrom\(\)/);
assert.match(body, /!beyond && this\.ways === count/,
"a preset chip can still light up while the stepper holds a different number");
assert.match(body, /if \(beyond\) row\.appendChild\(this\.buildWaysStepper\(\)\)/,
"the stepper is drawn even when the chips are the control");
});
test("and the count is written in one place, without a unit the label already gave", () => {
const at = SRC.indexOf("buildWaysStepper() {");
assert.ok(at >= 0);
const body = SRC.slice(at, SRC.indexOf("\n },", at));
assert.match(body, /readout\.textContent = String\(this\.ways\)/);
assert.ok(!/\$\{this\.ways\} ways/.test(body), "the stepper is spelling the count out again");
});
});
describe("both ends of both steppers", () => {
test("a step that cannot happen says so", () => {
const at = SRC.indexOf("makeStepButton(label, onPick, options) {");
assert.ok(at >= 0, "the step buttons still take no disabled state");
assert.match(SRC.slice(at, SRC.indexOf("\n },", at)), /btn\.disabled = /);
});
test("the ways stepper disables its floor and its ceiling", () => {
const at = SRC.indexOf("buildWaysStepper() {");
const body = SRC.slice(at, SRC.indexOf("\n },", at));
assert.match(body, /disabled: this\.ways <= this\.overflowFrom\(\)/);
assert.match(body, /disabled: this\.ways >= this\.MAX_WAYS/);
});
test("so does the per-pot chop", () => {
const at = SRC.indexOf("addChopControl(row, amount, contenderCount) {");
const body = SRC.slice(at, SRC.indexOf("\n updateUneven()", at));
assert.match(body, /disabled: ways <= 2/);
assert.match(body, /disabled: ways >= ceiling/);
});
// 40x44 until the sweep first opened the even mode and read back `hittable 39x43`
// on every touch viewport there is.
test("and both ends are a fingertip wide, not just a fingertip tall", () => {
const rule = ruleFor(".stepBtn");
const px = axis => Number((rule.match(new RegExp(`${axis}:\\s*(\\d+)px`)) || [])[1]);
assert.ok(px("width") >= 44, `a step button is ${px("width")}px wide`);
assert.ok(px("height") >= 44, `a step button is ${px("height")}px tall`);
});
// Disabled has to look disabled, or it is the same silent tap with extra steps.
test("and a disabled end is painted as one", () => {
assert.match(ruleFor(".stepBtn:disabled"), /opacity:\s*0\.\d+/);
assert.match(CSS, /\.stepBtn:not\(:disabled\):hover/,
"a dead button still lights up under the mouse");
});
});
describe("how many ways a pot can go", () => {
// The whole of the bug: `Math.max` where `Math.min` was meant.
test("never more ways than there are players who can win it", () => {
assert.equal(QuickSplit.chopCeiling(2), 2);
assert.equal(QuickSplit.chopCeiling(3), 3);
assert.equal(QuickSplit.chopCeiling(9), 9);
});
test("and never past the screen's own ceiling", () => {
assert.equal(QuickSplit.chopCeiling(20), QuickSplit.MAX_WAYS);
});
// A pot is only offered a chop at two contenders or more, but the floor is stated
// here too so that a caller that gets it wrong can't produce a one-way split.
test("a chop is at least two ways", () => {
assert.equal(QuickSplit.chopCeiling(1), 2);
assert.equal(QuickSplit.chopCeiling(0), 2);
});
test("the chop's + is held to it", () => {
const at = SRC.indexOf("addChopControl(row, amount, contenderCount) {");
const body = SRC.slice(at, SRC.indexOf("\n updateUneven()", at));
assert.match(body, /const ceiling = this\.chopCeiling\(contenderCount\)/);
assert.match(body, /if \(ways < ceiling\)/);
assert.ok(!/Math\.max\(contenderCount/.test(body), "the old ceiling is still there");
});
// Two eligible players is two ways and nothing else. A stepper with both ends dead
// is furniture.
test("a heads-up pot is offered no stepper at all", () => {
const at = SRC.indexOf("addChopControl(row, amount, contenderCount) {");
const body = SRC.slice(at, SRC.indexOf("\n updateUneven()", at));
assert.match(body, /if \(ceiling > 2\) \{/);
assert.ok(body.indexOf("if (ceiling > 2) {") < body.indexOf('className = "unevenChopWays"'));
});
});
describe("the bar that was never meant to be a bar", () => {
test("the stepper is as wide as what is in it", () => {
const rule = ruleFor(".waysStepper");
assert.match(rule, /flex:\s*0 0 auto/);
assert.ok(!/min-width/.test(rule),
"a min-width in a wrapping row is what handed it the whole second line");
});
test("and the readout no longer pushes the two buttons apart", () => {
const rule = ruleFor(".waysReadout");
assert.ok(!/flex:\s*1(\s|;)/.test(rule));
assert.match(rule, /min-width:\s*[\d.]+ch/);
assert.match(rule, /tabular-nums/);
});
// It is standing in for a selected chip, so it is painted like one.
test("the count is painted like the chip it replaces", () => {
const chip = ruleFor(".choiceChip.selected");
// Not `border-color`, which comes first in the rule and is a different gold.
const colour = (chip.match(/(?:^|[^-\w])color:\s*(#[0-9a-f]{3,6})/i) || [])[1];
assert.ok(colour, "the selected chip has no colour to match");
assert.match(ruleFor(".waysReadout"), new RegExp(`color:\\s*${colour}`, "i"));
});
// The measurement that found it, kept where the next round will read it.
test("what it measured is written beside the rule", () => {
const at = CSS.indexOf("/* As wide as the three things in it");
assert.ok(at >= 0, "the rule carries no note saying why");
const note = CSS.slice(at, CSS.indexOf("*/", at));
assert.match(note, /304px/);
assert.match(note, /252px/);
});
});
describe("what the sweep looks at", () => {
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
// It went straight to the all-in mode, so the mode the screen opens in was audited
// zero times in fourteen rounds.
test("both modes of the screen, not just the second one", () => {
const at = DRIVER.indexOf("const modes = [");
assert.ok(at >= 0, "the quick split pass still audits one mode");
const body = DRIVER.slice(at, DRIVER.indexOf("];", at));
assert.match(body, /"even"/);
assert.match(body, /"all-in"/);
assert.match(DRIVER, /quick split · \$\{mode\}/, "a failure won't say which mode it was in");
});
test("with the stepper open, since it isn't there otherwise", () => {
const at = DRIVER.indexOf('["even", () => {');
assert.ok(at >= 0);
assert.match(DRIVER.slice(at, DRIVER.indexOf("}]", at)), /#waysChoices \.choiceChip/);
});
});
+192 -30
View File
@@ -143,6 +143,15 @@ const LayoutAudit = {
// and this file has no automation of its own: scroll-padding-bottom on the scroll
// container has to reserve at least as much room as the furniture standing there.
focusHasRoom() {
// Not a question that means anything while a sheet is open. The page behind is
// inert, and the sheet's own scrim is `position: fixed; inset: 0` — bottom-anchored
// and full height — so this read it as furniture standing 100% of the window tall
// and demanded scroll padding for all of it. Every overlay failed, every viewport.
if (this.modalScope()) {
return { name: "focus has room under the fixed bar", offenders: [],
skipped: "a sheet is open — the page behind it is not being tabbed" };
}
const furniture = this.bottomFurniture();
if (furniture.length === 0) {
return { name: "focus has room under the fixed bar", offenders: [],
@@ -182,6 +191,13 @@ const LayoutAudit = {
// stops being read.
furnitureBudget() {
const name = "the fixed bar leaves the table its room";
// Same reason as focusHasRoom above: a scrim is not furniture, it is the whole
// point of the thing on top of it, and "the felt has less room than the furniture"
// is not a complaint anyone can act on while a sheet is deliberately covering it.
if (this.modalScope()) {
return { name, offenders: [], skipped: "a sheet is open — it is meant to cover the table" };
}
const furniture = this.bottomFurniture();
if (furniture.length === 0) {
return { name, offenders: [], skipped: "nothing is fixed to the bottom here" };
@@ -213,6 +229,26 @@ const LayoutAudit = {
// accessibility tree, and because a fixed element does not move when the page does,
// no amount of scrolling could bring it back. visibility:hidden puts it away; a
// transform only moves it.
// A fixed panel that scrolls is a different thing from a fixed panel that doesn't,
// and this check could not tell them apart. The landscape rail is `overflow-y: auto`
// on purpose — the raise pad outgrows a 360px-tall screen the moment the text is
// turned up, and the stylesheet chose overflow over drawing controls on top of one
// another — so a button below its fold is one flick away, not lost. Reported as
// unreachable it is simply untrue, and a sweep that cries wolf is a sweep nobody
// reads.
//
// The ancestor has to be on screen itself for this to hold, which the rail is: it
// is `top: 0; bottom: 0`. If a scrolling panel is ever parked off the side, the
// thing to report is the panel, not the button inside it.
scrollableAncestor(el) {
for (let node = el.parentElement; node; node = node.parentElement) {
const overflow = getComputedStyle(node).overflowY;
if ((overflow === "auto" || overflow === "scroll") &&
node.scrollHeight > node.clientHeight + 1) return node;
}
return null;
},
focusablesReachable() {
const offenders = this.visibleControls()
.filter(el => {
@@ -225,6 +261,7 @@ const LayoutAudit = {
const b = el.getBoundingClientRect();
return b.top >= innerHeight || b.bottom <= 0 || b.left >= innerWidth || b.right <= 0;
})
.filter(el => !this.scrollableAncestor(el))
.map(el => `${this.describe(el)} :: fixed and off-screen, so nothing can scroll to it`);
return { name: "no control is parked where scrolling can't reach it", offenders };
@@ -252,6 +289,25 @@ const LayoutAudit = {
return { name: "tap-highlight", offenders, checked: this.visibleControls().length };
},
// A control that is on the screen and cannot act has to say why, on the device it is
// being used on. `disabled` cannot: it drops the control out of the tab order, so a
// screen reader never lands on it to hear the `title`, and it eats the tap on a touch
// screen, where there is no hover to show a `title` anyway. That left the card picker
// answering "who has the ace of spades?" only to a mouse, and Export CSV answering
// nobody at all.
//
// So a control that is dimmed is either marked `aria-disabled` with a reason on it —
// which the one listener in main.js turns into a sentence when it is pressed — or it
// is `disabled` and there is genuinely nothing to say, which is the seat sheet's
// fields and a stepper sitting on its own end. Having a `title` and being `disabled`
// is the combination that cannot work, and it is the one this looks for.
dimmedControlsExplain() {
const offenders = this.visibleControls()
.filter(el => el.disabled && (el.getAttribute("title") || "").trim())
.map(el => `${this.describe(el)} :: disabled, and its only explanation is a title`);
return { name: "a control that can't act says why where it can be read", offenders };
},
// Text selection must stay ON for anything a dealer types into.
textSelectable() {
const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")]
@@ -282,11 +338,25 @@ const LayoutAudit = {
const scrolls = /auto|scroll|overlay/.test(style.overflowY) || node === document.body;
if (!scrolls) continue;
const box = node === document.body ? { top: 0, bottom: innerHeight } : node.getBoundingClientRect();
const room = node === document.body
const down = node === document.body
? document.documentElement.scrollHeight - innerHeight - scrollY
: node.scrollHeight - node.clientHeight - node.scrollTop;
if (rect.top >= box.bottom && room > 0) return true;
if (rect.bottom <= box.top && (node === document.body ? scrollY > 0 : node.scrollTop > 0)) return true;
const up = node === document.body ? scrollY : node.scrollTop;
// How far past each edge the element sits, and whether there is that much
// scrolling left in this direction. Partly past counts.
//
// This used to ask whether the element had left the box *entirely* —
// `rect.top >= box.bottom` — which is true of something well below the fold
// and false of something hanging eight pixels over the edge of it. So a card
// half-visible at the bottom of a sheet with 254px of scroll still in it came
// back "cannot be scrolled to", and the row above it, fully out, came back
// fine. Verified in a browser before believing either: the sheet scrolls, the
// two of clubs comes fully into view and hit-tests as itself.
const below = rect.bottom - box.bottom;
const above = box.top - rect.top;
if (below > 0.5 && down >= below - 0.5) return true;
if (above > 0.5 && up >= above - 0.5) return true;
}
return false;
},
@@ -334,7 +404,15 @@ const LayoutAudit = {
return null;
},
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
// Everything a dealer can press, not three selectors' worth.
//
// This asked about the action bar, the header and the seats, and nothing else on the
// page had ever been checked for being covered — which is how the toast came to sit
// on 98% of "Common rulings" and 98% of "Quick Split" on every phone, every hand,
// for eight seconds. Both are controls, both sit below the felt, and neither was in
// any of the three groups, so the sweep never looked.
occlusion(selector = null) {
selector = selector || this.CONTROL_SELECTOR;
const offenders = [];
const warnings = [];
// While something is open on top, only what is inside it can be judged. The
@@ -346,6 +424,21 @@ const LayoutAudit = {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
// Inside a disclosure nobody has opened. A shut `<details>` still hands out
// a real rect for everything in it — the rake fields measure 54x28 at a
// sensible place on the page while House take is closed — so a size check
// alone lets them through and they hit-test to the next summary along.
// `checkVisibility` is the browser's own answer to the same question.
if (typeof el.checkVisibility === "function" &&
!el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true })) return;
// A control that has opted out of the pointer is not being covered — it is
// a marker that happens to be a button, and the hit test lands on whatever
// is behind it because that is what it asked for. The dealer button on the
// seat that holds it is exactly this: `pointer-events: none`, deliberately,
// because giving the button to the seat that has it is not a thing to do.
if (getComputedStyle(el).pointerEvents === "none") return;
const outsideViewport = rect.bottom <= 0 || rect.top >= innerHeight ||
rect.right <= 0 || rect.left >= innerWidth;
if (outsideViewport) {
@@ -383,6 +476,7 @@ const LayoutAudit = {
let blocked = 0;
let testable = 0;
let blockedBy = null;
let blocker = null;
points.forEach(([x, y]) => {
const top = document.elementFromPoint(x, y);
// A null result means the point isn't hit-testable — typically it sits
@@ -408,7 +502,7 @@ const LayoutAudit = {
if (!isControl) return;
}
blocked++;
if (!blockedBy) blockedBy = this.describe(top);
if (!blockedBy) { blockedBy = this.describe(top); blocker = top; }
});
if (testable === 0) {
@@ -428,8 +522,29 @@ const LayoutAudit = {
// was a real bug once, where a seat sat exactly under the Undo button and a
// tap meant for a player took back the last action instead.
const transient = blockedBy && /toast/i.test(blockedBy);
if (transient && blocked < testable) warnings.push(note);
else offenders.push(note);
if (transient && blocked < testable) { warnings.push(note); return; }
// Content in the flow, under something fixed, on a page that scrolls.
//
// The fixed thing stays where it is and the content moves, so this is a
// position rather than a state: scroll, and the control comes out from
// under. Worth saying — a dealer reaching for Common rulings while a toast
// is up finds a toast — but it is not the same fault as a control that no
// amount of scrolling will free, which is what the rest of this reports.
// Widening the check from three selectors to every control put forty of
// these in front of a rule that had only ever been shown the felt.
const pinned = node => {
for (let up = node; up && up !== document.body; up = up.parentElement) {
if (getComputedStyle(up).position === "fixed") return true;
}
return false;
};
const canScroll = document.documentElement.scrollHeight > innerHeight + 1;
if (blockedBy && !pinned(el) && pinned(blocker) && canScroll) {
warnings.push(`${note} — scrolls clear of it`);
return;
}
offenders.push(note);
});
return { name: "nothing important is covered", offenders, warnings };
},
@@ -812,8 +927,9 @@ const LayoutAudit = {
// `exempt` is for controls that are deliberately small *and* have a full-size path
// to the same action. The dealer puck is the only one: it's a 22px chip sitting on
// the rim of a seat, and growing it to 44px would take that area from the seat
// itself — which a dealer taps far more often. "Give the button" is in the seat
// menu at full size, so the puck is a shortcut, not the only way through.
// itself — which a dealer taps far more often. The seat menu carries the same
// action at full size, under "Give this seat", so the puck is a shortcut rather
// than the only way through.
touchTargets(minimum = 44, { exempt = ".dealerChip", enforceOnPointerDevices = false } = {}) {
const offenders = [];
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
@@ -841,7 +957,14 @@ const LayoutAudit = {
// check used to pass it in silence — which is how "Confirm Winner(s)",
// the button that pays the pot out, went unmeasured for 36 rounds.
// Bring it into view before probing.
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel");
// `.rulingsList` before `#rulingsSheet`, and `closest` takes the nearer
// of the two: on a phone held sideways the sheet fills the window and
// stops scrolling, and the twelve rulings scroll inside the list instead.
// Naming only the sheet there finds a box whose scrollHeight equals its
// clientHeight, decides nothing is out of view, and puts the check back
// in the silence this guard exists to break.
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, " +
".rulingsList, #rulingsSheet, #seatEditorPanel");
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
const box = scroller.getBoundingClientRect();
const own = el.getBoundingClientRect();
@@ -1236,36 +1359,67 @@ const LayoutAudit = {
// right without any of them being modelled. Line boxes rather than one bounding box,
// too: a two-line name that clears the chip on the line beside it is not covered
// because its *other* line happens to sit under where the chip is.
//
// Two things a puck wears were missing from this for eleven rounds, and between them
// they are why the chair number could sit on the felt covered up without the sweep
// ever saying so.
//
// The first is the chair number itself. This check has always asked whether the
// button or a blind badge sits on a name, and the mark that was sitting on names was
// neither of those: measured across 348 seats on 36 tables, 51 of them had the number
// printed on the first letter of the player's own name.
//
// The second is that marks were only ever tested against the *writing*, never against
// each other — so a badge painted straight over the chair number, which is what all
// 66 seats posting a blind were doing, was invisible to a check whose whole subject
// is a puck covering itself up.
seatLabelsClear() {
const offenders = [];
document.querySelectorAll(".seat").forEach(seat => {
const name = seat.querySelector(".seatPuckName");
if (!name || !name.textContent.trim()) return;
// What a puck wears, as opposed to what it writes.
const MARKS = ".dealerChip, .blindBadge, .seatChairNumber";
const painted = mark => {
const box = mark.getBoundingClientRect();
if (!box.width || !box.height) return null;
if (!(mark.textContent || "").trim()) return null;
const style = getComputedStyle(mark);
if (style.display === "none" || style.visibility === "hidden" ||
parseFloat(style.opacity) < 0.05) return null;
return box;
};
const across = (a, b) => {
const wide = Math.min(a.right, b.right) - Math.max(a.left, b.left);
const deep = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
return wide > 1 && deep > 1 ? wide : 0;
};
document.querySelectorAll(".seat").forEach(seat => {
const marks = [...seat.querySelectorAll(MARKS)]
.map(mark => ({ mark, box: painted(mark) }))
.filter(entry => entry.box);
const name = seat.querySelector(".seatPuckName");
if (name && name.textContent.trim()) {
const range = document.createRange();
range.selectNodeContents(name);
const lines = [...range.getClientRects()].filter(line => line.width > 1 && line.height > 1);
if (!lines.length) return;
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
const over = mark.getBoundingClientRect();
if (!over.width || !over.height) return;
const marked = getComputedStyle(mark);
if (marked.display === "none" || marked.visibility === "hidden" ||
parseFloat(marked.opacity) < 0.05) return;
let worst = 0;
lines.forEach(line => {
const across = Math.min(line.right, over.right) - Math.max(line.left, over.left);
const down = Math.min(line.bottom, over.bottom) - Math.max(line.top, over.top);
if (across > 1 && down > 1) worst = Math.max(worst, across);
});
marks.forEach(({ mark, box }) => {
const worst = lines.reduce((most, line) => Math.max(most, across(line, box)), 0);
if (worst > 1) {
offenders.push(`${this.describe(name)} :: ${Math.round(worst)}px under ${this.describe(mark)}`);
}
});
}
// And the marks against each other. The rim holds three of them — the blinds
// at one end, the button at the other — and there is not room for a third.
marks.forEach((a, i) => marks.slice(i + 1).forEach(b => {
const worst = across(a.box, b.box);
if (worst > 1) {
offenders.push(`${this.describe(a.mark)} :: ${Math.round(worst)}px across ${this.describe(b.mark)}`);
}
}));
});
return { name: "seat names aren't painted under their own badges", offenders };
return { name: "nothing a seat wears covers its name or another mark", offenders };
},
// A seat's writing, covered by something belonging to a different seat.
@@ -1504,10 +1658,17 @@ const LayoutAudit = {
const r = button.getBoundingClientRect();
const label = this.describe(button);
if (r.bottom > innerHeight || r.top < 0) {
// Below the fold is only a fault if it is also out of reach. A sheet
// that scrolls is allowed to be taller than the window — the card
// picker is fifty-two cards and always will be, and this reported the
// bottom four rows of it as unreachable buttons on every phone there
// is. What the rule is actually for is a dialog whose Confirm has
// been pushed off a short screen with no way to get to it.
if (this.reachableByScrolling(button)) return;
const off = r.bottom > innerHeight
? Math.round(r.bottom - innerHeight) + "px below the fold"
: Math.round(-r.top) + "px above it";
offenders.push(`${label} is ${off}`);
offenders.push(`${label} is ${off}, and nothing scrolls to it`);
return;
}
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
@@ -1530,6 +1691,7 @@ const LayoutAudit = {
this.furnitureBudget(),
this.touchAction(),
this.tapHighlight(),
this.dimmedControlsExplain(),
this.textSelectable(),
this.occlusion(options.occlusionSelector),
this.collapsedColumns(),
+173
View File
@@ -0,0 +1,173 @@
// The size the table is drawn at before anything has been measured.
//
// The felt is not sized by CSS alone. It is sized from `--feltRoomPx`, which actionBar.js
// publishes from the bar's real height — because the bar is fixed to the bottom of the
// screen and grows with the hand, so nothing else can know how much of the window is
// left. That measurement is right. What it was measuring was not.
//
// `ActionBar.mount()` called `watchHeight()` before `render()`, and the bar ships
// `<div id="actionBar" hidden>` in the markup. So the first publish measured a bar that
// was not there yet and published `0px` — not "I don't know", but "the bar takes none of
// the window", which is the one answer that makes the felt draw itself too big.
//
// Measured on a cold load, nine-handed:
//
// 360x640 --feltRoomPx 525px -> felt 336x504, then 336x374 at t=300ms
// 1280x900 --feltRoomPx 755px -> felt 857x617, then 839x604
//
// A hundred and thirty pixels of table, with every seat on it, moving after it was on
// screen and readable. The ResizeObserver does come round on its own once the markup's
// `hidden` comes off — but a frame later at best, and measured with nine seats to lay
// out it was 875ms behind.
//
// Two rules hold it shut, and they are separate on purpose: one stops the wrong number
// being published at all, the other makes sure a right one is published straight away.
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 } = require("./load");
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
const BAR = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// A document with one action bar in it, and a note of everything published.
const pageWith = ({ hidden, height, layout }) => {
const published = [];
const host = {
hidden,
getBoundingClientRect: () => ({ height, top: 0 }),
};
global.document = {
getElementById: id => (id === "actionBar" ? host : null),
documentElement: {
style: {
setProperty: (name, value) => published.push([name, value]),
getPropertyValue: () => "",
},
},
};
global.getComputedStyle = () => ({ getPropertyValue: () => layout });
global.window = { innerHeight: 844, scrollY: 0, addEventListener: () => {} };
return { host, published, barHeight: () => published.filter(p => p[0] === "--actionBarHeight") };
};
// watchHeight wires listeners and publishes once. The felt half of it is stubbed: it
// reads a wrap that is not in this document, and what is under test here is the number
// the bar decides to hand over.
const publishOnce = page => {
const realFelt = ActionBar.publishFeltRoom;
const roomAsked = [];
ActionBar.publishFeltRoom = value => roomAsked.push(value);
ActionBar.heightWatcher = null;
try {
ActionBar.watchHeight();
} finally {
ActionBar.publishFeltRoom = realFelt;
}
return roomAsked;
};
test.afterEach(() => {
delete global.document;
delete global.getComputedStyle;
delete global.window;
ActionBar.heightWatcher = null;
delete ActionBar.publishHeight;
});
// ---------------------------------------------------------------------------
describe("a bar nobody has drawn yet", () => {
test("says nothing, rather than saying nothing is there", () => {
// The distinction the whole fix rests on. Zero is an answer; "not yet" is not,
// and publishing the first as though it were the second is what made the felt
// believe it had the whole window.
const page = pageWith({ hidden: true, height: 0, layout: "bar" });
publishOnce(page);
assert.deepEqual(page.barHeight(), [],
"a hidden bar published a height, and the felt will be drawn against it");
});
test("and the felt is not asked to resize on the strength of it either", () => {
const page = pageWith({ hidden: true, height: 0, layout: "bar" });
const roomAsked = publishOnce(page);
assert.deepEqual(roomAsked, [], "the felt was handed a number measured off nothing");
});
test("a bar that has been drawn publishes what it measures", () => {
const page = pageWith({ hidden: false, height: 151, layout: "bar" });
publishOnce(page);
assert.deepEqual(page.barHeight(), [["--actionBarHeight", "151px"]]);
});
});
describe("a rail is not an undrawn bar", () => {
// A phone held sideways puts the bar down the right-hand side, where it genuinely
// takes none of the bottom of the window. That zero is a measurement and has to keep
// being published — otherwise the felt goes on reserving room for a bar that has
// moved out of its way.
test("it publishes zero, and it does so even while hidden", () => {
const page = pageWith({ hidden: true, height: 0, layout: "rail" });
publishOnce(page);
assert.deepEqual(page.barHeight(), [["--actionBarHeight", "0px"]],
"a rail stopped saying it takes no height");
});
test("and a drawn rail publishes zero rather than its own height", () => {
const page = pageWith({ hidden: false, height: 390, layout: "rail" });
publishOnce(page);
assert.deepEqual(page.barHeight(), [["--actionBarHeight", "0px"]],
"the rail's height went into the number that means 'how much of the bottom is gone'");
});
});
describe("and something is published as soon as there is a bar to measure", () => {
test("mount publishes after it has rendered, not only before", () => {
// watchHeight still goes first, because render() may reach for publishHeight.
// The publish that matters is the one after.
const mount = BAR.slice(BAR.indexOf("mount(state, hooks) {"), BAR.indexOf("watchHeight() {"));
const watch = mount.indexOf("this.watchHeight();");
const render = mount.indexOf("this.render();");
const publish = mount.indexOf("this.publishHeight();");
assert.ok(watch >= 0 && render >= 0 && publish >= 0,
"mount no longer does all three of these");
assert.ok(watch < render, "watchHeight has to be in place before render can call it");
assert.ok(render < publish,
"mount publishes before the bar has been drawn, which is the whole bug");
});
});
describe("what the felt falls back on before any of that", () => {
// The scripts are at the end of the body and the table is laid out inside main.js's
// DOMContentLoaded, so there is a real frame where --feltRoomPx does not exist yet.
// `100vh` was the worst guess available for it: it says the whole window is free.
test("a phone does not assume the whole window", () => {
// The phone's rule is the one that caps the felt's height; the wide layout caps
// its width instead, from the same number.
const found = CSS.match(/max-height:\s*var\(--feltRoomPx,([^)]*\)?[^;]*);/);
assert.ok(found, "nothing caps the felt's height by the measured room any more");
const fallback = found[1].trim();
assert.notEqual(fallback, "100vh",
"the fallback claims the whole window, so the first frame draws a table that has to shrink");
assert.match(fallback, /var\(--actionBarHeight, 9rem\)/,
"9rem is what the rest of this file assumes a bar costs before it can be asked");
});
test("and 9rem is a number this file already stands behind", () => {
// Two other rules had reached the same conclusion before this one did. If they
// ever disagree, the felt's first frame and the toast's first frame disagree.
const nines = CSS.match(/var\(--actionBarHeight, 9rem\)/g) || [];
assert.ok(nines.length >= 3,
`only ${nines.length} rules assume 9rem; the felt's guess should be the same guess as the rest`);
});
});
+192
View File
@@ -0,0 +1,192 @@
// The pill that names what Undo would take back, and whether it manages to.
//
// The bar's undo is a standing control rather than a toast per action, and it is a fixed
// width on purpose: its text changes on every tap, and a control that resizes under a
// thumb reads as the interface twitching. Measured on a phone that leaves the label
// 78px, which is not enough for any label with an actor on it — so the label is three
// elements (arrow, actor, act) and `ActionBar.fitUndoLabel` drops the actor whole rather
// than letting an ellipsis land wherever it lands.
//
// That machinery was right and the labels being fed to it were not. Two rules follow
// from "the actor is the half that goes", and six of the fifteen labels broke one:
//
// The act has to name the action on its own. Four labels put a *verb* in the actor's
// slot — "dealing" + "the hand", "dealing" + "everybody in", "calling" + "the straddle
// off" — so dropping the lead left the pill reading "↶ the hand", which is what it
// said after every deal of the night, and "↶ the straddle off". Neither names an
// action.
//
// The act has to fit, because whatever is left is ellipsised. Measured at 390x844 and
// 360x640 with a thumb: "hand killed on the clock" wanted 125px of 78, "check on the
// clock" 99, "waiting for the blind" 103, "posting to come in" 97. All four came out
// cut mid-phrase — "↶ waiting for th…".
//
// Both are fixed by writing the labels differently rather than by changing the fitting:
// the qualifier moves into `long`, which is the whole sentence, and the verb stays on
// the pill. Every short form is a substring of its long form, which is what Label in
// Name asks of a control that has shortened its own name — and it is what lets `long`
// be the accessible name rather than only a `title` nothing on a phone can reach.
//
// Measured after, on both phone sizes: nothing ellipsised, and every visible form
// contained in the name. The one exception is stated below.
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 } = require("./load");
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
const BAR = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8");
// Every act the bar can put on the pill, with what the pill shows once the actor has
// been dropped and the whole sentence it belongs to.
//
// This table is the rule written down. A new action has to appear here before its act is
// allowed, which is the point: the two things that go wrong — an act that needs its lead
// to mean anything, and an act too long for the box — are both invisible until somebody
// reads the pill on a phone.
const ACTS = {
"fold": { shows: "↶ Dee's fold", long: "Dee's fold" },
"check": { shows: "↶ Dee's check", long: "Dee's check" },
"call": { shows: "↶ Dee's call", long: "Dee's call" },
"all-in": { shows: "↶ Dee's all-in", long: "Dee's all-in" },
"hand killed": { shows: "↶ hand killed", long: "Dee's hand killed on the clock" },
"waiting": { shows: "↶ Dee waiting", long: "Dee waiting for the blind" },
"posting": { shows: "↶ Dee posting", long: "Dee posting to come in" },
"the deal": { shows: "↶ the deal", long: "the deal" },
"dealt in": { shows: "↶ dealt in", long: "everybody dealt in" },
"straddle": { shows: "↶ the straddle", long: "the straddle" },
"re-straddle": { shows: "↶ the re-straddle", long: "the re-straddle" },
"called off": { shows: "↶ called off", long: "the straddle called off" },
};
// Acts that carry an amount, and so cannot be shortened without losing the number that
// is the whole of what they say. "raise to $12,500" is 86px of 78 and does ellipsise —
// accepted, because the leading digits survive, the amount is on that player's own seat
// puck, and the whole of it is on the button's name. Below five figures they all fit:
// "raise to $1,200" is 79.
const AMOUNTS = ["raise to ", "bet of "];
// One street name per act, filled in by the caller.
const TEMPLATED = ["the ${street}"];
// The longest non-amount act that fits, in characters. A proxy for the 78px measured on
// the two phones in the sweep — "re-straddle" and "hand killed" are 11 and both fit;
// "forced check" is 12 and 68px, the widest that was tried and cleared.
const BUDGET = 12;
// Every `act:` in the bar, pulled out of the source rather than listed by hand — a new
// one that nobody adds to ACTS above has to fail this.
const actsInSource = () => {
const found = new Set();
BAR.split("\n").forEach(line => {
let at = line.indexOf("act:");
while (at >= 0) {
const rest = line.slice(at + 4);
// Quoted and back-ticked tokens on that property, minus anything that is
// the right-hand side of a comparison — `expired.verb === "fold" ? …` picks
// the act, it is not one.
const tokens = rest.match(/(["`])((?:\\.|(?!\1)[^\\])*)\1/g) || [];
tokens.forEach(raw => {
const before = rest.slice(0, rest.indexOf(raw));
if (/[=!]==\s*$/.test(before)) return;
found.add(raw.slice(1, -1));
});
at = line.indexOf("act:", at + 4);
}
});
return [...found];
};
// ---------------------------------------------------------------------------
describe("what the pill is allowed to say", () => {
test("every act in the bar is one this file has looked at", () => {
const known = new Set([...Object.keys(ACTS), ...TEMPLATED]);
const stray = actsInSource().filter(a =>
!known.has(a) && !AMOUNTS.some(prefix => a.startsWith(prefix)));
assert.deepEqual(stray, [],
"a new act, which has to be checked against a 78px box before it is trusted");
});
test("and every act this file has looked at is still in the bar", () => {
const inSource = new Set(actsInSource());
const gone = Object.keys(ACTS).filter(a => !inSource.has(a));
assert.deepEqual(gone, [], "this table describes labels the bar no longer produces");
});
test("none of them needs its actor to mean anything", () => {
// The failure this is here for: "the hand", which is what the pill read after
// every deal, and "the straddle off". An act is what survives, so an act that
// only parses as the tail of a phrase survives as nothing.
Object.entries(ACTS).forEach(([act, { shows }]) => {
assert.ok(shows.includes(act), `"${shows}" does not contain "${act}"`);
assert.doesNotMatch(act, /^(the hand|everybody in|the straddle off)$/,
`"${act}" is one of the acts that read as nothing on their own`);
});
});
test("and none of them is too long for the box", () => {
Object.keys(ACTS).forEach(act => {
assert.ok(act.length <= BUDGET,
`"${act}" is ${act.length} characters; over ${BUDGET} it ellipsises on a phone`);
});
});
test("the amounts are the exception, and they are the only one", () => {
const amounts = actsInSource().filter(a => AMOUNTS.some(p => a.startsWith(p)));
assert.ok(amounts.length > 0, "the raise labels have moved and this exemption is stale");
amounts.forEach(a => assert.ok(!(a in ACTS),
`"${a}" is listed as both an amount and a fixed act`));
});
});
describe("the whole sentence is kept where the short one cannot go", () => {
test("a label may spell itself out at length", () => {
const parts = ActionBar.undoParts({
lead: "Dee's", act: "hand killed", long: "Dee's hand killed on the clock",
});
assert.equal(parts.act, "hand killed", "the pill gets the short one");
assert.equal(parts.long, "Dee's hand killed on the clock", "the name gets the whole one");
});
test("and without one it is still built from the halves", () => {
assert.equal(ActionBar.undoParts({ lead: "Dee's", act: "fold" }).long, "Dee's fold");
assert.equal(ActionBar.undoParts({ act: "the deal" }).long, "the deal",
"an act with no actor should not be given a leading space");
});
test("a plain string still works, with nothing to drop", () => {
const parts = ActionBar.undoParts("something older");
assert.deepEqual(parts, { lead: "", act: "something older", long: "something older" });
});
test("every short form is inside the sentence it came from", () => {
// Label in Name: a speech user saying the words they can see has to hit the
// control, so the accessible name cannot replace the visible text with
// different words.
Object.entries(ACTS).forEach(([act, { long }]) => {
assert.ok(long.includes(act), `the name "${long}" does not contain "${act}"`);
});
});
});
describe("what the pill is called out loud", () => {
test("the sentence is the accessible name, not only a hover", () => {
// Before this the name was whatever text was visible — on a phone, "↶ hand
// killed U" — and the whole of it lived in a `title`, which a touch screen has
// no way to show and a screen reader is not obliged to read.
assert.match(BAR, /undo\.setAttribute\("aria-label", `Undo \$\{undoLabel\}`\)/);
assert.match(BAR, /undo\.title = `Undo \$\{undoLabel\}`/);
});
test("and the decoration stays out of it", () => {
const undoBlock = BAR.slice(BAR.indexOf('undo.className = "actionUndo"'),
BAR.indexOf("acting.appendChild(undo)"));
assert.match(undoBlock, /glyph\.setAttribute\("aria-hidden", "true"\)/,
'the arrow is read as part of the name — "left arrow over hand killed"');
assert.match(undoBlock, /key\.setAttribute\("aria-hidden", "true"\)/,
"the keycap is read as part of the name");
});
});