Three findings this round, all measured on the running page.
**1. The tool needed the internet to exist.**
The night already survived anything — it lives in localStorage — but the page
did not. Reload in somebody's back room with one bar of signal and a dealer
holding a phone in one hand and a deck in the other got a browser error page
with nine players waiting. Verified: with no worker, an offline reload is
net::ERR_INTERNET_DISCONNECTED.
There is now a service worker (wwwroot/casino/sw.js) and a manifest, so the
tool installs to a home screen and runs with the network switched off. Measured
end to end: 19 assets cached from one ordinary visit, then with the context
offline the page loads, draws six seats, applies its CSS, and plays a whole
hand — blinds posted, four calls, $800 still on the table.
Three decisions worth writing down:
* The precache list is read off the document rather than kept in the worker.
Every stylesheet and script is already in the DOM; a hand-written file list
stays right until somebody adds a script tag, and then the tool is quietly
offline-broken until a dealer finds out at a table.
* No skipWaiting. A new worker taking over a live page can hand it assets
from a different release than the ones it started with, and this page loads
seventeen scripts that expect to agree with each other.
* Stale-while-revalidate for assets, not cache-first — see finding 3.
Scope is /casino/ rather than /casino/texasholdem/, because a scope has to be a
prefix of the page it controls and "/casino/texasholdem" does not start with
"/casino/texasholdem/". Anything in scope the worker wasn't told about goes
straight to the network.
Program.cs needed .webmanifest added to the MIME map: an unknown extension is
not served with the wrong type, it is not served at all, and a 404 manifest is
exactly the silent failure that stops a browser ever offering "Add to Home
Screen".
**2. A screen reader read out the whole action bar after every tap.**
The bar carried aria-live on the element whose entire contents render() rebuilds
each action. Measured, after one tap:
"PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U
Fold Call $10 Raise"
Nothing in it is wrong; it is 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. There is now a small
role="status" element that exists from page load and says one sentence:
"Dee to act, $10 to call. Pot $15." Guarded on the sentence rather than on the
state, because render() runs for many reasons that don't change whose turn it
is, and a live region handed the same words twice reads them twice.
**3. Every tap rebuilt ninety DOM nodes.**
renderSeats did `innerHTML = ""` and then cloned a template, attached three sets
of listeners and set a dozen attributes per chair — on every render, and
renderAll runs after every action. Profiled on a nine-handed table:
renderSeats 7.3ms -> 4.0ms
renderAll 12.4ms -> 9.5ms
The pucks are built once and updated in place, which is what the rest of that
function was already doing to their contents. A seat's node belongs to its index
for as long as the table is that size, so the listeners still close over the
index; only a change in the number of chairs rebuilds the row. Verified that
listeners don't stack: one click after six renders fires the hook exactly once,
and resizing 9 -> 4 -> 10 renumbers correctly.
Found along the way and deliberately left alone: ViewData["cssVersion"] renders
empty on every page of the site, so the ?v= cache-busting has never worked. It
is inert — Program.cs sends Cache-Control: no-store on all static files — and
the service worker was the only reason to care, which is why it uses
stale-while-revalidate instead. Noted in TexasHoldem.cshtml.
608 tests, 0 failures, including a new announcing.test.js. 52 layout audits
across 13 viewports x 4 table sizes: 0 failures, and the action bar still
doesn't change height mid-hand. Gestures re-verified after the seat refactor:
double-tap fold, long-press menu, give-the-button, and the active dealer chip.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
126 lines
4.8 KiB
JavaScript
126 lines
4.8 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 nothing at all", () => {
|
|
const state = HoldemState.createDefault();
|
|
assert.equal(barOn(state).announcement(), "");
|
|
});
|
|
});
|
|
|
|
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;
|
|
});
|
|
});
|