Stop the empty table shouting, and point at the next step
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
This commit is contained in:
@@ -4067,6 +4067,14 @@ textarea:focus-visible {
|
|||||||
min-width: 7rem;
|
min-width: 7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A prompt with one thing to do gives that thing the whole row. The empty-table
|
||||||
|
prompt is the first control a dealer ever meets here, and a button sitting in the
|
||||||
|
left third of the bar reads as one option among several that haven't loaded yet. */
|
||||||
|
.dealPromptButtons:has(> :only-child) .actionDeal {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* The quieter half of the deal prompt: things the deal is about to do that a dealer
|
/* The quieter half of the deal prompt: things the deal is about to do that a dealer
|
||||||
would otherwise only discover afterwards. */
|
would otherwise only discover afterwards. */
|
||||||
.dealPromptLine.dealPromptAside {
|
.dealPromptLine.dealPromptAside {
|
||||||
|
|||||||
@@ -113,12 +113,19 @@ const ActionBar = {
|
|||||||
if (!host) return;
|
if (!host) return;
|
||||||
host.innerHTML = "";
|
host.innerHTML = "";
|
||||||
|
|
||||||
|
// A table with nobody at it used to hide this bar altogether, which meant the
|
||||||
|
// one moment a dealer has never used the tool before is the one moment it says
|
||||||
|
// nothing at all. What they got instead was a felt printing EMPTY ten times and
|
||||||
|
// a green Resolve Pot button that would resolve nothing.
|
||||||
|
//
|
||||||
|
// An empty table is a state with an obvious next step, so it says what it is.
|
||||||
if (!this.isRunning()) {
|
if (!this.isRunning()) {
|
||||||
host.hidden = true;
|
host.hidden = false;
|
||||||
document.body.classList.remove("hasActionBar");
|
document.body.classList.add("hasActionBar");
|
||||||
document.documentElement.style.setProperty("--actionBarHeight", "0px");
|
host.appendChild(this.renderEmptyTable());
|
||||||
this.stopTankTimer();
|
this.stopTankTimer();
|
||||||
this.tankSeatIndex = null;
|
this.tankSeatIndex = null;
|
||||||
|
this.announce();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
host.hidden = false;
|
host.hidden = false;
|
||||||
@@ -145,6 +152,32 @@ const ActionBar = {
|
|||||||
this.announce();
|
this.announce();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The empty table. One sentence and one button, in the place every other prompt in
|
||||||
|
// this tool appears, so a dealer who has never opened it before is looking at the
|
||||||
|
// next step rather than at ten chairs labelled EMPTY.
|
||||||
|
renderEmptyTable() {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "dealPrompt";
|
||||||
|
|
||||||
|
const line = document.createElement("p");
|
||||||
|
line.className = "dealPromptLine";
|
||||||
|
line.textContent = "Nobody's at the table yet.";
|
||||||
|
wrap.appendChild(line);
|
||||||
|
|
||||||
|
const buttons = document.createElement("div");
|
||||||
|
buttons.className = "dealPromptButtons";
|
||||||
|
|
||||||
|
const seat = document.createElement("button");
|
||||||
|
seat.type = "button";
|
||||||
|
seat.className = "actionBtn actionDeal";
|
||||||
|
seat.textContent = "Seat a player";
|
||||||
|
seat.addEventListener("click", () => this.hooks.seatFirstPlayer());
|
||||||
|
buttons.appendChild(seat);
|
||||||
|
|
||||||
|
wrap.appendChild(buttons);
|
||||||
|
return wrap;
|
||||||
|
},
|
||||||
|
|
||||||
// One sentence, for anyone running this by ear.
|
// One sentence, for anyone running this by ear.
|
||||||
//
|
//
|
||||||
// The bar used to carry aria-live itself, and because render() rebuilds the whole
|
// The bar used to carry aria-live itself, and because render() rebuilds the whole
|
||||||
@@ -172,6 +205,7 @@ const ActionBar = {
|
|||||||
|
|
||||||
announcement() {
|
announcement() {
|
||||||
const state = this.state;
|
const state = this.state;
|
||||||
|
if (!this.isRunning()) return "Nobody's at the table yet.";
|
||||||
if (!state.bettingOpen) {
|
if (!state.bettingOpen) {
|
||||||
const preview = Betting.blindPreview(state);
|
const preview = Betting.blindPreview(state);
|
||||||
return preview ? "Ready to deal." : "";
|
return preview ? "Ready to deal." : "";
|
||||||
|
|||||||
@@ -317,6 +317,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
rerender: hooks.rerender,
|
rerender: hooks.rerender,
|
||||||
resolvePot: openPotWizard,
|
resolvePot: openPotWizard,
|
||||||
|
|
||||||
|
// The first thing a dealer does on a table nobody is sitting at. Opens the
|
||||||
|
// seat editor on the first free chair — the same panel tapping the seat gives
|
||||||
|
// them, so the button teaches the gesture rather than replacing it.
|
||||||
|
seatFirstPlayer: () => {
|
||||||
|
const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat));
|
||||||
|
Render.toggleSeatEditor(index < 0 ? 0 : index);
|
||||||
|
const panel = document.getElementById("seatEditorPanel");
|
||||||
|
if (panel) panel.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
},
|
||||||
|
|
||||||
beginAction: label => {
|
beginAction: label => {
|
||||||
actionHistory.push({ label, snapshot: snapshotState() });
|
actionHistory.push({ label, snapshot: snapshotState() });
|
||||||
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
||||||
|
|||||||
@@ -71,6 +71,32 @@ const Render = {
|
|||||||
const misdeal = document.getElementById("misdealBtn");
|
const misdeal = document.getElementById("misdealBtn");
|
||||||
if (!misdeal) return;
|
if (!misdeal) return;
|
||||||
misdeal.hidden = !this.state.bettingOpen;
|
misdeal.hidden = !this.state.bettingOpen;
|
||||||
|
|
||||||
|
// On a table nobody is sitting at, Next Hand has no button to move and Resolve
|
||||||
|
// Pot has no pot — but Resolve Pot is the accent green, so the loudest control
|
||||||
|
// on a new dealer's first screen was the one that could do the least. It is
|
||||||
|
// now visibly not-yet, which puts the only green left on the felt where the
|
||||||
|
// actual next step is.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
const nothingToRunYet = !this.state.seats.some(seat => HoldemState.seatInPlay(seat));
|
||||||
|
[["resolvePotBtn", "Nothing to resolve — nobody is at the table yet"],
|
||||||
|
["nextHandBtn", "Nothing to move on from — nobody is at the table yet"]]
|
||||||
|
.forEach(([id, why]) => {
|
||||||
|
const button = document.getElementById(id);
|
||||||
|
if (!button) return;
|
||||||
|
// Next Hand explains itself in the markup, and that sentence has to
|
||||||
|
// survive being borrowed for the disabled reason and handed back.
|
||||||
|
if (button.dataset.titleWhenUsable === undefined) {
|
||||||
|
button.dataset.titleWhenUsable = button.getAttribute("title") || "";
|
||||||
|
}
|
||||||
|
button.disabled = nothingToRunYet;
|
||||||
|
const title = nothingToRunYet ? why : button.dataset.titleWhenUsable;
|
||||||
|
if (title) button.setAttribute("title", title);
|
||||||
|
else button.removeAttribute("title");
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Whether this device can actually drag a seat. HTML5 drag events are never
|
// Whether this device can actually drag a seat. HTML5 drag events are never
|
||||||
@@ -548,8 +574,24 @@ const Render = {
|
|||||||
updatePuckContent(node, player, index) {
|
updatePuckContent(node, player, index) {
|
||||||
const empty = this.isEmptySeat(player);
|
const empty = this.isEmptySeat(player);
|
||||||
const nameEl = node.querySelector(".seatPuckName");
|
const nameEl = node.querySelector(".seatPuckName");
|
||||||
nameEl.textContent = empty ? "Empty" : (player.name || "").trim();
|
// An open chair used to say so, in capitals, on every seat that had nobody in
|
||||||
|
// it — which on a table a dealer has just opened is the word EMPTY ten times
|
||||||
|
// across the felt, and it is the first thing they ever see this tool do.
|
||||||
|
//
|
||||||
|
// The puck already says it without a word: dashed instead of solid, faded, no
|
||||||
|
// stack under it. That reads at a glance, which is how a dealer looks at the
|
||||||
|
// felt, and it reads the same on the one open chair at a full table — which is
|
||||||
|
// the case the label was actually for. Repeating a word ten times is not
|
||||||
|
// emphasis, it's noise, and it was drowning the seat numbers underneath it.
|
||||||
|
nameEl.textContent = empty ? "" : (player.name || "").trim();
|
||||||
nameEl.classList.toggle("seatEmptyLabel", empty);
|
nameEl.classList.toggle("seatEmptyLabel", empty);
|
||||||
|
// Dashed-and-faded is a thing you can see, not a thing you can hear, so the
|
||||||
|
// word an open chair no longer prints goes here instead. Anyone reading the
|
||||||
|
// felt by ear gets more than they had: the seat number too, which the sighted
|
||||||
|
// version has always shown in the corner.
|
||||||
|
node.setAttribute("aria-label", empty
|
||||||
|
? `Seat ${index + 1}, empty`
|
||||||
|
: `Seat ${index + 1}, ${(player.name || "").trim() || "unnamed"}`);
|
||||||
|
|
||||||
const cardsEl = node.querySelector(".seatPuckCards");
|
const cardsEl = node.querySelector(".seatPuckCards");
|
||||||
cardsEl.innerHTML = "";
|
cardsEl.innerHTML = "";
|
||||||
@@ -811,6 +853,12 @@ const Render = {
|
|||||||
// Safe to do on every keystroke: the bar is small, it's rebuilt on every action
|
// Safe to do on every keystroke: the bar is small, it's rebuilt on every action
|
||||||
// anyway, and nothing inside it can hold focus while the sidebar has it.
|
// anyway, and nothing inside it can hold focus while the sidebar has it.
|
||||||
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
||||||
|
// And the header answers the same question the bar does — Resolve Pot and Next
|
||||||
|
// Hand are switched off until somebody is at the table, and typing a name is
|
||||||
|
// exactly the keystroke that changes that. Caught by walking the first-run flow:
|
||||||
|
// after naming a player both stayed greyed out, still captioned "nobody is at
|
||||||
|
// the table yet", because this path deliberately doesn't redraw everything.
|
||||||
|
this.renderHeaderActions();
|
||||||
},
|
},
|
||||||
|
|
||||||
wireSeatFields(form, player, index) {
|
wireSeatFields(form, player, index) {
|
||||||
|
|||||||
@@ -30,9 +30,19 @@ describe("before the cards are out", () => {
|
|||||||
assert.equal(barOn(state).announcement(), "Ready to deal.");
|
assert.equal(barOn(state).announcement(), "Ready to deal.");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("and an empty table says nothing at all", () => {
|
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();
|
const state = HoldemState.createDefault();
|
||||||
assert.equal(barOn(state).announcement(), "");
|
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.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user