Files
JoshHeaps.Net/tests/holdem/theKeyboardInASheet.test.js
T
jheapsandClaude Opus 5 e9ec4afafe Ask a card the two questions it is, not one out of fifty-two
The picker put the whole deck on the screen at once — four suits across,
thirteen ranks down — and that grid is 646px tall. Measured with the
board tracked and seven cards already out:

  640x360   816px of content in a 317px sheet, 40 of the 52 below the fold
  667x375   816 in 330, 40 below
  844x390   774 in 343, 36 below
  375x667   816 in 587, 20 below
  1280x900  774 in 775, none

So on every phone the tool is actually used on, entering the flop began
with a scroll, and a dealer looking for the nine of diamonds was reading
down a column to find it. No arrangement fixes that: fifty-two targets at
the 44px this sweep holds everything else to need 572px of width before
gaps if they are thirteen across, which is wider than any phone in
portrait, and thirteen rows deep if they are four.

A card is not one choice out of fifty-two. It is two — a suit and a rank
— and that is how a dealer says it out loud. So the sheet asks them in
that order: four suits on a row, then the thirteen ranks of the one
chosen. Measured afterwards at eight viewports from 360x640 to 1280x900:
nothing below the fold, nothing scrolling, every button still at least
44x44 and still hit-testing as itself. Portrait content went 816 -> 372,
landscape 816 -> 322.

The second tap is not a second cost in the usual case. The suit stays
where it was put, so a second card of the same suit is one tap — and one
tap is what picking a card used to cost *plus* a scroll through thirteen
rows. Opening on a slot that already holds a card shows that card's suit
with it under the cursor, which is the case where the dealer came to
change something rather than add it.

Three things that had to move with it:

  - the cursor is a rank now, 0-12, and changing suit deliberately leaves
    it alone: the same rank in another suit is the card beside the one
    you were on.
  - the arrows read the column count off the layout instead of assuming
    SUITS.length. The rank row is auto-fill — five across on a 360px
    phone, seven at 460 — and the last time this number disagreed with
    the grid it slid the cursor into a column nobody asked for.
  - the suits are a radiogroup, not a tablist: the grid below is not
    another panel of content, it is the same thirteen controls filtered,
    and the unchosen red suits stay red, because reading four shapes to
    find the hearts is the search this sheet exists to remove.

deck() still returns all fifty-two and the duplicate check still reads
all of it, so splitting the grid by suit cannot let a card be dealt
twice.

1276 tests pass; the layout sweep reports clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-14 01:03:45 -06:00

414 lines
19 KiB
JavaScript

// Where a sheet puts the dealer, and whether they can get anywhere from there.
//
// Modal exists because six overlays were each missing the same three things, and it
// fixed all six at once by answering one question the same way everywhere: focus the
// first control, and hold Tab between the first and the last. Measured this round, that
// single answer had gone wrong in three separate directions.
//
// It could not see a <summary>. The rulings sheet is twelve of them and one Done
// button, so the trap decided Done was both ends of the ring and bounced every press
// back to it: eighty Tab presses, one element ever reached, twelve rulings that could
// not be opened without a mouse.
//
// "The first control" was the card picker's Clear. The sheet a dealer opens to write a
// card down opened on the button that rubs one out — the one thing the APG says to keep
// a dialog off. And "the first control" is not an answer six dialogs share: Roselli's
// rule is that a dialog long enough to scroll should land on its heading so the reader
// can orient, and a short question with one irreversible answer should land on the
// answer that leaves them no worse off.
//
// And a sheet that redraws itself was deleting the control that held focus. Choosing a
// buy-in, picking a card, switching slots, confirming a side pot: all four left focus on
// the document body, which inside a modal is nowhere at all.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Modal, CardPicker } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const src = name => fs.readFileSync(
path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
// The markup of one overlay, from its opening div to the next one's.
const OVERLAY_ORDER = ["addChipsOverlay", "rulingsOverlay", "seatMenuOverlay",
"cardPickerOverlay", "confirmOverlay", "potWizardOverlay"];
const overlay = id => {
const at = PAGE.indexOf(`<div id="${id}"`);
assert.ok(at >= 0, `there is no ${id}`);
const ends = OVERLAY_ORDER
.map(other => PAGE.indexOf(`<div id="${other}"`, at + 1))
.filter(i => i > at);
return PAGE.slice(at, ends.length ? Math.min(...ends) : PAGE.length);
};
// A document small enough to answer the handful of questions these functions ask of
// one, and honest about the two that matter: a detached node knows it is detached, and
// a disabled button refuses focus.
function fakeSheet(specs) {
const doc = { activeElement: null };
const nodes = specs.map(spec => ({
dataset: spec.key === undefined ? {} : { focusKey: spec.key },
disabled: !!spec.disabled,
isConnected: true,
tabIndex: 0,
label: spec.key || spec.label,
focus() {
if (this.disabled) return;
doc.activeElement = this;
},
}));
const root = {
nodes,
contains: el => nodes.includes(el),
querySelectorAll: () => nodes.filter(n => n.dataset.focusKey !== undefined),
};
return { doc, root, nodes };
}
// preservingFocus and the picker's cursor both reach for the global document. They are
// given one for the length of a test and it is taken away again afterwards.
function withDocument(doc, body) {
const had = Object.prototype.hasOwnProperty.call(globalThis, "document");
const before = globalThis.document;
globalThis.document = doc;
try {
return body();
} finally {
if (had) globalThis.document = before;
else delete globalThis.document;
}
}
// ---------------------------------------------------------------------------
describe("what counts as somewhere you can Tab to", () => {
const selectors = Modal.FOCUSABLE.split(",");
// The whole of the rulings sheet hung on this one omission.
test("a summary is a control, because the browser already treats it as one", () => {
assert.ok(selectors.some(s => /details\s*>\s*summary/.test(s)),
"a <details> summary is focusable without a tabindex, and this list can't see it");
});
// Left off, a card taken out of the Tab order still matches `button`, and the grid
// goes back to being fifty-two stops with a roving tabindex painted on top.
test("every line excludes what has been taken out of the Tab order, not just the last", () => {
selectors.forEach(selector => {
assert.ok(/:not\(\[tabindex="-1"\]\)/.test(selector) || /^\s*\[tabindex\]/.test(selector),
`\`${selector.trim()}\` still matches controls that are not Tab stops`);
});
});
test("and the sheet itself is still off the list", () => {
assert.ok(/\[tabindex\]:not\(\[tabindex="-1"\]\)/.test(Modal.FOCUSABLE));
});
});
describe("where each sheet lands the dealer", () => {
const LANDS_ON_ITS_HEADING = [
["cardPickerOverlay", "cardPickerTitle"],
["addChipsOverlay", "addChipsTitle"],
["rulingsOverlay", "rulingsTitle"],
["potWizardOverlay", "potWizardTitle"],
["seatMenuOverlay", "seatMenuTitle"],
];
LANDS_ON_ITS_HEADING.forEach(([id, heading]) => {
test(`${id} opens on the line that says which`, () => {
const markup = overlay(id);
const tag = markup.match(new RegExp(`<h2 id="${heading}"[^>]*>`));
assert.ok(tag, `${heading} is not an h2 in ${id}`);
assert.match(tag[0], /data-modal-focus/);
assert.match(tag[0], /tabindex="-1"/,
"a heading that is a Tab stop is a Tab stop the dealer has to press past");
});
});
// The APG's own example of when to land on a control rather than a heading, and the
// reason: one of the two answers cannot be taken back.
test("the confirm dialog is the exception, and it lands on Cancel", () => {
const markup = overlay("confirmOverlay");
const cancel = markup.match(/<button id="confirmCancelBtn"[^>]*>/);
assert.ok(cancel);
assert.match(cancel[0], /data-modal-focus/);
assert.ok(!/<button id="confirmAcceptBtn"[^>]*data-modal-focus/.test(markup),
"the dialog lands on the answer that cannot be taken back");
});
test("no sheet names two landing spots, which would be a coin toss", () => {
OVERLAY_ORDER.forEach(id => {
const found = (overlay(id).match(/data-modal-focus/g) || []).length;
assert.equal(found, 1, `${id} names ${found} landing spots`);
});
});
test("the card picker no longer opens on Clear", () => {
const markup = overlay("cardPickerOverlay");
assert.ok(markup.indexOf('id="cardPickerTitle"') < markup.indexOf('id="cardPickerClearBtn"'),
"the heading has moved below the button it was there to keep focus off");
assert.ok(!/id="cardPickerClearBtn"[^>]*data-modal-focus/.test(markup));
});
// The landing spot is deliberately outside the ring, and a wrap that only knows
// "is this the first one" walks straight out of the dialog on the first Shift+Tab.
test("the trap wraps from outside the ring as well as from its ends", () => {
const modal = src("modal");
const at = modal.indexOf("const at = focusable.indexOf(");
assert.ok(at >= 0, "the Tab trap no longer asks where the dealer is in the ring");
const body = modal.slice(at, modal.indexOf("\n },", at));
assert.match(body, /shiftKey && at <= 0/);
assert.match(body, /at === -1/);
});
});
describe("a redraw that does not drop the dealer", () => {
test("focus comes back to the control with the same name", () => {
const { doc, root, nodes } = fakeSheet([
{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
doc.activeElement = nodes[1];
withDocument(doc, () => Modal.preservingFocus(root, () => {
// What a rebuild does: the old buttons go, identical new ones arrive.
nodes.forEach(n => { n.isConnected = false; });
root.nodes = root.querySelectorAll = null;
const rebuilt = fakeSheet([{ key: "quick-500" }, { key: "quick-1000" }, { key: "quick-2000" }]);
rebuilt.nodes.forEach(n => { n.focus = () => { doc.activeElement = n; }; });
root.querySelectorAll = () => rebuilt.nodes;
root.contains = el => rebuilt.nodes.includes(el);
}));
assert.equal(doc.activeElement.dataset.focusKey, "quick-1000");
});
// The card just picked is a dealt card now, and a dealt card is a disabled button.
// There is no coming back to it, so the caller is asked where to go instead.
test("a name that comes back disabled hands the decision to the caller", () => {
const { doc, root, nodes } = fakeSheet([{ key: "AS" }, { key: "KH" }]);
doc.activeElement = nodes[0];
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {
nodes[0].isConnected = false;
nodes[0].disabled = true;
}, () => { asked = true; }));
assert.ok(asked, "focus was left on the body of a modal");
});
test("and a name that is simply gone asks too", () => {
const { doc, root, nodes } = fakeSheet([{ key: "confirm" }]);
doc.activeElement = nodes[0];
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {
nodes[0].isConnected = false;
root.querySelectorAll = () => [];
}, () => { asked = true; }));
assert.ok(asked);
});
// Clear is in the header. Nothing rebuilds it, so nothing should move it — a redraw
// the dealer asked for is not a reason to take them somewhere else.
test("a control that survived the redraw is left exactly where it was", () => {
const { doc, root, nodes } = fakeSheet([{ label: "Clear" }, { key: "AS" }]);
doc.activeElement = nodes[0];
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {
nodes[1].isConnected = false;
}, () => { asked = true; }));
assert.equal(doc.activeElement, nodes[0]);
assert.ok(!asked);
});
test("and a dealer who was never in this sheet is not dragged into it", () => {
const { doc, root } = fakeSheet([{ key: "AS" }]);
const elsewhere = { dataset: {}, isConnected: true };
doc.activeElement = elsewhere;
let asked = false;
withDocument(doc, () => Modal.preservingFocus(root, () => {}, () => { asked = true; }));
assert.equal(doc.activeElement, elsewhere);
assert.ok(!asked);
});
// Four sheets redraw themselves on a tap. All four go through this now, and each
// one hands over the piece of itself it is about to throw away.
[["addChips", "render()", "addChipsSheet"],
["cardPicker", "render()", "cardPickerSheet"],
["seatMenu", "render()", "seatMenuOverlay"],
["potWizard", "renderStepInto()", "potWizardModal"]].forEach(([module, method, root]) => {
test(`${module}'s ${method} redraws through it`, () => {
const text = src(module);
const at = text.indexOf(`\n ${method} {`);
assert.ok(at >= 0, `${module} has no ${method}`);
const body = text.slice(at, text.indexOf("\n },", at));
assert.match(body, /Modal\.preservingFocus\(/,
`${module} rebuilds its controls without saying what to do about focus`);
assert.ok(body.includes(`"${root}"`),
`${module} hands preservingFocus something other than ${root}`);
});
});
});
describe("thirteen cards, one Tab stop", () => {
test("the deck is written once, ace down to two", () => {
const deck = CardPicker.deck();
assert.equal(deck.length, 52);
assert.equal(new Set(deck.map(c => c.card)).size, 52);
assert.deepEqual(deck.slice(0, 4).map(c => c.card), ["AS", "AH", "AD", "AC"]);
assert.equal(deck[4].card, "KS");
assert.equal(deck[51].card, "2C");
});
// A dealt card is `aria-disabled` rather than `disabled`, so it holds focus like any
// other cell — which is what lets the arrows tell the truth about the grid, and what
// lets pressing one be answered instead of ignored. See theUnansweredPress.
const grid = keys => keys.map(key => ({
dataset: { focusKey: key.replace("!", "") },
getAttribute: name => (name === "aria-disabled" && key.includes("!") ? "true" : null),
tabIndex: 0,
focus() {},
}));
const withGrid = (cards, body) => {
const nodes = grid(cards);
return withDocument({ querySelectorAll: () => nodes }, () => body(nodes));
};
test("a step is one card, whether or not that card is already out", () => {
// The seven is dealt. Stepping along from the eight lands on it and says so,
// rather than quietly arriving at the six.
withGrid(["8C", "7C!", "6C"], () => {
assert.equal(CardPicker.nextCursor(0, 1), 1);
});
});
// The whole reason the skipping had to go. Hunting for the next card that could take
// focus did the exact thing the no-wrap rule below was written to prevent: it moved
// the dealer sideways without being asked. Right from the ace over a dealt king
// landed on the queen, so the column silently became the one to its right, and the
// next press of Down walked a rank nobody had asked for.
test("and it never slides the dealer into a column they didn't ask for", () => {
withGrid(["AS", "KS!", "QS", "JS", "TS", "9S", "8S", "7S"], () => {
const afterRight = CardPicker.nextCursor(0, 1);
assert.equal(afterRight, 1, "a dealt king is still the cell to the right of the ace");
assert.equal(CardPicker.nextCursor(afterRight, 4), 5,
"and down a row of four from it is the nine, four ranks along");
});
});
// Wrapping would answer "move me down a row" by moving the dealer to the other end
// of the grid, which is not what they asked and not where they would look.
test("the edge of the grid is the edge, not a way round to the other side", () => {
withGrid(["AS", "KS", "QS", "JS"], () => {
assert.equal(CardPicker.nextCursor(3, 1), 3);
assert.equal(CardPicker.nextCursor(0, -1), 0);
});
});
test("only one card is in the Tab order at a time", () => {
withGrid(["AS", "KS", "QS", "JS"], nodes => {
CardPicker.aimCursor(2);
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, 0, -1]);
});
});
// Which happens after every pick: the cursor is sitting on a card that has just
// become a dealt card. It stays there, because that cell is still a cell and its
// neighbours are still where they were a moment ago.
test("a cursor left on a dealt card stays on it", () => {
withGrid(["AS!", "KS", "QS", "JS"], nodes => {
CardPicker.aimCursor(0);
assert.equal(CardPicker.cursor, 0);
assert.deepEqual(nodes.map(n => n.tabIndex), [0, -1, -1, -1]);
});
});
test("and a grid with nothing left in it still has somewhere to put the cursor", () => {
withGrid(["5S!", "4S!", "3S!", "2S!"], nodes => {
CardPicker.aimCursor(3);
assert.equal(CardPicker.cursor, 3);
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, -1, 0]);
});
});
// An index from outside the grid is clamped rather than wrapped, which is what
// Home and End lean on: they ask for one card past either end and take what they
// are given.
test("Home and End ask past the ends and land on them", () => {
withGrid(["AS", "KS", "QS", "JS"], () => {
assert.equal(CardPicker.nextCursor(-1, 1), 0);
assert.equal(CardPicker.nextCursor(4, -1), 3);
});
});
// It used to step by `SUITS.length`, because the grid was four suits wide at every
// size. The grid is thirteen ranks of one suit now and takes as many columns as the
// width holds — five on a 360px phone, seven at 460 — so a constant would be wrong
// on most screens, which is the thing that slid the cursor into the wrong column
// the last time these two disagreed. It asks the layout instead.
test("the arrows step by the row the grid actually has, not by a guess", () => {
const picker = src("cardPicker");
const at = picker.indexOf("onGridKey(event)");
assert.ok(at >= 0, "the grid has no arrow keys");
const body = picker.slice(at, picker.indexOf("\n },", at));
assert.match(body, /const stride = this\.gridStride\(\)/);
assert.match(body, /ArrowRight: 1.*ArrowLeft: -1.*ArrowDown: stride.*ArrowUp: -stride/s);
assert.match(body, /"Home"/);
assert.match(body, /"End"/);
});
test("and the row it actually has is read off the grid", () => {
const picker = src("cardPicker");
const at = picker.indexOf("gridStride() {");
assert.ok(at >= 0, "there is no gridStride");
const body = picker.slice(at, picker.indexOf("\n },", at));
assert.match(body, /getComputedStyle\(grid\)\.gridTemplateColumns/);
// Outside a browser there is nothing to measure, and thirteen ranks in one row
// is the shape that makes Up and Down do nothing rather than something wrong.
assert.match(body, /return this\.RANKS\.length/);
});
test("a fresh sheet starts at the top of the deck", () => {
const picker = src("cardPicker");
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
.forEach(signature => {
const at = picker.indexOf(signature);
assert.ok(at >= 0, `there is no \`${signature}\``);
assert.match(picker.slice(at, picker.indexOf("this.render();", at)), /this\.cursor = 0;/);
});
});
});
describe("seeing where you are", () => {
// Until a summary could be Tabbed to at all, there was no way to notice that
// twenty of them had no ring.
test("a summary gets the tool's own focus ring, not the browser's", () => {
const at = CSS.indexOf("---------- Keyboard focus ----------");
assert.ok(at >= 0);
const block = CSS.slice(at, CSS.indexOf("outline: 2px solid #7fc4ff", at));
assert.match(block, /(^|\n)summary:focus-visible,/);
});
// Only script ever focuses these, so `:focus` would ring the title of every sheet
// on a phone that has no keyboard to ring it for.
test("the landing heading rings for a keyboard and stays quiet for a thumb", () => {
const at = CSS.indexOf('h2[data-modal-focus]:focus-visible {');
assert.ok(at >= 0, "the heading a sheet lands on shows nothing when it takes focus");
const rule = CSS.slice(at, CSS.indexOf("}", at));
assert.match(rule, /outline: 2px solid #7fc4ff/);
// These headings run to the edge of their sheet; an outward ring is clipped.
assert.match(rule, /outline-offset: -2px/);
assert.ok(!/\[data-modal-focus\]:focus\b(?!-)/.test(CSS),
"a plain :focus rule would ring the title on every tap");
});
});