Sort a seat's eleven actions by when, not draw them all the same
The long-press menu had grown to eleven actions and drew every one of them as the same full-width bar. On the fullest menu the tool can build — a live seat that isn't the dealer, blinds still the only money in, an override already set — that came to 796px and took 85% of the screen on all four viewports measured. It scrolled on all four, and Cancel was off the bottom of the screen on all four: 79px past it on a 390px phone, 252px on a 360px one, 448px in landscape. Apple puts the comfortable number for an action sheet at three to five and says not to let one scroll at all. Nothing is cut. Every one of the eleven is the only route to something, and "Blinds follow the button again" is the only route in the whole tool to clearing a blind override. They are sorted by *when* instead: a seat action is either something you do with cards in the air or housekeeping between hands, and those two want different targets. The hand keeps the full-width bars — Fold, All-in, Un-fold, and seating the next player off the rail, because a chair opens between two hands with people watching it. Everything else goes to a two-column grid of quieter 44px tiles underneath. The captions are not decoration. A half-width tile reading "Small blind" is a label, and the puck already wears one of those; under "Give this seat" it is a verb again. That is what lets three of the longest labels here lose ten characters each and still fit a half tile on one line, which is the height the caption pays back. The override reset spans the grid rather than sitting in it as a fourth position to give, dashed and unfilled — the felt's own word for "nothing set by hand here" — so it doesn't read as the Cancel below it either. Same fullest menu afterwards: 650px, 77%, no scroll on a phone or a desktop. What a dealer actually sees mid-hand is eight actions and 546px, 65%. The move picker, which was eleven more full-width bars, now fits all eleven chairs of a twelve-handed table on a 360px phone without scrolling. Two things the height was hiding, fixed with it: The header is stuck to the top of the sheet's scrollport and carries a real close button. NN/g is explicit that a grab handle is not a close affordance, and this is the sheet that can still outgrow a landscape phone — so the way out, and whose menu this is, are now in the part that cannot scroll away. That close button is also the first control in the markup, which is what Modal focuses. It used to focus Fold. The APG says to keep a dialog's initial focus off the destructive choice; a close button at the top puts it there without a special case. Escape finds that one rather than the button at the foot, so Escape closes the sheet while the foot steps back — and it now has somewhere to step back to: opening the move picker used to replace the sheet and leave only a control that threw the whole menu away. 1068 tests pass; the 418-audit sweep is clean. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -675,13 +675,26 @@
|
||||
|
||||
<div id="seatMenuOverlay" hidden>
|
||||
<div id="seatMenuSheet" role="dialog" aria-labelledby="seatMenuTitle">
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
@* The grip was the only thing at the top of this sheet, and NN/g is explicit
|
||||
that a grab handle is not a close affordance — a sheet wants a real one. It
|
||||
earns its place twice over here: this sheet is the one that can outgrow the
|
||||
screen, and with the header stuck to the top the way out never scrolls away.
|
||||
|
||||
Third job, the quiet one: Modal focuses a dialog's first control, and the
|
||||
first control used to be Fold. The APG says to keep initial focus off the
|
||||
destructive choice, and a close button at the top is what puts it there
|
||||
without a special case. Escape finds this one rather than the button at the
|
||||
foot, so Escape closes the sheet while Back steps back inside it. *@
|
||||
<div class="seatMenuHeader">
|
||||
<h2 id="seatMenuTitle"></h2>
|
||||
<span id="seatMenuSubtitle"></span>
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="seatMenuHeading">
|
||||
<h2 id="seatMenuTitle"></h2>
|
||||
<span id="seatMenuSubtitle"></span>
|
||||
</div>
|
||||
<button id="seatMenuCloseBtn" data-modal-close class="seatMenuClose" type="button" aria-label="Close this menu">×</button>
|
||||
</div>
|
||||
<div id="seatMenuActions"></div>
|
||||
<button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 ---------- */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// What the long-press menu offers, and in what shape.
|
||||
//
|
||||
// It had grown to eleven actions drawn as eleven identical full-width bars. Measured
|
||||
// on the fullest menu the tool can build — a live seat that isn't the dealer, blinds
|
||||
// still the only money in, an override already set — the sheet came to 796px and took
|
||||
// 85% of every viewport in the sweep. It scrolled on all four, and Cancel was off the
|
||||
// bottom of the screen on all four: 79px past it on a 390px phone, 252px on a 360px
|
||||
// one, 448px in landscape. Apple puts the comfortable number for an action sheet at
|
||||
// three to five and says not to let one scroll at all.
|
||||
//
|
||||
// Nothing was cut — every one of the eleven is the only route to something, and
|
||||
// "Blinds follow the button again" is the only route in the whole tool to clearing an
|
||||
// override. They were sorted by *when* instead: what you tap with cards in the air
|
||||
// keeps the full-width bars, and the housekeeping goes to a two-column grid under a
|
||||
// caption. Same fullest menu afterwards: 650px, 77%, no scroll, and the way out is in
|
||||
// a header that can't scroll away.
|
||||
//
|
||||
// Two of these are answered by running the code — which actions a seat is offered,
|
||||
// and which group each lands in. The rest are layout decisions, checked as text
|
||||
// against the markup and the stylesheet.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Betting, HoldemState, SeatMenu, makeTable } = require("./load");
|
||||
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
const sheetMarkup = () => {
|
||||
const at = PAGE.indexOf(`<div id="seatMenuOverlay"`);
|
||||
assert.ok(at >= 0, "there is no seat menu overlay");
|
||||
return PAGE.slice(at, PAGE.indexOf("</div>\n\n", at));
|
||||
};
|
||||
|
||||
// The rule block for a selector, so a test asks about one rule rather than about
|
||||
// whether a string happens to appear anywhere in 6000 lines.
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// The fullest menu the tool can build, aimed at seat 3.
|
||||
function fullestMenu() {
|
||||
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 500, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
state.blindOverride = { sbIndex: 1, bbIndex: 2 };
|
||||
state.waitingList = [{ id: "w1", name: "Nina" }];
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
return { state, actions: SeatMenu.actionsFor(state.seats[3], 3) };
|
||||
}
|
||||
|
||||
const labels = actions => actions.map(a => a.label);
|
||||
const inGroup = (actions, group) => labels(actions.filter(a => a.group === group));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("everything the menu used to offer, it still offers", () => {
|
||||
test("all eleven are there on the fullest menu", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.deepEqual(labels(actions).sort(), [
|
||||
"Add chips…",
|
||||
"All-in",
|
||||
"Big blind",
|
||||
"Blinds follow the button again",
|
||||
"Cash out…",
|
||||
"Edit seat…",
|
||||
"Fold",
|
||||
"Move to…",
|
||||
"Sit out",
|
||||
"Small blind",
|
||||
"The button",
|
||||
]);
|
||||
});
|
||||
|
||||
// The three that lost words lost them to the caption above them, not to brevity
|
||||
// for its own sake: "Give this seat" is what makes "Small blind" a verb, and a
|
||||
// verb that short is what fits a half-width tile on one line.
|
||||
test("the three that were shortened are the three the caption speaks for", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.deepEqual(inGroup(actions, "position"), [
|
||||
"The button", "Small blind", "Big blind", "Blinds follow the button again",
|
||||
]);
|
||||
});
|
||||
|
||||
// This menu is the only place in the tool that offers it. If it ever stops being
|
||||
// offered here, a dealer who names a blind by hand can never take it back.
|
||||
test("the only way out of a blind override is still here", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.ok(labels(actions).includes("Blinds follow the button again"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a group is when you do it, not what it touches", () => {
|
||||
test("the hand keeps only what a dealer reaches for with cards on the table", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.deepEqual(inGroup(actions, "hand"), ["Fold", "All-in"]);
|
||||
});
|
||||
|
||||
test("a folded player's one move is in the hand too", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
|
||||
Betting.beginHand(state);
|
||||
state.seats[2].folded = true;
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
const actions = SeatMenu.actionsFor(state.seats[2], 2);
|
||||
assert.deepEqual(inGroup(actions, "hand"), ["Un-fold"]);
|
||||
});
|
||||
|
||||
// Seating the front of the queue is not housekeeping. A chair opens between two
|
||||
// hands with people watching it, and the dealer fills it then or not at all.
|
||||
test("seating the next player off the rail is a hand action", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2], stack: 200 });
|
||||
// makeTable names every chair; an open one has nobody on it.
|
||||
state.seats[4].name = "";
|
||||
state.waitingList = [{ id: "w1", name: "Nina" }];
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
const actions = SeatMenu.actionsFor(state.seats[4], 4);
|
||||
assert.deepEqual(inGroup(actions, "hand"), ["Seat Nina"]);
|
||||
});
|
||||
|
||||
test("every action lands in a group the sheet knows how to draw", () => {
|
||||
const { actions } = fullestMenu();
|
||||
const known = ["hand", ...SeatMenu.GROUPS.map(g => g.name)];
|
||||
actions.forEach(action => {
|
||||
assert.ok(known.includes(action.group),
|
||||
`"${action.label}" is in group "${action.group}", which nothing draws`);
|
||||
});
|
||||
});
|
||||
|
||||
// A caption for nothing is worse than no caption, and this group empties often:
|
||||
// past the blinds there is nothing to give a seat but the button.
|
||||
test("a group that has nothing to say is empty rather than thin", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 0 });
|
||||
Betting.beginHand(state);
|
||||
Betting.call(state, 3);
|
||||
SeatMenu.state = state;
|
||||
SeatMenu.hooks = {};
|
||||
const actions = SeatMenu.actionsFor(state.seats[3], 3);
|
||||
assert.deepEqual(inGroup(actions, "position"), ["The button"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the one action here that is not about this seat", () => {
|
||||
// It puts the whole table's blinds back on automatic. Spanning the grid is what
|
||||
// stops it reading as a fourth position to give.
|
||||
test("the override reset spans the grid and nothing else does", () => {
|
||||
const { actions } = fullestMenu();
|
||||
assert.deepEqual(labels(actions.filter(a => a.wide)),
|
||||
["Blinds follow the button again"]);
|
||||
});
|
||||
|
||||
test("and it is drawn lighter than the Cancel underneath it", () => {
|
||||
const rule = ruleFor(".seatMenuBtn.menuTileWide");
|
||||
assert.match(rule, /grid-column:\s*1 \/ -1/);
|
||||
assert.match(rule, /border-style:\s*dashed/);
|
||||
// Lighter, not smaller. A thumb does not care how rare an action is, so it
|
||||
// keeps the 44px every other tile has rather than shrinking out of reach.
|
||||
assert.ok(!/min-height/.test(rule),
|
||||
"the wide row sets its own height, which takes it under the touch floor");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sheet a dealer reads it in", () => {
|
||||
test("the header is stuck to the top, so whose menu this is cannot scroll away", () => {
|
||||
const rule = ruleFor(".seatMenuHeader");
|
||||
assert.match(rule, /position:\s*sticky/);
|
||||
assert.match(rule, /top:\s*0/);
|
||||
// A sticky header inside a padded scroller shows the list sliding through the
|
||||
// gap either side of it unless it reaches the sheet's own edges.
|
||||
assert.match(rule, /margin:\s*0 calc\(var\(--sp-4\) \* -1\)/);
|
||||
assert.match(rule, /background:/);
|
||||
});
|
||||
|
||||
test("and the sheet keeps no padding above it to slide through", () => {
|
||||
assert.match(ruleFor("#seatMenuSheet"), /padding:\s*0 var\(--sp-4\)/);
|
||||
});
|
||||
|
||||
// NN/g is explicit that a grab handle is not a close affordance. This sheet is the
|
||||
// one that can outgrow the screen, so it is also the one that most needs a way out
|
||||
// in the part that cannot leave.
|
||||
test("there is a real close button, not just a grip", () => {
|
||||
const markup = sheetMarkup();
|
||||
assert.match(markup, /id="seatMenuCloseBtn"/);
|
||||
assert.match(markup, /aria-label="Close this menu"/);
|
||||
});
|
||||
|
||||
// Modal focuses a dialog's first control, and the first control used to be Fold.
|
||||
// The APG says to keep initial focus off the destructive choice; a close button at
|
||||
// the top of the markup is what puts it there without a special case.
|
||||
test("the first control in the sheet is the one that does nothing", () => {
|
||||
const markup = sheetMarkup();
|
||||
assert.ok(markup.indexOf("seatMenuCloseBtn") < markup.indexOf("seatMenuActions"),
|
||||
"the actions come before the close button, so focus opens on Fold again");
|
||||
});
|
||||
|
||||
// Escape finds the first [data-modal-close] in the overlay. That has to be the one
|
||||
// that shuts the sheet, not the one at the foot that steps back inside it.
|
||||
test("Escape closes the sheet rather than stepping back inside it", () => {
|
||||
const markup = sheetMarkup();
|
||||
const closer = markup.indexOf("data-modal-close");
|
||||
assert.ok(closer >= 0 && closer < markup.indexOf("seatMenuActions"),
|
||||
"Escape does not reach a closer above the action list");
|
||||
assert.ok(!/id="seatMenuCancelBtn"[^>]*data-modal-close/.test(markup),
|
||||
"the button at the foot still answers Escape, so Escape only goes back");
|
||||
});
|
||||
});
|
||||
|
||||
describe("two columns, and what they are for", () => {
|
||||
test("the housekeeping half is a grid", () => {
|
||||
assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
|
||||
});
|
||||
|
||||
// Half the width and a step quieter, but not below the floor a fingertip needs.
|
||||
test("a tile is quieter than a bar without being smaller than a thumb", () => {
|
||||
const rule = ruleFor(".seatMenuBtn.menuTile");
|
||||
assert.match(rule, /min-height:\s*44px/);
|
||||
assert.match(rule, /font-size:\s*var\(--fs-base\)/);
|
||||
});
|
||||
|
||||
test("a bar is still a bar", () => {
|
||||
assert.match(ruleFor(".seatMenuBtn"), /min-height:\s*52px/);
|
||||
});
|
||||
|
||||
// The dashed row and the Cancel under it are both full width. Two rows of the same
|
||||
// size touching, one of which changes the table and one of which changes nothing,
|
||||
// is a mistake waiting at the end of a scroll.
|
||||
test("the last action and the way out are not two rows touching", () => {
|
||||
assert.match(ruleFor("#seatMenuCancelBtn"), /margin-top:\s*var\(--sp-3\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the move picker is no longer a one-way door", () => {
|
||||
const MAIN = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "main.js"), "utf8");
|
||||
|
||||
// Opening it used to replace the whole sheet, and the only control left closed the
|
||||
// menu outright — a dealer who opened the seat list by mistake lost their place.
|
||||
test("the button at the foot goes back while there is an inside to go back to", () => {
|
||||
const wiring = MAIN.slice(MAIN.indexOf('getElementById("seatMenuCancelBtn")'));
|
||||
const body = wiring.slice(0, wiring.indexOf("});"));
|
||||
assert.match(body, /SeatMenu\.mode === "move"/);
|
||||
assert.match(body, /SeatMenu\.closeMovePicker\(\)/);
|
||||
assert.match(body, /SeatMenu\.close\(\)/);
|
||||
});
|
||||
|
||||
test("and the menu knows how to come back", () => {
|
||||
assert.equal(typeof SeatMenu.closeMovePicker, "function");
|
||||
SeatMenu.mode = "move";
|
||||
SeatMenu.state = { seats: [] };
|
||||
SeatMenu.seatIndex = 0;
|
||||
// render() needs a document, so the mode change is checked where it is made.
|
||||
assert.match(SeatMenu.closeMovePicker.toString(), /this\.mode = null/);
|
||||
});
|
||||
|
||||
test("every other chair is a destination, including the empty ones", () => {
|
||||
const state = makeTable({ seatCount: 6, seated: [0, 1], stack: 100 });
|
||||
SeatMenu.state = state;
|
||||
const seats = SeatMenu.destinationsFor(2).map(d => d.seat);
|
||||
assert.deepEqual(seats, [0, 1, 3, 4, 5]);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user