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 @@
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(`\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) {