+
@@ -374,5 +374,6 @@
+
}
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index a7e6145..8687072 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -219,7 +219,11 @@ button,
padding: 0.6rem 1rem;
font-size: 0.85rem;
cursor: pointer;
- transition: all 0.15s ease;
+ /* Named rather than `all`, so the focus ring isn't animated. With `all` the
+ outline faded in over 150ms, which on keyboard navigation reads as the ring
+ lagging a step behind the Tab key. Nothing else here changes. */
+ transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease,
+ box-shadow 0.15s ease, color 0.15s ease, opacity 0.15s ease, filter 0.15s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
@@ -2690,3 +2694,41 @@ body.hasActionBar #toastHost {
display: none;
}
}
+
+/* ---------- Keyboard focus ----------
+ Three focus rules existed for the whole tool, so tabbing through it relied on
+ whatever ring the browser draws by default — which on these dark, custom-painted
+ controls is close to invisible. `:focus-visible` rather than `:focus` keeps it to
+ keyboard use: a dealer tapping Fold shouldn't be left with a ring on it. */
+.holdemBtn:focus-visible,
+.linkBtn:focus-visible,
+.seatMenuBtn:focus-visible,
+.seatSheetClose:focus-visible,
+.pickerCard:focus-visible,
+.actionBtn:focus-visible,
+.raiseQuickBtn:focus-visible,
+.actionUndo:focus-visible,
+.toastAction:focus-visible,
+.toastClose:focus-visible,
+.potWizardChoice:focus-visible,
+.cardSlot:focus-visible,
+.subPanel summary:focus-visible,
+.seat:focus-visible,
+button:focus-visible,
+[role="button"]:focus-visible,
+input:focus-visible,
+select:focus-visible,
+textarea:focus-visible {
+ outline: 2px solid #7fc4ff;
+ outline-offset: 2px;
+ /* The ring has to sit above the neighbouring seat that overlaps this one. */
+ position: relative;
+ z-index: 3;
+}
+
+/* A sheet with nothing focusable in it takes focus itself so its title is read out.
+ That shouldn't paint a ring around the whole panel. */
+[role="dialog"]:focus,
+[role="alertdialog"]:focus {
+ outline: none;
+}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
index 3c928ed..803d739 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js
@@ -39,11 +39,16 @@ const Betting = {
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
},
+ // Never below zero. A seat can't have negative chips in front of it, and a typo in
+ // the stack field used to come back as-is — which then flowed into putIn(), where
+ // `Math.min(amount, -50)` moves a negative amount and *adds* chips to the table.
stackOf(state, player) {
const own = player.stack;
- if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own);
+ if (own !== null && own !== undefined && String(own).trim() !== "") {
+ return Math.max(0, this.round(own));
+ }
const fallback = Number(state.defaultStack);
- return Number.isNaN(fallback) ? 0 : this.round(fallback);
+ return Number.isNaN(fallback) ? 0 : Math.max(0, this.round(fallback));
},
toCall(state, player) {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
index aea2b0d..56c2494 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js
@@ -1,6 +1,10 @@
document.addEventListener("DOMContentLoaded", () => {
const state = HoldemState.load();
+ // Focus, Tab-trapping and Escape for every overlay, wired once by watching the
+ // `hidden` attribute they all already toggle.
+ Modal.mount();
+
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
// by reference to every module at mount time, so it has to be refilled in place —
// reassigning the variable would leave the renderer and the betting bar pointing at
@@ -369,6 +373,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (e.key === "Enter") AddChips.confirm();
});
+ // Escape is Modal's job now, for this sheet and the five others that never had it.
const rulingsOverlay = document.getElementById("rulingsOverlay");
const closeRulings = () => { rulingsOverlay.hidden = true; };
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
@@ -376,9 +381,6 @@ document.addEventListener("DOMContentLoaded", () => {
rulingsOverlay.addEventListener("click", e => {
if (e.target === rulingsOverlay) closeRulings();
});
- document.addEventListener("keydown", e => {
- if (e.key === "Escape" && !rulingsOverlay.hidden) closeRulings();
- });
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/modal.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/modal.js
new file mode 100644
index 0000000..f7aeefc
--- /dev/null
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/modal.js
@@ -0,0 +1,147 @@
+// Keyboard and screen-reader behaviour for every overlay in the tool, in one place.
+//
+// There are six of them — seat menu, card picker, add chips, rulings, pot wizard,
+// confirm — opened and closed by five different modules, and each one was missing
+// the same three things: focus never moved into the dialog, Tab walked straight out
+// of it into the page behind the scrim, and Escape did nothing (only the rulings
+// sheet handled it). Measured before this: 0 of 5 moved focus in, 5 of 5 leaked Tab,
+// 1 of 5 closed on Escape.
+//
+// Rather than patch that into five modules, this watches the `hidden` attribute every
+// overlay already toggles. Nothing else had to change, and an overlay added later
+// gets the same behaviour without anyone remembering to wire it up — which is the
+// failure this is fixing in the first place.
+const Modal = {
+ // `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
+ // must not be a Tab stop.
+ FOCUSABLE: [
+ "a[href]",
+ "button:not([disabled])",
+ 'input:not([disabled]):not([type="hidden"])',
+ "select:not([disabled])",
+ "textarea:not([disabled])",
+ '[tabindex]:not([tabindex="-1"])',
+ ].join(","),
+
+ open: [],
+
+ mount() {
+ document.querySelectorAll('[id$="Overlay"]').forEach(overlay => this.watch(overlay));
+ // Capture phase: the dialog gets first refusal on Escape and Tab, before any
+ // table-level shortcut sees them.
+ document.addEventListener("keydown", e => this.onKeyDown(e), true);
+ },
+
+ watch(overlay) {
+ const sync = () => {
+ const isOpen = !overlay.hidden;
+ const known = this.open.some(entry => entry.overlay === overlay);
+ if (isOpen && !known) this.onOpened(overlay);
+ else if (!isOpen && known) this.onClosed(overlay);
+ };
+ new MutationObserver(sync).observe(overlay, { attributes: true, attributeFilter: ["hidden"] });
+ sync();
+ },
+
+ sheetOf(overlay) {
+ return overlay.querySelector('[role="dialog"], [role="alertdialog"]') || overlay.firstElementChild || overlay;
+ },
+
+ focusableIn(overlay) {
+ return [...overlay.querySelectorAll(this.FOCUSABLE)].filter(el => {
+ if (el.closest("[hidden]")) return false;
+ const rect = el.getBoundingClientRect();
+ return rect.width > 0 && rect.height > 0;
+ });
+ },
+
+ onOpened(overlay) {
+ const sheet = this.sheetOf(overlay);
+ // aria-modal is only true while the thing is actually open, so it's set here
+ // rather than baked into the markup.
+ if (sheet) sheet.setAttribute("aria-modal", "true");
+
+ this.open.push({ overlay, returnFocus: document.activeElement });
+
+ // Focus the first real control. A dialog that leaves focus on the button
+ // behind it reads, to a screen reader, as though nothing happened at all.
+ // Content-only sheets get focus on the sheet so the title is announced.
+ const focusable = this.focusableIn(overlay);
+ const target = focusable[0];
+ if (target) {
+ target.focus({ preventScroll: true });
+ } else if (sheet) {
+ if (!sheet.hasAttribute("tabindex")) sheet.setAttribute("tabindex", "-1");
+ sheet.focus({ preventScroll: true });
+ }
+ },
+
+ onClosed(overlay) {
+ const index = this.open.findIndex(entry => entry.overlay === overlay);
+ if (index === -1) return;
+ const [entry] = this.open.splice(index, 1);
+
+ const sheet = this.sheetOf(overlay);
+ if (sheet) sheet.removeAttribute("aria-modal");
+
+ // Put the dealer back where they were. Without this, closing the pot wizard
+ // drops focus to the top of the document and the next Tab starts from the
+ // page header.
+ const back = entry.returnFocus;
+ if (back && back.isConnected && typeof back.focus === "function") {
+ back.focus({ preventScroll: true });
+ }
+ },
+
+ topmost() {
+ return this.open.length ? this.open[this.open.length - 1] : null;
+ },
+
+ // Escape presses the dialog's own cancel/close button rather than just hiding the
+ // overlay, so whatever that button does — resolving the confirm promise, clearing
+ // the add-chips draft — still happens. Only if there isn't one does it hide the
+ // overlay directly.
+ dismiss(overlay) {
+ const closer = overlay.querySelector("[data-modal-close]");
+ if (closer) closer.click();
+ else overlay.hidden = true;
+ },
+
+ onKeyDown(event) {
+ const top = this.topmost();
+ if (!top) return;
+
+ if (event.key === "Escape") {
+ event.preventDefault();
+ event.stopPropagation();
+ this.dismiss(top.overlay);
+ return;
+ }
+
+ if (event.key !== "Tab") return;
+
+ // Keep Tab inside the dialog. Everything behind the scrim is unreachable by
+ // mouse, so letting the keyboard wander back there is a trap with no way out.
+ const focusable = this.focusableIn(top.overlay);
+ if (focusable.length === 0) {
+ event.preventDefault();
+ return;
+ }
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ const active = document.activeElement;
+
+ if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
+ event.preventDefault();
+ last.focus({ preventScroll: true });
+ } else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
+ event.preventDefault();
+ first.focus({ preventScroll: true });
+ }
+ },
+};
+
+if (typeof module !== "undefined" && module.exports) {
+ module.exports = Modal;
+}
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js
index 95bac23..2a767b0 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/shortcuts.js
@@ -32,14 +32,12 @@ const Shortcuts = {
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
},
- // An overlay owns the keyboard while it's up: the confirm dialog already binds
- // Enter and Escape, and the card picker and seat menu are modal.
+ // An overlay owns the keyboard while it's up. Asking the document which overlays
+ // are open, rather than listing them, means a sheet added later can't quietly
+ // start letting "f" fold the table behind it — the same reason Modal watches for
+ // them instead of being told about each one.
overlayOpen() {
- return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay", "rulingsOverlay", "addChipsOverlay"]
- .some(id => {
- const el = document.getElementById(id);
- return el && !el.hidden;
- });
+ return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
},
onKey(e) {
diff --git a/tests/holdem/robustness.test.js b/tests/holdem/robustness.test.js
new file mode 100644
index 0000000..6de29d1
--- /dev/null
+++ b/tests/holdem/robustness.test.js
@@ -0,0 +1,144 @@
+// Values a dealer can actually produce with a typo, and the ones a corrupted save
+// can produce on its own. None of these should be able to move chips.
+const { test, describe } = require("node:test");
+const assert = require("node:assert/strict");
+const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
+
+describe("a stack can never go negative", () => {
+ test("a mistyped negative stack reads as empty, not as owing chips", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
+ state.seats[0].stack = -50;
+ assert.equal(Betting.stackOf(state, state.seats[0]), 0);
+ });
+
+ test("a negative stack can't be bet with", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
+ state.seats[0].stack = -50;
+ const before = state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0);
+
+ const moved = Betting.putIn(state, 0, 25);
+
+ assert.equal(moved, 0, "nothing moves out of an empty stack");
+ assert.ok(Betting.stackOf(state, state.seats[0]) >= 0);
+ assert.equal(state.seats[0].committed, 0, "and nothing appears in front of them");
+ assert.ok(state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0) <= before);
+ });
+
+ test("a negative table default doesn't hand everyone a negative stack", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
+ state.seats.forEach(p => { p.stack = null; });
+ state.defaultStack = -100;
+ state.seats.forEach(p => assert.equal(Betting.stackOf(state, p), 0));
+ });
+
+ test("a negative stack doesn't make a player look like they can still bet", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
+ state.seats[0].stack = -50;
+ assert.ok(!Betting.seatsWithChips(state).some(s => s.index === 0));
+ });
+});
+
+describe("blinds that can't be posted", () => {
+ test("negative blinds post nothing rather than paying players to sit down", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
+ state.smallBlind = -5;
+ state.bigBlind = -10;
+ const before = chipsOnTable(state);
+
+ Betting.beginHand(state);
+
+ state.seats.forEach(p => assert.equal(p.committed, 0));
+ assert.equal(chipsOnTable(state), before);
+ });
+
+ test("an unreadable blind doesn't wedge the hand", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
+ state.bigBlind = NaN;
+ state.smallBlind = 1;
+
+ Betting.beginHand(state);
+
+ assert.ok(Number.isFinite(state.currentBet), "the bet to match is still a number");
+ assert.ok(!Number.isNaN(Betting.potSoFar(state)));
+ });
+
+ test("blinds nobody can cover put those players all-in and move on", () => {
+ const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 10 });
+ state.smallBlind = 500;
+ state.bigBlind = 1000;
+ const before = chipsOnTable(state);
+
+ Betting.beginHand(state);
+
+ assert.equal(chipsOnTable(state), before, "no chips invented covering a blind");
+ state.seats.forEach(p => assert.ok((Number(p.stack) || 0) >= 0));
+ assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
+ });
+});
+
+describe("a saved table that comes back damaged", () => {
+ test("unreadable storage falls back to a fresh table", () => {
+ // load() has to survive whatever is in localStorage, because the alternative
+ // is a dealer opening the tool mid-session to a blank screen.
+ const original = globalThis.localStorage;
+ globalThis.localStorage = {
+ getItem: () => "{not json at all",
+ setItem: () => {},
+ removeItem: () => {},
+ };
+ const state = HoldemState.load();
+ globalThis.localStorage = original;
+
+ assert.equal(state.seats.length, HoldemState.DEFAULT_SEAT_COUNT);
+ assert.equal(state.handNumber, 1);
+ });
+
+ test("a save missing its seats is treated as no save at all", () => {
+ const original = globalThis.localStorage;
+ globalThis.localStorage = {
+ getItem: () => JSON.stringify({ handNumber: 7, smallBlind: 3 }),
+ setItem: () => {},
+ removeItem: () => {},
+ };
+ const state = HoldemState.load();
+ globalThis.localStorage = original;
+
+ assert.ok(Array.isArray(state.seats) && state.seats.length > 0);
+ });
+
+ test("a save from before a field existed gets the field, not undefined", () => {
+ const original = globalThis.localStorage;
+ globalThis.localStorage = {
+ getItem: () => JSON.stringify({
+ seats: [{ id: "a", name: "Ann", inHand: true }, { id: "b", name: "Ben", inHand: true }],
+ handNumber: 4,
+ }),
+ setItem: () => {},
+ removeItem: () => {},
+ };
+ const state = HoldemState.load();
+ globalThis.localStorage = original;
+
+ assert.equal(state.blindOverride, null);
+ assert.equal(state.street, "preflop");
+ state.seats.forEach(p => {
+ assert.equal(p.committed, 0);
+ assert.equal(p.hasActed, false);
+ assert.equal(p.buyIns, 0);
+ });
+ // And it has to be playable, not just parseable.
+ assert.doesNotThrow(() => Betting.beginHand(state));
+ });
+
+ test("storage that refuses to be written doesn't take the table down", () => {
+ const original = globalThis.localStorage;
+ globalThis.localStorage = {
+ getItem: () => null,
+ setItem: () => { throw new Error("QuotaExceededError"); },
+ removeItem: () => {},
+ };
+ const state = HoldemState.createDefault();
+ assert.doesNotThrow(() => HoldemState.save(state), "a full disk must not break the hand in progress");
+ globalThis.localStorage = original;
+ });
+});