diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 499b7c8..ae308f9 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -4067,6 +4067,14 @@ textarea:focus-visible { 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 would otherwise only discover afterwards. */ .dealPromptLine.dealPromptAside { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index d635143..0c3a31f 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -113,12 +113,19 @@ const ActionBar = { if (!host) return; 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()) { - host.hidden = true; - document.body.classList.remove("hasActionBar"); - document.documentElement.style.setProperty("--actionBarHeight", "0px"); + host.hidden = false; + document.body.classList.add("hasActionBar"); + host.appendChild(this.renderEmptyTable()); this.stopTankTimer(); this.tankSeatIndex = null; + this.announce(); return; } host.hidden = false; @@ -145,6 +152,32 @@ const ActionBar = { 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. // // The bar used to carry aria-live itself, and because render() rebuilds the whole @@ -172,6 +205,7 @@ const ActionBar = { announcement() { const state = this.state; + if (!this.isRunning()) return "Nobody's at the table yet."; if (!state.bettingOpen) { const preview = Betting.blindPreview(state); return preview ? "Ready to deal." : ""; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index cb60916..8b00304 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -317,6 +317,16 @@ document.addEventListener("DOMContentLoaded", () => { rerender: hooks.rerender, 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 => { actionHistory.push({ label, snapshot: snapshotState() }); if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift(); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 38f561c..d02dc96 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -71,6 +71,32 @@ const Render = { const misdeal = document.getElementById("misdealBtn"); if (!misdeal) return; 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 @@ -548,8 +574,24 @@ const Render = { updatePuckContent(node, player, index) { const empty = this.isEmptySeat(player); 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); + // 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"); cardsEl.innerHTML = ""; @@ -811,6 +853,12 @@ const Render = { // 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. 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) { diff --git a/tests/holdem/announcing.test.js b/tests/holdem/announcing.test.js index 88f7929..eaa196d 100644 --- a/tests/holdem/announcing.test.js +++ b/tests/holdem/announcing.test.js @@ -30,9 +30,19 @@ describe("before the cards are out", () => { 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(); - 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."); }); });