Make the way back reachable, and stop it leaving while it is being reached for

This tool prefers undo to confirmation, deliberately — runUndoable says so in
as many words — which makes the toast's Undo the safety net under the biggest
things it does. It was not one.

Every whole-step undo clears the per-action history on its way past, so the
bar has no pill left and the toast's 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. And U — the key the sidebar legend advertises as taking back the
last action — found nothing and did nothing: it only ever looked in the bar.
So U looks at the toast first, by pressing the button that is on the screen
rather than running a second copy of what it does, and only at a button that
named the shortcut: "Turn off" on the wake lock and "Reload" on the other-tab
warning are not things a stray keypress should reach.

The eight seconds also ran whatever the dealer was doing. A cursor resting on
the toast did not stop it; nor did tabbing onto the button. Measured before:
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. Touching it or tabbing into it now stops the clock, and letting go
starts it again with at least the time to act rather than whatever was left
of the time to notice: let go at 7.9 seconds of 8 and 100ms is not an offer.
Dismissing with focus inside puts focus back where the dealer had got to,
which is read off where focus crossed into the toast rather than off where it
was when the message arrived.

And 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 one in the markup is the one that works, because it is the one
anything has been watching.

1342 tests pass, 18 of them new — and 8 of those fail against the old code.
The sweep is clean across 428 audits; contrast unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-15 04:59:18 -06:00
co-authored by Claude Opus 5
parent 023228efc0
commit 6992833821
5 changed files with 470 additions and 11 deletions
@@ -7,7 +7,20 @@
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
const Toast = {
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,
endsAt: 0,
remaining: 0,
// Where focus was standing when this arrived.
returnFocusTo: null,
show(message, options = {}) {
const host = document.getElementById("toastHost");
@@ -17,7 +30,11 @@ const Toast = {
const toast = document.createElement("div");
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");
text.className = "toastText";
@@ -29,6 +46,12 @@ const Toast = {
action.type = "button";
action.className = "toastAction";
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", () => {
this.dismiss();
options.onAction();
@@ -44,6 +67,27 @@ const Toast = {
close.addEventListener("click", () => this.dismiss());
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);
// Next frame, so the entrance transition actually runs.
requestAnimationFrame(() => {
@@ -51,10 +95,51 @@ const Toast = {
this.publishHeight();
});
this.timer = setTimeout(
() => this.dismiss(),
options.duration || this.DEFAULT_DURATION_MS
);
this.arm(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() {
@@ -62,12 +147,25 @@ const Toast = {
clearTimeout(this.timer);
this.timer = null;
}
this.remaining = 0;
// 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
// is otherwise plain bookkeeping that answers without a document.
if (typeof document === "undefined") return;
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();
},
+24 -1
View File
@@ -61,6 +61,7 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show(message, {
actionLabel: "Undo",
shortcut: "undo",
onAction: () => {
restoreState(snapshot);
Toast.show("Undone.", { duration: 2500 });
@@ -453,6 +454,24 @@ document.addEventListener("DOMContentLoaded", () => {
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 =>
[...document.querySelectorAll("#actionBar .raiseQuickBtn")]
.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,
// streets and showdown without reaching for the trackpad.
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"),
});
@@ -493,6 +515,7 @@ document.addEventListener("DOMContentLoaded", () => {
Toast.show(message, {
actionLabel: "Undo",
shortcut: "undo",
onAction: () => {
restoreState(snapshot);
Toast.show("Undone.", { duration: 2500 });
@@ -30,7 +30,9 @@ const Shortcuts = {
{ keys: ["C"], action: "checkOrCall", what: "Check, or call the bet" },
{ keys: ["R"], action: "raise", what: "Open the amount pad to bet or raise" },
{ 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: ["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.
const WakeLock = load("wakeLock");
// feedback.js exports two namespaces and a third; Unavailable is the one with anything
// to test, and render.js and cardPicker.js reach for it as a global the way the browser
// hands it to them. See theUnansweredPress.
// feedback.js exports two namespaces and a third. Two of them answer here: Unavailable,
// which is nearly all logic (see theUnansweredPress), and Toast, whose timer and focus
// 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"));
globalThis.Toast = Toast;
globalThis.Dialog = Dialog;
@@ -125,6 +127,7 @@ module.exports = {
SeatMenu,
Modal,
QuickSplit,
Toast,
Unavailable,
makeTable,
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);
});
});