Hold'em: every seat on the ring is the same seat, and the ring is measured before it is believed #122
@@ -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">
|
||||
<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