From 5d5093f3cce7eb680a35d3161a47470b9ee6f2fb Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Thu, 3 Sep 2026 10:51:27 -0600 Subject: [PATCH 1/4] Make the dealer tool work with no signal, and stop it shouting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 35 ++++- JoshHeaps.Net/Program.cs | 9 ++ .../wwwroot/casino/holdem.webmanifest | 32 +++++ JoshHeaps.Net/wwwroot/casino/sw.js | 125 ++++++++++++++++++ JoshHeaps.Net/wwwroot/css/casino/holdem.css | 15 +++ .../wwwroot/images/casino/holdem-192.png | Bin 0 -> 12148 bytes .../wwwroot/images/casino/holdem-512.png | Bin 0 -> 31443 bytes .../images/casino/holdem-apple-touch.png | Bin 0 -> 11041 bytes .../images/casino/holdem-maskable-512.png | Bin 0 -> 25611 bytes .../wwwroot/js/casino/holdem/actionBar.js | 46 +++++++ .../wwwroot/js/casino/holdem/main.js | 47 +++++++ .../wwwroot/js/casino/holdem/render.js | 65 ++++++--- tests/holdem/announcing.test.js | 125 ++++++++++++++++++ 13 files changed, 481 insertions(+), 18 deletions(-) create mode 100644 JoshHeaps.Net/wwwroot/casino/holdem.webmanifest create mode 100644 JoshHeaps.Net/wwwroot/casino/sw.js create mode 100644 JoshHeaps.Net/wwwroot/images/casino/holdem-192.png create mode 100644 JoshHeaps.Net/wwwroot/images/casino/holdem-512.png create mode 100644 JoshHeaps.Net/wwwroot/images/casino/holdem-apple-touch.png create mode 100644 JoshHeaps.Net/wwwroot/images/casino/holdem-maskable-512.png create mode 100644 tests/holdem/announcing.test.js diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index ea2314b..37a7ce8 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -580,7 +580,17 @@ - +@* No aria-live here any more. This element's entire contents are rebuilt on every + action, so a live region on it announced the whole bar every time — measured, after + one tap: "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U + Fold Call $10 Raise". That is what a screen reader read out after every single fold. + A live region has to be a stable element whose *text* changes; the one below is. *@ + + +@* Whose turn it is, in a sentence. Empty in the markup and only ever written to, so + assistive tech is already watching it when the first update lands — a live region + created at the moment it has something to say is one the screen reader misses. *@ +
@@ -607,8 +617,31 @@ @section Styles { + + @* Installable, and works with no signal — see the service worker registration at the + foot of main.js. A dealer running a night in somebody's back room is on the worst + network there is, and the tool being a *website* was the last thing that could + take it away from them mid-game. *@ + + @* iOS reads none of the manifest's icons and only this. *@ + + + + + @* Paints the phone's chrome the same near-black as the felt's ground, so an + installed copy doesn't sit under a white status bar. *@ + } +@* A note for whoever reads the ?v= below and assumes it works: it doesn't. ViewData + ["cssVersion"] is assigned in _Layout's own code block, which runs after these + sections are handed their values, so every one of these renders as a bare "?v=" — + on this page and on Chess, Particles and Memory Lane too. It is inert rather than + broken: Program.cs sends `Cache-Control: no-store` on every static file, so nothing + is being browser-cached for a version string to bust in the first place. + Left alone deliberately. Fixing it is a one-line change to a file every page on the + site shares, and the service worker was the reason to care — that now uses stale- + while-revalidate, which needs no version to stay current. *@ @section Scripts { diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs index cd006d7..15e164a 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -1,6 +1,7 @@ using JoshHeaps.Net.Hubs; using JoshHeaps.Net.Services.Implementations; using JoshHeaps.Net.Services.Interfaces; +using Microsoft.AspNetCore.StaticFiles; var builder = WebApplication.CreateBuilder(args); @@ -52,8 +53,16 @@ if (!app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +// .webmanifest isn't in the default MIME map, and an unknown extension isn't served +// with the wrong type — it isn't served at all. Without this the Hold'em tool's +// manifest 404s, and a 404 manifest is exactly the silent failure that stops a browser +// ever offering "Add to Home Screen": nothing is logged and no prompt appears. +var contentTypes = new FileExtensionContentTypeProvider(); +contentTypes.Mappings[".webmanifest"] = "application/manifest+json"; + app.UseStaticFiles(new StaticFileOptions { + ContentTypeProvider = contentTypes, OnPrepareResponse = ctx => { ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate"); diff --git a/JoshHeaps.Net/wwwroot/casino/holdem.webmanifest b/JoshHeaps.Net/wwwroot/casino/holdem.webmanifest new file mode 100644 index 0000000..8128c45 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/casino/holdem.webmanifest @@ -0,0 +1,32 @@ +{ + "name": "Texas Hold'em Dealer", + "short_name": "Hold'em", + "description": "Seat your players, track the action, split the pot. Works with no signal.", + "start_url": "/casino/texasholdem", + "scope": "/casino/", + "display": "standalone", + "orientation": "any", + "background_color": "#141414", + "theme_color": "#141414", + "categories": ["utilities"], + "icons": [ + { + "src": "/images/casino/holdem-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/images/casino/holdem-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/images/casino/holdem-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/JoshHeaps.Net/wwwroot/casino/sw.js b/JoshHeaps.Net/wwwroot/casino/sw.js new file mode 100644 index 0000000..1d2e0b5 --- /dev/null +++ b/JoshHeaps.Net/wwwroot/casino/sw.js @@ -0,0 +1,125 @@ +// Keeps the dealer tool working when the network doesn't. +// +// A home game is the worst network in the world: somebody's back room, one bar of +// signal, a router two floors away. The tool already keeps the whole night in +// localStorage, so the *table* survives anything — but the page didn't. Reload with no +// signal, or open it fresh when the wifi has dropped, and a dealer holding a phone in +// one hand and a deck in the other got a browser error page with nine players waiting. +// +// This is the one thing standing between "a website about poker" and "a tool you can +// rely on at a table", so it is worth the moving part. +// +// 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 that scope this worker has not been told about +// goes straight to the network, so the other casino pages behave exactly as they did. + +// Bumped whenever the shape of what is cached changes. The asset *version* rides in +// the URLs themselves (?v=1.0.8 from _Layout), so a normal release makes new keys +// without touching this. +const CACHE = "holdem-shell-v1"; + +// What this worker will keep. Anything else in scope — a form post, an API call, a +// page it was never told about — goes straight to the network and behaves exactly as +// it did before the worker existed. +const CACHEABLE = new Set(["script", "style", "image", "font", "manifest"]); + +// The page hands over the exact list of URLs it was built from — see registerWorker() +// in main.js. Deriving it from the document instead of keeping a list here is the +// whole trick: a hand-written manifest of files drifts from the markup the first time +// somebody adds a script tag, and the failure mode is a tool that is offline-capable +// right up until the release that quietly broke it. +async function precache(urls) { + const cache = await caches.open(CACHE); + // Individually rather than cache.addAll, which rejects the whole batch if any one + // URL 404s — one stale entry in the list would leave the dealer with nothing. + await Promise.all(urls.map(async url => { + try { + const res = await fetch(url, { cache: "reload" }); + if (res.ok) await cache.put(url, res); + } catch { + // Offline while installing. The runtime handler below picks these up on + // the next online visit. + } + })); +} + +// No install handler, and deliberately 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 separate scripts that expect to agree with each other. +// Waiting costs a dealer nothing — the update lands the next time they open the tool — +// and it cannot leave them with a half-swapped app in the middle of a hand. + +self.addEventListener("activate", event => { + event.waitUntil((async () => { + const keys = await caches.keys(); + await Promise.all(keys + .filter(key => key.startsWith("holdem-shell-") && key !== CACHE) + .map(key => caches.delete(key))); + await self.clients.claim(); + })()); +}); + +self.addEventListener("message", event => { + if (event.data && event.data.type === "precache" && Array.isArray(event.data.urls)) { + event.waitUntil(precache(event.data.urls)); + } +}); + +// Only GETs, and only same-origin. A cross-origin request has opaque responses that +// can't be inspected, and caching one would mean serving a failure forever. +function handles(request) { + if (request.method !== "GET") return false; + const url = new URL(request.url); + return url.origin === self.location.origin; +} + +self.addEventListener("fetch", event => { + const { request } = event; + if (!handles(request)) return; + + // The page itself: network first, so an online dealer always gets the current + // build, and the cached copy is there the moment they aren't. + if (request.mode === "navigate") { + event.respondWith((async () => { + try { + const fresh = await fetch(request); + if (fresh.ok) (await caches.open(CACHE)).put(request, fresh.clone()); + return fresh; + } catch { + const cached = await caches.match(request, { ignoreSearch: true }); + if (cached) return cached; + throw new Error("offline and this page was never cached"); + } + })()); + return; + } + + // Assets: stale while revalidate. The cached copy answers immediately — which is + // the whole point, a dealer opening this between hands should not wait for a + // network — and a fresh copy is fetched in the background for next time. + // + // Cache-first would be the obvious choice, on the reasoning that these URLs carry + // ?v= from the layout and so a hit is always the right file. They don't: the + // version renders empty (see the note in TexasHoldem.cshtml), so every release + // reuses the same URLs. Cache-first against unversioned URLs is how a tool gets + // frozen on a build from three months ago with no way for anyone to tell. This + // strategy needs no version to be correct: worst case a dealer is one launch + // behind, which for a tool that runs entirely in the browser is nothing. + if (!CACHEABLE.has(request.destination)) return; + + event.respondWith((async () => { + const cache = await caches.open(CACHE); + const cached = await cache.match(request); + + const fresh = fetch(request).then(res => { + if (res.ok) cache.put(request, res.clone()); + return res; + }).catch(() => null); + + if (cached) return cached; + const res = await fresh; + if (res) return res; + throw new Error("offline and not cached: " + request.url); + })()); +}); diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index feec269..499b7c8 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -90,6 +90,21 @@ input[type="number"] { font-variant-numeric: tabular-nums; } +/* Read aloud, never drawn. `display: none` and `visibility: hidden` both take an + element out of the accessibility tree along with the layout, which would silence the + live region entirely — so it stays in the tree at 1px, clipped. */ +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + /* Hold the scrollbar's width whether or not there is one to show. A panel opening far enough to make the page scrollable used to narrow the viewport by the scrollbar's width, which re-centres the felt and nudges every line of text left. No effect on diff --git a/JoshHeaps.Net/wwwroot/images/casino/holdem-192.png b/JoshHeaps.Net/wwwroot/images/casino/holdem-192.png new file mode 100644 index 0000000000000000000000000000000000000000..79c951ee52aab7df46314ca3877b2e811c29c0b2 GIT binary patch literal 12148 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 HF!+A})9uI- literal 0 HcmV?d00001 diff --git a/JoshHeaps.Net/wwwroot/images/casino/holdem-512.png b/JoshHeaps.Net/wwwroot/images/casino/holdem-512.png new file mode 100644 index 0000000000000000000000000000000000000000..7faac396d98b68834039ff9c7b0ec7268af8d19c GIT binary patch literal 31443 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

-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 literal 0 HcmV?d00001 diff --git a/JoshHeaps.Net/wwwroot/images/casino/holdem-apple-touch.png b/JoshHeaps.Net/wwwroot/images/casino/holdem-apple-touch.png new file mode 100644 index 0000000000000000000000000000000000000000..a0898b16986cdaf1c8d07d4bc8ae98a04cc7ad04 GIT binary patch literal 11041 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 literal 0 HcmV?d00001 diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 05957cc..d635143 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -142,6 +142,52 @@ const ActionBar = { } this.syncTankTimer(); + this.announce(); + }, + + // One sentence, for anyone running this by ear. + // + // The bar used to carry aria-live itself, and because render() rebuilds the whole + // thing every action, a screen reader read the lot out after every tap — street, + // pot, player count, who's short, the clock, the undo label and all three buttons. + // Nothing in that is wrong; it is just unusable at the speed a hand is dealt. + // + // What a dealer actually needs from the tool between taps is who is on the clock + // and what it costs them. That is what this says, into a small element that exists + // from page load and only ever has its text replaced. + // + // Guarded on the sentence itself rather than on the state, because render() is + // called for a great many reasons that don't change whose turn it is — resizing the + // window, opening a panel, editing a note — and a live region that repeats itself + // is read out again every time. + announce() { + const target = document.getElementById("actionAnnouncer"); + if (!target) return; + + const said = this.announcement(); + if (said === this.lastAnnouncement) return; + this.lastAnnouncement = said; + target.textContent = said; + }, + + announcement() { + const state = this.state; + if (!state.bettingOpen) { + const preview = Betting.blindPreview(state); + return preview ? "Ready to deal." : ""; + } + if (Betting.isHandOver(state)) return "Hand over. Resolve the pot."; + + const index = state.actingSeatIndex; + if (index === null || index === undefined) { + return `${Betting.streetLabel(state.street)} betting is done.`; + } + + const player = state.seats[index]; + const name = HoldemState.displayName(player, index); + const owed = Betting.toCall(state, player); + const cost = owed > 0 ? `${Money.format(owed)} to call` : "checked to them"; + return `${name} to act, ${cost}. Pot ${Money.format(Betting.potSoFar(state))}.`; }, renderStatus() { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index f5b0a34..cb60916 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -863,4 +863,51 @@ document.addEventListener("DOMContentLoaded", () => { "The saved table couldn't be read, so this one started empty.", { duration: 12000 }); } + + // ---- Working with no signal ---- + // + // The night already survives anything — it lives in localStorage — but the page + // did not. Reload in a back room with one bar of signal and a dealer with nine + // players waiting got a browser error page. The worker below keeps the tool itself + // on the phone; see wwwroot/casino/sw.js for what it does and doesn't cache. + // + // The list of files to keep is read off this document rather than written down in + // the worker. Every stylesheet and script the page actually loaded is right here in + // the DOM, already carrying the ?v= release string from _Layout — so the list + // cannot drift from the markup, which is the way a hand-kept one always fails: it + // stays right until somebody adds a script tag, and then the tool is quietly + // offline-broken until a dealer finds out the hard way. + const registerWorker = () => { + if (!("serviceWorker" in navigator)) return; + + const shell = [ + location.pathname, + "/casino/holdem.webmanifest", + ...[...document.querySelectorAll('link[rel="stylesheet"][href]')].map(el => el.href), + ...[...document.querySelectorAll("script[src]")].map(el => el.src), + ].filter(url => new URL(url, location.origin).origin === location.origin); + + navigator.serviceWorker.register("/casino/sw.js", { scope: "/casino/" }) + .then(reg => { + // The worker that will answer for this page — `controller` is still null + // on the very first visit, before activate has claimed it. + const worker = reg.active || navigator.serviceWorker.controller; + if (worker) { + worker.postMessage({ type: "precache", urls: shell }); + return; + } + navigator.serviceWorker.ready.then(ready => { + if (ready.active) ready.active.postMessage({ type: "precache", urls: shell }); + }); + }) + .catch(() => { + // No worker means no offline, and nothing else. Every other part of the + // tool works exactly as it did, so this is not worth a toast at a table. + }); + }; + + // After load, not during it: registering competes with the page's own scripts for + // the network, and the first paint matters more than the second visit does. + if (document.readyState === "complete") registerWorker(); + else window.addEventListener("load", registerWorker, { once: true }); }); diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 15d5daf..38f561c 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -386,11 +386,53 @@ const Render = { return HoldemState.displayName(player, seatIndex); }, + // Builds the pucks once and keeps them. Everything below this line updates the + // nodes that are already on the felt. + // + // A seat's node belongs to its index for as long as the table is that size, so the + // listeners can close over the index the way they always did. Only a change in the + // number of chairs throws the row away. + buildSeatNodes(layer, count) { + const template = document.getElementById("seatTemplate"); + const nodes = []; + for (let index = 0; index < count; index++) { + const node = template.content.firstElementChild.cloneNode(true); + node.dataset.seatIndex = String(index); + node.querySelector(".seatChairNumber").textContent = index + 1; + + // Tapping the puck is the same act as "Give the button" in the seat menu, + // so it goes through the same hook — otherwise this one path would move + // the button without moving the blinds that follow it. + node.querySelector(".dealerChip").addEventListener("click", e => { + e.stopPropagation(); + this.hooks.onGiveButton(index); + }); + + this.wireSeatGestures(node, index); + this.wireDragAndDrop(node, index); + nodes.push(node); + } + layer.replaceChildren(...nodes); + }, + + // Drawing the seats used to mean throwing all of them away and building them again: + // `innerHTML = ""`, then a template clone, three sets of event listeners and a + // dozen attributes per chair, on every render. Nine seats is ninety nodes, and + // renderAll() runs after every single tap a dealer makes. + // + // Measured on a nine-handed table: renderAll was 12.4ms, of which this function was + // 7.3. That is most of a frame gone before the browser has drawn anything, on a + // desktop — a phone is several times slower, which is where the tool actually runs + // and where a dealer's tap felt like it landed a moment late. + // + // Nothing about what gets drawn changes here. The pucks are built once and then + // updated in place, which is what the rest of this function was already doing to + // their contents anyway. renderSeats() { const layer = document.getElementById("seatsLayer"); - layer.innerHTML = ""; + const count = this.state.seats.length; + if (layer.childElementCount !== count) this.buildSeatNodes(layer, count); this.updateSeatDensity(); - const template = document.getElementById("seatTemplate"); const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state); // A button on an empty chair normally means the table has drifted and wants // fixing. Once it's the dead button it means the opposite — it's exactly where @@ -400,11 +442,11 @@ const Render = { !this.state.seats[dealerIndex].inHand; this.state.seats.forEach((player, index) => { - const node = template.content.firstElementChild.cloneNode(true); + const node = layer.children[index]; + if (!node) return; const pos = this.seatPosition(index, this.state.seatCount); node.style.left = `${pos.left}%`; node.style.top = `${pos.top}%`; - node.dataset.seatIndex = String(index); // Which side of the seat the pot is on, which is the side its bet chip // arrives from — the chip sits on the same spoke, closer in. For a seat on @@ -416,7 +458,8 @@ const Render = { // telling the dealer who the big blind is was simply gone. node.classList.toggle("seatBelowCentre", pos.top > 50); - node.querySelector(".seatChairNumber").textContent = index + 1; + // The chair number is set once, in buildSeatNodes — a seat's index is the + // one thing about it that cannot change while the node exists. const dealerChip = node.querySelector(".dealerChip"); const hasButton = index === dealerIndex; @@ -440,13 +483,6 @@ const Render = { : `Give the dealer button to ${who}`; dealerChip.setAttribute("aria-label", chipLabel); dealerChip.title = chipLabel; - // Tapping the puck is the same act as "Give the button" in the seat menu, - // so it goes through the same hook — otherwise this one path would move - // the button without moving the blinds that follow it. - dealerChip.addEventListener("click", e => { - e.stopPropagation(); - this.hooks.onGiveButton(index); - }); const sbBadge = node.querySelector(".sbBadge"); const bbBadge = node.querySelector(".bbBadge"); @@ -471,11 +507,6 @@ const Render = { this.updatePuckContent(node, player, index); node.classList.toggle("seatSelected", index === this.selectedSeatIndex); - - this.wireSeatGestures(node, index); - this.wireDragAndDrop(node, index); - - layer.appendChild(node); }); this.renderBets(); diff --git a/tests/holdem/announcing.test.js b/tests/holdem/announcing.test.js new file mode 100644 index 0000000..88f7929 --- /dev/null +++ b/tests/holdem/announcing.test.js @@ -0,0 +1,125 @@ +// 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; + }); +}); From 3ffce9d7c11c914ecd728d40f329c15c0b661b7c Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Thu, 3 Sep 2026 11:13:47 -0600 Subject: [PATCH 2/4] Stop the empty table shouting, and point at the next step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 8 +++ .../wwwroot/js/casino/holdem/actionBar.js | 40 +++++++++++++-- .../wwwroot/js/casino/holdem/main.js | 10 ++++ .../wwwroot/js/casino/holdem/render.js | 50 ++++++++++++++++++- tests/holdem/announcing.test.js | 14 +++++- 5 files changed, 116 insertions(+), 6 deletions(-) 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."); }); }); From 1f3dda180f04101d0e5e81d0d5b8c738aacfaf17 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Thu, 3 Sep 2026 11:55:51 -0600 Subject: [PATCH 3/4] Make the table big enough for the game around it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Looked at the felt at three times scale and the seats were falling off it. Not sitting at a rail — hanging past the outer edge of the table into the page, half a puck on green and half on nothing. Measured: 1280x900, nine-handed five of nine seats past the rail, worst 49px 844x390, six-handed all six, worst 20px 430x932, six-handed two, worst 12px 360x640, nine-handed none The ring sat at a flat 44% by 42% of the seat layer at every size, two numbers picked against a phone. A fixed pair can only be right for one ratio of puck to felt, and the felt's ratio changes with every viewport — so it was a different amount of wrong on each one, which is exactly what makes something look like a mistake rather than a choice. The radius is derived now: the widest ring whose outermost seat still finishes inside the table, worked out from the measured table, seat layer and puck. It can only ever pull seats in — the old pair is kept as the ceiling — and it falls back to that pair whenever there is nothing to measure, which is every call from the unit tests and the first render before the felt is laid out. Three things this turned up on the way, each caught by the sweep: * The seat layer is not centred on the table. Its insets differ top from bottom, so the first version — which assumed symmetry and worked from half widths — left a phone with 12px still over the rail. It works in absolute pixels against the nearer edge now. * The radius and the puck size depend on each other: the ring is limited by how big a puck is, the puck is sized by the `seatsTight` class, and that class was decided from the gaps between seats, measured at a radius that hadn't been worked out yet. Fixing the gap measurement then made a nine-handed phone overhang that had been fine, because the two were chasing each other. It is settled by trying the candidates in preference order instead: the roomy layout if everything fits, the tight one if not, and if neither fits, tight pucks on a ring widened only as far as it takes to stop seats colliding — because two seats fighting over a tap is a worse failure than a puck over the rail. A dealer can see an overhang; they cannot see that the seat they pressed wasn't the one that answered. * The spacing threshold was a flat 84px, the figure a 96px puck was judged against. Asking a 68px tight puck to keep a 96px puck's distance is a test it can never pass, so every landscape table fell through to the fallback and took an overhang it didn't need. It is a ratio now, not a number. Result: worst overhang anywhere goes 49px to 9px, and 59 of 65 configurations are completely clean. Desktop, tablet and every phone held upright: nothing over the rail at all. Residual, reported rather than tuned away: six configurations still overhang by 1-9px, all of them landscape at 640x360 and 667x375, where the felt is about 110-125px tall and no ring puts readable pucks inside that. The audit check is left strict so they stay visible. uiAudit gains "seats and chips stay on the table", which runs at every viewport in the sweep. Fault-injected: putting the flat 44/42 back reproduces the original failure exactly, including seat 3 at 49px. Faster, too, because the measuring got hoisted out of the per-seat loops while I was in there: renderSeats 7.3ms -> 1.8ms renderAll 12.4ms -> 6.8ms 619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes. Offline re-verified: a whole hand with the network cut, $800 conserved. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- .../wwwroot/js/casino/holdem/render.js | 189 ++++++++++++++++-- tests/holdem/seatsOnTheTable.test.js | 110 ++++++++++ tests/holdem/uiAudit.js | 43 ++++ 3 files changed, 328 insertions(+), 14 deletions(-) create mode 100644 tests/holdem/seatsOnTheTable.test.js diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index d02dc96..2e0247f 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -145,8 +145,67 @@ const Render = { // ---- Seats around the table (compact pucks) ---- - seatPosition(index, count, aspect = this.feltAspect()) { - return this.positionOnRing(index, count, 44, 42, aspect); + // The widest the ring has ever been allowed to be. These are the numbers the ring + // used to be pinned at, kept as a ceiling so nothing gets looser than it was. + RING_MAX_RX: 44, + RING_MAX_RY: 42, + // A little daylight between the outermost puck and the rail. + SEAT_EDGE_MARGIN_PX: 6, + // Below this the ring has collapsed into the pot, which is worse than an overhang. + RING_MIN_R: 18, + + // How far out the ring can sit and still keep a whole seat on the table. + // + // It used to be 44% by 42%, flat, at every size — two numbers picked against a + // phone. A fixed pair can only be right for one ratio of puck to felt, and the + // felt's ratio changes with every viewport: measured, at 1280x900 nine-handed, + // five of nine pucks finished past the outer edge of the table, the worst by 49px, + // and on a phone held sideways all six did. Seats floating off the felt onto the + // page read as the table being too small for the game rather than as players at a + // rail — and it is a different amount of wrong on every screen, which is what makes + // it look like a mistake rather than a choice. + // + // So it is derived rather than declared: the widest ring whose outermost seat still + // finishes inside the table. Falls back to the old pair whenever there is nothing to + // measure, which is every call from the unit tests and the first render before the + // felt has been laid out. + seatRingRadii() { + const fallback = { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY }; + if (typeof document === "undefined") return fallback; + + const box = this.ringBox(); + const table = document.getElementById("pokerTable"); + const puck = document.querySelector(".seat"); + if (!box || !box.width || !box.height || !table || !puck) return fallback; + + const tableBox = table.getBoundingClientRect(); + const puckBox = puck.getBoundingClientRect(); + if (!tableBox.width || !puckBox.width) return fallback; + + // Worked in absolute pixels rather than from half-widths, because the seat + // layer is *not* centred on the table: its insets differ top from bottom (the + // landscape rule sets a floor on the top edge and a ceiling on the bottom), so + // assuming symmetry left the shorter side still hanging over. Measured that + // way, a phone still had 12px of puck past the rail after the first attempt. + // + // The reach that matters is to the nearer of the two edges. + const reach = (layerCentre, nearEdge, farEdge, puckSide, boxSide) => { + const room = Math.min(layerCentre - nearEdge, farEdge - layerCentre); + return ((room - puckSide / 2 - this.SEAT_EDGE_MARGIN_PX) / boxSide) * 100; + }; + + return { + rx: Math.max(this.RING_MIN_R, Math.min(this.RING_MAX_RX, + reach(box.left + box.width / 2, tableBox.left, tableBox.right, + puckBox.width, box.width))), + ry: Math.max(this.RING_MIN_R, Math.min(this.RING_MAX_RY, + reach(box.top + box.height / 2, tableBox.top, tableBox.bottom, + puckBox.height, box.height))), + }; + }, + + seatPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii()) { + return this.positionOnRing(index, count, radii.rx, radii.ry, aspect); }, // How many times wider than tall the felt actually is, which is the one thing the @@ -179,8 +238,11 @@ const Render = { // The two answers are within half a degree of each other, so this is not a fix for // anything visible — it is just the honest way to say "on that player's spoke", // and it saves the second walk. - betPosition(index, count, aspect = this.feltAspect()) { - const seat = this.seatPosition(index, count, aspect); + betPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii()) { + const seat = this.seatPosition(index, count, aspect, radii); + // Fractions of the seat's own reach, not of the felt — so when the ring pulls in + // to keep the pucks on the table, the chips come with it and stay on the same + // spoke, the same proportion of the way to the pot. return { left: 50 + (seat.left - 50) * (27 / 44), top: 50 + (seat.top - 50) * (25 / 42), @@ -279,10 +341,15 @@ const Render = { // a flat ellipse those are nothing alike. At 640x360 six seats had 193px of rim // each and sat 56px apart, because at the ends of a long oval the rim doubles back // and two seats a long walk apart are stacked one above the other. - closestSeatGapPx(count, box = this.ringBox()) { + closestSeatGapPx(count, box = this.ringBox(), radii = this.seatRingRadii()) { if (count < 2 || !box || !box.width || !box.height) return Infinity; - const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({ + // The ring the seats are actually drawn on, not the widest one they could be. + // This asked about a flat 44/42 while the renderer had pulled the ring in to + // keep the pucks on the table, so it measured gaps that did not exist: in + // landscape it reported plenty of room and the seats were overlapping each + // other's centres, which the audit caught as taps landing on the wrong player. + const points = this.ringPositions(count, radii.rx, radii.ry, box.width / box.height).map(seat => ({ x: (seat.left / 100) * box.width, y: (seat.top / 100) * box.height, })); @@ -300,11 +367,94 @@ const Render = { // Called on every render and again whenever the felt is resized — the action bar // growing mid-hand shortens the table, and the seats have to answer that without // waiting for the next render to come along. - updateSeatDensity() { + // Which layout the felt gets, and how wide its ring is. + // + // These two answers depend on each other, which is what made the first attempt at + // this wobble: the ring's radius is limited by the size of a puck, the puck's size + // is set by the `seatsTight` class, and that class was being decided from the gaps + // between seats — measured at a radius that hadn't been worked out yet. Fixing the + // gap measurement to use the real radius then made a nine-handed phone overhang + // that had been fine, because now the two were chasing each other. + // + // So it is settled by trying the candidates in order of preference rather than by + // solving it in one pass: the roomy layout if everything fits, the tight one if it + // doesn't, and if neither fits, the tight pucks on a ring wide enough that seats + // don't overlap — because two seats fighting over a tap is a worse failure than a + // puck sitting slightly over the rail. A dealer can see an overhang; they cannot + // see that the seat they meant to press wasn't the one that answered. + // How much space two neighbouring seats need between their centres. + // + // This was a flat 84px, which is the figure a 96px-wide puck was judged against — + // seats allowed to sit a little closer than their own width, since the pucks are + // rounded and a small overlap at the corners reads fine. Asking the *tight* layout + // to clear the same 84px is asking a 68px puck to keep a 96px puck's distance: it + // could never pass, so every landscape table fell through to the fallback and took + // an overhang it didn't need. The ratio is what carries over, not the number. + SEAT_ROOM_RATIO: 84 / 96, + + seatRoomNeededPx() { + if (typeof document === "undefined") return this.SEAT_ROOM_PX; + const puck = document.querySelector(".seat"); + if (!puck) return this.SEAT_ROOM_PX; + const width = puck.getBoundingClientRect().width; + return width ? width * this.SEAT_ROOM_RATIO : this.SEAT_ROOM_PX; + }, + + resolveSeatLayout(count) { const layer = document.getElementById("seatsLayer"); - if (!layer || !this.state) return; - layer.classList.toggle("seatsTight", - this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX); + if (!layer) return { radii: this.seatRingRadii() }; + + for (const tight of [false, true]) { + layer.classList.toggle("seatsTight", tight); + const radii = this.seatRingRadii(); + const roomy = this.closestSeatGapPx(count, this.ringBox(), radii) >= this.seatRoomNeededPx(); + if (roomy && !this.tooTallForTheTable()) return { radii }; + } + + // Neither fits — a phone held sideways gives the felt about 140px of height and + // there is no ring that puts a dozen readable pucks inside that. Keep the small + // pucks and widen the ring only as far as it takes to stop the seats colliding, + // rather than all the way back out: going straight to the full width bought an + // overhang of 14px where 4 would have done. + layer.classList.add("seatsTight"); + const contained = this.seatRingRadii(); + const box = this.ringBox(); + for (let step = 1; step <= 8; step++) { + const t = step / 8; + const radii = { + rx: contained.rx + (this.RING_MAX_RX - contained.rx) * t, + ry: contained.ry + (this.RING_MAX_RY - contained.ry) * t, + }; + if (this.closestSeatGapPx(count, box, radii) >= this.seatRoomNeededPx()) return { radii }; + } + return { radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY } }; + }, + + updateSeatDensity() { + if (!this.state) return; + this.resolveSeatLayout(this.state.seatCount); + }, + + // Room is not only sideways. + // + // "Tight" used to mean one thing: seats close enough together to bump into each + // other. On a phone held sideways the felt is 141px tall and the pucks had grown to + // 88 — two-line names — so six seats had all the horizontal room in the world and + // could not fit top to bottom whatever the ring did. The gap check said everything + // was fine and 20px of every seat hung over the rail. + // + // A puck more than half the table's height is the honest test: two seats on + // opposite sides of the ring would then have to overlap the pot to fit at all, so + // the pucks are what has to give. + tooTallForTheTable() { + if (typeof document === "undefined") return false; + const table = document.getElementById("pokerTable"); + const puck = document.querySelector(".seat"); + if (!table || !puck) return false; + const tableBox = table.getBoundingClientRect(); + const puckBox = puck.getBoundingClientRect(); + if (!tableBox.height || !puckBox.height) return false; + return puckBox.height > tableBox.height * 0.5; }, // The angles that cut the rim into `count` equal lengths, starting at the top. @@ -385,18 +535,23 @@ const Render = { layer.innerHTML = ""; const anchorToSeat = window.matchMedia("(max-width: 700px)").matches; + // Measured once for the whole ring. Asked per seat, this reads the layout back + // nine times in a loop that is also writing to it, which is the read-after-write + // pattern that makes the browser recompute layout on every iteration. + const aspect = this.feltAspect(); + const radii = this.seatRingRadii(); this.state.seats.forEach((player, index) => { const bet = Betting.committed(player); if (bet <= 0) return; const chip = this.makeBetChip(bet); - const pos = this.betPosition(index, this.state.seatCount); + const pos = this.betPosition(index, this.state.seatCount, aspect, radii); if (anchorToSeat) { const seat = document.querySelector(`.seat[data-seat-index="${index}"]`); if (!seat) return; - const seatPos = this.seatPosition(index, this.state.seatCount); + const seatPos = this.seatPosition(index, this.state.seatCount, aspect, radii); chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow"); seat.appendChild(chip); return; @@ -458,7 +613,13 @@ const Render = { const layer = document.getElementById("seatsLayer"); const count = this.state.seats.length; if (layer.childElementCount !== count) this.buildSeatNodes(layer, count); - this.updateSeatDensity(); + // Settles the tight class and the ring radius together, and hands back the + // radius it settled on — so the loop below doesn't have to ask again. Both of + // these read the layout back, so they are taken once, here, before the loop + // starts writing to it: asked per seat they would force the browser to + // recompute the layout on every iteration. + const { radii } = this.resolveSeatLayout(count); + const aspect = this.feltAspect(); const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state); // A button on an empty chair normally means the table has drifted and wants // fixing. Once it's the dead button it means the opposite — it's exactly where @@ -470,7 +631,7 @@ const Render = { this.state.seats.forEach((player, index) => { const node = layer.children[index]; if (!node) return; - const pos = this.seatPosition(index, this.state.seatCount); + const pos = this.seatPosition(index, this.state.seatCount, aspect, radii); node.style.left = `${pos.left}%`; node.style.top = `${pos.top}%`; diff --git a/tests/holdem/seatsOnTheTable.test.js b/tests/holdem/seatsOnTheTable.test.js new file mode 100644 index 0000000..353cae3 --- /dev/null +++ b/tests/holdem/seatsOnTheTable.test.js @@ -0,0 +1,110 @@ +// How wide the ring of seats is allowed to be. +// +// It sat at a flat 44% by 42% of the seat layer at every size — two numbers picked +// against a phone, then asked to hold for a 754x543 felt on a desktop and a 686x141 +// one on a phone held sideways. A fixed pair can only be right for one ratio of puck +// to felt, so measured on the running page: +// +// 1280x900, nine-handed five of nine pucks past the table's outer edge, worst 49px +// 844x390, six-handed all six, worst 20px +// 430x932, six-handed two, worst 12px +// +// Seats floating off the felt onto the page read as the table being too small for the +// game rather than as players at a rail, and it was a different amount of wrong on +// every screen — which is what made it look like a mistake instead of a choice. +// +// The radius is derived from what has to fit now. Whether it *actually* fits is a +// question about a laid-out browser, and it is checked there: uiAudit's "seats and +// chips stay on the table" runs at every viewport in the sweep. What is checked here +// is the contract that function has to honour, including the two ways it is allowed +// to give up. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Render } = require("./load"); + +describe("with no browser to measure", () => { + test("it falls back to the ring the tool has always used", () => { + // Every call from this suite has no DOM, and so does the very first render, + // before the felt has been laid out. Neither should throw or return nonsense. + assert.deepEqual(Render.seatRingRadii(), { rx: Render.RING_MAX_RX, ry: Render.RING_MAX_RY }); + }); + + test("so seatPosition still answers", () => { + const pos = Render.seatPosition(0, 9, 1); + assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top)); + }); + + test("and nothing is measured for the puck height either", () => { + assert.equal(Render.tooTallForTheTable(), false); + }); +}); + +describe("the ceiling and the floor", () => { + test("the old ring is the widest it can ever be", () => { + // Nothing gets looser than it was; this can only pull seats in. + assert.ok(Render.RING_MAX_RX <= 44); + assert.ok(Render.RING_MAX_RY <= 42); + }); + + test("and it never collapses the ring into the pot", () => { + // A puck wider than the table it sits on has no honest answer. Overhanging is + // the lesser evil — seats stacked on the pot would be unreadable, and the + // density rule shrinks the pucks before it ever gets this far. + assert.ok(Render.RING_MIN_R > 0); + assert.ok(Render.RING_MIN_R < Render.RING_MAX_RY); + }); + + test("with a margin that keeps the outermost seat off the rail", () => { + assert.ok(Render.SEAT_EDGE_MARGIN_PX > 0); + }); +}); + +describe("the ring the radii describe", () => { + const spread = (radii, count = 9, aspect = 1.4) => + Render.ringPositions(count, radii.rx, radii.ry, aspect); + + test("a smaller radius puts every seat nearer the middle", () => { + const wide = spread({ rx: 44, ry: 42 }); + const tight = spread({ rx: 30, ry: 28 }); + wide.forEach((seat, i) => { + const wideOut = Math.hypot(seat.left - 50, seat.top - 50); + const tightOut = Math.hypot(tight[i].left - 50, tight[i].top - 50); + assert.ok(tightOut < wideOut, `seat ${i + 1} should have come in`); + }); + }); + + test("and the seats stay evenly spread however far in they are", () => { + // Pulling the ring in must not bunch them: the spacing rule is arc length, and + // shrinking an ellipse uniformly keeps every gap in proportion. + const gaps = ring => ring.map((p, i) => { + const next = ring[(i + 1) % ring.length]; + return Math.hypot(p.left - next.left, p.top - next.top); + }); + const tight = gaps(spread({ rx: 30, ry: 28 })); + const smallest = Math.min(...tight), largest = Math.max(...tight); + assert.ok(largest / smallest < 1.35, `gaps ranged ${smallest.toFixed(1)}..${largest.toFixed(1)}`); + }); +}); + +describe("the bets follow the seats", () => { + test("a chip sits on its own player's spoke, proportionally in", () => { + // The chip is a fraction of the seat's reach rather than of the felt, so when + // the ring pulls in the chips come with it instead of being left by the rail. + const radii = { rx: 30, ry: 28 }; + const seat = Render.seatPosition(2, 8, 1.4, radii); + const bet = Render.betPosition(2, 8, 1.4, radii); + const seatOut = Math.hypot(seat.left - 50, seat.top - 50); + const betOut = Math.hypot(bet.left - 50, bet.top - 50); + assert.ok(betOut < seatOut, "the chip is nearer the pot than the player is"); + assert.ok(betOut > seatOut * 0.4, "but still out on the player's side of the felt"); + }); + + test("and it is the same spoke, not just the same distance", () => { + const radii = { rx: 30, ry: 28 }; + const seat = Render.seatPosition(5, 9, 1.2, radii); + const bet = Render.betPosition(5, 9, 1.2, radii); + const angleOf = p => Math.atan2(p.top - 50, p.left - 50); + assert.ok(Math.abs(angleOf(seat) - angleOf(bet)) < 0.01); + }); +}); diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index aebb402..25ff456 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -793,8 +793,51 @@ const LayoutAudit = { // on a mouse-driven browser — that is right, a 24px control is fine with a cursor // on it — but "not measured" and "measured and fine" are different answers and the // runner now prints which one it got. + // The table has to be big enough for the game around it. + // + // The seat ring sat at a flat 44% by 42% of the seat layer at every size — two + // numbers picked against a phone. A fixed pair can only be right for one ratio of + // puck to felt, and measured on the running page it was wrong nearly everywhere: + // at 1280x900 nine-handed, five of nine pucks finished past the outer edge of the + // table, the worst by 49px; on a phone held sideways all six did. Seats floating + // off the felt onto the page read as the table being too small for the game rather + // than as players at a rail — and it was a different amount of wrong on every + // screen, which is what made it look like a mistake instead of a choice. + // + // Checked against the table's outer edge, border included: sitting *on* the rail is + // fine, hanging off the far side of it is not. The bet chips are held to the same + // rule — a chip is meant to be on the felt between its player and the pot. + seatsOnTheTable(slackPx = 1) { + const name = "seats and chips stay on the table"; + if (this.modalScope()) { + return { name, offenders: [], skipped: "a sheet is open over the table" }; + } + const table = document.getElementById("pokerTable"); + if (!table) return { name, offenders: [], skipped: "no table on this page" }; + + const felt = table.getBoundingClientRect(); + if (!felt.width || !felt.height) return { name, offenders: [], skipped: "table not laid out" }; + + const offenders = []; + const check = (el, what) => { + const r = el.getBoundingClientRect(); + if (!r.width || !r.height) return; + const over = Math.max( + felt.left - r.left, r.right - felt.right, + felt.top - r.top, r.bottom - felt.bottom); + if (over > slackPx) offenders.push(`${what} hangs ${Math.round(over)}px past the rail`); + }; + + [...document.querySelectorAll(".seat")].forEach((seat, i) => check(seat, `seat ${i + 1}`)); + [...document.querySelectorAll("#betsLayer > *")].forEach((chip, i) => + check(chip, `bet chip ${i + 1}`)); + + return { name, offenders }; + }, + runAll(options = {}) { const checks = [ + this.seatsOnTheTable(options.seatSlack), this.touchAction(), this.tapHighlight(), this.textSelectable(), From c052ee32c86ba75dadcab7283846ff6ff83be6ca Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Thu, 3 Sep 2026 12:20:02 -0600 Subject: [PATCH 4/4] Put the Confirm button back on screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opened the pot wizard — the one dialog in this tool that divides money — and its Confirm button was below a scroll. Not on a tall table, not on one screen: at every size I measured, with six players and a side pot. iPhone 14 390x844 244px of the dialog below the fold Galaxy S8 360x640 417px 844x390 landscape 583px desktop 1280x900 172px Nothing on screen said there was anything under the list of names, so a dealer with the table waiting had to scroll a list of players to find the only control that finishes the hand. Two goes at this, and the first was wrong. A sticky footer is the obvious answer and it cannot work here: the actions were the last child of the scrolling content, so their containing block ended exactly where they did and `bottom` had no room to lift them. Sticky on the *header* did work, for the mirror-image reason — its container extends below it — which is what made the failure confusing to read. Capping the player list wasn't enough either; the pot breakdown and the log still overflowed on a short screen. So the dialog is a proper column now: a title that doesn't move, a body that scrolls, and a footer outside the scroll. potWizard.js appends its buttons to the footer instead of into the body, and clears both together so a step can't inherit the previous step's buttons. Measured after: Confirm on screen and hit-testable at all six configurations, twelve-handed on a Galaxy S8 included, and the dialog itself no longer scrolls at all. The checkboxes are painted now, too. `accent-color` only ever coloured the tick — the box around it stayed the platform's own bright white square, which on a near-black dialog was the loudest thing on screen and the last raw control in a tool where everything else had been drawn. Same surface ramp, same corner radius, and a tick drawn with clip-path so it can't pick up a substituted font. uiAudit gains "an open dialog's buttons are on screen", which hit-tests rather than trusting geometry — a button can be inside the viewport and still be under something. Fault-injected: putting the footer back inside the scrolling body reports Confirm 105px below the fold. Two things checked and left alone, because they turned out to be right: the standings table hides three columns on narrow screens through a container query keyed to the sidebar rather than the viewport, which is why a desktop shows fewer columns than an iPad — the sidebar is the narrower box there. And In/Won/ Net read $0 for hands settled outside the wizard, which the comment above them already explains. Caught by my own guard on the way past: the token test read `scroll-padding` as `padding` and failed the spacing scale. Correct by its rules, wrong in substance — those values are a sticky bar's measured height, not a step in the rhythm — so the lookbehind now rejects a hyphen as well as a word character. Re-fault- injected to confirm it still catches a real ad-hoc gap. 619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes and 35 across the five overlays. The whole wizard walked end to end: side pot resolved, hand logged, $1,560 on the table before and after. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 7 ++ JoshHeaps.Net/wwwroot/css/casino/holdem.css | 103 +++++++++++++++++- .../wwwroot/js/casino/holdem/potWizard.js | 17 ++- tests/holdem/designTokens.test.js | 7 +- tests/holdem/uiAudit.js | 47 ++++++++ 5 files changed, 172 insertions(+), 9 deletions(-) diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 37a7ce8..bd78595 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -612,6 +612,13 @@

+ @* The buttons live outside the scrolling body on purpose. This is the one + dialog that divides money, and its Confirm sat below a scroll at every screen + size — 244px off the bottom on an iPhone, 583 on a phone held sideways. A + sticky footer cannot fix that from inside: the actions were the last child of + the scrolling content, so their containing block ended exactly where they did + and `bottom` had no room to lift them. Out here they simply don't scroll. *@ +
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index ae308f9..aca2131 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -3185,15 +3185,58 @@ body.hasActionBar #toastHost { controls couldn't be tapped. Leaving the bar's own height out of the budget keeps the whole dialog above it. */ max-height: calc(85vh - var(--actionBarHeight, 0px)); - overflow-y: auto; - overscroll-behavior: contain; + /* A column of three: the title, the part that scrolls, and the buttons. The modal + itself no longer scrolls — only its middle does — which is what keeps Confirm on + screen without asking a sticky footer to do something it cannot. */ + display: flex; + flex-direction: column; + overflow: hidden; padding: var(--sp-5) var(--sp-5) var(--sp-5); } +#potWizardBody { + overflow-y: auto; + overscroll-behavior: contain; + /* Without this a flex child refuses to be shorter than its content, and the middle + would push the buttons back off the bottom of the dialog. */ + min-height: 0; + /* Room for a focus ring on the first and last rows, which a flush edge clips. */ + padding: 2px; + margin: -2px; + scroll-padding: var(--sp-4); +} + +/* The footer is itself the row of buttons, so a step that appends a single button gets + the same full-width treatment as one that appends a pair — the winner step used to + put a lone Confirm in the left half of the dialog, looking like one option out of + several that hadn't drawn yet. */ +#potWizardFooter { + flex: none; + display: flex; + gap: var(--sp-2); + margin-top: var(--sp-4); + /* A hairline, so the buttons read as sitting below the list rather than as its + last row — the body scrolls under them and the edge is what says so. */ + padding-top: var(--sp-3); + border-top: 1px solid var(--line); +} + +#potWizardFooter > .holdemBtn, +#potWizardFooter > .potWizardActions { + flex: 1; +} + +#potWizardFooter:empty { + display: none; +} + +/* No longer sticky, and doesn't need to be: the modal stopped scrolling when its body + became the only thing that does, so the title and the ✕ stay put on their own. */ .potWizardHeader { display: flex; align-items: center; justify-content: space-between; + flex: none; margin-bottom: var(--sp-3); } @@ -3220,6 +3263,19 @@ body.hasActionBar #toastHost { margin-bottom: var(--sp-3); } +/* This is the one screen in the tool that divides money, and its Confirm button sat + below a scroll at every size — measured with six players and a side pot: 244px of it + off the bottom on an iPhone 14, 417 on a Galaxy S8, 583 on a phone held sideways, + 172 on a desktop. A dealer with the table waiting had to scroll a list of names to + find the only control that finishes the hand, and nothing on screen said there was + anything below it. + + Capping the list rather than pinning the button, because a sticky footer cannot work + here: the actions are the last child of the scrolling content, so their containing + block ends exactly where they do and there is no room for `bottom` to lift them. The + list is the only part that grows without limit anyway — the pot breakdown, the + prompt and the buttons are all a fixed size — so bounding it keeps the whole dialog + inside the screen and the button where the dealer's thumb already is. */ .potWizardChoices { display: flex; flex-direction: column; @@ -3251,11 +3307,52 @@ body.hasActionBar #toastHost { } } +/* The last raw browser control in the tool. + `accent-color` only paints the tick; the box around it stayed the platform's own — + a bright white square, which on this near-black dialog was the loudest thing on + screen and read as unfinished next to controls that had all been drawn. Painted to + match the rest, on the same surface ramp and the same corner radius. */ .potWizardChoice input { flex: none; + appearance: none; + -webkit-appearance: none; width: 22px; height: 22px; - accent-color: #1e8449; + border: 2px solid var(--line-strong); + border-radius: var(--r-sm); + background: var(--surface-2); + display: grid; + place-content: center; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} + +/* The tick is drawn rather than typed, so it can't pick up a font the device happens + to substitute — and it scales with the box instead of with the text around it. */ +.potWizardChoice input::before { + content: ""; + width: 12px; + height: 12px; + transform: scale(0); + transition: transform 0.12s ease; + background: var(--surface-0); + clip-path: polygon(14% 46%, 0 60%, 40% 100%, 100% 22%, 86% 8%, 38% 72%); +} + +.potWizardChoice input:checked { + background: #1e8449; + border-color: #2fa563; +} + +.potWizardChoice input:checked::before { + transform: scale(1); +} + +@media (prefers-reduced-motion: reduce) { + .potWizardChoice input, + .potWizardChoice input::before { + transition: none; + } } /* Selected has to be obvious at a glance — a dealer confirms this with players diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js index 7ab20d4..b1c182b 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/potWizard.js @@ -87,6 +87,11 @@ const PotWizard = { const body = document.getElementById("potWizardBody"); body.innerHTML = ""; + // The footer is a sibling of the body, outside the scroll — see the note in + // TexasHoldem.cshtml. Cleared alongside it so a step can never inherit the + // previous step's buttons. + const footer = this.footer(); + footer.innerHTML = ""; if (this.log.length === 0) body.appendChild(this.renderBreakdown()); body.appendChild(this.renderLog()); @@ -191,9 +196,13 @@ const PotWizard = { this.entries = result.updatedEntries; this.renderStep(); }); - round.appendChild(confirmBtn); - body.appendChild(round); + footer.appendChild(confirmBtn); + }, + + // The row of buttons that never scrolls away. + footer() { + return document.getElementById("potWizardFooter"); }, recordRound(result) { @@ -331,12 +340,10 @@ const PotWizard = { const note = document.createElement("p"); note.className = "fieldHint potWizardPendingNote"; note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand."; - wrap.appendChild(actions); wrap.appendChild(note); - return wrap; } - wrap.appendChild(actions); + this.footer().appendChild(actions); return wrap; }, }; diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js index e5bdf97..6dacae8 100644 --- a/tests/holdem/designTokens.test.js +++ b/tests/holdem/designTokens.test.js @@ -33,7 +33,12 @@ const body = declarations.slice(declarations.indexOf("#potBadgeAmount,")); const valuesIn = (properties, source = body) => { const found = []; - const re = new RegExp(`\\b(${properties.join("|")})\\s*:([^;}]*)`, "g"); + // The lookbehind rejects a hyphen as well as a word character, so `scroll-padding` + // isn't read as `padding`. It caught the pot wizard's scroll-padding the moment + // that went in — correctly by its own rules, wrongly in substance: those two values + // are the measured heights of the dialog's sticky header and footer, a clearance + // like the action bar's, not a step in the spacing rhythm. + const re = new RegExp(`(? { + const rect = d.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0 && getComputedStyle(d).visibility !== "hidden"; + }); + if (dialogs.length === 0) return { name, offenders: [], skipped: "no dialog is open" }; + + const offenders = []; + dialogs.forEach(dialog => { + const buttons = [...dialog.querySelectorAll("button")].filter(b => { + const r = b.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }); + buttons.forEach(button => { + const r = button.getBoundingClientRect(); + const label = this.describe(button); + if (r.bottom > innerHeight || r.top < 0) { + const off = r.bottom > innerHeight + ? Math.round(r.bottom - innerHeight) + "px below the fold" + : Math.round(-r.top) + "px above it"; + offenders.push(`${label} is ${off}`); + return; + } + const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); + if (hit && hit !== button && !button.contains(hit)) { + offenders.push(`${label} is covered by ${this.describe(hit)}`); + } + }); + }); + + return { name, offenders }; + }, + runAll(options = {}) { const checks = [ this.seatsOnTheTable(options.seatSlack), + this.dialogActionsReachable(), this.touchAction(), this.tapHighlight(), this.textSelectable(),