diff --git a/.gitignore b/.gitignore
index 6a4cfa1..de02e99 100644
--- a/.gitignore
+++ b/.gitignore
@@ -366,4 +366,6 @@ MigrationBackup/
FodyWeavers.xsd
# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/)
-native/**/build/
\ No newline at end of file
+native/**/build/
+# Playwright MCP scratch output
+.playwright-mcp/
diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
index b5951ae..731b53f 100644
--- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
+++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml
@@ -641,6 +641,11 @@
@* iOS reads none of the manifest's icons and only this. *@
+ @* The standard name first, then Apple's older one. Chrome has been printing
+ " is deprecated" into this page's
+ console on every load; the replacement is the unprefixed name, and Safari still
+ wants the prefixed one, so both are here. *@
+
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 64df86f..e98541b 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -264,6 +264,35 @@ button,
}
}
+/* ---------- Border-box, everywhere ----------
+ A width is the width the thing is drawn at. Under the default `content-box` it isn't:
+ padding and border are added on afterwards, so `width: 192px` on a padded panel draws
+ 217, and every other rule that reserved room for it was 25px short in the same
+ direction.
+
+ That is not hypothetical here. Nine elements in this file already opt in one at a
+ time — the seat puck's comment explains that it had to, because the search that sizes
+ it compares the number against distances on the felt — and the two that hadn't both
+ went wrong in the same afternoon: the action rail overran the page's padding by 25px
+ and put Resolve Pot underneath itself, and the raise pad's amount field at `100%`
+ plus 16px of padding pushed a horizontal scrollbar across a 640px phone.
+
+ Opting in one element at a time leaves the trap set for the next one. This is the
+ inherited form, so a new element is border-box unless it says otherwise, and the
+ `inherit` means anything that does say otherwise still governs its own children. */
+*,
+*::before,
+*::after {
+ box-sizing: inherit;
+}
+
+#holdemApp,
+#actionBar,
+#toastHost,
+[id$="Overlay"] {
+ box-sizing: border-box;
+}
+
#holdemApp {
max-width: 1400px;
margin: 0 auto;
@@ -1092,13 +1121,32 @@ button,
color: #f0d98c;
}
+/* The badge hangs off the puck's edge, and how far is not a taste question — it is
+ two measurements pulling in opposite directions.
+
+ Downward, it has to stay off its own player's name. On a tight puck the name starts
+ 8px in, so a badge 15px tall had to hang 9px off to clear it, and 9px is what the
+ seat search has to charge every chair in the ring for (see seatFootprint in
+ render.js). Measured at 640x360, that charge is the difference between a 66px puck
+ and a 48px one — which is "Christopher, Mike, Alexandra" against "Christo…, Mike…,
+ Alexan…" on nine chairs.
+
+ Upward, it has to stay off the neighbour. The badge is the thing painted on top of
+ everything else on the felt, so where it reaches past its own puck onto the next
+ one, what the dealer reads there is somebody else's stack.
+
+ So the badge gets shorter instead of hanging further. Tight leading and no vertical
+ 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. */
.blindBadge {
display: none;
position: absolute;
- top: -9px;
+ top: -5px;
font-size: var(--fs-2xs);
font-weight: 700;
- padding: var(--sp-0) var(--sp-1);
+ line-height: 1.1;
+ padding: 0 var(--sp-1);
border-radius: var(--r-pill);
z-index: 2;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
@@ -1114,7 +1162,7 @@ button,
was, and the chip is a layer above it. See render.js for the measurement. */
.seat.seatBelowCentre .blindBadge {
top: auto;
- bottom: -9px;
+ bottom: -5px;
}
/* And the badge outranks the chips regardless, so a layout this doesn't anticipate
@@ -2532,6 +2580,10 @@ button,
/* ---------- Action bar ---------- */
#actionBar {
+ /* Which of the two shapes this bar is in, declared for actionBar.js to read back —
+ see isRail(). The layout is chosen by a media query, and the one thing that must
+ not happen is the same breakpoint written out a second time in JavaScript. */
+ --actionBarLayout: bar;
position: fixed;
/* Centred with auto margins rather than 100vw + translate: on a phone with a
scrollbar, 100vw is wider than the viewport and the overflow drags the whole
@@ -4264,6 +4316,216 @@ body.hasActionBar #toastHost {
}
}
+/* ---------- A phone on its side: the bar goes beside the table ----------
+
+ Everything above spends its effort dividing one column of height between a table and
+ a bar, on the screen that has the least height in the world. Measured at 640x360
+ mid-hand, the split comes out: 70px of header, 85px of felt, 205px of bar. The felt
+ is 529x85. Two rows of seat pucks 55px tall have to fit in 85px of it, which they
+ cannot, so seven of the nine finish past the rail — up to 17px of puck floating on
+ the page. That is 96 of the 98 offenders the layout audit finds anywhere.
+
+ No amount of shrinking fixes it, because the shortage is not in the table. A phone
+ on its side is 640x360: short of height and swimming in width. The bar is 205px tall
+ and 529 wide to hold three buttons and a status line, and it is taking that height
+ from the one element on the page that needs height.
+
+ So it moves. In landscape the bar becomes a right-hand rail, the table takes the
+ whole column beside it, and the felt goes from 529x85 to roughly 420x270 — an actual
+ oval, with room for two rows of pucks and a gap between them.
+
+ The rail is also where the hand already is. This is the layout every mapping and
+ drawing app uses on a rotated phone for the same reason, and it puts Fold/Call/Raise
+ under the right thumb rather than across the bottom edge where a landscape grip has
+ the palm.
+
+ Scoped to phones. Above 900px the sidebar is a right-hand column already (see
+ #holdemLayout) and a rail would land on top of it; short desktop windows have height
+ to spare and don't have the problem. */
+@media (orientation: landscape) and (max-height: 560px) and (max-width: 900px) {
+ :root {
+ /* Wide enough for "Raise to" and a four-figure amount side by side, and for a
+ 48px control to keep its own padding; never more than a third of the screen,
+ because the rest of it is the table. */
+ --actionRailW: clamp(11.5rem, 30vw, 15rem);
+ }
+
+ #actionBar {
+ --actionBarLayout: rail;
+ top: 0;
+ bottom: 0;
+ left: auto;
+ right: 0;
+ /* Border-box, so --actionRailW is the whole rail. Content-box made it 217px of
+ a 192px token, and everything that reserves room for it — the page's padding,
+ the seat sheet's right edge, the toast — was 25px short in the same direction:
+ Resolve Pot finished 13px underneath it. One number, one meaning. */
+ box-sizing: border-box;
+ width: var(--actionRailW);
+ max-width: none;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+ /* The raise pad is the tallest thing that appears here. On the shortest screen
+ in the list it can outgrow the rail, and a control that has fallen off the
+ end of a fixed panel is unreachable rather than merely awkward. */
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ border-radius: var(--r-lg) 0 0 var(--r-lg);
+ border: 1px solid var(--line);
+ border-right: none;
+ padding: var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-right, 0px)) var(--sp-3) var(--sp-3);
+ box-shadow: -12px 0 34px rgba(0, 0, 0, 0.6);
+ }
+
+ /* Anchored low: the stack reads top-to-bottom in the order it always has — status,
+ then the buttons — and sitting at the bottom of the rail puts Fold, Call and
+ Raise where the thumb rests rather than where the hand has to reach up.
+
+ Done with a margin on the first child rather than `justify-content: flex-end`,
+ which is the same picture until the content outgrows the rail. Then flex-end
+ overflows the *start* edge, and a scroll container cannot scroll back past its
+ own start — the status line would be painted off the top and unreachable. A
+ margin pushes the same distance and collapses to nothing when there is none to
+ give, so the overflow lands at the end, where scrolling can reach it. Measured at
+ 640x360 the rail is 358px of content in 360px: it is one side pot away from
+ needing this. */
+ #actionBar > :first-child {
+ margin-top: auto;
+ }
+
+ /* And nothing in the rail gives up its own height to make room. A flex item shrinks
+ by default, so once the raise pad and the status line together outgrew the rail —
+ which they do at 640x360 the moment the amount field is thumb-sized — the status
+ line was squeezed under the pad and the undo pill came out two-fifths covered by
+ the Min button. Overflow is the honest outcome, and the scroll above can reach
+ it; a control drawn on top of another one cannot be reached at all. */
+ #actionBar > * {
+ flex: none;
+ }
+
+ /* The page gives up the width instead of the height. The bottom padding that was
+ clearing the bar has nothing left to clear — and 5rem of it on a 360px-tall
+ screen is a seventh of the window held empty for a bar that isn't there. */
+ #holdemApp {
+ padding: var(--sp-2) calc(var(--actionRailW) + var(--sp-3)) var(--sp-2) var(--sp-3);
+ }
+
+ /* Everything in the rail is a column now: three buttons stacked, and a status line
+ that was written to sit on one row of a 529px bar. */
+ .actionButtons,
+ .dealPromptButtons {
+ flex-direction: column;
+ }
+
+ /* This row already wraps, which is the right behaviour in a narrow column — it just
+ has to stop reserving two lines it no longer needs. That reservation exists so a
+ bar fixed to the bottom edge doesn't move its top edge under the dealer's thumb
+ as the text rewraps; a rail is anchored at the bottom the same way, but it is
+ 190px wide, where every one of these readings is on its own line regardless. */
+ .actionStatus,
+ .actionStatus:has(.actionUndo) {
+ gap: var(--sp-1) var(--sp-2);
+ margin-bottom: 0;
+ min-height: 0;
+ }
+
+ .actionUndo {
+ width: 100%;
+ }
+
+ /* Four quick-raise buttons across 190px is 40px each. Two rows of two is 90px each,
+ which is a control rather than a target to aim at. */
+ .raiseQuick {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .raiseEntry {
+ flex-wrap: wrap;
+ }
+
+ .raiseInput {
+ flex: 1 0 100%;
+ }
+
+ .raiseCancel,
+ .raiseConfirm {
+ flex: 1 1 0;
+ min-width: 0;
+ }
+
+ /* Back to the middle of the screen. The old landscape rule pushed the toast to the
+ right edge to keep it off a full-width felt; that edge is the rail now, and the
+ felt no longer runs under it. */
+ body.hasActionBar #toastHost,
+ #toastHost {
+ left: var(--sp-3);
+ right: calc(var(--actionRailW) + var(--sp-3));
+ max-width: none;
+ bottom: var(--sp-3);
+ }
+
+ /* The pot total comes back. It was hidden here because the badge took 58% of an
+ 85px felt and stood in the only band a bet chip could go in — on a felt three
+ times as tall it is the middle of the table again, which is where a dealer looks
+ for the pot. */
+ #potBadge {
+ display: flex;
+ }
+
+ /* And the ring goes back to being measured rather than held off the edges by hand.
+ The insets above exist to stop a flat felt's top seat putting its dealer button
+ through the header; with the felt this tall the ring has its own room, and the
+ 44px side inset was reserving a fifth of the width of a felt that is now the
+ narrower dimension. */
+ #seatsLayer,
+ #betsLayer {
+ inset: 0;
+ }
+
+ /* And the felt fills the box it is given. The 6% inset everywhere else is the strip
+ a seat puck straddles — half on the rail, half off it, the way a player sits at a
+ table. In this column that strip is 25px of a 409px width taken off both ends of
+ the widest dimension the table has left, and the ring does not want it: the seats
+ are held inside the rail here by measurement, so nothing is ever painted in it.
+ Measured, giving it back is 49px of felt, which is a puck size for every chair. */
+ #pokerTable {
+ inset: 0;
+ }
+
+ /* The links under the table are the last thing in the column, and on the old layout
+ they were simply behind the bar. There is room for them now, but not for the
+ margins of a desktop. */
+ .tableHintActions {
+ margin: var(--sp-1) 0 0;
+ }
+
+ /* The phone layout gives this row the full width, which puts it on a line of its
+ own — 36px of a 360px-tall screen for three buttons that need 217. Beside the
+ title instead, which is where they sit at every other size and which the header
+ has room for now that it is a column rather than the whole window. The wrap stays
+ as the backstop for a narrower screen than any in the list. */
+ #holdemHeader {
+ flex-wrap: wrap;
+ }
+
+ #holdemHeaderActions {
+ width: auto;
+ margin-left: auto;
+ }
+
+ /* The seat editor comes up as a sheet from the bottom on a phone, full width. Full
+ width now runs under the rail, and it sits a layer above it, so it was covering
+ Fold, Call and Raise with a form. It stops at the rail instead — which also means
+ the dealer can still see the hand they are in while editing a chair. */
+ #seatEditorPanel {
+ right: var(--actionRailW);
+ border-top-right-radius: var(--r-lg);
+ }
+}
+
/* ---------- Keyboard focus ----------
Three focus rules existed for the whole tool, so tabbing through it relied on
whatever ring the browser draws by default — which on these dark, custom-painted
@@ -4326,7 +4588,14 @@ textarea:focus-visible {
.seatEditorForm input:not([type="checkbox"]):not([type="color"]),
.panelBody input:not([type="checkbox"]):not([type="color"]),
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
- .panelBody select {
+ .panelBody select,
+ /* The raise amount, which this rule reached for the settings fields and missed —
+ it is in the action bar rather than in a panel or the seat form, so it is the
+ one field the list didn't name. Measured on a phone: 36px, on the one surface in
+ the tool that exists to be hit with a thumb, holding the one number that is the
+ size of somebody's bet. The audit never caught it either, because the sweep had
+ no step that opened the raise pad; it has one now. */
+ .raiseInput {
min-height: 44px;
padding: var(--sp-2) var(--sp-2);
}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index f3bcbc9..d4a0792 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -32,9 +32,17 @@ const ActionBar = {
const publish = () => {
const visible = !host.hidden;
- const height = visible ? host.getBoundingClientRect().height : 0;
+ const rail = this.isRail(host);
+ // On a phone held sideways the bar is a rail down the right-hand side, so it
+ // takes no height at all — and `--actionBarHeight` is what four other rules
+ // and the felt measurement all mean by "how much of the bottom is spoken
+ // for". Publishing the rail's height there would reserve the whole window.
+ const height = visible && !rail ? host.getBoundingClientRect().height : 0;
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
- this.publishFeltRoom(height);
+ // What is under the felt is the bar in one layout and the two links in the
+ // other. Either way it is the same question — how much of the bottom is
+ // already spoken for — so it is the same argument.
+ this.publishFeltRoom(rail ? this.roomBelowFelt() : height);
};
// Kept so anything that moves the table without touching the bar can ask for
@@ -50,6 +58,33 @@ const ActionBar = {
publish();
},
+ // Is the bar a rail down the side rather than a bar across the bottom?
+ //
+ // Asked of the stylesheet rather than answered again here. The layout is chosen by
+ // a media query — orientation, height and width together — and a second copy of
+ // that query written in JavaScript is a copy that drifts: the first thing anyone
+ // changes is the breakpoint, in one of the two places. So the CSS declares which
+ // layout it picked in a custom property and this reads the answer back.
+ isRail(host) {
+ if (!host || typeof getComputedStyle !== "function") return false;
+ return getComputedStyle(host).getPropertyValue("--actionBarLayout").trim() === "rail";
+ },
+
+ // What sits between the bottom of the felt and the bottom of the window when the
+ // bar is off to one side: the table hint and the row of links under it. Measured,
+ // because both come and go — the hint is empty mid-hand and the links wrap on the
+ // narrowest column.
+ // Taken as the distance from the bottom of the felt to the bottom of the section it
+ // sits in, which is a difference rather than a height — so it doesn't move when the
+ // felt's own height changes, and this can't chase itself around the screen.
+ roomBelowFelt() {
+ const wrap = document.getElementById("pokerTableWrap");
+ const section = wrap && wrap.parentElement;
+ if (!section) return 0;
+ const gap = section.getBoundingClientRect().bottom - wrap.getBoundingClientRect().bottom;
+ return Math.max(0, Math.round(gap));
+ },
+
// Half a seat, the gap left under the lowest one, and the smallest felt worth
// drawing. A seat is about 73px tall at every width — the puck is text-sized, not
// table-sized — so the overhang is a constant rather than something read back off
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
index b45a272..b5a8fd7 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js
@@ -418,7 +418,7 @@ const Render = {
// `bottom: -9px` in holdem.css. It points away from the table at both ends, so it
// takes no room between two seats; what it must not do is reach *sideways*, which is
// what seatFootprint.test.js holds the stylesheet to.
- SEAT_BADGE_OVERHANG_PX: 9,
+ SEAT_BADGE_OVERHANG_PX: 5,
// How much space two neighbouring seats need between their centres.
//
@@ -497,20 +497,33 @@ const Render = {
// left corner onto whatever was beside it, and that is fixed where it was caused —
// in the stylesheet, by moving the badges inside the puck's own width.
//
- // Its remaining overhang is vertical and costs nothing, because it always points
- // *away* from the table: `top: -9px` on the upper arc, `bottom: -9px` on the lower
- // one, so two seats facing each other across the felt wear their badges back to
- // back. Two seats on the same arc wear them at the same height, offset by exactly
- // as much as the pucks are.
+ // Its remaining overhang is vertical: `top: -9px` on the upper arc, `bottom: -9px`
+ // on the lower one, so it always points away from the table and two seats facing
+ // each other across the felt wear their badges back to back.
//
- // Adding it anyway was tried first, and it broke the case it was least expected to:
- // a phone held sideways gives the ring 87px of height, two rows of 55px pucks leave
- // 18px between them, and demanding 18px more for badges that point outward made a
- // six-handed landscape table unsatisfiable at any width. Twelve-seat tables on three
- // phones went the same way. The clearance below is what keeps rounded corners from
- // reading as a touch.
+ // That is why it was left out of this for a while, and the reasoning has one hole
+ // in it. "Two seats on the same arc wear them at the same height" is true of a
+ // straight rail and false of a ring: neighbours on a curve sit at different heights,
+ // so the badge that points away from the table on one of them points at the corner
+ // of the other. Measured, three tables in the sweep had a player's stack painted
+ // under the next player's SB or BB — the badge outranks everything on the felt, so
+ // what a dealer reads there is the wrong number entirely.
+ //
+ // Counted once, not twice. Both boxes here are the same size, so the separation
+ // seatsCollide asks for is one box plus this — which is exactly right for two seats
+ // each wearing a badge on the same side, and generous for the far commoner case
+ // where only one of them is in the blinds at all.
+ //
+ // Adding it was tried once before and abandoned, because a phone held sideways gave
+ // the ring 87px of height and two rows of 55px pucks had 18px between them: nine
+ // more was unsatisfiable at any width. That felt is 205px tall now — see the rail in
+ // holdem.css — and the shortage that made this impossible is the one that block went
+ // and fixed.
seatFootprint(puckBox) {
- return puckBox && { width: puckBox.width, height: puckBox.height };
+ return puckBox && {
+ width: puckBox.width,
+ height: puckBox.height + this.SEAT_BADGE_OVERHANG_PX,
+ };
},
applySeatWidth(layer, width) {
@@ -1135,19 +1148,7 @@ const Render = {
this.state.seats.forEach((player, index) => {
const node = layer.children[index];
if (!node) return;
- const pos = this.seatPosition(index, this.state.seatCount, aspect, radii);
- node.style.left = `${pos.left}%`;
- node.style.top = `${pos.top}%`;
-
- // Which side of the seat the pot is on, which is the side its bet chip
- // arrives from — the chip sits on the same spoke, closer in. For a seat on
- // the top of the ring that's from below; on the bottom, from above.
- //
- // The blind badge has to be on the other side or the chip lands on top of
- // it. Measured on a 390px phone: the big blind's own $2 chip covered 196 of
- // the badge's 288 square pixels, and it sits a layer higher, so the marker
- // telling the dealer who the big blind is was simply gone.
- node.classList.toggle("seatBelowCentre", pos.top > 50);
+ this.placeSeat(node, index, aspect, radii);
// The chair number is set once, in buildSeatNodes — a seat's index is the
// one thing about it that cannot change while the node exists.
@@ -1200,10 +1201,169 @@ const Render = {
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
});
+ this.fitSeatNames(layer);
+ // The names have just changed how tall some of these pucks are, so this comes
+ // after them: a two-line name shortened to one line is 15px of height back.
+ const settled = this.settleSeatsOnTable(layer, aspect, radii);
+
// Kept so the board can ask for the chips to be laid out again if placing it
// changes what is in the middle of the felt — see placeBoard().
- this.lastBetLayout = { aspect, radii };
- this.renderBets(aspect, radii);
+ this.lastBetLayout = { aspect, radii: settled };
+ this.renderBets(aspect, settled);
+ },
+
+ // One seat, onto the ring.
+ placeSeat(node, index, aspect, radii) {
+ const pos = this.seatPosition(index, this.state.seatCount, aspect, radii);
+ node.style.left = `${pos.left}%`;
+ node.style.top = `${pos.top}%`;
+
+ // Which side of the seat the pot is on, which is the side its bet chip arrives
+ // from — the chip sits on the same spoke, closer in. For a seat on the top of
+ // the ring that's from below; on the bottom, from above.
+ //
+ // The blind badge has to be on the other side or the chip lands on top of it.
+ // Measured on a 390px phone: the big blind's own $2 chip covered 196 of the
+ // badge's 288 square pixels, and it sits a layer higher, so the marker telling
+ // the dealer who the big blind is was simply gone.
+ node.classList.toggle("seatBelowCentre", pos.top > 50);
+ },
+
+ // Every seat on the table, checked against the table rather than predicted onto it.
+ //
+ // seatRingRadii works out the widest ring whose outermost puck still finishes inside
+ // the rail, and it is right about that — but it is right about a puck it measured
+ // before the ring moved, before the width search settled, and before a name was
+ // shortened or wrapped to a second line. Every one of those changes a puck's height
+ // by up to 15px after the arithmetic is done. Measured on the running page, four of
+ // the thirteen viewports finished with the top seat 5-6px past the rail: not a wrong
+ // formula, a stale input to a right one.
+ //
+ // So the ring is settled the way the layout audit judges it — by looking at where
+ // the pucks actually landed. If the worst of them hangs 6px over, the ring comes in
+ // by 6px and they are placed again. Two passes, because the first correction is
+ // exact and the second only exists in case moving them changed a height again.
+ //
+ // Never past RING_MIN_R. Below that the ring has collapsed into the pot, which is a
+ // worse table than one with a puck touching the rail — and a felt that small is the
+ // case the floor was put there for.
+ SEAT_SETTLE_PASSES: 2,
+ // The audit's own slack. Anything under a pixel is a rounding difference between
+ // two ways of measuring the same edge, and chasing it would move the ring forever.
+ SEAT_SETTLE_SLACK_PX: 1,
+
+ settleSeatsOnTable(layer, aspect, radii) {
+ const table = typeof document !== "undefined" && document.getElementById("pokerTable");
+ const box = this.ringBox();
+ if (!table || !box || !box.width || !box.height) return radii;
+
+ let current = radii;
+ for (let pass = 0; pass < this.SEAT_SETTLE_PASSES; pass++) {
+ const felt = table.getBoundingClientRect();
+ if (!felt.width || !felt.height) return current;
+
+ let overX = 0;
+ let overY = 0;
+ for (let index = 0; index < layer.childElementCount; index++) {
+ const seat = layer.children[index].getBoundingClientRect();
+ if (!seat.width || !seat.height) continue;
+ overX = Math.max(overX, felt.left - seat.left, seat.right - felt.right);
+ overY = Math.max(overY, felt.top - seat.top, seat.bottom - felt.bottom);
+ }
+ if (overX <= this.SEAT_SETTLE_SLACK_PX && overY <= this.SEAT_SETTLE_SLACK_PX) return current;
+
+ const next = {
+ rx: Math.max(this.RING_MIN_R, current.rx - (Math.max(0, overX) / box.width) * 100),
+ ry: Math.max(this.RING_MIN_R, current.ry - (Math.max(0, overY) / box.height) * 100),
+ };
+ // The floor has taken over, and pulling in further is no longer something
+ // this can do. Stopping is the honest answer; looping would only spend
+ // another layout to be told the same thing.
+ if (next.rx === current.rx && next.ry === current.ry) return current;
+
+ current = next;
+ for (let index = 0; index < layer.childElementCount; index++) {
+ this.placeSeat(layer.children[index], index, aspect, current);
+ }
+ }
+ return current;
+ },
+
+ // Names that don't fit are shortened at a word, not chopped in the middle of one.
+ //
+ // A puck is 34px of text on a nine-handed phone, and the label wraps to two lines
+ // and then ellipsises. What that produced for the four real names on the test table
+ // was "Christo…", "Mike / from…", "Alexan…" — and "Mike from work" is the one of
+ // those where the ellipsis is throwing away nothing the dealer needs. He is Mike.
+ // The rest of it is how this table tells him from the other Mike.
+ //
+ // So a name that overflows drops whole trailing words until it fits: "Mike from
+ // work" becomes "Mike from", then "Mike", and stops there with a real word rather
+ // than a fragment. A single word that still doesn't fit is left to the ellipsis it
+ // always had — there is no honest way to shorten "Christopher" that a dealer
+ // glancing across a table would read faster than "Christo…".
+ //
+ // Written as three passes over all the seats rather than a loop per seat: each pass
+ // writes to every puck and then reads back from every puck, so the browser lays the
+ // felt out three times whatever the table's size. Interleaved, nine seats would
+ // have cost nine layouts, and this runs on every render.
+ fitSeatNames(layer) {
+ if (!layer) return;
+ const labels = [];
+ this.state.seats.forEach((player, index) => {
+ const node = layer.children[index];
+ const el = node && node.querySelector(".seatPuckName");
+ if (!el || !el.textContent) return;
+ const full = (player.name || "").trim();
+ // The tooltip is the whole name whether or not this one gets shortened: a
+ // label that wraps to two clamped lines was already hiding some of it.
+ el.title = full;
+ const words = full.split(/\s+/).filter(Boolean);
+ if (words.length > 1) labels.push({ el, words, kept: words.length });
+ });
+ if (!labels.length) return;
+
+ for (let pass = 0; pass < this.SEAT_NAME_PASSES; pass++) {
+ const shorten = labels.filter(label => label.kept > 1 && this.overflowing(label.el));
+ if (!shorten.length) return;
+ shorten.forEach(label => {
+ label.kept = this.dropAWord(label.words, label.kept);
+ label.el.textContent = label.words.slice(0, label.kept).join(" ");
+ });
+ }
+ },
+
+ // Words a name can't end on. Dropping "work" off "Mike from work" leaves "Mike
+ // from", which is not a shorter name — it is a name that looks cut off, and it
+ // reads worse than the ellipsis it was meant to replace. Whereas "Mike" is just
+ // Mike, which is what the table calls him.
+ //
+ // Small and closed on purpose. This is not trying to understand names; it is the
+ // handful of English joining words that turn up in the way people label each other
+ // at a home game — "Mike from work", "Al with the hat", "Dee at the end". Anything
+ // it doesn't know stays, which is the safe direction to be wrong in: a name that
+ // keeps a word it could have dropped is merely long.
+ NAME_TAIL_WORDS: new Set(["from", "of", "the", "at", "in", "on", "with", "and", "&",
+ "de", "van", "von", "da", "di", "der", "la", "le"]),
+
+ // One word off, plus any joining word that leaves behind. Never below one.
+ dropAWord(words, kept) {
+ let next = Math.max(1, kept - 1);
+ while (next > 1 && this.NAME_TAIL_WORDS.has(words[next - 1].toLowerCase())) next -= 1;
+ return next;
+ },
+
+ // A pass is a layout of the whole felt, and this runs on every render, so the budget
+ // is small. Three is more than the names actually need: "Mike from work" reaches
+ // "Mike" in one, because dropAWord takes the joining word with it, and a name of
+ // four plain words reaches its first in three.
+ SEAT_NAME_PASSES: 3,
+
+ // Past the box in either direction. Both matter: the label clamps to two lines, so
+ // a name too long runs out of height, while a single word too wide for the puck
+ // runs out of width without ever reaching the second line.
+ overflowing(el) {
+ return el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
},
isRedSuit(card) {
diff --git a/tests/holdem/README.md b/tests/holdem/README.md
index 4b97663..3b425c9 100644
--- a/tests/holdem/README.md
+++ b/tests/holdem/README.md
@@ -22,6 +22,7 @@ other in the page, in the load order from `TexasHoldem.cshtml`.
| `splitCalculator.test.js` | Chopped pots, odd chips, unmakeable denominations |
| `handRank.test.js` | What beats what: categories, the wheel, kickers, best five of seven |
| `readingTheShowdown.test.js` | The board, the picker that fills it, and who wins a side pot |
+| `theRail.test.js` | A phone on its side: which shape the bar is in, what it leaves under the felt, how a name is shortened, how the ring is pulled back onto the table |
| `invariants.test.js` | 1000 randomised hands per property (see below) |
## The layout audit
@@ -38,13 +39,23 @@ enough not to trigger iOS zoom, that nothing important is covered or off-screen
no way to scroll to it, that no grid column has collapsed to nothing, that no
text is painted outside its box, that no seat covers another seat's writing, and that
no bet chip is hidden behind a seat or another chip. It
-runs over the table, all five overlays and Quick Split, at thirteen viewports from a
+runs over the table, the raise pad, all five overlays and Quick Split, at thirteen
+viewports from a
360px phone to a 1600px desktop and the same phones on their side — each viewport
twice, once with a mouse and once with a thumb, and at three table shapes, mid-hand:
the sweep posts blinds, deals and calls three times before it measures anything, so
the bets layer has chips on it. It used to audit a table nobody had ever bet at,
which is a third of what is on the felt during a hand left unlooked at.
+The raise pad is newer than the rest and was worth every second it costs. It is part
+of the bar rather than an overlay, so nothing here had a step that opened it, and it is
+both the tallest thing the bar ever holds and the only place in the tool a dealer types
+an amount. The first sweep that opened it found a 36px-tall field on every phone in the
+list: the `hover: none` rule that sizes fields for a thumb names the settings panels and
+the seat form, and the one field on the one surface built for thumbs was not in the
+list. It also found the rail squeezing its own status line under the pad, which is the
+kind of thing that only happens in the state nobody was looking at.
+
"Covered" and "out of view" are told apart before anything is hit-tested. An element
clipped away by a panel that scrolls is not covered by whatever happens to be painted
at that height, and reporting it that way is a diagnosis of something that isn't
@@ -68,6 +79,26 @@ felt height. The seats follow a felt that changes size on their own, being place
percentages of it; the chips anchored inside them do not, being placed in pixels. They
were laid out for a felt 23px taller than the one they were painted on.
+## What the sweep taught the layout
+
+For a long run of rounds the seat ring was *predicted* onto the table: work out the
+widest ring whose outermost puck still finishes inside the rail, place the seats, and
+trust it. The prediction is correct arithmetic and it was wrong on the page anyway,
+because every input to it moves after it is taken — the width search settles on a
+narrower puck, a name wraps to a second line or gets shortened out of one, the board
+appears in the middle. Each of those is up to 15px of puck height arriving after the
+answer. Ninety-six of the ninety-eight offenders this sweep could find were that.
+
+So render.js now finishes the way the audit judges it. After the seats are placed it
+measures where they actually landed, and if the worst of them hangs 6px past the rail
+the ring comes in by 6px and they are placed again — the same question this file asks,
+asked by the code that has to get it right. Two passes, bounded, because each one is a
+forced layout of the whole felt and it runs after every tap a dealer makes.
+
+That is the general shape of the lesson here: a layout that predicts itself has to be
+right about the future, and a layout that measures itself only has to be right about
+the present.
+
That last part, and two of those checks, are newer than this paragraph claiming them.
`runAll()` did not include the contrast check at all, and touch targets sat behind an
opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than
diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js
index 9e5911a..063a981 100644
--- a/tests/holdem/runUiAudit.js
+++ b/tests/holdem/runUiAudit.js
@@ -166,6 +166,40 @@ async function main() {
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
+ // The raise pad, which is part of the bar rather than an overlay and so
+ // was never once measured by any of this. It is the tallest thing the
+ // bar ever holds and the only place in the tool a dealer types an
+ // amount, and it had a 36px-tall field on every phone in the list: the
+ // `hover: none` rule that sizes fields for a thumb names the settings
+ // panels and the seat form, and the one field on the thumb surface was
+ // not in the list. Nothing here could see that, because nothing here
+ // ever opened it.
+ if (await page.evaluate(() => {
+ const btn = [...document.querySelectorAll("#actionBar button")]
+ .find(b => /^Raise|^Bet/.test(b.textContent.trim()) && !b.disabled);
+ if (!btn) return false;
+ btn.click();
+ return true;
+ })) {
+ await page.waitForTimeout(200);
+ checks++;
+ const pad = await page.evaluate(() => LayoutAudit.runAll({
+ occlusionSelector: "#actionBar button, #actionBar input",
+ }));
+ if (!pad.clean) {
+ failures.push({
+ where: `raise pad · ${label} ${width}x${height}`,
+ offenders: pad.failed.flatMap(f => f.offenders),
+ });
+ }
+ await page.evaluate(() => {
+ const cancel = [...document.querySelectorAll("#actionBar button")]
+ .find(b => /^Cancel/.test(b.textContent.trim()));
+ if (cancel) cancel.click();
+ });
+ await page.waitForTimeout(150);
+ }
+
// The overlays don't change with the table's shape, so they are only
// opened over the half-full one — twice the viewports is enough time
// spent already.
diff --git a/tests/holdem/seatFootprint.test.js b/tests/holdem/seatFootprint.test.js
index beeec27..e9ac9cd 100644
--- a/tests/holdem/seatFootprint.test.js
+++ b/tests/holdem/seatFootprint.test.js
@@ -165,10 +165,62 @@ describe("what the stylesheet has to keep holding up", () => {
});
});
+describe("the blind badge counts", () => {
+ // It didn't, for a while, and the reasoning had one hole in it. The badge hangs 9px
+ // off the puck and always points away from the table — `top: -9px` on the upper arc,
+ // `bottom: -9px` on the lower — so two seats facing each other across the felt wear
+ // them back to back and it costs nothing. That much is true.
+ //
+ // What was also claimed is that two seats on the *same* arc "wear them at the same
+ // height, offset by exactly as much as the pucks are". True of a straight rail,
+ // false of a ring: neighbours on a curve sit at different heights, so the badge
+ // pointing away from the table on one of them points at the corner of the other.
+ // Measured on the running page, three tables in the sweep had a player's stack
+ // painted under the next player's SB or BB — and the badge outranks everything else
+ // on the felt, so what the dealer reads in that spot is the wrong number entirely.
+ test("a seat needs room for the badge it might be wearing", () => {
+ const puck = { width: 64, height: 40 };
+ assert.deepEqual(Render.seatFootprint(puck),
+ { width: 64, height: 40 + Render.SEAT_BADGE_OVERHANG_PX });
+ });
+
+ test("sideways it costs nothing, because the badge doesn't go sideways", () => {
+ // It sits inside the puck's own width — see "all three sit in the same place"
+ // above, which holds the stylesheet to it.
+ assert.equal(Render.seatFootprint({ width: 64, height: 40 }).width, 64);
+ });
+
+ test("the number here is the number the stylesheet draws", () => {
+ // Two files have to agree about one distance: holdem.css hangs the badge off the
+ // puck by it, render.js charges every chair in the ring for it. They are wired
+ // together by nothing but this test. Drift either way is silent — too small and
+ // badges land on neighbours again, too large and every puck on a nine-handed
+ // phone gives up a size step for clearance nothing needs.
+ const top = CSS.match(/\.blindBadge \{[^}]*?(? {
+ // seatsCollide is given one box and compares centres against it, so the
+ // separation it asks for is one puck plus one badge. That is exactly right for
+ // two seats on the same arc each wearing one, and generous for the far commoner
+ // case where only one of the two is in the blinds at all. Counting it on both
+ // sides is what made a landscape table unsatisfiable the first time this was
+ // tried, back when that felt was 87px tall.
+ const puck = { width: 64, height: 40 };
+ const grew = Render.seatFootprint(puck).height - puck.height;
+ assert.equal(grew, Render.SEAT_BADGE_OVERHANG_PX);
+ assert.ok(grew < 2 * Render.SEAT_BADGE_OVERHANG_PX);
+ });
+});
+
describe("with no browser to measure", () => {
test("nothing here throws", () => {
assert.equal(Render.measuredSeatBox(), null);
assert.equal(Render.seatFootprint(null), null);
- assert.deepEqual(Render.seatFootprint({ width: 64, height: 40 }), { width: 64, height: 40 });
});
});
diff --git a/tests/holdem/theRail.test.js b/tests/holdem/theRail.test.js
new file mode 100644
index 0000000..99256d3
--- /dev/null
+++ b/tests/holdem/theRail.test.js
@@ -0,0 +1,402 @@
+// A phone held sideways, and the three pieces of arithmetic that make the table fit on
+// one.
+//
+// The layout audit is what proved this was broken and what proves it is fixed — it
+// measures the drawn page, which is the only place a layout is really true. What is
+// here is the part underneath that can be wrong on its own: which shape the bar is in,
+// how much of the bottom that leaves spoken for, how a name is shortened when it will
+// not fit, and how the ring is pulled back in when a puck lands past the rail.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const fs = require("fs");
+const path = require("path");
+const { ActionBar, Render } = require("./load");
+
+const CSS = fs.readFileSync(
+ path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
+
+// ---------------------------------------------------------------------------
+
+describe("which shape the bar is in", () => {
+ // The layout is chosen by a media query — orientation, height and width together.
+ // The one thing that must not happen is a second copy of that query in JavaScript,
+ // because the first thing anybody changes is the breakpoint, in one of the two
+ // places. So the stylesheet declares which layout it picked and actionBar.js reads
+ // the answer back.
+ const withComputed = (value, run) => {
+ const host = { id: "actionBar" };
+ global.getComputedStyle = el => ({
+ getPropertyValue: prop => (el === host && prop === "--actionBarLayout" ? value : ""),
+ });
+ try { return run(host); } finally { delete global.getComputedStyle; }
+ };
+
+ test("the stylesheet says, and this reads", () => {
+ assert.equal(withComputed(" rail ", host => ActionBar.isRail(host)), true);
+ assert.equal(withComputed(" bar ", host => ActionBar.isRail(host)), false);
+ });
+
+ test("anything it does not recognise is the bar it has always been", () => {
+ assert.equal(withComputed("", host => ActionBar.isRail(host)), false);
+ assert.equal(withComputed("sidebar", host => ActionBar.isRail(host)), false);
+ });
+
+ test("and with no browser at all, likewise", () => {
+ assert.equal(ActionBar.isRail(null), false);
+ assert.equal(ActionBar.isRail({ id: "actionBar" }), false,
+ "no getComputedStyle to ask — this runs in the unit suite too");
+ });
+
+ test("the stylesheet declares both halves of it", () => {
+ // If either of these goes, isRail() quietly answers "bar" forever and the felt
+ // is measured against a bar that is not underneath it.
+ assert.match(CSS, /#actionBar\s*\{[^}]*--actionBarLayout:\s*bar/,
+ "the default, on the base rule");
+ assert.match(CSS, /--actionBarLayout:\s*rail/,
+ "and the rail, inside the landscape query");
+ });
+
+ test("the rail is scoped to a phone, not to any short window", () => {
+ // Above 900px the sidebar is already a right-hand column and a rail would land
+ // on top of it.
+ const query = CSS.match(/@media \(orientation: landscape\)[^{]*\{[\s\S]{0,400}?--actionBarLayout: rail/);
+ assert.ok(query, "the rail lives in a landscape media query");
+ assert.match(query[0], /max-width:\s*900px/);
+ assert.match(query[0], /max-height:\s*560px/);
+ });
+});
+
+// ---------------------------------------------------------------------------
+
+describe("how much of the bottom is spoken for", () => {
+ // feltRoomFor asks one question — what is under the felt — and gets two different
+ // answers depending on the layout. Under the bar it is the bar. Under the rail it is
+ // the table hint and the two links, because the bar is off to the side.
+ const fakeSection = (wrapBottom, sectionBottom) => {
+ const wrap = {
+ getBoundingClientRect: () => ({ bottom: wrapBottom }),
+ parentElement: { getBoundingClientRect: () => ({ bottom: sectionBottom }) },
+ };
+ global.document = { getElementById: id => (id === "pokerTableWrap" ? wrap : null) };
+ };
+
+ test.afterEach(() => { delete global.document; });
+
+ test("it is the gap under the felt, links and all", () => {
+ fakeSection(300, 342);
+ assert.equal(ActionBar.roomBelowFelt(), 42);
+ });
+
+ test("a difference, not a height — so it does not chase the felt it is sizing", () => {
+ // The wrap's own height comes from --feltRoomPx, which is what this feeds. Read
+ // as "how tall is everything under the felt" off the section's height it would
+ // move every time the felt did, and settle nowhere.
+ fakeSection(300, 342);
+ const first = ActionBar.roomBelowFelt();
+ fakeSection(500, 542); // the felt grew by 200; what is under it did not
+ assert.equal(ActionBar.roomBelowFelt(), first);
+ });
+
+ test("nothing under the felt is nothing to reserve", () => {
+ fakeSection(300, 300);
+ assert.equal(ActionBar.roomBelowFelt(), 0);
+ });
+
+ test("and a section that measures shorter than its own child is not negative room", () => {
+ fakeSection(300, 288);
+ assert.equal(ActionBar.roomBelowFelt(), 0);
+ });
+
+ test("with no table on the page it asks for nothing", () => {
+ global.document = { getElementById: () => null };
+ assert.equal(ActionBar.roomBelowFelt(), 0);
+ });
+
+ test("the felt gets the height the bar used to take", () => {
+ // The whole point, in one line. At 640x360 the bar was 205px of a 360px window
+ // and the felt got 85. Beside the table instead, the same window gives the felt
+ // everything but the header and the links.
+ const asABar = ActionBar.feltRoomFor(360, 205, 70);
+ const asARail = ActionBar.feltRoomFor(360, 30, 70);
+ assert.ok(asARail > asABar * 2, `${asABar}px became ${asARail}px`);
+ });
+});
+
+// ---------------------------------------------------------------------------
+
+describe("a name that will not fit", () => {
+ // Shortened at a word, not chopped in the middle of one. A puck is 34px of text on
+ // a nine-handed phone, and what the ellipsis produced for the four names on the test
+ // table was "Christo…", "Mike / from…", "Alexan…". Only one of those is throwing
+ // away something the dealer does not need: he is Mike, and the rest of it is how
+ // this table tells him from the other Mike.
+ //
+ // The label element is faked here rather than measured. What is under test is the
+ // ladder — which words go, in what order, and when it stops — and the browser's part
+ // is one boolean per pass.
+ const label = (text, fits) => {
+ const el = {
+ textContent: text,
+ title: "",
+ // The browser's answer, standing in for a real one: this name fits if
+ // `fits` says the version currently written to it does.
+ get scrollWidth() { return fits(this.textContent) ? 10 : 100; },
+ get clientWidth() { return 10; },
+ get scrollHeight() { return 10; },
+ get clientHeight() { return 10; },
+ querySelector: () => el,
+ };
+ return el;
+ };
+
+ const layer = (labels) => ({
+ children: labels,
+ childElementCount: labels.length,
+ });
+
+ const seatsNamed = (...names) => {
+ Render.state = { seats: names.map(name => ({ name })) };
+ };
+
+ test.afterEach(() => { Render.state = null; });
+
+ test("trailing words go until it fits", () => {
+ const el = label("Christopher John Robinson", text => text === "Christopher John");
+ seatsNamed("Christopher John Robinson");
+ Render.fitSeatNames(layer([el]));
+ assert.equal(el.textContent, "Christopher John");
+ });
+
+ test("and it will go all the way to the first word", () => {
+ const el = label("Christopher John Robinson", text => text === "Christopher");
+ seatsNamed("Christopher John Robinson");
+ Render.fitSeatNames(layer([el]));
+ assert.equal(el.textContent, "Christopher");
+ });
+
+ test("a name never ends on a word a name cannot end on", () => {
+ // Dropping "work" off "Mike from work" leaves "Mike from", which is not a
+ // shorter name — it is a name that looks cut off, and it reads worse than the
+ // ellipsis this was meant to replace. He is Mike.
+ const el = label("Mike from work", () => false);
+ seatsNamed("Mike from work");
+ Render.fitSeatNames(layer([el]));
+ assert.equal(el.textContent, "Mike");
+ });
+
+ test("and it takes a run of them in one go", () => {
+ const el = label("Al with the hat", () => false);
+ seatsNamed("Al with the hat");
+ Render.fitSeatNames(layer([el]));
+ assert.equal(el.textContent, "Al");
+ });
+
+ test("but a joining word inside a name is nobody's business", () => {
+ // The list is closed and small on purpose: it is not trying to understand names,
+ // only the handful of English joining words people label each other with at a
+ // home game. Anything it doesn't know stays, which is the safe way to be wrong —
+ // a name that keeps a word it could have dropped is merely long.
+ const el = label("Katarzyna Nowak Kowalska", text => text === "Katarzyna Nowak");
+ seatsNamed("Katarzyna Nowak Kowalska");
+ Render.fitSeatNames(layer([el]));
+ assert.equal(el.textContent, "Katarzyna Nowak");
+ });
+
+ test("dropping never goes below the first word", () => {
+ // Every word after the first being a joining word is not a real name, but it is
+ // typeable, and it must not come out empty.
+ assert.equal(Render.dropAWord(["Mike", "from", "of", "the"], 4), 1);
+ assert.equal(Render.dropAWord(["from", "the"], 2), 1);
+ assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop");
+ });
+
+ test("a single word is left to the ellipsis it always had", () => {
+ // There is no honest way to shorten "Christopher" that a dealer glancing across
+ // a table would read faster than "Christo…".
+ const el = label("Christopher", () => false);
+ seatsNamed("Christopher");
+ Render.fitSeatNames(layer([el]));
+ assert.equal(el.textContent, "Christopher", "never cut mid-word here");
+ });
+
+ test("a name that fits is not touched", () => {
+ const el = label("Mike from work", () => true);
+ seatsNamed("Mike from work");
+ Render.fitSeatNames(layer([el]));
+ assert.equal(el.textContent, "Mike from work");
+ });
+
+ test("the whole name is on the tooltip whether it was shortened or not", () => {
+ // A label clamped to two lines was already hiding some of it before any of this.
+ const shortened = label("Mike from work", text => text === "Mike");
+ const untouched = label("Bob", () => true);
+ seatsNamed("Mike from work", "Bob");
+ Render.fitSeatNames(layer([shortened, untouched]));
+ assert.equal(shortened.title, "Mike from work");
+ assert.equal(untouched.title, "Bob");
+ });
+
+ test("every seat is written to before any of them is measured", () => {
+ // Three passes over all the seats, not a loop per seat: each pass writes to
+ // every puck and then reads back from every puck, so the browser lays the felt
+ // out three times whatever the table's size. Interleaved, nine seats would cost
+ // nine layouts, and this runs on every render.
+ const reads = [];
+ const watched = (text, fits) => {
+ const el = label(text, fits);
+ Object.defineProperty(el, "scrollWidth", {
+ get() { reads.push(this.textContent); return fits(this.textContent) ? 10 : 100; },
+ });
+ return el;
+ };
+ const a = watched("Christopher John Robinson", t => t === "Christopher");
+ const b = watched("Katarzyna Nowak Kowalska", t => t === "Katarzyna");
+ seatsNamed("Christopher John Robinson", "Katarzyna Nowak Kowalska");
+ Render.fitSeatNames(layer([a, b]));
+
+ // Both names are read at each length before either is written down again.
+ assert.deepEqual(reads.slice(0, 2),
+ ["Christopher John Robinson", "Katarzyna Nowak Kowalska"]);
+ assert.deepEqual(reads.slice(2, 4), ["Christopher John", "Katarzyna Nowak"]);
+ assert.equal(a.textContent, "Christopher");
+ assert.equal(b.textContent, "Katarzyna");
+ });
+
+ test("an empty chair has no name to shorten", () => {
+ const el = label("", () => false);
+ seatsNamed("");
+ assert.doesNotThrow(() => Render.fitSeatNames(layer([el])));
+ });
+
+ test("and with no layer there is nothing to do", () => {
+ assert.doesNotThrow(() => Render.fitSeatNames(null));
+ });
+});
+
+// ---------------------------------------------------------------------------
+
+describe("pulling the ring back onto the table", () => {
+ // seatRingRadii works out the widest ring whose outermost puck still finishes inside
+ // the rail, and it is right about that — but it is right about a puck it measured
+ // before the ring moved, before the width search settled, and before a name was
+ // shortened or wrapped to a second line. Each of those changes a puck's height by up
+ // to 15px after the arithmetic is done.
+ //
+ // Measured on the running page, four of the thirteen viewports finished with the top
+ // seat 5-6px past the rail. Not a wrong formula: a stale input to a right one. So
+ // the ring is settled the way the audit judges it, by looking at where the pucks
+ // actually landed.
+ //
+ // The felt is 400x400 at (100, 100) throughout. Seats are placed by writing a
+ // percentage into style.left/top, which this reads back to work out where the puck
+ // would be — the same relationship the browser has, without a browser.
+ const felt = { left: 100, top: 100, right: 500, bottom: 500, width: 400, height: 400 };
+
+ const seat = (puckW, puckH, box) => ({
+ style: { left: "", top: "" },
+ classList: { toggle() {} },
+ getBoundingClientRect() {
+ // Whatever percentage was last written, resolved against the ring box.
+ const cx = box.left + (parseFloat(this.style.left) / 100) * box.width;
+ const cy = box.top + (parseFloat(this.style.top) / 100) * box.height;
+ return {
+ left: cx - puckW / 2, right: cx + puckW / 2,
+ top: cy - puckH / 2, bottom: cy + puckH / 2,
+ width: puckW, height: puckH,
+ };
+ },
+ });
+
+ // The ring box the percentages resolve against, deliberately the same as the felt so
+ // the arithmetic in the test stays readable.
+ const ringBox = { ...felt };
+
+ const setUp = (count, puckW, puckH) => {
+ const seats = Array.from({ length: count }, () => seat(puckW, puckH, ringBox));
+ Render.state = { seats: seats.map(() => ({ name: "" })), seatCount: count };
+ global.document = { getElementById: id => (id === "pokerTable"
+ ? { getBoundingClientRect: () => felt }
+ : { getBoundingClientRect: () => ringBox }) };
+ // ringBox() reads #seatsLayer; the fake above hands the same box back for it.
+ return { children: seats, childElementCount: seats.length };
+ };
+
+ const place = (layer, radii) => {
+ for (let i = 0; i < layer.childElementCount; i++) {
+ Render.placeSeat(layer.children[i], i, 1, radii);
+ }
+ };
+
+ const worstOverhang = layer => {
+ let over = 0;
+ for (let i = 0; i < layer.childElementCount; i++) {
+ const b = layer.children[i].getBoundingClientRect();
+ over = Math.max(over, felt.left - b.left, b.right - felt.right,
+ felt.top - b.top, b.bottom - felt.bottom);
+ }
+ return over;
+ };
+
+ test.afterEach(() => { Render.state = null; delete global.document; });
+
+ test("a ring already inside the rail is left exactly where it is", () => {
+ const layer = setUp(6, 60, 50);
+ const radii = { rx: 30, ry: 30 };
+ place(layer, radii);
+ assert.deepEqual(Render.settleSeatsOnTable(layer, 1, radii), radii);
+ });
+
+ test("a puck past the rail brings the whole ring in", () => {
+ // 44% of 400 puts a seat's centre 176px from the middle; half a 60px puck is
+ // another 30, against a felt whose half-height is 200. Six pixels over, at the
+ // top and the bottom and both sides — which is the size the running page was
+ // actually wrong by.
+ const layer = setUp(4, 60, 60);
+ const radii = { rx: 44, ry: 44 };
+ place(layer, radii);
+ assert.equal(Math.round(worstOverhang(layer)), 6, "the fault is there to fix");
+
+ const settled = Render.settleSeatsOnTable(layer, 1, radii);
+ assert.ok(settled.ry < radii.ry, "the ring came in");
+ assert.ok(worstOverhang(layer) <= Render.SEAT_SETTLE_SLACK_PX,
+ `still ${worstOverhang(layer).toFixed(1)}px over`);
+ });
+
+ test("it comes in by what it was over, not by a step it guessed", () => {
+ const layer = setUp(4, 60, 60);
+ const radii = { rx: 44, ry: 44 };
+ place(layer, radii);
+ const over = worstOverhang(layer);
+ const settled = Render.settleSeatsOnTable(layer, 1, radii);
+ // Six pixels of a 400px felt is 1.5% of the ring, so 44 becomes 42.5 — the
+ // measurement, not a step size that would have to be tuned per screen.
+ assert.equal(settled.ry.toFixed(2), (44 - (over / felt.height) * 100).toFixed(2));
+ assert.equal(settled.ry, 42.5);
+ });
+
+ test("and it stops at the floor rather than collapsing into the pot", () => {
+ // A puck taller than the felt has an overhang no ring can fix. Pulling in
+ // forever would put every seat on top of the pot badge, which is a worse table
+ // than one with a puck touching the rail.
+ const layer = setUp(4, 60, 600);
+ const radii = { rx: 40, ry: 40 };
+ place(layer, radii);
+ const settled = Render.settleSeatsOnTable(layer, 1, radii);
+ assert.equal(settled.ry, Render.RING_MIN_R);
+ assert.ok(settled.ry > 0);
+ });
+
+ test("a table with nothing laid out yet is handed back untouched", () => {
+ Render.state = { seats: [], seatCount: 0 };
+ global.document = { getElementById: () => null };
+ const radii = { rx: 44, ry: 42 };
+ assert.deepEqual(Render.settleSeatsOnTable({ children: [], childElementCount: 0 }, 1, radii), radii);
+ });
+
+ test("it is bounded — this runs after every tap a dealer makes", () => {
+ assert.ok(Render.SEAT_SETTLE_PASSES <= 2,
+ "each pass is a forced layout of the whole felt");
+ assert.ok(Render.SEAT_NAME_PASSES <= 3);
+ });
+});