Hold'em: every seat on the ring is the same seat, and the ring is measured before it is believed #122

Open
jheaps wants to merge 27 commits from polish/holdem-seats-one-size into master
5 changed files with 470 additions and 11 deletions
Showing only changes of commit 6992833821 - Show all commits
@@ -7,7 +7,20 @@
// (in practice: Undo). Only one is ever on screen; a second replaces the first. // (in practice: Undo). Only one is ever on screen; a second replaces the first.
const Toast = { const Toast = {
DEFAULT_DURATION_MS: 8000, DEFAULT_DURATION_MS: 8000,
// What the clock is set back to when the dealer lets go of a toast.
//
// Pausing while it is touched or focused is only half the job. Let go at 7.9
// seconds of 8 and the message goes while the thumb is still travelling: the
// timer that matters once somebody has reached for a toast is the time to act
// on it, not what happened to be left of the time to notice it.
RESUME_MS: 2500,
timer: null, timer: null,
endsAt: 0,
remaining: 0,
// Where focus was standing when this arrived.
returnFocusTo: null,
show(message, options = {}) { show(message, options = {}) {
const host = document.getElementById("toastHost"); const host = document.getElementById("toastHost");
@@ -17,7 +30,11 @@ const Toast = {
const toast = document.createElement("div"); const toast = document.createElement("div");
toast.className = "toast"; toast.className = "toast";
toast.setAttribute("role", "status"); // No role on the toast itself. #toastHost is the live region, and it is in the
// markup — which is what makes it observed. A region that arrives already
// carrying its text has not been watched by anything yet, and is routinely
// never spoken at all; one nested inside a region that is watched only buys a
// second reading of the same sentence.
const text = document.createElement("span"); const text = document.createElement("span");
text.className = "toastText"; text.className = "toastText";
@@ -29,6 +46,12 @@ const Toast = {
action.type = "button"; action.type = "button";
action.className = "toastAction"; action.className = "toastAction";
action.textContent = options.actionLabel; action.textContent = options.actionLabel;
// Which keyboard shortcut, if any, presses this. Measured on a desktop,
// this button is the 67th of the page's 68 tab stops, and it is gone in
// eight seconds: reaching it by Tab is not a thing anybody can do. Only
// the toasts that say so opt in — "Turn off" and "Reload" are not things
// a stray keypress should be able to reach.
if (options.shortcut) action.dataset.shortcut = options.shortcut;
action.addEventListener("click", () => { action.addEventListener("click", () => {
this.dismiss(); this.dismiss();
options.onAction(); options.onAction();
@@ -44,6 +67,27 @@ const Toast = {
close.addEventListener("click", () => this.dismiss()); close.addEventListener("click", () => this.dismiss());
toast.appendChild(close); toast.appendChild(close);
// Read after dismiss() above has had its chance to put focus back, so that a
// toast replacing another remembers where the dealer actually is.
const active = document.activeElement;
this.returnFocusTo = active && active !== document.body ? active : null;
// A message with a button in it is on a timer the reader has to beat, which is
// what WCAG's Timing Adjustable is about. Touching it or tabbing into it stops
// the clock; letting go starts it again.
toast.addEventListener("pointerenter", () => this.hold());
toast.addEventListener("focusin", event => {
// Where focus came *from* beats where it was when this appeared: a dealer
// who moves on and then tabs back into the toast should be put back where
// they had got to, not where they were eight seconds ago.
if (event.relatedTarget && !toast.contains(event.relatedTarget)) {
this.returnFocusTo = event.relatedTarget;
}
this.hold();
});
["pointerleave", "focusout"].forEach(type =>
toast.addEventListener(type, () => this.resume()));
host.appendChild(toast); host.appendChild(toast);
// Next frame, so the entrance transition actually runs. // Next frame, so the entrance transition actually runs.
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -51,10 +95,51 @@ const Toast = {
this.publishHeight(); this.publishHeight();
}); });
this.timer = setTimeout( this.arm(options.duration || this.DEFAULT_DURATION_MS);
() => this.dismiss(), },
options.duration || this.DEFAULT_DURATION_MS
); arm(ms) {
if (this.timer) clearTimeout(this.timer);
this.endsAt = Date.now() + ms;
this.timer = setTimeout(() => this.dismiss(), ms);
},
// Is the dealer holding this open — a finger on it, or the keyboard inside it?
heldOpen() {
if (typeof document === "undefined") return false;
const host = document.getElementById("toastHost");
const toast = host && host.firstElementChild;
if (!toast) return false;
if (document.activeElement && toast.contains(document.activeElement)) return true;
// :hover rather than a flag of our own, so a pointer that left the window
// without a pointerleave — or one that never sent pointerenter because the
// toast appeared underneath it — is answered by the same question either way.
try {
return typeof toast.matches === "function" && toast.matches(":hover");
} catch (err) {
return false;
}
},
hold() {
if (!this.timer) return;
clearTimeout(this.timer);
this.timer = null;
this.remaining = Math.max(0, this.endsAt - Date.now());
},
resume() {
// `focusout` arrives before the new focus lands, so "is anything in here still
// focused?" has no true answer until the next tick. pointerleave is deferred
// with it rather than against it: leaving with the keyboard still inside must
// not restart the clock.
setTimeout(() => {
if (this.timer || this.heldOpen()) return;
if (typeof document === "undefined") return;
const host = document.getElementById("toastHost");
if (!host || !host.firstElementChild) return;
this.arm(Math.max(this.remaining, this.RESUME_MS));
}, 0);
}, },
dismiss() { dismiss() {
@@ -62,12 +147,25 @@ const Toast = {
clearTimeout(this.timer); clearTimeout(this.timer);
this.timer = null; this.timer = null;
} }
this.remaining = 0;
// The same guard publishHeight below already carries, for the same reason: the // The same guard publishHeight below already carries, for the same reason: the
// bar dismisses a toast on the way into every action it commits, and that path // bar dismisses a toast on the way into every action it commits, and that path
// is otherwise plain bookkeeping that answers without a document. // is otherwise plain bookkeeping that answers without a document.
if (typeof document === "undefined") return; if (typeof document === "undefined") return;
const host = document.getElementById("toastHost"); const host = document.getElementById("toastHost");
if (host) host.innerHTML = ""; if (host) {
// Never leave the keyboard standing on nothing. A control removed while it
// has focus hands focus to <body>, and on this page that is 66 tab stops
// back from where the dealer was — so whoever pressed Undo, or ✕, or was
// simply reading when a second toast arrived, goes back where they were.
const active = document.activeElement;
const goBack = host.contains(active) ? this.returnFocusTo : null;
host.innerHTML = "";
if (goBack && goBack.isConnected && typeof goBack.focus === "function") {
goBack.focus();
}
}
this.returnFocusTo = null;
this.publishHeight(); this.publishHeight();
}, },
+24 -1
View File
@@ -61,6 +61,7 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show(message, { Toast.show(message, {
actionLabel: "Undo", actionLabel: "Undo",
shortcut: "undo",
onAction: () => { onAction: () => {
restoreState(snapshot); restoreState(snapshot);
Toast.show("Undone.", { duration: 2500 }); Toast.show("Undone.", { duration: 2500 });
@@ -453,6 +454,24 @@ document.addEventListener("DOMContentLoaded", () => {
return true; return true;
}; };
// The toast's Undo, pressed the same way the bar's is: by pressing the button that
// is actually on the screen, not by running a second copy of what it does.
//
// It has to be reachable. Every whole-step undo — next hand, resolve pot, a seat
// edited — clears the per-action history on its way past, so the bar has no pill to
// press and this button is the only way back. Measured on a desktop, it is the 67th
// of the page's 68 tab stops and it is gone in eight seconds, so U was the key the
// sidebar legend advertised and nothing answered.
//
// Only a button that named this shortcut, so the wake lock's "Turn off" and the
// other-tab warning's "Reload" are not things a stray U can reach.
const pressToastAction = shortcut => {
const btn = document.querySelector(`#toastHost .toastAction[data-shortcut="${shortcut}"]`);
if (!btn) return false;
btn.click();
return true;
};
const quickRaiseButton = label => const quickRaiseButton = label =>
[...document.querySelectorAll("#actionBar .raiseQuickBtn")] [...document.querySelectorAll("#actionBar .raiseQuickBtn")]
.find(b => b.querySelector(".actionBtnLabel").textContent === label); .find(b => b.querySelector(".actionBtnLabel").textContent === label);
@@ -473,7 +492,10 @@ document.addEventListener("DOMContentLoaded", () => {
// Dealing is an advance too — Enter should carry a dealer through setup, deal, // Dealing is an advance too — Enter should carry a dealer through setup, deal,
// streets and showdown without reaching for the trackpad. // streets and showdown without reaching for the trackpad.
advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"), advance: () => pressInBar(".raiseConfirm") || pressInBar(".actionAdvance") || pressInBar(".actionDeal"),
undo: () => pressInBar(".actionUndo"), // The toast first, because it is the more recent of the two. They can both be
// up at once — a seat edited mid-hand leaves the bar's pill alone — and the one
// the dealer just caused is the one on the screen saying what it will take back.
undo: () => pressToastAction("undo") || pressInBar(".actionUndo"),
cancel: () => pressInBar(".raiseCancel"), cancel: () => pressInBar(".raiseCancel"),
}); });
@@ -493,6 +515,7 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show(message, { Toast.show(message, {
actionLabel: "Undo", actionLabel: "Undo",
shortcut: "undo",
onAction: () => { onAction: () => {
restoreState(snapshot); restoreState(snapshot);
Toast.show("Undone.", { duration: 2500 }); Toast.show("Undone.", { duration: 2500 });
@@ -30,7 +30,9 @@ const Shortcuts = {
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" }, { keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" }, { keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
{ keys: ["A"], action: "allIn", what: "Put this player all-in" }, { keys: ["A"], action: "allIn", what: "Put this player all-in" },
{ keys: ["U"], action: "undo", what: "Take back the last action" }, // Both undos, because there are two and a dealer has no reason to know that:
// the bar's pill takes back a bet, and the toast's takes back a whole step.
{ keys: ["U"], action: "undo", what: "Take back the last thing you did" },
{ keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" }, { keys: ["Space", "Enter"], action: "advance", what: "Deal, or move to the next street" },
{ keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" }, { keys: ["Esc"], action: "cancel", what: "Close the amount pad without betting" },
], ],
+6 -3
View File
@@ -43,9 +43,11 @@ const CardPicker = load("cardPicker");
// navigator.wakeLock — see wakeLock.test.js. // navigator.wakeLock — see wakeLock.test.js.
const WakeLock = load("wakeLock"); const WakeLock = load("wakeLock");
// feedback.js exports two namespaces and a third; Unavailable is the one with anything // feedback.js exports two namespaces and a third. Two of them answer here: Unavailable,
// to test, and render.js and cardPicker.js reach for it as a global the way the browser // which is nearly all logic (see theUnansweredPress), and Toast, whose timer and focus
// hands it to them. See theUnansweredPress. // bookkeeping ask little enough of a document that a stand-in can answer (see
// theWayBack). All three are also set as globals, the way the browser hands them to
// render.js and cardPicker.js.
const { Toast, Dialog, Unavailable } = require(path.join(SRC, "feedback.js")); const { Toast, Dialog, Unavailable } = require(path.join(SRC, "feedback.js"));
globalThis.Toast = Toast; globalThis.Toast = Toast;
globalThis.Dialog = Dialog; globalThis.Dialog = Dialog;
@@ -125,6 +127,7 @@ module.exports = {
SeatMenu, SeatMenu,
Modal, Modal,
QuickSplit, QuickSplit,
Toast,
Unavailable, Unavailable,
makeTable, makeTable,
chipsOnTable, chipsOnTable,
+333
View File
@@ -0,0 +1,333 @@
// Undo, and whether a dealer can actually get to it.
//
// This tool prefers undo to confirmation — NN/g's own preference, and `runUndoable`
// says so in as many words: "it now just happens, and the toast offers the way back for
// the few seconds anyone would notice a misclick." That makes the toast's Undo the
// safety net under the biggest things the tool does. Three ways it wasn't one:
//
// * Every whole-step undo clears the per-action history on its way past, so the bar
// has no pill left and this button is the only way back. Measured on a desktop it
// is the 67th of the page's 68 tab stops, and it is gone in eight seconds. `U` —
// the key the sidebar legend advertises as taking back the last action — found
// nothing at all and did nothing.
// * The eight seconds ran whatever the dealer was doing. A cursor resting on the
// toast did not stop it; nor did tabbing onto the button itself. Measured: focus
// the Undo, wait 8.6 seconds, and the toast is gone and focus has been handed to
// <body> — the offer withdrawn and the dealer's place in the page with it.
// * Each toast carried its own role="status" inside #toastHost, which is already a
// live region in the markup. A region nested in a region, announced twice.
//
// The timing rules here are the usual ones: three to four seconds for a plain
// confirmation, six to ten for anything carrying a button, pause on hover and on
// keyboard focus, and always a manual way out — which is what WCAG 2.2.1 asks of
// content that disappears on a clock.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Toast } = require("./load");
const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
const MAIN = fs.readFileSync(path.join(SRC, "main.js"), "utf8");
const FEEDBACK = fs.readFileSync(path.join(SRC, "feedback.js"), "utf8");
const SHORTCUTS = fs.readFileSync(path.join(SRC, "shortcuts.js"), "utf8");
const PAGE = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
// ---------------------------------------------------------------------------
// Enough of a document for a toast: a host it can be put into, elements that can hold
// listeners and focus, and a `:hover` that a test can turn on.
let active = null;
const node = (tag, className) => {
const self = {
tagName: String(tag).toUpperCase(),
className: className || "",
textContent: "",
dataset: {},
children: [],
parent: null,
isConnected: true,
hovered: false,
listeners: {},
attrs: {},
classList: { add() {} },
appendChild(child) { child.parent = self; self.children.push(child); return child; },
addEventListener(type, fn) { (self.listeners[type] = self.listeners[type] || []).push(fn); },
setAttribute(name, value) { self.attrs[name] = String(value); },
getAttribute(name) { return name in self.attrs ? self.attrs[name] : null; },
contains(other) {
for (let n = other; n; n = n.parent) if (n === self) return true;
return false;
},
matches(selector) { return selector === ":hover" ? self.hovered : false; },
focus() { active = self; },
getBoundingClientRect: () => ({ height: 44 }),
// Fire one of the listeners the toast hung on itself.
fire(type, event) { (self.listeners[type] || []).forEach(fn => fn(event || {})); },
find(className) {
if (self.className === className) return self;
for (const child of self.children) {
const hit = child.find(className);
if (hit) return hit;
}
return null;
},
};
Object.defineProperty(self, "firstElementChild", { get: () => self.children[0] || null });
Object.defineProperty(self, "innerHTML", {
get: () => "",
set(value) {
if (value !== "") return;
self.children.forEach(child => { child.parent = null; child.isConnected = false; });
self.children.length = 0;
},
});
return self;
};
let host;
const openDocument = () => {
host = node("div", "toastHost");
host.id = "toastHost";
active = null;
global.document = {
getElementById: id => (id === "toastHost" ? host : null),
createElement: tag => node(tag),
documentElement: { style: { setProperty() {} } },
};
Object.defineProperty(global.document, "activeElement", { get: () => active });
global.requestAnimationFrame = fn => fn();
};
const toastOnScreen = () => host.firstElementChild;
const undoButton = () => toastOnScreen() && toastOnScreen().find("toastAction");
// The toast's own timers are the thing under test, so they are driven rather than
// waited on. `resume` defers to the next tick on purpose — see feedback.js — so every
// release is followed by a tick of 1 to let that land.
const settle = t => t.mock.timers.tick(1);
const show = (options = {}) => Toast.show(options.message || "Button moved on.", options);
test.beforeEach(t => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
openDocument();
});
test.afterEach(() => {
Toast.dismiss();
delete global.document;
delete global.requestAnimationFrame;
});
// ---------------------------------------------------------------------------
describe("a toast nobody touches", () => {
test("stays for the time it was given, and then goes", t => {
show({ duration: 5000 });
t.mock.timers.tick(4900);
assert.ok(toastOnScreen(), "it left early");
t.mock.timers.tick(200);
assert.equal(toastOnScreen(), null, "it outstayed its duration");
});
test("and the default is the one a message with a button in it gets", () => {
// Three to four seconds for a plain confirmation, six to ten for anything
// carrying a button. Every plain one here names its own shorter duration; this
// default is what the undoable steps and the wake lock fall back to.
assert.ok(Toast.DEFAULT_DURATION_MS >= 6000 && Toast.DEFAULT_DURATION_MS <= 10000,
`${Toast.DEFAULT_DURATION_MS}ms is outside the range for a toast with an action in it`);
});
});
describe("a toast the dealer has reached for", () => {
test("a finger on it stops the clock", t => {
show({ duration: 8000 });
t.mock.timers.tick(1000);
const toast = toastOnScreen();
toast.hovered = true;
toast.fire("pointerenter");
t.mock.timers.tick(30000);
assert.ok(toastOnScreen(), "eight seconds ran out under a finger that was holding it");
});
test("and so does tabbing onto the button", t => {
show({ duration: 8000, actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
undoButton().focus();
toast.fire("focusin", { relatedTarget: null });
t.mock.timers.tick(30000);
assert.ok(toastOnScreen(), "the offer was withdrawn while the keyboard was standing on it");
assert.equal(active, undoButton(), "and focus went with it");
});
test("letting go starts it again", t => {
show({ duration: 8000 });
const toast = toastOnScreen();
toast.hovered = true;
toast.fire("pointerenter");
t.mock.timers.tick(30000);
toast.hovered = false;
toast.fire("pointerleave");
settle(t);
t.mock.timers.tick(7000);
assert.ok(toastOnScreen(), "it left before the time it still had");
t.mock.timers.tick(1200);
assert.equal(toastOnScreen(), null, "letting go did not restart the clock at all");
});
test("and letting go late still leaves time to act", t => {
// The half of this that pausing alone doesn't fix: touch it at 7.9 seconds of
// 8, let go, and what is left of the clock to *notice* the message is 100ms —
// gone while the thumb is still travelling to Undo.
show({ duration: 8000 });
const toast = toastOnScreen();
t.mock.timers.tick(7900);
toast.hovered = true;
toast.fire("pointerenter");
t.mock.timers.tick(3000);
toast.hovered = false;
toast.fire("pointerleave");
settle(t);
t.mock.timers.tick(Toast.RESUME_MS - 500);
assert.ok(toastOnScreen(), "it went in the 100ms that was left rather than the time to act");
t.mock.timers.tick(700);
assert.equal(toastOnScreen(), null, "and then it never went at all");
});
test("a cursor leaving while the keyboard is still inside does not restart it", t => {
// Two ways to hold it open, one timer. The pointer leaving is not an answer to
// "is anybody still using this?"
show({ duration: 8000, actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
toast.hovered = true;
toast.fire("pointerenter");
undoButton().focus();
toast.fire("focusin", { relatedTarget: null });
toast.hovered = false;
toast.fire("pointerleave");
settle(t);
t.mock.timers.tick(30000);
assert.ok(toastOnScreen(), "the clock restarted under a focused button");
});
});
describe("what happens to focus when it goes", () => {
test("the dealer is put back where they had got to", t => {
const elsewhere = node("button");
show({ actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
elsewhere.focus();
undoButton().focus();
toast.fire("focusin", { relatedTarget: elsewhere });
Toast.dismiss();
settle(t);
assert.equal(active, elsewhere,
"focus was left on a button that no longer exists, which hands it to <body>");
});
test("and not somewhere they left eight seconds ago", t => {
// Captured when focus crosses into the toast, not when the toast appeared: a
// dealer who carries on working and then tabs back should land where they got
// to, not where they were when the message arrived.
const whenItArrived = node("button");
const whereTheyGotTo = node("button");
whenItArrived.focus();
show({ actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
whereTheyGotTo.focus();
undoButton().focus();
toast.fire("focusin", { relatedTarget: whereTheyGotTo });
Toast.dismiss();
settle(t);
assert.equal(active, whereTheyGotTo);
});
test("focus is left alone when it was never in the toast", () => {
const elsewhere = node("button");
show({ actionLabel: "Undo", onAction: () => {} });
elsewhere.focus();
Toast.dismiss();
assert.equal(active, elsewhere, "dismissing a toast nobody was in moved the dealer");
});
test("and a control that has gone since is not reached for", () => {
const gone = node("button");
show({ actionLabel: "Undo", onAction: () => {} });
const toast = toastOnScreen();
gone.focus();
undoButton().focus();
toast.fire("focusin", { relatedTarget: gone });
// A re-render between the toast going up and coming down. This happens on every
// undo: restoreState rebuilds the table.
gone.isConnected = false;
assert.doesNotThrow(() => Toast.dismiss());
});
});
describe("the key that reaches it", () => {
test("a toast says which shortcut presses its button", () => {
show({ actionLabel: "Undo", shortcut: "undo", onAction: () => {} });
assert.equal(undoButton().dataset.shortcut, "undo");
});
test("and one that named none is not reachable by a stray keypress", () => {
// "Turn off" on the wake lock and "Reload" on the other-tab warning both live
// in this same strip. Neither is a thing U should be able to do.
show({ actionLabel: "Turn off", onAction: () => {} });
assert.equal(undoButton().dataset.shortcut, undefined);
});
test("both of the undoable paths opt in", () => {
// runUndoable, for whole steps, and seatAction, for a gesture on a seat.
const offers = MAIN.split('actionLabel: "Undo"').length - 1;
const named = MAIN.split('shortcut: "undo"').length - 1;
assert.equal(offers, 2, "the number of Undo toasts has changed");
assert.equal(named, offers, "an Undo the keyboard cannot reach");
});
test("the key presses the button rather than running its own copy of it", () => {
// The house rule for every other shortcut in this file: press what is on the
// screen, so there is no second code path to drift out of step with it.
assert.match(MAIN, /#toastHost \.toastAction\[data-shortcut=/);
assert.match(MAIN, /undo: \(\) => pressToastAction\("undo"\) \|\| pressInBar\("\.actionUndo"\)/,
"the toast is the more recent of the two and has to be asked first");
});
test("and the legend covers both undos, because a dealer has no reason to know there are two", () => {
const line = SHORTCUTS.match(/keys: \["U"\][^}]*what: "([^"]+)"/);
assert.ok(line, "the U shortcut has no legend line");
assert.doesNotMatch(line[1], /last action$/,
"the legend still promises only the bar's pill");
});
});
describe("one live region, and it is in the markup", () => {
test("the host is the region", () => {
assert.match(PAGE, /id="toastHost"[^>]*aria-live="polite"/);
});
test("and a toast does not bring a second one with it", () => {
// A region that arrives already carrying its text has not been watched by
// anything yet, which is the documented reason such a message is never spoken.
// The host is in the markup precisely so that it has been.
assert.ok(!/toast\.setAttribute\("role"/.test(FEEDBACK),
"each toast is its own live region again, nested inside the one that works");
show({});
assert.equal(toastOnScreen().getAttribute("role"), null);
});
});