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