From 4a2ec1295b9f7ef0cc454c3c9ec8a41c9f71aeb2 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Thu, 10 Sep 2026 11:08:14 -0600
Subject: [PATCH 01/27] Make every seat on the ring the same seat, and measure
it before believing it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Nine chairs, and until this no two of them were the same size. Measured at 1280x900
with five players seated: 64px for Bob, 70 for Christopher because his name wrapped,
71 for Alexandra because she had chips out, 89 for "Mike from work" who had both.
The ragged edge was the smaller half of it. The larger half is that the sizes moved.
A puck grew 17px the moment its player put money in and shrank again when the street
closed, so the felt reflowed twice a hand under the thumb of someone trying to tap it
— and because the ring is sized against the tallest puck on it, the whole table could
resize with it.
Three rows are held open instead: the name, the amount, and the stack. The name gets
one line whatever it says, the amount pill keeps its row when it is empty rather than
collapsing, and each row is a token rather than an em so an amount stepping its type
down to fit shortens its digits and not its row. What the puck says changes all night;
how big it is does not.
It costs nothing. measuredSeatBox already took the tallest puck on the ring, on
purpose, so the room was reserved at every seat and the short ones simply weren't
drawn into it. What pays for the third row is the second line of name going away: an
earlier round measured uniform-with-two-lines at 88px and rightly refused it.
Uniform-with-one is 71.
A name with no word to drop steps its type down instead of ellipsising. fitSeatNames
turns "Mike from work" into "Mike", which is what the table calls him, and could do
nothing at all with "Christopher" — so that one was cut to "Christo…" on a felt that
may also have a Christine at it. The amounts on the same puck have never taken that
trade; this is fitAmount's rule applied to the other thing a dealer reads across a
table. One step, floored at 9px, then the ellipsis.
The float that kept the button seat's name off the dealer chip goes with the second
line — three rules of it, indenting the first line so a long name could drop below
the chip onto a second. There is no second line to drop to, and left in place it drew
"Christopher" as an empty label. The padding every crowded ring already used is the
rule at all sizes now, widened from 22px to 25 so it actually covers the chip and the
gap it is inset by.
Then the part that was quietly wrong. renderSeats builds nine empty pucks and fills
them afterwards, so the layout search ran against blank boxes and cached the answer
under a key naming a table it had never measured. On a 390px phone that is a ring
sized for empty seats — and the comment where it was cached said "let the resize that
follows do this properly". Nothing resizes a phone. Turning the phone sideways and
back was the only thing that ever fixed it, and it fixed it into a different layout:
same viewport, same table, 90px pucks on load and 72px after a rotate.
So the finished pucks are measured against the box the search settled on, and if they
disagree the search is asked again with the real thing in front of it. Once, and the
second answer is cached. Fresh load and rotate-and-back now agree exactly.
That also lets the cache key shrink to almost nothing. It used to name every player's
name, the dealer's seat, whether anybody had cards and whether anybody had chips out —
four things, three of which flip during an ordinary hand, each flip a ten-candidate
re-search. None of them can change a puck's height any more. Measured over three calls
in a row: no re-search, and not one of the nine seats moved a pixel.
The crowded ring gives up the 1.4 leading on its money rows to pay for the row it now
holds open — 10px of figures is 12px of ink and the rest was blank. Without it the
Galaxy S8 twelve-handed, where the pucks are already at the 44px floor, put
neighbouring seats' blind badges down onto each other's "ALL IN". Hanging the badges
further out was tried first and is the wrong direction: the badge landing on a stack
figure belongs to the seat next door, so every pixel further out is a pixel further
onto a neighbour.
992 unit tests. 320 layout audits across 13 viewports with a mouse and a thumb: one
failure, the chip behind seat 11 that master already carries.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 224 ++++++++++------
.../wwwroot/js/casino/holdem/render.js | 169 +++++++++---
tests/holdem/designTokens.test.js | 13 +-
tests/holdem/seatPuckShape.test.js | 249 +++++++++++++-----
tests/holdem/theRail.test.js | 118 +++++----
5 files changed, 536 insertions(+), 237 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index bc405a4..a1633d1 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1032,25 +1032,56 @@ button,
color: var(--text-muted);
}
-/* Two lines, not an ellipsis.
- A 96px puck fits about nine characters on a line, so "Christopher" came out as
- "Chri…" and "Mike from work" as "Mike fro…" — and "from work" is the half a dealer
- typed to tell two Mikes apart, so the ellipsis was eating exactly the part that
- carried the information. Wrapping keeps both, and a name is the one thing on the
- felt read at a glance rather than looked up. */
+/* ---- The puck is a stack of rows, and every row is the height of a row ----
+
+ Nine chairs on a ring, and until this block no two of them were the same size. A puck
+ was as tall as whatever happened to be written in it: measured at 1280x900 with five
+ players seated, 64px for Bob, 70 for Christopher because his name wrapped, 71 for
+ Alexandra because she had chips out, 89 for "Mike from work" who had both. Twenty-five
+ pixels of difference around one ring, and the ragged edge was the smaller half of it.
+
+ The larger half is that the sizes *moved*. A puck grew 17px the moment its player put
+ money in and shrank again when the street closed, so the felt reflowed twice a hand
+ under a dealer's thumb — and the ring is sized against the tallest puck on it, which
+ meant every one of those changes could resize the whole table.
+
+ Three rows are held open instead: the name, the amount, and the stack. Each is a token
+ rather than an em, so an amount that steps its type down to fit (see .puckAmountLong)
+ shortens its digits and not its row. What the puck says changes all night; how big it
+ is does not.
+
+ Sized so it costs nothing. The ring already reserved room for the tallest puck at every
+ seat — measuredSeatBox() takes the maximum, on purpose — so drawing the short ones at
+ that height spends room that was already spent. What pays for the third row is the
+ second line of name going away above: uniform-with-two-lines was 88px, which an earlier
+ round measured and rightly refused. Uniform-with-one is 71. */
+#seatsLayer {
+ --seatRowName: 15px; /* 12px of name at 1.2 */
+ --seatRowMoney: 14px; /* 10px of figures at 1.4 */
+ --seatNameSize: var(--fs-sm);
+}
+
+/* One line, and the ellipsis is not what saves it.
+ A 96px puck fits about nine characters, so "Christopher" came out as "Chri…" and
+ "Mike from work" as "Mike fro…" — and "from work" is the half a dealer typed to tell
+ two Mikes apart, so the ellipsis was eating exactly the part that carried the
+ information. The second line was the first answer to that. fitSeatNames() is the
+ better one: it drops whole trailing words, so the same name becomes "Mike", which is
+ what the table calls him and reads at a glance rather than being looked up.
+
+ The second line is what made the pucks different sizes from each other, and its cost
+ was never only the pixels. See the row heights below. */
.seatPuckName {
- /* Not shrunk to buy a line. Dropping a step did fit another character and a half,
- and still didn't get "Christopher" onto one line on the button seat, where the
- dealer chip takes 22px of the row — so it bought nothing and cost legibility on
- the one label a dealer reads at a glance across a table. The name stays the size
- it was; the wrap deals with the length. */
- font-size: var(--fs-sm);
+ /* The size a name is read at. Dropping a word is what deals with length first,
+ because a word is a unit a dealer recognises and half a step of type is not; the
+ type only moves for the name with no word left to drop — see .puckNameTight. */
+ font-size: var(--seatNameSize);
font-weight: 600;
color: var(--text-bright);
display: -webkit-box;
-webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
- line-clamp: 2;
+ -webkit-line-clamp: 1;
+ line-clamp: 1;
overflow: hidden;
/* A single word longer than the puck has nothing to break on. `anywhere` was the
first try and it gave "Christo / pher", which reads as a rendering fault; with
@@ -1066,72 +1097,46 @@ button,
overflow-wrap: break-word;
hyphens: auto;
line-height: 1.2;
- /* Deliberately no reserved second line. Reserving one made every puck the height of
- the tallest — measured, 69-73px became a uniform 88 — and 15px added to all nine
- seats is fifteen more pixels of felt spent on chrome, which is the thing this
- round is trying to give back. A ragged ring is cheaper than a crowded one, and
- the ring was already ragged by 4px before any of this. */
+ /* The row is as tall as the row, whether or not there is a name in it — see the
+ token block above .seatPuckStack. An open chair's blank label held no height at
+ all, so the one puck on the ring with nobody in it was the one puck a different
+ size from the rest. */
+ min-height: var(--seatRowName);
margin-top: var(--sp-2);
}
/* The button's chip sits in this puck's top-right corner, on a layer above the text, so
- the name has to keep out from under it.
+ the name has to keep out from under it. The chip, plus the 3px it is inset by.
- It used to do that with `padding-right: 22px`, which takes the chip's width off every
- line of the name — and the chip is on one of them. Measured at 1280x900: the name box
- is 78px wide, "Christopher" is 66px and fits, and on the seat holding the button it
- was cut to 56px and broke as "Christop / her". Mid-word, and no hyphen (see below).
- The button moves every hand, so that is a player whose name reads properly for four
- hands and comes apart on the fifth. On a Pixel 7 it went to three lines and the clamp
- ate the end of it.
+ A float used to do this on the roomier rings — three rules of it, indenting the name's
+ first line and letting a long one drop below the chip onto a second. That answer only
+ existed because there was a second line to drop to. With one line there is nowhere to
+ go, and a float would clip the name to nothing rather than shorten it: measured, the
+ float plus a one-line clamp drew "Christopher" as an empty label, because the word
+ cannot fit beside the chip and its whole line is then hidden.
- A float is what indents only the lines it sits beside, which is the whole of the
- problem. Line one gets 56px and the rest get 78, so a name too long to fit beside the
- chip drops below it whole instead of being broken to fit next to something that isn't
- there. A short name still sits up beside the chip exactly as it did.
-
- The padding stays for the crowded ring, and it has to. A tight puck gives the name one
- line on purpose — see `.seatsTight .seatPuckName` — and one line has nowhere to move
- to, so the only way to keep it off the chip there is to make the line shorter. Trying
- the float on both cost a whole sweep to find out: with two lines granted, the name
- dropped below the float onto a second line that the chip's lower half was still
- sitting on, and the audit called it at every viewport in the list.
-
- So the float is for the ring that has room for two lines, and the padding is for the
- one that doesn't. Two rules, matching the two regimes the puck already had. */
+ So the padding is the rule at every size, which is what the crowded ring always used.
+ What made padding unacceptable before is fixed where it belongs instead: at 1280x900
+ the name box is 78px, "Christopher" is 66, and the button seat cuts it to 56 — so it
+ was broken mid-word as "Christop / her" for the one hand in nine that player has the
+ button. It now steps its type down and stays whole. See .puckNameTight. */
.seat.seatHasButton .seatPuckName {
- padding-right: 22px;
+ padding-right: 25px;
}
-/* Two things have to go with the float. It does nothing inside `display: -webkit-box`,
- so the clamp is replaced by the same two lines held as a max-height. And
- `overflow-wrap: break-word` has to stand down, because it is what stops the float
- working: it breaks a word to fit the short line rather than letting it move past the
- float to the long one, which is the whole manoeuvre.
+/* The name with no word left to drop.
- Measured, at the name-box widths a roomy button seat gets:
+ fitSeatNames() shortens "Mike from work" to "Mike", which is what the table calls him.
+ It can do nothing at all with "Christopher", and what was left for that name was the
+ ellipsis — "Christop…" — on a felt that may well also have a Christine at it.
- 78px (desktop, iPad) "Christopher" was 2 lines of 54+12 now 1 line of 66
- 78px (desktop, iPad) "Katarzyna" was 2 lines of 49+6 now 1 line of 56
-
- The float is one line tall, which is the height it has to be: tall enough to cover the
- chip's reach into the name — the chip's bottom edge is 26px down and the name's first
- line ends at 27.4 — and no taller, because a float that spills into the second line
- indents that one too and puts the whole problem back. */
-#seatsLayer:not(.seatsTight) .seat.seatHasButton .seatPuckName {
- padding-right: 0;
- display: block;
- max-height: 2.4em;
- overflow: hidden;
- overflow-wrap: normal;
-}
-
-#seatsLayer:not(.seatsTight) .seat.seatHasButton .seatPuckName::before {
- content: "";
- float: right;
- /* The chip, plus the 3px it is inset by. */
- width: 25px;
- height: 1.2em;
+ This is the rule the amounts have had all along, applied to the other thing a dealer
+ reads across a table: shrink rather than abbreviate, because a name read wrong is a
+ worse failure than a name read small. One step only, and floored, because past about
+ 9px it stops being legible from across a table and an honest ellipsis is better than
+ an unreadable word. The row it sits in does not move — see --seatRowName. */
+.seatPuckName.puckNameTight {
+ font-size: max(9px, calc(var(--seatNameSize) * 0.85));
}
.seatPuckCards {
@@ -1156,8 +1161,17 @@ button,
background: rgba(9, 255, 0, 0.1);
border-radius: var(--r-pill);
padding: 0 var(--sp-1);
- min-height: 1.1em;
+ /* The token, not an em: .puckAmountLong steps this pill's type down so a tournament
+ stack keeps every digit, and an em would have taken the row down with it. */
+ min-height: var(--seatRowMoney);
line-height: 1.4;
+ /* An inline-block sits on its own 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. Which put the two seats that had
+ money out 2px below everyone else: the one difference this row was reserved to
+ remove, reappearing as its own mirror image. Aligned to the top, the pill is the
+ same box whether or not there is anything written in it. */
+ vertical-align: top;
}
/* Neither row has anything in it most of the time, and both were holding their space
@@ -1176,15 +1190,32 @@ button,
less height to fit on the ring.
`:empty` is exact here: both rows are written by updatePuckContent, which sets an
- empty string rather than a space when there is nothing to say. What the collapse
- needs from the layout is that the ring re-measures when one of them fills — see
- seatShapeSignature() in render.js, which now names both. */
+ empty string rather than a space when there is nothing to say.
+
+ The cards row still collapses, and the amount pill no longer does. They are not the
+ same case, and the difference is how often each one changes.
+
+ Hole cards are a whole-table decision — a dealer either tracks them or doesn't, and
+ for most tables the row is empty from the first hand to the last. Holding 15px open on
+ nine seats all night for a feature the table isn't using is the crowding that comment
+ above is about.
+
+ Chips are the opposite. Somebody has money out for most of every hand, and *which*
+ seats changes on every action. Collapsing that row is what made a puck grow 17px when
+ its player called and shrink again when the street closed, under the thumb of someone
+ trying to tap it — and because the ring is sized against the tallest puck, the whole
+ table could resize with it. The pill keeps its row and hides its paint instead, so the
+ lozenge that comment is about never appears and the row underneath never moves.
+
+ Visibility rather than display, because that is the difference between a box that
+ holds its place and a box that isn't there. It leaves the accessibility tree either
+ way, which is right: there is nothing to announce. */
.seatPuckCards:empty {
display: none;
}
.seatPuckTotal:empty {
- display: none;
+ visibility: hidden;
}
.dealerChip {
@@ -1325,6 +1356,17 @@ button,
padding take it from 15px to 12, which clears the name at 5px of overhang instead of
9 — and four pixels off what every seat is charged is a whole size step of puck back.
The label is the same 10px bold it always was; what went was the air around it. */
+/* How far it hangs off the edge, and the number matters in two places at once: this is
+ what render.js's SEAT_BADGE_OVERHANG_PX reserves between two seats on the ring, so the
+ two have to keep agreeing. seatFootprint.test.js holds them to it.
+
+ Hanging it further out was tried, to stop it reaching into the writing on its own puck,
+ and it is the wrong direction: on the crowded rings the badge that lands on a stack
+ figure is the one belonging to the seat *next door*, so every pixel further out is a
+ pixel further onto a neighbour. Measured on a Galaxy S8 twelve-handed with everyone
+ all-in, -8px took the worst case from four of seven probes across an "ALL IN" to five.
+ The room a crowded puck needs comes out of its own leading instead — see
+ --seatRowMoney on .seatsTight. */
.blindBadge {
display: none;
position: absolute;
@@ -1442,12 +1484,29 @@ button,
hasn't got — it already gives up the hole cards to fit — and a puck that grew taller
here would push its neighbours into each other, which is the one thing the tight
layout exists to stop. */
+/* The clamp used to be here, because this was the only ring that gave the name one line.
+ Every ring does now, so what is left is the type and the tighter row it sits in. */
+/* Tighter leading on both money rows, which is where a crowded ring pays for holding
+ the amount row open. 1.4 is a comfortable line for a figure with room around it and
+ this ring has none: a 10px figure is 12px of ink, and the extra 2px was blank.
+
+ Four pixels off the puck, and it is the four that keep this ring where it was. The
+ Galaxy S8 twelve-handed is the tightest table in the sweep — the pucks are already at
+ the 44px floor there — and at 58px tall the neighbouring seats' badges came down onto
+ each other's stack figures. At 54 they don't, which is the height that table's pucks
+ measured before the amount row was held open at all. */
+#seatsLayer.seatsTight {
+ --seatRowName: 12px; /* 10px of name at 1.2 */
+ --seatRowMoney: 12px; /* and 10px of figures at 1.2, not 1.4 */
+ --seatNameSize: var(--fs-2xs);
+}
+
+.seatsTight .seatPuckTotal {
+ line-height: 1.2;
+}
+
.seatsTight .seatPuckName {
margin-top: var(--sp-1);
- font-size: var(--fs-2xs);
- -webkit-line-clamp: 1;
- line-clamp: 1;
- min-height: 0;
text-overflow: ellipsis;
}
@@ -1480,8 +1539,10 @@ button,
Steps on the scale rather than a clamp(), for the same reason the tight set is: the
width is a continuum and the type is a set of deliberate sizes. */
-.seatsRoomy .seatPuckName {
- font-size: var(--fs-md);
+#seatsLayer.seatsRoomy {
+ --seatRowName: 18px; /* 15px of name at 1.2 */
+ --seatRowMoney: 17px; /* 12px of figures at 1.4 */
+ --seatNameSize: var(--fs-md);
}
.seatsRoomy .seatPuckStack,
@@ -3676,6 +3737,9 @@ body.hasActionBar #holdemApp {
font-variant-numeric: tabular-nums;
margin-top: var(--sp-0);
white-space: nowrap;
+ /* Held open on the empty chairs too, so an open seat is the same puck as a taken
+ one with nothing written in it — which is what an open seat is. */
+ min-height: var(--seatRowMoney);
}
/* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index c6ad5c8..46cae0b 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -631,10 +631,11 @@ 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 blind badge hangs off the puck it belongs to — the `top` and `bottom`
+ // on .blindBadge 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.
+ //
SEAT_BADGE_OVERHANG_PX: 5,
// How much space two neighbouring seats need between their centres.
@@ -792,29 +793,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 +879,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 +909,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 +921,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 +1428,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
@@ -1475,8 +1495,31 @@ 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.
+
+ // 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);
+ }
+
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 +1528,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 +1632,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 +1656,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
diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js
index 5a2d260..f05a0f6 100644
--- a/tests/holdem/designTokens.test.js
+++ b/tests/holdem/designTokens.test.js
@@ -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(
@@ -79,7 +81,10 @@ describe("the type scale", () => {
// 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"]);
+ // 9px — the floor under the same 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.
+ const ALLOWED = new Set(["16px", "1.05em", "0.44rem", "9px"]);
test("every font-size comes off the scale", () => {
const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value));
diff --git a/tests/holdem/seatPuckShape.test.js b/tests/holdem/seatPuckShape.test.js
index 2d5d481..069e8e2 100644
--- a/tests/holdem/seatPuckShape.test.js
+++ b/tests/holdem/seatPuckShape.test.js
@@ -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,163 @@ 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);
});
});
diff --git a/tests/holdem/theRail.test.js b/tests/holdem/theRail.test.js
index 4e5c9de..0ee1352 100644
--- a/tests/holdem/theRail.test.js
+++ b/tests/holdem/theRail.test.js
@@ -115,53 +115,46 @@ 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 \{([^}]*)\}/);
+ // The dealer button's chip sits in the puck's top-right corner, and the name has to
+ // keep out from under it. `padding-right` takes the chip's width off the name's line,
+ // which is the whole of the answer now that there is only ever one line.
+ //
+ // A float used to do this on the roomier rings — three rules of it, indenting the
+ // first line so a name too long to fit beside the chip could drop below it onto a
+ // second. That only worked because there was a second line. There isn't: see the row
+ // heights in holdem.css, and the fitSeatNames tests below for what happens instead to
+ // a name that does not fit.
const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
- test("a ring with room for two lines indents only the line beside the chip", () => {
- assert.ok(roomy && float, "the roomy regime should use a float");
- assert.match(float[1], /float:\s*right/);
- assert.match(roomy[1], /padding-right:\s*0/);
+ test("the chip's width comes off the name, at every size", () => {
+ assert.ok(base, "the base rule should reserve the chip's width");
+ assert.match(base[1], /padding-right:\s*25px/);
});
- test("the float is exactly one line tall", () => {
- // Tall enough to cover the chip's reach into the name, and no taller: a float
- // that spills into the second line indents that one too, which puts the whole
- // problem back.
- assert.match(float[1], /height:\s*1\.2em/);
- });
-
- test("break-word stands down where the float is doing the work", () => {
- // It is what stops the float working — it breaks a word to fit the short line
- // rather than letting it move past the float to the long one.
- assert.match(roomy[1], /overflow-wrap:\s*normal/);
- });
-
- test("a crowded ring keeps the padding, because it has only one line", () => {
- // `.seatsTight .seatPuckName` is `line-clamp: 1` on purpose, and one line has
- // nowhere to move to — the only way to keep it off the chip is to shorten it.
- // Trying the float on both cost a whole sweep to find out: the name dropped onto
- // a second line that the chip's lower half was still sitting on, and the audit
- // called it at every viewport in the list.
- assert.ok(base, "the base rule should still reserve the chip's width");
- assert.match(base[1], /padding-right:\s*22px/);
- assert.match(CSS, /\.seatsTight \.seatPuckName \{[^}]*line-clamp:\s*1/);
- });
-
- test("the float is as wide as the chip it stands in for", () => {
+ test("and it is as wide as the chip it stands in for", () => {
const chip = CSS.match(/\n\.dealerChip \{[^}]*?(? {
+ // Left in place with a one-line clamp it would clip the name to nothing: the
+ // word cannot fit beside the float, so it moves to a second line, and the second
+ // line is what the clamp hides. 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("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.
+ const clamps = CSS.match(/(? 0, "the name should still be clamped to something");
+ clamps.forEach(clamp => assert.match(clamp, /:\s*1$/, clamp));
});
});
@@ -234,9 +227,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 +310,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 +327,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", () => {
--
2.43.0
From ab04c43ffd11c77c6de45aa51a017e214df5bcc6 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Thu, 10 Sep 2026 11:48:04 -0600
Subject: [PATCH 02/27] Stop the table resizing under the thumb that is
reaching for it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two things moved the felt, and neither of them was the felt's business.
The raise pad. It is 57px taller than the row it replaces, and the table was sized
against it — so pressing Raise shrank the whole thing, re-ran the ten-candidate width
search, redrew every seat at a different size, and undid all of it when the dealer
pressed Raise to. Measured, five players nine-handed:
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
Every raise, all night, at the one moment the dealer is reading the felt to decide
what to type. A phone held sideways already scored 0px: the rail is a column down the
side, and its height never was the table's business. So the felt is sized against the
bar at rest and the pad floats up over the bottom of it, which is what a transient
surface is supposed to do — the context it is over stays where it was. --actionBarHeight
is untouched and still says what is really covering the window, because that is what the
toast has to clear.
And the status row. It wraps, and flex settles its line breaks before it decides what
to shrink, so a name too long for the room left beside the clock and the undo pill did
not give way — it pushed them onto a third line. That is 22px off a felt underneath a
bar fixed to the bottom. 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.
An earlier round saw this and reserved two lines for the row; three is what it takes.
Reserving three would spend the 22px on every table for the sake of some of them. So
the actor, the clock and the undo pill get a line of their own that cannot wrap, and
the name shrinks into it — with the stack held out of the shrink, because a number read
wrong is a worse failure than a name read short. The row is 66px whoever is acting.
It claims that line only when there is somebody on it. Claiming it unconditionally
cost one in the state that can least afford it — a board running out has nobody to
act, so the row is the undo pill alone, and it used to sit up beside "All-in —
running out the board".
The pill gives width back to the name on a phone: 10rem is 160px, and at 360 it took
half the row, leaving "Christopher" cut to 23px of its 75. A media query, not
`min(10rem, 34%)` — that was tried, and a percentage against a row sized to its own
contents chases itself: the sweep caught the pill drawn 38px wide at ten viewports.
Verified by walking a hand at five viewports: one felt size, one puck width, and not
one seat moving, across a raise pad opened and shut and a full street of action.
1003 unit tests. 320 layout audits across 13 viewports with a mouse and a thumb, and
this is the first round in a while with nothing in them — the chip behind seat 11 on
the twelve-handed Galaxy S8, which master has carried for rounds, clears too.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 97 ++++++++++++-
.../wwwroot/js/casino/holdem/actionBar.js | 96 ++++++++++++-
tests/holdem/designTokens.test.js | 16 ++-
tests/holdem/theRail.test.js | 134 ++++++++++++++++++
4 files changed, 332 insertions(+), 11 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index a1633d1..75728c2 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -3200,12 +3200,98 @@ body.hasActionBar #holdemApp {
font-size: var(--fs-sm);
}
-.actionWho {
+/* Who is being waited on, and what they have behind.
+
+ It already said `text-overflow: ellipsis`, and the ellipsis never once appeared. A
+ flex item's `min-width` is `auto`, which means "never smaller than your own content",
+ so instead of giving way this pushed the clock and the undo pill onto a third line —
+ and the row is in a bar fixed to the bottom, so a third line comes straight off the
+ felt. Measured at 360x640 mid-street: "Dee · $600 behind" left the bar 151px tall,
+ "Christopher · $1,200 behind" made it 173, and the table resized between one player
+ and the next. Whose turn it was decided how big the felt was.
+
+ `min-width: 0` is what lets the ellipsis do the job it was already written for. What
+ gives way is the name — see .actionWhoName. */
+/* The second line of the status block, and it is a line because it is written as one.
+
+ `flex: 1 1 100%` is what makes it take a line of its own inside a wrapping parent —
+ which is the whole point. Flex settles its line breaks before it decides what to
+ shrink, so a long name in the wrapping row did not give way, it pushed the clock and
+ the undo pill onto a third line. Here the three of them are on a row that can shrink
+ instead of a row that can wrap. */
+.actionActing {
+ /* Only what it holds, when all it holds is the undo pill. Claiming a line
+ unconditionally cost one in the state that can least afford it: while a board is
+ running out there is nobody to act, so this row is the pill alone — it used to sit
+ up beside "All-in — running out the board" and now it wanted a line of its own.
+ That is 22px off the felt on the twelve-handed 360x640 table where the pucks are
+ already at their 44px floor, and the sweep caught it as a seat's "ALL IN" going
+ back under the blind badge next door. */
+ flex: 0 1 auto;
+ display: flex;
+ /* The one that matters. Left wrapping, this row reproduced the fault it was made to
+ fix one level down: the undo pill is a fixed 9rem, so a long name pushed it onto a
+ line of its own and the block went back to being 84px for some players and 66 for
+ others. Not wrapping is what forces the shrink, and the shrink is what the name
+ is set up to absorb. */
+ flex-wrap: nowrap;
+ align-items: baseline;
+ gap: var(--sp-1) var(--sp-2);
+ min-width: 0;
+}
+
+/* And a line of its own the moment there is somebody being waited on, which is the case
+ this row exists for — the name, what they have behind, the clock, and the way back.
+ `flex: 1 1 100%` is what takes the line; nowrap above is what makes the name give way
+ inside it instead of pushing the pill onto another one. */
+.actionActing:has(.actionWho) {
+ flex: 1 1 100%;
+}
+
+/* On a phone the pill gives some of its width back to the name beside it.
+ 10rem is 160px, and at 360px the acting row has 319 to spend: the pill took half, the
+ clock took 50, and what was left over went to the player being waited on — measured,
+ "Christopher" was cut to 23px of its 75, which reads "Ch…". A third of the row is
+ enough for a safety net when the other thing on the line is the question the dealer is
+ actually answering.
+
+ Still one width at a time, which is the property that matters: the pill names the last
+ action, so its label changes on every tap, and it must not resize under a thumb.
+ A media query keeps that true. `min(10rem, 34%)` does not — the percentage resolves
+ against this row, the row is sized to its contents, and the two chase each other: the
+ sweep caught the pill drawn 38px wide at ten viewports. */
+@media (max-width: 480px) {
+ .actionUndo {
+ width: 7rem;
+ }
+}
+
+/* Pushed to the far end of its own line, where it was before — the difference is that
+ it is now the end of a line this block chose rather than wherever the text ran out. */
+.actionActing .actionUndo {
margin-left: auto;
+}
+
+.actionWho {
color: var(--text-muted);
+ display: flex;
+ align-items: baseline;
+ min-width: 0;
+ white-space: nowrap;
+}
+
+/* The half that shrinks. */
+.actionWhoName {
overflow: hidden;
text-overflow: ellipsis;
- white-space: nowrap;
+ min-width: 0;
+}
+
+/* And the half that doesn't. 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 and the name
+ is what loses characters. */
+.actionWhoStack {
+ flex: none;
}
/* Stopwatch, not a shot clock: it colours up as a prompt to the dealer, and does
@@ -5248,6 +5334,13 @@ body.hasActionBar #toastHost {
min-height: 0;
}
+ /* The rail is 190px wide, so its acting row is a stack rather than a line — every
+ one of these readings is on its own line here regardless, and squeezing a name
+ into a fifth of the column would be shrinking to fit a row that isn't there. */
+ .actionActing {
+ flex-wrap: wrap;
+ }
+
.actionUndo {
width: 100%;
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 46fa16d..0c20df8 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -42,7 +42,13 @@ const ActionBar = {
// 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 +64,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
@@ -336,20 +378,59 @@ 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";
+ whoStack.textContent = ` · ${Money.format(Betting.stackOf(this.state, player))} behind`;
+ who.appendChild(whoStack);
+
+ acting.appendChild(who);
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);
+ 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 +456,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
@@ -410,9 +491,12 @@ const ActionBar = {
key.textContent = this.KEY_HINTS.actionUndo;
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;
},
diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js
index f05a0f6..133d5ea 100644
--- a/tests/holdem/designTokens.test.js
+++ b/tests/holdem/designTokens.test.js
@@ -156,9 +156,19 @@ 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 => /(?
+ assert.match(body, /(? /(? {
diff --git a/tests/holdem/theRail.test.js b/tests/holdem/theRail.test.js
index 0ee1352..2a7da9e 100644
--- a/tests/holdem/theRail.test.js
+++ b/tests/holdem/theRail.test.js
@@ -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");
// ---------------------------------------------------------------------------
@@ -160,6 +162,138 @@ describe("the button seat's name", () => {
// ---------------------------------------------------------------------------
+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("how much of the bottom is spoken for", () => {
// feltRoomFor asks one question — what is under the felt — and gets two different
// answers depending on the layout. Under the bar it is the bar. Under the rail it is
--
2.43.0
From 42b36a1464de9f0aa4bfe4242bf52c634f13fb8d Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Thu, 10 Sep 2026 12:43:12 -0600
Subject: [PATCH 03/27] Stop the bar dropping the one word it exists to say
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The acting line names whoever the table is waiting on, and on a phone it
had stopped doing it. The name was set up as the half that gives way —
right rule, no floor under it. Flex shrinks the only item that can shrink
until the line fits, and with the clock button on the line there was no
fitting to be had: measured with five players nine-handed, mid-street,
360x640 "Christopher" wanted 62px, drawn 0. "Alexandra" 54 -> 0.
390x844 "Christopher" wanted 62px, drawn 0. "Alexandra" 54 -> 0.
430x932 "Christopher" 62 -> 31.
What the bar read on the first two was "· $1,200 behind" — a dangling
separator and a figure, at the moment the dealer is looking at it to say
whose turn it is out loud. The ellipsis the rule was written around never
appeared; there were no characters left to put one after.
The rule stays: a number read wrong is worse than a name read short, so
the stack is never truncated. It comes off the line whole instead, and
it is not lost — the same figure is on that player's own seat puck.
The stopwatch pays for the rest. It was on the line from the first second
of every decision, reading "0:07" in grey: 50px of the narrowest row in
the tool spent on a number nobody acts on. It is a prompt, not a readout,
and it now arrives when the button that acts on it arrives — the same
thirty-second threshold, asked once.
360x640 "Christopher" 62 -> 44 ("Christo…"), "Alexandra" 54 -> 44
390x844 both whole, where both were nothing
430x932 both whole
and "Mike from work" whole at 360 where it was cut to 68
with the bar 151px and the felt 309x463 either side of the thirty-second
mark, and 0px of seat movement across it.
Also: the space before the separator. These are two flex items and a flex
item's own leading whitespace is collapsed at its edge, so the bar has
read "Alexandra· $1,500 behind" since the sentence was split in two. The
gap is in the stylesheet now, where it can survive that.
1018 unit tests; 320 layout audits across 13 viewports, clean.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 27 ++-
.../wwwroot/js/casino/holdem/actionBar.js | 129 +++++++++++--
tests/holdem/theRail.test.js | 170 ++++++++++++++++++
3 files changed, 307 insertions(+), 19 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 75728c2..f73f0b1 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -3272,10 +3272,16 @@ body.hasActionBar #holdemApp {
margin-left: auto;
}
+/* The gap is the space before the separator, and it is here rather than in the string
+ because it cannot be in the string: these are two flex items, and a flex item's own
+ leading whitespace is collapsed at its edge. The bar has been reading "Alexandra·
+ $1,500 behind" ever since the name and the stack were split into two spans to decide
+ which of them gives way. */
.actionWho {
color: var(--text-muted);
display: flex;
align-items: baseline;
+ gap: var(--sp-1);
min-width: 0;
white-space: nowrap;
}
@@ -3289,13 +3295,26 @@ body.hasActionBar #holdemApp {
/* And the half that doesn't. 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 and the name
- is what loses characters. */
+ is what loses characters.
+
+ With a floor under the name, which is what this was missing: shrinking the only
+ shrinkable item until the line fits took "Christopher" to 0px on two of the three
+ phones in the sweep, and what the bar then read was "· $1,200 behind". So when the
+ name is being cut, this comes off the line instead — see ActionBar.fitActingLine.
+ The figure is on that player's seat puck either way; the name is the thing only the
+ bar is saying. */
.actionWhoStack {
flex: none;
}
+.actionWhoStack[hidden] {
+ display: none;
+}
+
/* Stopwatch, not a shot clock: it colours up as a prompt to the dealer, and does
- nothing else. */
+ nothing else — and a prompt that is on the line from the first second of every
+ decision is not a prompt. It arrives when the button beside it does, half a minute
+ in, which is also 50px of a 349px line handed back to the name. */
.actionTank {
flex: none;
min-width: 3.1rem;
@@ -3305,6 +3324,10 @@ body.hasActionBar #holdemApp {
transition: color 0.3s ease;
}
+.actionTank[hidden] {
+ display: none;
+}
+
.actionTank.tankNudge {
color: var(--gold);
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 0c20df8..9fe74e6 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -244,6 +244,9 @@ 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.announce();
},
@@ -420,16 +423,23 @@ const ActionBar = {
const whoStack = document.createElement("span");
whoStack.className = "actionWhoStack";
- whoStack.textContent = ` · ${Money.format(Betting.stackOf(this.state, player))} behind`;
+ // 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";
+ tank.hidden = true;
acting.appendChild(tank);
// Rendered every time and shown by the tick once the wait is long enough
@@ -475,6 +485,23 @@ const ActionBar = {
// ellipsize, 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.
+ //
+ // Known, measured, and not fixed here: on a phone this label has 73px and
+ // four of the five labels a hand produces want more than that —
+ //
+ // ↶ 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 fits)
+ //
+ // so the pill reads "↶ dealing …" most of the night. Widening it is not
+ // available — the room it would take is the room the name beside it needs,
+ // and that is what fitActingLine is spending. The answer is the same shape
+ // as the acting line's: the actor and the act as two spans, the actor
+ // shrinking first, so "Mike from work's fold" degrades to "Mike f…'s fold"
+ // rather than to "Mike from wor…" and the *act* — the half that says what
+ // would come back — survives. That needs the two halves to come from the
+ // call sites rather than be guessed out of a string with an apostrophe in
+ // it, which is a change to what commit() is handed, and a round of its own.
const label = document.createElement("span");
label.className = "actionUndoLabel";
label.textContent = `↶ ${undoLabel}`;
@@ -626,12 +653,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
@@ -641,39 +715,60 @@ 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.
+ 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
diff --git a/tests/holdem/theRail.test.js b/tests/holdem/theRail.test.js
index 2a7da9e..33c513c 100644
--- a/tests/holdem/theRail.test.js
+++ b/tests/holdem/theRail.test.js
@@ -294,6 +294,176 @@ describe("the status block is the same number of lines whoever is acting", () =>
// ---------------------------------------------------------------------------
+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("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\(\)/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+
describe("how much of the bottom is spoken for", () => {
// feltRoomFor asks one question — what is under the felt — and gets two different
// answers depending on the layout. Under the bar it is the bar. Under the rail it is
--
2.43.0
From d4c39f209aa58718de92bed3cf28f90dc7e647d4 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Thu, 10 Sep 2026 13:40:44 -0600
Subject: [PATCH 04/27] Follow the browser's text size instead of sliding out
from under it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every length in this stylesheet that isn't a structural clearance is in
`rem`. That is the right way round — it means the page follows the
browser's font size, which is what a phone's text-size slider changes —
and it means there was a whole axis of behaviour that 320 audits had
never once touched, because every one of them ran at the default 16px.
The sweep gets a third pass: the same surfaces on the eight phone-shaped
viewports at 20px of root text, which is 125% on an Android slider.
Everything it found on its first run was caught by a check that had been
sitting there for months with nothing to say.
The page slid sideways. Three header buttons that will not break and will
not shrink — `white-space: nowrap` and a `min-width: auto` floor apiece —
were told `flex-wrap: nowrap`, 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,
60px past the edge of a 390px one. They take a second row now on the day
they need one, and still fit on the first and still grow into it at any
ordinary size.
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 on a 44px puck, "$1,200" is
six characters, took no step at all, and wanted 38px of the 34 it had.
Render.fitAmounts measures now, in the batched passes fitSeatNames
already uses, and takes the separators out before it takes a step of
type: "$1200" at full size beats "$1,200" at three-quarters of it.
And the pucks grew, which the ring cannot answer. The row heights are px
tokens so an amount stepping its type down shortens its digits and not
its row — but a token is a floor, not a ceiling. At 125% the puck went
from 55px tall to 65 and twelve of them came down on each other, because
at the 44px floor a thumb needs there is nowhere narrower to search. The
two sizes a puck is written at are capped at exactly the sizes they have
always been drawn at, so nothing changes for anybody at an ordinary text
size — and the felt is a diagram, so the rule that already governed a
puck's width now governs its height. Roomy pucks are exempt; they have
the room. The same figure is in Standings in full, at whatever size its
owner asked for.
Two smaller things on the way. The landscape rail opened at its top, so
its overflow came off the bottom — where Cancel and Raise to are; it
opens at the end now, which is the same reason it is anchored low at all.
And the check that called those buttons unreachable was wrong: the rail
scrolls on purpose, and a sweep that cries wolf is a sweep nobody reads.
418 audits, clean. One shape it is not held to, with the arithmetic in
runUiAudit.js: twelve players all-in on a 360x640 phone leaves the felt
290x163 once the bar has taken 283px of the 640, and twelve 44x62 pucks
do not fit around an ellipse 163px deep. Reported as a note every run
rather than a failure every run. 1026 unit tests.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 86 +++++++++----
.../wwwroot/js/casino/holdem/actionBar.js | 18 +++
.../wwwroot/js/casino/holdem/render.js | 110 +++++++++++++----
tests/holdem/README.md | 47 +++++++
tests/holdem/designTokens.test.js | 25 ++--
tests/holdem/runUiAudit.js | 82 ++++++++++---
tests/holdem/seatPuckShape.test.js | 115 ++++++++++++++++++
tests/holdem/uiAudit.js | 21 ++++
8 files changed, 431 insertions(+), 73 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index f73f0b1..d2e4018 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1055,10 +1055,35 @@ button,
that height spends room that was already spent. What pays for the third row is the
second line of name going away above: uniform-with-two-lines was 88px, which an earlier
round measured and rightly refused. Uniform-with-one is 71. */
+/* The two sizes a puck is written at, named so that the steps below them are steps *of*
+ them rather than separate sizes to be kept in agreement — and capped, which is the one
+ place in this stylesheet where type does not follow the browser's font size.
+
+ Why it has to be. Every row above is a `px` token 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 simply makes the row taller. Turn a phone's text up to the
+ 125% its slider offers and --fs-2xs goes from 10px to 12.5, the two money rows go from
+ 14px each to 17.5, and the puck grows with them. At 150% the figures were being cut
+ off by the puck's own edge; on the tightest ring, where twelve pucks are already at
+ the 44px a thumb needs and there is nowhere narrower to go, they came down on each
+ other instead — the sweep measured seat 3's "$1,495" five-sevenths under seat 4's BB
+ badge at every phone in the list.
+
+ `min(…, Npx)` is exactly the size each of these has always been drawn at, so nothing
+ changes for anybody at an ordinary text size: the cap only ever engages where the
+ alternative is a figure clipped in half or two seats printed on top of each other.
+ And what it holds is the one thing on the page that is also somewhere that does
+ scale — the Standings panel lists every stack in full, at whatever size its owner
+ asked for. The felt is a diagram; its cells are sized by the table, and their writing
+ has to fit inside them. That rule already governed a puck's width. This is the same
+ rule for its height.
+
+ .seatsRoomy is the exception, below: a puck past the ordinary ceiling has the room. */
#seatsLayer {
--seatRowName: 15px; /* 12px of name at 1.2 */
--seatRowMoney: 14px; /* 10px of figures at 1.4 */
- --seatNameSize: var(--fs-sm);
+ --seatNameSize: min(var(--fs-sm), 12px);
+ --seatAmountSize: min(var(--fs-2xs), 10px);
}
/* One line, and the ellipsis is not what saves it.
@@ -1155,7 +1180,7 @@ button,
.seatPuckTotal {
display: inline-block;
margin-top: var(--sp-0);
- font-size: var(--fs-2xs);
+ font-size: var(--seatAmountSize);
font-weight: 700;
color: #a9f5b8;
background: rgba(9, 255, 0, 0.1);
@@ -1498,10 +1523,12 @@ button,
#seatsLayer.seatsTight {
--seatRowName: 12px; /* 10px of name at 1.2 */
--seatRowMoney: 12px; /* and 10px of figures at 1.2, not 1.4 */
- --seatNameSize: var(--fs-2xs);
+ --seatNameSize: min(var(--fs-2xs), 10px);
+ --seatAmountSize: min(var(--fs-2xs), 10px);
}
-.seatsTight .seatPuckTotal {
+.seatsTight .seatPuckTotal,
+.seatsTight .seatPuckStack {
line-height: 1.2;
}
@@ -1510,10 +1537,6 @@ button,
text-overflow: ellipsis;
}
-.seatsTight .seatPuckStack {
- font-size: var(--fs-2xs);
-}
-
/* Not wrapping is only half an answer: a line that cannot wrap and still does not fit
paints over the seat next door instead. So on the tightest pucks the mark gives up
most of its letter-spacing, which is 3.6px of the 36 it wanted.
@@ -1543,11 +1566,7 @@ button,
--seatRowName: 18px; /* 15px of name at 1.2 */
--seatRowMoney: 17px; /* 12px of figures at 1.4 */
--seatNameSize: var(--fs-md);
-}
-
-.seatsRoomy .seatPuckStack,
-.seatsRoomy .seatPuckTotal {
- font-size: var(--fs-sm);
+ --seatAmountSize: var(--fs-sm);
}
.seatsRoomy .seatChairNumber {
@@ -3841,7 +3860,7 @@ body.hasActionBar #holdemApp {
600 — so the hierarchy is intact; it is just carried by size and weight instead of by
fading the number underneath it. */
.seatPuckStack {
- font-size: var(--fs-2xs);
+ font-size: var(--seatAmountSize);
color: var(--text);
font-variant-numeric: tabular-nums;
margin-top: var(--sp-0);
@@ -3868,21 +3887,30 @@ body.hasActionBar #holdemApp {
border-color: rgba(255, 207, 107, 0.45);
}
-/* Tournament-sized amounts. A number has no spaces to break on, so an amount too
- wide for the 64px puck neither wraps nor ellipsises — it draws straight out over
- the seat next to it. These step the size down so every digit stays on its own
- puck. Shrinking rather than abbreviating is deliberate: "$1.25M" would fit more
- comfortably, but a dealer reading a rounded stack off the felt is a worse failure
- than a small one, and the exact figure is what the tool is for. */
+/* The two steps an amount comes down when it is too wide for its puck. A number has no
+ spaces to break on, so an amount too wide neither wraps nor ellipsises — it draws
+ straight out over the seat next to it. Shrinking rather than abbreviating is
+ deliberate: "$1.25M" would fit more comfortably, but a dealer reading a rounded stack
+ off the felt is a worse failure than a small one, and the exact figure is what the
+ tool is for.
+
+ Both are steps *of* --seatAmountSize rather than sizes of their own, so they follow
+ the tight ring's cap down instead of stepping back over it — and so the ladder is one
+ number with two fractions under it rather than three sizes to keep in agreement.
+
+ Which step a figure gets is measured, not counted. See Render.fitAmounts: the old
+ rule was eight characters for the first and ten for the second, which is a guess at
+ how wide a figure is, and it stopped being true the moment anybody turned their
+ phone's text up. */
.seatPuckStack.puckAmountLong,
.seatPuckTotal.puckAmountLong {
- font-size: var(--fs-2xs);
+ font-size: calc(var(--seatAmountSize) * 0.85);
letter-spacing: -0.01em;
}
.seatPuckStack.puckAmountHuge,
.seatPuckTotal.puckAmountHuge {
- font-size: 0.44rem;
+ font-size: calc(var(--seatAmountSize) * 0.7);
letter-spacing: -0.02em;
}
@@ -4656,10 +4684,20 @@ body.hasActionBar #toastHost {
/* Takes the row rather than shrink-wrapping to the right of it. Sized to their
labels the three buttons left 78px of nothing where the title used to be, and
that gap buys nothing — spent on the buttons instead it is about 26px of extra
- target each, on the controls a dealer presses once a hand with a thumb. */
+ target each, on the controls a dealer presses once a hand with a thumb.
+
+ Wrapping, though, and this used to say `nowrap`. Three labels that will not break
+ and will not shrink — `white-space: nowrap` and a `min-width: auto` floor apiece —
+ have to go somewhere when they stop fitting, and with nowrap that somewhere was
+ off the side of the screen. Measured with the text turned up, which every phone
+ lets you do: at 20px of root text (an Android slider at 125%) "Resolve Pot" ended
+ 23px past the right edge of a 360px window and put a horizontal scrollbar under
+ the whole page; at 24px it was 60px past the edge of a 390px one. Nothing is lost
+ by letting them take a second row on the day they need one — at any ordinary text
+ size they still fit on the first and still grow into it. */
#holdemHeaderActions {
flex: 1 1 auto;
- flex-wrap: nowrap;
+ flex-wrap: wrap;
min-width: 0;
gap: var(--sp-1);
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 9fe74e6..66027ab 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -247,9 +247,27 @@ const ActionBar = {
// 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.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.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 46cae0b..38c390d 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -1495,6 +1495,7 @@ const Render = {
});
this.fitSeatNames(layer);
+ 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
@@ -1518,6 +1519,8 @@ const Render = {
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);
@@ -2028,7 +2031,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
@@ -2039,7 +2046,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);
@@ -2049,35 +2056,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 ----
diff --git a/tests/holdem/README.md b/tests/holdem/README.md
index bfbd9b9..be45b8b 100644
--- a/tests/holdem/README.md
+++ b/tests/holdem/README.md
@@ -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
diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js
index 133d5ea..5d07f20 100644
--- a/tests/holdem/designTokens.test.js
+++ b/tests/holdem/designTokens.test.js
@@ -79,15 +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.
- // 9px — the floor under the same 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.
- const ALLOWED = new Set(["16px", "1.05em", "0.44rem", "9px"]);
+ // 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");
});
diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js
index 1157a9f..2a905e0 100644
--- a/tests/holdem/runUiAudit.js
+++ b/tests/holdem/runUiAudit.js
@@ -110,23 +110,72 @@ 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.
+ const CROWDED_BEYOND_HELP = "all in";
+ 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 (shape === soft) 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 +250,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 +273,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()));
@@ -253,12 +298,12 @@ async function main() {
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
});
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);
}
}
}
- 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`);
@@ -278,7 +323,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");
diff --git a/tests/holdem/seatPuckShape.test.js b/tests/holdem/seatPuckShape.test.js
index 069e8e2..0f7e1e6 100644
--- a/tests/holdem/seatPuckShape.test.js
+++ b/tests/holdem/seatPuckShape.test.js
@@ -240,3 +240,118 @@ describe("and the ring checks its own measurement afterwards", () => {
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], []);
+ });
+});
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index c3e5c8e..be59993 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -213,6 +213,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 +245,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 };
--
2.43.0
From 81360255448c57a418b68c2991e7403af411286d Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Thu, 10 Sep 2026 14:13:25 -0600
Subject: [PATCH 05/27] Say what the undo would take back, and let the empty
chairs be empty
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two things on the felt and the bar that were spending weight on the wrong
half of what they had to say.
The undo pill names the last action, and on a phone it had stopped doing
it. It is a fixed width — its text changes on every tap, and a control
that resizes under a thumb reads as the interface twitching — and 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 …". Cut as one
string it keeps the wrong half: "↶ Mike from wor…" names everybody except
the action. So an action's name is handed to commit() in two halves now —
who it was, and what they did — and the actor comes off whole when the
act cannot fit beside it. The whole sentence 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.
Two things fell out of it. 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 — which is why the fit asks the act
and not the label; asked the label, "↶ dealing the hand" kept its actor
and quietly cut "the hand" off inside it. And the pill was still printing
a keycap on phones: every other control on the bar has dropped its cap
under `hover: none` since that rule was written, but this one is a
different class and had been left out of it, so the narrowest label on the
bar was the only one spending 22px on a shortcut nothing could press.
That 22px is exactly what "raise to $1,200" needed to keep its digits.
Nothing is cut now 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.
And the empty chairs. An open seat was 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 on the felt
they read as holes punched in the table: 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 was there for one reason, which is that the chair number's
contrast cannot depend on whether its chair sits over the rim, the dark
green or the light — 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. The dashes still say "open", the box a thumb lands on has not moved a
pixel, and the felt gets four boxes' worth of quiet back.
1039 unit tests; 418 layout audits across 13 viewports, clean.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 75 ++++++++-
.../wwwroot/js/casino/holdem/actionBar.js | 138 ++++++++++++----
.../wwwroot/js/casino/holdem/main.js | 9 +-
tests/holdem/designTokens.test.js | 58 ++++++-
tests/holdem/theRail.test.js | 147 ++++++++++++++++++
5 files changed, 387 insertions(+), 40 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index d2e4018..80b24ba 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1001,12 +1001,40 @@ button,
about two points of Lc and cost four dark rectangles sitting on the felt like holes.
Taking the lighter one is free. */
.seat.seatEmpty {
- background: rgba(18, 18, 18, 0.62);
+ background: rgba(0, 0, 0, 0.28);
border-style: dashed;
- border-color: rgba(255, 255, 255, 0.22);
+ border-color: rgba(255, 255, 255, 0.2);
box-shadow: none;
}
+/* The ground moved off the chair and under the one thing on it.
+
+ An open chair was 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 on the felt they read as holes punched in the table rather than as
+ chairs nobody is in: the eye has to sort the loud empty boxes from the loud full ones
+ before it can find who is in the hand.
+
+ The wash is off the chair now and under its number, which is the only thing an open
+ chair has to say. That keeps what the wash was actually for — the number's contrast
+ cannot depend on whether its chair happens to sit over the rim, the dark green or the
+ light, which is a difference of about Lc 5 across one ring — while giving the felt
+ back four boxes' worth of quiet. The dashes and the outline still say "open", which
+ they always did, and the box a thumb lands on has not moved a pixel.
+
+ Centred rather than in the corner, because on an empty chair the number is the label
+ and not a reference mark on something else. */
+.seatEmpty .seatChairNumber {
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ min-width: 18px;
+ padding: 0 var(--sp-1);
+ border-radius: var(--r-pill);
+ background: rgba(18, 18, 18, 0.72);
+ text-align: center;
+}
+
.seatEmptyLabel {
color: var(--text-dim);
font-size: var(--fs-2xs);
@@ -3625,12 +3653,39 @@ body.hasActionBar #holdemApp {
}
/* The undo pill's key, inline rather than pinned to a corner — see actionBar.js. */
-/* The half of the pill that gives: the label shortens, the keycap never does. */
+/* The half of the pill that gives: the label shortens, the keycap never does.
+
+ And which part of the label shortens is decided too. It is three items rather than one
+ string — the arrow, the actor, the act — so that a pill too narrow for all of it drops
+ the actor whole instead of cutting the sentence off wherever the ellipsis lands. On a
+ phone this label has 73px and "↶ Mike from work's fold" wants 128; cut, that reads
+ "↶ Mike from wor…", which names everything except the thing you would be taking back.
+ See ActionBar.fitUndoLabel. */
.actionUndoLabel {
+ display: flex;
+ align-items: baseline;
+ gap: var(--sp-1);
+ min-width: 0;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.actionUndoGlyph,
+.actionUndoLead {
+ flex: none;
+}
+
+.actionUndoLead[hidden] {
+ display: none;
+}
+
+/* The half that stays. It can still ellipsize — "raise to $1,200" is 69px of the 59 the
+ label has on the narrowest phone once the arrow is drawn — and it is the only act that
+ ever does. */
+.actionUndoAct {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
- white-space: nowrap;
}
.actionUndoKey {
@@ -3675,9 +3730,17 @@ body.hasActionBar #holdemApp {
}
/* No keyboard, no key badges — on a phone they'd just be clutter on the buttons
- the dealer is already tapping. */
+ the dealer is already tapping.
+
+ The undo pill's cap is a different class (see .actionUndoKey: this one pins itself to
+ a corner and that one sits inline) and had been left out of this rule since the day it
+ was written, so the one control on the bar too narrow to spare the room was the only
+ one still printing a shortcut nothing could press. It is 18px and a 4px gap of a 73px
+ label — enough, on its own, for "raise to $1,200" to keep its last three digits. The
+ Keyboard panel still lists the shortcut for anyone who has a keyboard to use it. */
@media (hover: none) {
- .actionKey {
+ .actionKey,
+ .actionUndoKey {
display: none;
}
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 66027ab..66d5c9e 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -247,6 +247,7 @@ const ActionBar = {
// 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();
},
@@ -499,30 +500,43 @@ const ActionBar = {
// 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.
//
- // Known, measured, and not fixed here: on a phone this label has 73px and
- // four of the five labels a hand produces want more than that —
+ // Three elements, because what it gives up matters. Measured on a phone, the
+ // label has 73px and four of the five labels a hand produces want 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 fits)
+ // ↶ Dee's fold 69 (the only one that fitted)
//
- // so the pill reads "↶ dealing …" most of the night. Widening it is not
- // available — the room it would take is the room the name beside it needs,
- // and that is what fitActingLine is spending. The answer is the same shape
- // as the acting line's: the actor and the act as two spans, the actor
- // shrinking first, so "Mike from work's fold" degrades to "Mike f…'s fold"
- // rather than to "Mike from wor…" and the *act* — the half that says what
- // would come back — survives. That needs the two halves to come from the
- // call sites rather than be guessed out of a string with an apostrophe in
- // it, which is a change to what commit() is handed, and a round of its own.
+ // so the pill spent most of a night reading "↶ dealing …" — a safety net
+ // that would not say what it was a net for. 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
+ // instead, whole, and the act stays — see fitUndoLabel.
const label = document.createElement("span");
label.className = "actionUndoLabel";
- label.textContent = `↶ ${undoLabel}`;
+
+ const glyph = document.createElement("span");
+ glyph.className = "actionUndoGlyph";
+ glyph.textContent = "↶";
+ 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
@@ -777,6 +791,41 @@ const ActionBar = {
// 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");
@@ -907,12 +956,35 @@ 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 two halves the undo pill needs it in: who it was, and
+ // what they did.
+ //
+ // 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 want more than that: "↶ dealing the hand" 104,
+ // "↶ Christopher's fold" 109, "↶ Mike from work's fold" 128, "↶ Alexandra's fold"
+ // 100. Only "↶ Dee's fold" fitted, at 69. So the pill spent most of a night reading
+ // "↶ dealing …" — a safety net that would not say what it was a net for.
+ //
+ // 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". A label handed over as one string keeps working and simply has
+ // nothing to drop.
+ undoParts(label) {
+ const lead = typeof label === "string" ? "" : (label.lead || "");
+ const act = typeof label === "string" ? label : label.act;
+ return { lead, act, long: lead ? `${lead} ${act}` : act };
+ },
+
renderActions() {
const index = this.state.actingSeatIndex;
const player = this.state.seats[index];
@@ -941,7 +1013,8 @@ 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`,
+ { lead: `${expired.who}'s`,
+ act: expired.verb === "fold" ? "hand killed on the clock" : "check on the clock" },
() => {
this.stopClock();
if (expired.verb === "fold") Betting.fold(this.state, index);
@@ -953,16 +1026,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) {
@@ -1060,9 +1133,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));
}));
@@ -1169,12 +1246,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: "calling", act: "the straddle off" },
() => { this.state.straddleCount = 0; })));
}
return buttons;
@@ -1262,7 +1339,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: "dealing", act: "everybody in" }, () => {
benched.forEach(index => {
this.state.seats[index].inHand = true;
this.state.seats[index].folded = false;
@@ -1351,7 +1428,7 @@ 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`,
+ { lead: who, act: posting ? "waiting for the blind" : "posting to come in" },
() => { this.state.postingIn = posting ? [] : ids; })
));
wrap.appendChild(line);
@@ -1370,7 +1447,7 @@ 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));
+ this.commit({ lead: "dealing", act: "the hand" }, () => Betting.beginHand(this.state));
}));
wrap.appendChild(row);
@@ -1431,7 +1508,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({ lead: "dealing", 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.
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 82224e3..6344551 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -407,13 +407,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;
diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js
index 5d07f20..8007303 100644
--- a/tests/holdem/designTokens.test.js
+++ b/tests/holdem/designTokens.test.js
@@ -163,6 +163,44 @@ 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
@@ -182,9 +220,25 @@ describe("controls that must not resize under a thumb", () => {
"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/);
});
});
diff --git a/tests/holdem/theRail.test.js b/tests/holdem/theRail.test.js
index 33c513c..c4af5a1 100644
--- a/tests/holdem/theRail.test.js
+++ b/tests/holdem/theRail.test.js
@@ -399,6 +399,153 @@ describe("and the name is the half that is still there at the end of it", () =>
// ---------------------------------------------------------------------------
+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
--
2.43.0
From 37abc72de10d680d1134700c00f157ebcc3bc426 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Thu, 10 Sep 2026 16:20:37 -0600
Subject: [PATCH 06/27] Take the dealer button out of the writing and put it on
the rim
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A puck can say four things about where a seat sits in the hand: small blind,
big blind, straddle, button. Three of them were pills hanging off the top-left
edge. The fourth was a 22px disc inside the top-right corner, on a layer above
the name — so the dealer's eye had two places to look for one class of fact,
and the corner one was charging the name 25px of its line to be there.
What that cost, measured across the sweep's tables with the twelve packed names:
Galaxy S8 9-handed name box 56px 11 of 12 fit — 7 on the button seat
Galaxy S8 12-handed name box 34px 7 of 12 fit — 1 on the button seat
iPhone 14 9-handed name box 68px 11 of 12 fit — 7 on the button seat
desktop 9-handed name box 105px 12 of 12 fit — 11 on the button seat
Four names in twelve, on a phone, cut on one seat and whole on the other eight.
And the button is the one marker on the felt that moves every hand, so that is
not four unlucky players — it is every player in turn, once a round, all night.
The chip now hangs off the same edge as the badges, at the same 5px, at the
other end of it. That distance is not free — SEAT_BADGE_OVERHANG_PX charges
every chair on the ring for it — but it is already spent, and it is charged
once per seat rather than once per marker, which stays right precisely because
the two markers share an edge and sit at opposite ends of it. So the ring does
not move at all. 12px is what fits between -5px and the top of the name on the
tight ring, and it is enough: a finger never reaches this chip anyway — on
touch the ghosts are pointer-events: none and the live one is inert, so giving
the button is the seat's long-press menu, and what is left is a mouse on a
ghost that only appears under the pointer.
Round where the badges are pills, and on the right where they are on the left,
because the button's gold and the big blind's now share one edge: what tells
them apart is the shape and the side, not the colour.
--
The other half of this was a name that could not be trusted to be one line.
The label was a `-webkit-box` clamped to one line, which hides the lines past
the first — but not the part of the second one that the row's own min-height
leaves uncovered. The row is a px token, 15px, "12px of name at 1.2", while
.puckNameTight steps the name down to 10.2px, whose line box is 12.24. That
leaves 2.76px. Measured on a 390px phone: "Christopher" on the button seat drew
as "Christ…" with the ascenders of "opher" showing underneath it as a stray
mark, which reads as a rendering fault on the first seat a dealer looks at.
`white-space: nowrap` with `text-overflow: ellipsis` has no second line to
leak. It also makes the box measure honestly, which the clamp did not: a name
that wrapped reported scrollWidth == clientWidth, so fitSeatNames() had only
ever been seeing the overflow as height — the right answer to the wrong
question, and only right while the row and the line agreed.
1046 tests pass; the 418-audit sweep is clean.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 144 +++++++++++-------
.../wwwroot/js/casino/holdem/render.js | 27 ++--
tests/holdem/seatFootprint.test.js | 30 ++++
tests/holdem/theRail.test.js | 65 ++++----
4 files changed, 172 insertions(+), 94 deletions(-)
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 80b24ba..159226c 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -1131,24 +1131,24 @@ button,
font-size: var(--seatNameSize);
font-weight: 600;
color: var(--text-bright);
- display: -webkit-box;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 1;
- line-clamp: 1;
- overflow: hidden;
- /* A single word longer than the puck has nothing to break on. `anywhere` was the
- first try and it gave "Christo / pher", which reads as a rendering fault; with
- `break-word` the browser only breaks a word that cannot fit on a line of its own.
+ /* One line because it cannot wrap, not because a second line is hidden afterwards.
- `hyphens` is here in hope rather than in trust. It is meant to put a hyphen in at
- that break, and where a browser has a hyphenation dictionary it does — but that is
- not a thing to build a layout on. Measured directly: a 56px box with `hyphens:
- auto` and nothing else does not hyphenate "Christopher", it overflows, which means
- the dictionary is simply absent and the property is inert. So nothing above this
- line depends on it; where it works it is a free improvement to a break that is
- already legible without it. */
- overflow-wrap: break-word;
- hyphens: auto;
+ A `-webkit-box` clamped to one line did the job for as long as the row was exactly
+ one line tall, and it stopped the moment the type inside it moved. The row is a
+ `px` token — 15px, "12px of name at 1.2" — while .puckNameTight steps the name down
+ to 10.2px, whose line box is 12.24. The clamp hides the lines past the first; it
+ does not hide the 2.76px of the second one that the row's own min-height leaves
+ uncovered. Measured on a 390px phone: "Christopher" on the button seat drew as
+ "Christ…" with the ascenders of "opher" showing underneath it as a stray mark — a
+ name that read as a rendering fault on the one seat a dealer looks at first.
+
+ `nowrap` has no second line to leak. It also makes the box measure honestly, which
+ the clamp did not: a name that wrapped reported scrollWidth == clientWidth, so
+ fitSeatNames() was detecting overflow by height and never by width — right answer,
+ wrong question, and only ever right while the row and the line agreed. */
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
line-height: 1.2;
/* The row is as tall as the row, whether or not there is a name in it — see the
token block above .seatPuckStack. An open chair's blank label held no height at
@@ -1158,24 +1158,25 @@ button,
margin-top: var(--sp-2);
}
-/* The button's chip sits in this puck's top-right corner, on a layer above the text, so
- the name has to keep out from under it. The chip, plus the 3px it is inset by.
+/* The name has no gutter reserved in it any more, on any seat.
- A float used to do this on the roomier rings — three rules of it, indenting the name's
- first line and letting a long one drop below the chip onto a second. That answer only
- existed because there was a second line to drop to. With one line there is nowhere to
- go, and a float would clip the name to nothing rather than shorten it: measured, the
- float plus a one-line clamp drew "Christopher" as an empty label, because the word
- cannot fit beside the chip and its whole line is then hidden.
+ It used to have one on the button seat: `padding-right: 25px`, the dealer chip's 22px
+ plus the 3px it was inset by, taken off the name's line because the chip sat inside the
+ puck's top-right corner on a layer above the text. Three attempts lived in this spot —
+ a float, then a float with an indent, then the padding — and all three were answers to
+ the same question, which was the wrong question. The chip has moved to the puck's rim
+ instead; see .dealerChip.
- So the padding is the rule at every size, which is what the crowded ring always used.
- What made padding unacceptable before is fixed where it belongs instead: at 1280x900
- the name box is 78px, "Christopher" is 66, and the button seat cuts it to 56 — so it
- was broken mid-word as "Christop / her" for the one hand in nine that player has the
- button. It now steps its type down and stays whole. See .puckNameTight. */
-.seat.seatHasButton .seatPuckName {
- padding-right: 25px;
-}
+ What the gutter cost, measured across the sweep's tables with the twelve packed names:
+
+ Galaxy S8 9-handed name box 56px 11 of 12 names fit — 7 on the button seat
+ Galaxy S8 12-handed name box 34px 7 of 12 names fit — 1 on the button seat
+ iPhone 14 9-handed name box 68px 11 of 12 names fit — 7 on the button seat
+ desktop 9-handed name box 105px 12 of 12 names fit — 11 on the button seat
+
+ Four names in twelve, on a phone, cut on one seat and whole on the other eight. And the
+ button is the one marker on the felt that moves every hand, so that is not four unlucky
+ players — it is every player in turn, once a round, all night. */
/* The name with no word left to drop.
@@ -1271,34 +1272,66 @@ button,
visibility: hidden;
}
+/* On the rim, in the slot the blind badges keep — which is what it is.
+
+ There are four things a puck can say about where a seat sits in the hand: small blind,
+ big blind, straddle, button. Three of them were pills hanging off the top-left edge and
+ the fourth was a 22px disc inside the top-right corner, in the middle of the writing.
+ So the dealer's eye had two places to look for one class of fact, and the corner one
+ was charging the name a quarter of its width to be there — see .seatPuckName above for
+ what that cost in names.
+
+ It hangs off the edge exactly as far as the badges do and no further, because that
+ distance is not free: render.js's SEAT_BADGE_OVERHANG_PX charges every chair on the
+ ring for it, once, and seatFootprint.test.js holds the three numbers together. Same
+ 5px, so the ring does not move at all — this chip now fits in room the felt had already
+ reserved for the badge on the other corner.
+
+ Which is what sets its size. 12px, the badges' height, is what fits between -5px and
+ the top of the name: on the tight ring the name starts 8px down (1px border, 3px
+ padding, 4px of margin), and the chip reaches 7.
+
+ It used to sit at -10px/-10px, hanging over the seat's corner — which on an oval of
+ overlapping seats means hanging over the seat *next to it*. Measured then on a nine-seat
+ table: tapping the centre of seat 1 gave the button to seat 9. That is why this stays
+ inside the puck's own width, at the badges' `2px`, and only ever hangs vertically.
+
+ 12px is smaller than the 22 it was, and the block below is why that costs nothing: a
+ finger never reaches this chip, so the only pointer it has to answer is a mouse on a
+ ghost that appears under it.
+
+ Round, where the badges are pills, and on the right, where they are on the left. Both
+ matter more now that the button's gold and the big blind's are on the same edge: what
+ tells them apart is the shape and the side, not the colour. */
.dealerChip {
position: absolute;
- /* Inside its own seat, not on the rim.
-
- It used to sit at -10px/-10px, hanging over the seat's corner — which on an oval
- of overlapping seats means hanging over the seat *next to it*. Measured on a
- nine-seat table: tapping the centre of seat 1 gave the button to seat 9. That
- was always slightly true and the enlarged hit area made it much worse; three of
- nine seats were losing taps, one of them losing its own middle.
-
- Tucked inside, the overlap is zero at every size. It reads as a puck in the
- corner of the seat rather than on its edge, which is a small price for tapping
- a player and getting that player. */
- top: 3px;
- right: 3px;
- width: 22px;
- height: 22px;
+ top: -5px;
+ right: 2px;
+ width: 12px;
+ height: 12px;
+ padding: 0;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #d8d8d8 55%, #b8b8b8 100%);
color: var(--surface-2);
- border: 2px solid var(--text-muted);
+ border: 1.5px solid var(--text-muted);
font-weight: 700;
- font-size: var(--fs-xs);
+ font-size: var(--fs-2xs);
+ line-height: 1;
cursor: pointer;
- z-index: 2;
+ /* The badges' layer, not the seat's. It shares their edge now, so it has to outrank
+ the bet chips that come down onto that edge for the same reason they do. */
+ z-index: 4;
transition: opacity 0.15s ease;
}
+/* And it follows them round the ring. Seats on the bottom arc wear their markers
+ underneath, because bets sit between a seat and the pot — on that arc the bet chip
+ lands on the seat's top edge, which is where this was. See .blindBadge. */
+.seat.seatBelowCentre .dealerChip {
+ top: auto;
+ bottom: -5px;
+}
+
/* There is no invisible pad around this chip any more, and that is the point.
It was here to make a 22px disc meet the 44px a fingertip needs. It was moved twice
@@ -1476,8 +1509,9 @@ button,
the big blind and stops short of the small one — so there was never anything for the
second position to avoid.
- Left rather than centred because the dealer button's chip owns the top-right corner,
- and the two are read together: who has the button, and who is posting. */
+ Left rather than centred because the button's chip has the other end of the same edge,
+ and the two are read together: who has the button, and who is posting. One line of
+ markers along the puck's rim, blinds at one end and the button at the other. */
.sbBadge {
left: 2px;
background: linear-gradient(180deg, #2f6cb5, #22508c);
@@ -1560,9 +1594,11 @@ button,
line-height: 1.2;
}
+/* The ellipsis that used to be repeated here did nothing: the base rule was a
+ `-webkit-box`, which `text-overflow` has no effect on. It is in the base rule properly
+ now, for every ring. */
.seatsTight .seatPuckName {
margin-top: var(--sp-1);
- text-overflow: ellipsis;
}
/* Not wrapping is only half an answer: a line that cannot wrap and still does not fit
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index 38c390d..2b4e105 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -631,11 +631,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` and `bottom`
- // on .blindBadge 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.
+ // 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.
@@ -1449,12 +1452,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.
@@ -1718,9 +1719,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;
},
diff --git a/tests/holdem/seatFootprint.test.js b/tests/holdem/seatFootprint.test.js
index e9ac9cd..59ce42f 100644
--- a/tests/holdem/seatFootprint.test.js
+++ b/tests/holdem/seatFootprint.test.js
@@ -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 \{[^}]*?(? {
+ // 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 \{[^}]*?(?= 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 \{[^}]*?(? {
// 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
diff --git a/tests/holdem/theRail.test.js b/tests/holdem/theRail.test.js
index c4af5a1..5edc8b6 100644
--- a/tests/holdem/theRail.test.js
+++ b/tests/holdem/theRail.test.js
@@ -117,35 +117,27 @@ 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, and the name has to
- // keep out from under it. `padding-right` takes the chip's width off the name's line,
- // which is the whole of the answer now that there is only ever one line.
+ // The button seat's name is the same name in the same box as every other seat's.
//
- // A float used to do this on the roomier rings — three rules of it, indenting the
- // first line so a name too long to fit beside the chip could drop below it onto a
- // second. That only worked because there was a second line. There isn't: see the row
- // heights in holdem.css, and the fitSeatNames tests below for what happens instead to
- // a name that does not fit.
- const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
+ // 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("the chip's width comes off the name, at every size", () => {
- assert.ok(base, "the base rule should reserve the chip's width");
- assert.match(base[1], /padding-right:\s*25px/);
- });
-
- test("and it is as wide as the chip it stands in for", () => {
- const chip = CSS.match(/\n\.dealerChip \{[^}]*?(? {
+ 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 that used to do this is gone, not merely unused", () => {
- // Left in place with a one-line clamp it would clip the name to nothing: the
- // word cannot fit beside the float, so it moves to a second line, and the second
- // line is what the clamp hides. Measured — "Christopher" drew as an empty label.
+ // 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");
});
@@ -154,9 +146,28 @@ describe("the button seat's name", () => {
// 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.
- const clamps = CSS.match(/(? 0, "the name should still be clamped to something");
- clamps.forEach(clamp => assert.match(clamp, /:\s*1$/, clamp));
+ assert.ok(name, "the name should have a base rule");
+ assert.match(name[1], /white-space:\s*nowrap/);
+ });
+
+ 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(/(? {
+ // 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");
});
});
--
2.43.0
From 9af65a7b82e12e788f406cc405e62616bce00653 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Fri, 11 Sep 2026 16:31:57 -0600
Subject: [PATCH 07/27] Sort a seat's eleven actions by when, not draw them all
the same
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The long-press menu had grown to eleven actions and drew every one of them as
the same full-width bar. 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
— that came to 796px and took 85% of the screen on all four viewports measured.
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 is 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 a blind override. They are sorted by *when* instead: a seat action is
either something you do with cards in the air or housekeeping between hands,
and those two want different targets. The hand keeps the full-width bars —
Fold, All-in, Un-fold, and seating the next player off the rail, because a
chair opens between two hands with people watching it. Everything else goes to
a two-column grid of quieter 44px tiles underneath.
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. The override reset spans the grid rather than sitting in it
as a fourth position to give, dashed and unfilled — the felt's own word for
"nothing set by hand here" — so it doesn't read as the Cancel below it either.
Same fullest menu afterwards: 650px, 77%, no scroll on a phone or a desktop.
What a dealer actually sees mid-hand is eight actions and 546px, 65%. The move
picker, which was eleven more full-width bars, now fits all eleven chairs of a
twelve-handed table on a 360px phone without scrolling.
Two things the height was hiding, fixed with it:
The header is stuck to the top of the sheet's scrollport and carries a real
close button. NN/g is explicit that a grab handle is not a close affordance,
and this is the sheet that can still outgrow a landscape phone — so the way
out, and whose menu this is, are now in the part that cannot scroll away.
That close button is also the first control in the markup, which is what Modal
focuses. It used to focus Fold. The APG says to keep a dialog's initial focus
off the destructive choice; a close button at the top puts it there without a
special case. Escape finds that one rather than the button at the foot, so
Escape closes the sheet while the foot steps back — and it now has somewhere to
step back to: opening the move picker used to replace the sheet and leave only
a control that threw the whole menu away.
1068 tests pass; the 418-audit sweep is clean.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 21 +-
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 113 +++++++-
.../wwwroot/js/casino/holdem/main.js | 9 +-
.../wwwroot/js/casino/holdem/seatMenu.js | 174 +++++++++---
tests/holdem/load.js | 6 +
tests/holdem/seatMenuShape.test.js | 268 ++++++++++++++++++
tests/holdem/uiAudit.js | 5 +-
7 files changed, 551 insertions(+), 45 deletions(-)
create mode 100644 tests/holdem/seatMenuShape.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index b6dea7b..8fc4291 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -675,13 +675,26 @@
-
+ @* 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: 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, and a close button at the top is what puts it there
+ without a special case. Escape finds this one rather than the button at the
+ foot, so Escape closes the sheet while Back steps back inside it. *@
-
-
+
+
+
+
+
+
-
+
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 159226c..6d42aa1 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -2248,7 +2248,9 @@ button,
border: 1px solid var(--line);
border-bottom: none;
border-radius: var(--r-lg) var(--r-lg) 0 0;
- padding: var(--sp-1) var(--sp-4) calc(var(--sp-4) + env(safe-area-inset-bottom, 0px));
+ /* No padding at the top: the header owns it, because the header is sticky and a
+ gap above a sticky thing is a gap the list scrolls through. */
+ padding: 0 var(--sp-4) calc(var(--sp-4) + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
/* This sheet had no height limit and didn't scroll, so on a phone held sideways
its first two actions — Fold and All-in, the two that matter — sat below the
@@ -2259,8 +2261,30 @@ button,
overscroll-behavior: contain;
}
+/* Stuck to the top of the sheet's own scrollport. Two reasons, and the second is the
+ one that made it necessary: whose menu this is should not scroll away above a list
+ of eleven verbs, and the way out should not either. Measured before this, on the
+ fullest menu the tool can build, Cancel was off the bottom of the screen on all four
+ viewports in the sweep — 79px past it on an iPhone, 252px on a 360px phone, 448px in
+ landscape. Now the close button is in the part that cannot leave. */
.seatMenuHeader {
- margin-bottom: var(--sp-3);
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ column-gap: var(--sp-2);
+ /* Out to the sheet's edges and back again, so nothing shows through either side
+ of a header sitting over a scrolling list. */
+ margin: 0 calc(var(--sp-4) * -1) var(--sp-3);
+ padding: var(--sp-1) var(--sp-4) var(--sp-2);
+ background: var(--surface-2);
+ border-bottom: 1px solid var(--line);
+}
+
+.seatMenuHeader .seatSheetGrip {
+ grid-column: 1 / -1;
}
.seatMenuHeader h2 {
@@ -2276,6 +2300,30 @@ button,
font-variant-numeric: tabular-nums;
}
+.seatMenuClose {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 44px;
+ min-height: 44px;
+ padding: 0;
+ border: 1px solid var(--line);
+ border-radius: var(--r-md);
+ background: var(--surface-1);
+ color: var(--text-muted);
+ font-family: inherit;
+ font-size: var(--fs-lg);
+ line-height: 1;
+ cursor: pointer;
+}
+
+@media (hover: hover) {
+ .seatMenuClose:hover {
+ border-color: var(--line-strong);
+ color: var(--text-bright);
+ }
+}
+
#seatMenuActions {
display: flex;
flex-direction: column;
@@ -2283,6 +2331,24 @@ button,
margin-bottom: var(--sp-2);
}
+/* What the group is for, said once, so the tiles under it don't each have to say it.
+ Small and unsaturated on purpose: a caption that competes with the actions has made
+ the sheet busier, which is the thing it is here to undo. */
+.seatMenuCaption {
+ margin: var(--sp-2) 0 0;
+ font-size: var(--fs-xs);
+ font-weight: var(--fw-medium);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-dim);
+}
+
+.seatMenuGrid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--sp-2);
+}
+
.seatMenuBtn {
width: 100%;
min-height: 52px;
@@ -2296,11 +2362,51 @@ button,
transition: all 0.12s ease;
}
+/* The housekeeping half. Same control, quieter and half the width: flat instead of
+ raised, a step down in type, and the 44px the touch guidance asks for rather than
+ the 52 a bar gets — because these are the ones you tap while looking, between
+ hands, and the ones above are the ones you tap without. */
+.seatMenuBtn.menuTile {
+ min-height: 44px;
+ padding: var(--sp-1) var(--sp-2);
+ background: var(--surface-2);
+ font-size: var(--fs-base);
+ color: var(--text-muted);
+}
+
+/* The one action here that isn't about this seat — it puts the whole table's blinds
+ back on automatic. It spans the grid so it doesn't read as a fourth position to
+ give, and it is drawn lighter than its neighbours so it doesn't read as the Cancel
+ directly under it either: two full-width rows the same weight, one of which changes
+ the table and one of which changes nothing, is a mistake waiting at the end of a
+ scroll. Lighter, not smaller: it keeps the 44px every other tile has, because a
+ thumb does not care how rare an action is. The dashed edge is the same word the
+ felt already uses for an empty chair — nothing set by hand here. */
+.seatMenuBtn.menuTileWide {
+ grid-column: 1 / -1;
+ background: none;
+ border-style: dashed;
+ font-size: var(--fs-sm);
+ color: var(--text-dim);
+}
+
@media (hover: hover) {
.seatMenuBtn:hover {
border-color: var(--line-strong);
background: linear-gradient(180deg, var(--line), #2a2a2a);
}
+
+ .seatMenuBtn.menuTile:hover {
+ background: var(--surface-3);
+ color: var(--text-bright);
+ }
+
+ /* After .menuTile, not before it: the two selectors weigh the same, so the one
+ that keeps the dashed row flat has to be the one the cascade reads last. */
+ .seatMenuBtn.menuTileWide:hover {
+ background: none;
+ color: var(--text);
+ }
}
.seatMenuBtn.menuDanger {
@@ -2319,6 +2425,9 @@ button,
width: 100%;
justify-content: center;
min-height: 48px;
+ /* A gap the eye reads as "the sheet ends here", so the last action and the way out
+ are not two rows of the same size touching. */
+ margin-top: var(--sp-3);
}
/* ---------- Card picker ---------- */
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 6344551..582cee4 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -928,7 +928,14 @@ document.addEventListener("DOMContentLoaded", () => {
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();
});
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
index c86b436..2cdf537 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js
@@ -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,34 +162,71 @@ 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() {
+ const el = document.createElement("div");
+ el.className = "seatMenuGrid";
+ return el;
+ },
+
+ caption(text) {
+ const el = document.createElement("p");
+ el.className = "seatMenuCaption";
+ el.textContent = text;
+ return el;
+ },
+
render() {
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 grid = this.grid();
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);
});
+ list.appendChild(grid);
return;
}
+ cancel.textContent = "Cancel";
title.textContent = HoldemState.displayName(player, this.seatIndex);
const stack = Betting.stackOf(this.state, player);
@@ -153,11 +236,8 @@ const SeatMenu = {
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 +248,30 @@ const SeatMenu = {
this.close();
action.run();
});
+ };
+
+ actions.filter(a => a.group === "hand").forEach(action => {
+ const btn = this.button(action.label, `seatMenuBtn ${action.className || ""}`.trim());
+ wire(btn, action);
list.appendChild(btn);
});
+
+ 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;
+ list.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);
+ });
+ list.appendChild(grid);
+ });
},
};
diff --git a/tests/holdem/load.js b/tests/holdem/load.js
index cafb460..85a23d1 100644
--- a/tests/holdem/load.js
+++ b/tests/holdem/load.js
@@ -43,6 +43,11 @@ const CardPicker = load("cardPicker");
// navigator.wakeLock — see wakeLock.test.js.
const WakeLock = load("wakeLock");
+// 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 +104,7 @@ module.exports = {
ActionBar,
CardPicker,
WakeLock,
+ SeatMenu,
makeTable,
chipsOnTable,
contributions,
diff --git a/tests/holdem/seatMenuShape.test.js b/tests/holdem/seatMenuShape.test.js
new file mode 100644
index 0000000..51bd945
--- /dev/null
+++ b/tests/holdem/seatMenuShape.test.js
@@ -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(`
= 0, "there is no seat menu overlay");
+ return PAGE.slice(at, PAGE.indexOf("
\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]);
+ });
+});
diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js
index be59993..c5517c4 100644
--- a/tests/holdem/uiAudit.js
+++ b/tests/holdem/uiAudit.js
@@ -833,8 +833,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) {
--
2.43.0
From 71c198cf2ebc844f9532763e89875e6706f34777 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Fri, 11 Sep 2026 16:59:18 -0600
Subject: [PATCH 08/27] Make "up to" true on the screen that pays the table
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The pot wizard printed a figure beside every name under the words "up to". It
was that player's own remaining contribution, which is not what winning pays
them, and the gap was large and in the wrong direction. Three players in for
$250 each: all three rows read "up to $250" while the prompt directly above
them said "$765 is in play" — and $765 is what the winner was 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". One dialog, two numbers for the same
money, and the wrong one next to the checkbox the dealer actually ticks.
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, and PotCalculator.ceilingFor computes it. Bob's row now
says $765, which is the main pot, which is what the breakdown box three lines
above has been saying all along.
It is also only said where it binds. A ceiling equal to everything on the table
is the prompt's own figure repeated once per row — redundant data-ink, and on a
pot everybody covered that was all it ever was. It now appears on exactly the
rows where picking that winner leaves a side pot still to award, which is the
one time a dealer needs telling. The flat pot's three rows carry a name and
nothing else; the side pot's carries one number, on Bob.
The test that matters is not that the ceiling equals a figure written out by
hand but that it equals what resolveRound pays, so every case checks the label
against the function that moves the chips — across seven shapes of pot, ragged
contributions and cent-sized stakes included.
1082 tests pass; the 418-audit sweep is clean.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
.../wwwroot/js/casino/holdem/potCalculator.js | 26 +++
.../wwwroot/js/casino/holdem/potWizard.js | 16 +-
tests/holdem/payoutCeiling.test.js | 154 ++++++++++++++++++
3 files changed, 192 insertions(+), 4 deletions(-)
create mode 100644 tests/holdem/payoutCeiling.test.js
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js
index 7894936..5f73421 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potCalculator.js
@@ -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
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
index 27feab9..7200a8e 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js
@@ -218,10 +218,18 @@ const PotWizard = {
}
label.appendChild(text);
- const upTo = document.createElement("span");
- upTo.className = "potWizardChoiceUpTo";
- upTo.textContent = `up to ${Money.format(c.remaining)}`;
- label.appendChild(upTo);
+ // 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(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.
diff --git a/tests/holdem/payoutCeiling.test.js b/tests/holdem/payoutCeiling.test.js
new file mode 100644
index 0000000..57d58dd
--- /dev/null
+++ b/tests/holdem/payoutCeiling.test.js
@@ -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");
+ });
+});
--
2.43.0
From 8da48e02c8794c483418533a1ceb653d1cccb483 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Fri, 11 Sep 2026 17:23:41 -0600
Subject: [PATCH 09/27] Let the rulings be a list of rulings, not a scroll with
a list on top
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The sheet a dealer opens mid-argument 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 tapped 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 it suits these unusually well because half of them answer the question
without being opened at all. "A short all-in doesn't reopen the betting" is the
ruling, not the topic. So is "Chips nobody called come back", and "A straddle
is a blind raise", and "A raise has to be a full raise".
Measured after: 29px below the fold on a 390px phone with all twelve rows on
screen at once, 51px on a desktop, and 244px with the longest of them open.
Not exclusive — no `name` attribute. Closing one because another opened moves
everything under the finger that is reaching, which is the disorientation the
accordion is here to remove rather than add. And the sheet shuts what it finds
open when it is opened again: keeps its state, so without that a
dealer who read the odd-chip rule an hour ago finds it still hanging open under
the one they came back for.
Two things that came with it:
The gold arrives when a ruling is opened and not before. Twelve gold headings
in a column is the accent doing the job of body text, which leaves it nothing
to say when one of them is the one being read. The sidebar's eight panel names
are --text for the same reason; this is the same component doing the same job.
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. It never showed while there was a gold heading every few lines to break
the column up.
1095 tests pass; the 418-audit sweep is clean.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
---
JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 90 ++++++-----
JoshHeaps.Net/wwwroot/css/casino/holdem.css | 106 +++++++------
.../wwwroot/js/casino/holdem/main.js | 51 ++-----
tests/holdem/theRulings.test.js | 142 ++++++++++++++++++
4 files changed, 262 insertions(+), 127 deletions(-)
create mode 100644 tests/holdem/theRulings.test.js
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index 8fc4291..896ee76 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -509,37 +509,53 @@
not during it.
-
+ @* Twelve rulings, shut.
-
-
Redeal it — misdeal
+ 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. *@
+
+
Redeal it — misdeal
The first card goes to the wrong seat.
A seat entitled to a hand gets dealt out, or a seat that isn't entitled gets dealt in.
Two or more boxed cards turn up on the initial deal.
Either of the first two cards is exposed by dealer error.
-
+
-
-
A board card turned too early
+
+
A board card turned too early
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.
-
+
-
-
Acting out of turn
+
+
Acting out of turn
A one-off is usually just backed up if no one has acted behind them.
Doing it repeatedly is what earns a penalty.
-
+
-
-
Calling the clock on a player
+
+
Calling the clock on a player
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 +569,10 @@
count once you tap Call the clock, and offers the ruling as one
button when it ends.
-
+
-
-
A straddle is a blind raise
+
+
A straddle is a blind raise
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 +586,10 @@
posts it, puts the right player on the clock and prices the raise;
the seat wears an STR badge for the hand.
-
+
-
-
A short all-in doesn't reopen the betting
+
+
A short all-in doesn't reopen the betting
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 +601,20 @@
the minimum raise stays measured off the last full raise, not
off the short one. The action bar follows this for you.
-
+
-
-
Chips nobody called come back
+
+
Chips nobody called come back
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.
-
+
-
-
One big chip, nothing said
+
+
One big chip, nothing said
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 +627,10 @@
clear verbal declaration in turn is binding either way — if words and
chips disagree, whichever came first is the action.
-
+
-
-
A raise has to be a full raise
+
+
A raise has to be a full raise
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 +638,10 @@
in. The raise pad says so if you type one, and won't stop you: your
ruling beats the arithmetic.
-
+
-
-
Who turns their hand over first
+
+
Who turns their hand over first
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 +654,20 @@
was. The action bar names the seat and the reason at every showdown, so
the call is already made before anyone asks.
-
+
-
-
The odd chip in a split pot
+
+
The odd chip in a split pot
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.
-
+
-
-
Betting in one motion — the string bet
+
+
Betting in one motion — the string bet
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 +680,7 @@
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.
-
+
Follows the Tournament Directors Association rules, which most rooms and
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 6d42aa1..f323aa9 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -2131,63 +2131,63 @@ button,
white-space: nowrap;
}
-/* One chip per ruling, wrapped across the top of the sheet. Names rather than icons:
- a dealer scanning this is looking for the words somebody at the table just said. */
-.rulingsIndex {
- display: flex;
- flex-wrap: wrap;
- gap: var(--sp-1);
- margin: 0 0 var(--sp-4);
-}
-
-.rulingsIndexBtn {
- box-sizing: border-box;
- min-height: 32px;
- padding: var(--sp-1) var(--sp-2);
- border-radius: var(--r-pill);
- border: 1px solid var(--line);
- background: var(--surface-2);
- color: var(--gold);
- font-family: inherit;
- font-size: var(--fs-sm);
- line-height: 1.25;
- text-align: left;
- cursor: pointer;
-}
-
-@media (hover: hover) {
- .rulingsIndexBtn:hover {
- background: var(--surface-3);
- border-color: var(--line-strong);
- }
-}
-
-/* A thumb needs the same 44px here as everywhere else. */
-@media (pointer: coarse) {
- .rulingsIndexBtn {
- min-height: 44px;
- font-size: var(--fs-sm);
- }
-}
-
-/* The heading the index jumps to takes focus, and a focus ring on a heading that the
- dealer just asked for reads as noise rather than as help. */
-.ruling h3:focus {
- outline: none;
-}
-
+/* A shut ruling is a row you can read; an open one is a passage you can read. The gold
+ left bar used to run down all twelve at once, which on a list of headings is twelve
+ vertical rules and no hierarchy. It belongs to the one that is open. */
.ruling {
- margin-bottom: var(--sp-4);
+ border-bottom: 1px solid var(--line);
+}
+
+.ruling[open] {
+ margin-bottom: var(--sp-2);
padding-left: var(--sp-3);
border-left: 2px solid rgba(216, 180, 106, 0.4);
+ border-bottom-color: transparent;
}
-.ruling h3 {
- margin: 0 0 var(--sp-1);
- color: var(--gold);
+/* Padding rather than a flex box with min-height, because a only draws its
+ disclosure triangle while it is `display: list-item` — and content-box means a
+ min-height would be *added* to the padding. Both traps are the sidebar's, one
+ stylesheet section over, and they are the same traps here. */
+.ruling > summary {
+ box-sizing: border-box;
+ min-height: 44px;
+ padding: var(--sp-2) 0;
+ color: var(--text);
font-size: var(--fs-base);
font-weight: 600;
letter-spacing: 0.03em;
+ line-height: 1.35;
+ cursor: pointer;
+}
+
+/* The gold arrives when a ruling is opened, and not before. These were all gold while
+ they were headings with prose between them; twelve of them in a column is the accent
+ doing the job of body text, which leaves it nothing to say when one of them is the
+ one the dealer is reading. The sidebar's eight panel names are `--text` for the same
+ reason — this is the same component doing the same job. */
+.ruling[open] > summary {
+ margin-bottom: var(--sp-1);
+ color: var(--gold);
+}
+
+/* A is announced as a button, not as a heading, so the twelve names — which
+ are now the whole of this sheet's navigation — would be invisible to anybody moving
+ through it by heading. Same fix as the sidebar's panel names: keep the heading and
+ make it look like nothing happened. Inline, because a block child of a summary
+ pushes the disclosure triangle onto its own line. */
+.ruling > summary > h3 {
+ display: inline;
+ margin: 0;
+ font: inherit;
+ color: inherit;
+ letter-spacing: inherit;
+}
+
+@media (hover: hover) {
+ .ruling > summary:hover {
+ color: var(--gold-bright);
+ }
}
.ruling p,
@@ -2201,6 +2201,14 @@ button,
margin: 0;
}
+/* Five of the twelve are two paragraphs, and they ran into each other: the sections
+ were spaced apart from one another but nothing separated the halves of one ruling,
+ so "the most that chip allows." and "Facing no bet," read as one paragraph. It did
+ not show while there was a gold heading every few lines to break the column up. */
+.ruling p + p {
+ margin-top: var(--sp-2);
+}
+
.ruling ul {
margin: 0;
padding-left: var(--sp-4);
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index 582cee4..9ffa602 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -879,50 +879,19 @@ 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. 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();
diff --git a/tests/holdem/theRulings.test.js b/tests/holdem/theRulings.test.js
new file mode 100644
index 0000000..6d61211
--- /dev/null
+++ b/tests/holdem/theRulings.test.js
@@ -0,0 +1,142 @@
+// 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('
= 0, "there is no rulings overlay");
+ return PAGE.slice(at, PAGE.indexOf('
{
+ const at = CSS.indexOf(`\n${selector} {`);
+ assert.ok(at >= 0, `there is no \`${selector}\` rule`);
+ return CSS.slice(at, CSS.indexOf("}", at));
+};
+
+// Each ruling as its own slice of markup.
+const rulings = () => {
+ const parts = sheet().split(' p.slice(0, p.indexOf("")));
+};
+
+// ---------------------------------------------------------------------------
+
+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(!/]*\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(!/]*\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, /
.+<\/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 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 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\)/);
+ });
+
+ // 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\)/);
+ });
+});
--
2.43.0
From 382238e7085d264a86ba7b988ef4752c3fd3fd79 Mon Sep 17 00:00:00 2001
From: JoshHeaps
Date: Fri, 11 Sep 2026 18:00:05 -0600
Subject: [PATCH 10/27] Tell the browser the page is dark, and stop painting
over it instead
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Everything in this stylesheet is a colour it paints. `color-scheme` is the half
it cannot: the browser draws the checkboxes, the menu a