The first screen a dealer ever sees was the one nobody had looked at. Opened
fresh, with nothing saved, the tool showed:
* the word EMPTY, in capitals, ten times across the felt
* Resolve Pot as the accent-green primary — on a table with no players and no
pot, so the loudest control on screen was the one that could do least
* Next Hand, with no button to move
* no indication anywhere of what to do first, except a grey caption under the
felt saying "tap a seat to edit"
An empty state is the one screen a new user is guaranteed to meet, and this one
described itself ten times without once saying what to do.
**The felt stops repeating itself.** An open chair is already dashed instead of
solid, faded, and has no stack under it — that reads at a glance, which is how a
dealer looks at a felt, and it reads the same on the one open chair at a full
table, which is the case the label was actually for. The word is gone from all
ten, and the seat numbers underneath it are now the thing you see. What the word
carried for anyone reading by ear moves to an aria-label on the seat, which now
says more than the sighted version ever did: "Seat 3, empty", "Seat 1, Ann".
**The bar says what the table is.** It used to hide itself when nobody was
seated — silent at exactly the moment a dealer has never used this before. It
now says "Nobody's at the table yet." with one full-width button, Seat a player,
which opens the seat editor on the first free chair. That is the same panel
tapping a seat gives them, so the button teaches the gesture rather than
replacing it.
**Two dead controls stop pretending.** Resolve Pot and Next Hand are disabled
until somebody is at the table, which takes the accent green off the button that
cannot work and leaves the only green on screen on the one that can. Disabled
rather than hidden: hiding them would move the header the moment the first
player is named, and a control that vanishes teaches nothing about what it was
for. Next Hand's own explanatory title is kept and handed back when it
re-enables.
One bug of my own, found by walking the flow rather than by reading it: after
typing a name both header buttons stayed greyed out, still captioned "nobody is
at the table yet". commitSeatChange refreshes narrowly on purpose — a full
render on every keystroke takes the name field out from under the dealer
mid-word — and the comment directly above it already warns about this exact trap
for the action bar. The header needed the same line.
609 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes,
0 failures — the empty table is now one of those sizes, and had never been
audited before. Re-verified with the network cut: the first-run screen, the CTA
and seating a player all work offline.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
136 lines
5.4 KiB
JavaScript
136 lines
5.4 KiB
JavaScript
// What the tool says out loud between taps.
|
|
//
|
|
// The action bar carried `aria-live="polite"` on the element whose entire contents
|
|
// render() rebuilds every action, so a screen reader read the whole bar after every
|
|
// single tap. Measured, after one:
|
|
//
|
|
// "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
|
|
// Fold Call $10 Raise"
|
|
//
|
|
// Nothing in that is wrong. It is just unusable at the speed a hand is dealt, and it
|
|
// is the documented anti-pattern: a live region has to be a stable element whose text
|
|
// changes, not a container that gets replaced.
|
|
//
|
|
// What a dealer running this by ear needs is who is on the clock and what it costs
|
|
// them. These are the sentences.
|
|
const test = require("node:test");
|
|
const { describe } = require("node:test");
|
|
const assert = require("node:assert");
|
|
const { ActionBar, Betting, HoldemState, makeTable } = require("./load");
|
|
|
|
const barOn = (state) => {
|
|
ActionBar.state = state;
|
|
ActionBar.lastAnnouncement = undefined;
|
|
return ActionBar;
|
|
};
|
|
|
|
describe("before the cards are out", () => {
|
|
test("a table that could be dealt says so", () => {
|
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] });
|
|
assert.equal(barOn(state).announcement(), "Ready to deal.");
|
|
});
|
|
|
|
test("and an empty table says what it is, rather than nothing", () => {
|
|
// This used to announce "" — silence being the honest answer when the bar was
|
|
// hidden on a table nobody was sitting at. The bar isn't hidden any more: an
|
|
// empty table is the one state a dealer meets before they know how any of this
|
|
// works, so it says so and offers the next step.
|
|
const state = HoldemState.createDefault();
|
|
assert.equal(barOn(state).announcement(), "Nobody's at the table yet.");
|
|
});
|
|
|
|
test("and a table with one named player has moved on from that", () => {
|
|
const state = HoldemState.createDefault();
|
|
state.seats[0].name = "Ann";
|
|
assert.notEqual(barOn(state).announcement(), "Nobody's at the table yet.");
|
|
});
|
|
});
|
|
|
|
describe("while a hand is running", () => {
|
|
const dealt = () => {
|
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
|
state.seats.forEach((p, i) => { p.name = ["Ann", "Ben", "Cal", "Dee"][i] || ""; });
|
|
Betting.beginHand(state);
|
|
return state;
|
|
};
|
|
|
|
test("names who is on the clock, what it costs, and the pot", () => {
|
|
const state = dealt();
|
|
const said = barOn(state).announcement();
|
|
assert.match(said, /to act/);
|
|
assert.match(said, /\$2 to call/);
|
|
assert.match(said, /Pot \$3\./);
|
|
});
|
|
|
|
test("it is the seat that is actually acting, not the one after it", () => {
|
|
const state = dealt();
|
|
const name = HoldemState.displayName(state.seats[state.actingSeatIndex], state.actingSeatIndex);
|
|
assert.ok(barOn(state).announcement().startsWith(name + " to act"));
|
|
});
|
|
|
|
test("says checked to them rather than $0 to call", () => {
|
|
// A number nobody owes is a number a dealer has to stop and parse.
|
|
const state = dealt();
|
|
state.currentBet = 0;
|
|
assert.match(barOn(state).announcement(), /checked to them/);
|
|
assert.doesNotMatch(barOn(state).announcement(), /\$0 to call/);
|
|
});
|
|
|
|
test("a street with nobody left to act says the street is done", () => {
|
|
const state = dealt();
|
|
state.actingSeatIndex = null;
|
|
state.street = "flop";
|
|
assert.equal(barOn(state).announcement(), "Flop betting is done.");
|
|
});
|
|
|
|
test("and a finished hand points at the pot", () => {
|
|
const state = dealt();
|
|
state.seats.forEach((p, i) => { if (i !== 0) p.folded = true; });
|
|
assert.equal(Betting.isHandOver(state), true);
|
|
assert.equal(barOn(state).announcement(), "Hand over. Resolve the pot.");
|
|
});
|
|
});
|
|
|
|
describe("how often it speaks", () => {
|
|
// A live region that is handed the same sentence twice reads it out twice, and
|
|
// render() runs for a great many reasons that have nothing to do with whose turn
|
|
// it is — a resize, a panel opening, a note being typed.
|
|
const stubTarget = () => {
|
|
const el = { textContent: "" };
|
|
global.document = { getElementById: id => (id === "actionAnnouncer" ? el : null) };
|
|
return el;
|
|
};
|
|
|
|
test("says nothing new when nothing changed", () => {
|
|
const el = stubTarget();
|
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
|
Betting.beginHand(state);
|
|
const bar = barOn(state);
|
|
|
|
bar.announce();
|
|
const first = el.textContent;
|
|
assert.ok(first.length > 0);
|
|
|
|
el.textContent = "CLEARED"; // if announce() writes again, this comes back
|
|
bar.announce();
|
|
assert.equal(el.textContent, "CLEARED", "the same sentence was not written twice");
|
|
delete global.document;
|
|
});
|
|
|
|
test("but does when the action moves", () => {
|
|
const el = stubTarget();
|
|
const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 });
|
|
Betting.beginHand(state);
|
|
const bar = barOn(state);
|
|
|
|
bar.announce();
|
|
const first = el.textContent;
|
|
Betting.call(state, state.actingSeatIndex);
|
|
bar.announce();
|
|
|
|
assert.notEqual(el.textContent, first);
|
|
assert.match(el.textContent, /to act/);
|
|
delete global.document;
|
|
});
|
|
});
|