diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index ea2314b..bd78595 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. *@ +
@@ -602,13 +612,43 @@
+ @* 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. *@ +
@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..aca2131 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 @@ -3170,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); } @@ -3205,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; @@ -3236,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 @@ -4052,6 +4164,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/images/casino/holdem-192.png b/JoshHeaps.Net/wwwroot/images/casino/holdem-192.png new file mode 100644 index 0000000..79c951e Binary files /dev/null and b/JoshHeaps.Net/wwwroot/images/casino/holdem-192.png differ 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 0000000..7faac39 Binary files /dev/null and b/JoshHeaps.Net/wwwroot/images/casino/holdem-512.png differ 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 0000000..a0898b1 Binary files /dev/null and b/JoshHeaps.Net/wwwroot/images/casino/holdem-apple-touch.png differ diff --git a/JoshHeaps.Net/wwwroot/images/casino/holdem-maskable-512.png b/JoshHeaps.Net/wwwroot/images/casino/holdem-maskable-512.png new file mode 100644 index 0000000..c92edfd Binary files /dev/null and b/JoshHeaps.Net/wwwroot/images/casino/holdem-maskable-512.png differ diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 05957cc..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; @@ -142,6 +149,79 @@ const ActionBar = { } this.syncTankTimer(); + 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 + // 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 (!this.isRunning()) return "Nobody's at the table yet."; + 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..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(); @@ -863,4 +873,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/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/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 15d5daf..2e0247f 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 @@ -119,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 @@ -153,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), @@ -253,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, })); @@ -274,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. @@ -359,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; @@ -386,11 +567,59 @@ 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 = ""; - this.updateSeatDensity(); - const template = document.getElementById("seatTemplate"); + const count = this.state.seats.length; + if (layer.childElementCount !== count) this.buildSeatNodes(layer, count); + // 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 @@ -400,11 +629,11 @@ const Render = { !this.state.seats[dealerIndex].inHand; this.state.seats.forEach((player, index) => { - const node = template.content.firstElementChild.cloneNode(true); - const pos = this.seatPosition(index, this.state.seatCount); + const node = layer.children[index]; + if (!node) return; + const pos = this.seatPosition(index, this.state.seatCount, aspect, radii); 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 +645,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 +670,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 +694,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(); @@ -517,8 +735,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 = ""; @@ -780,6 +1014,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 new file mode 100644 index 0000000..eaa196d --- /dev/null +++ b/tests/holdem/announcing.test.js @@ -0,0 +1,135 @@ +// What the tool says out loud between taps. +// +// The action bar carried `aria-live="polite"` on the element whose entire contents +// render() rebuilds every action, so a screen reader read the whole bar after every +// single tap. Measured, after one: +// +// "PRE-FLOP Pot $15 9 players Bob · $250 behind 0:00 ↶ dealing the hand U +// Fold Call $10 Raise" +// +// Nothing in that is wrong. It is just unusable at the speed a hand is dealt, and it +// is the documented anti-pattern: a live region has to be a stable element whose text +// changes, not a container that gets replaced. +// +// What a dealer running this by ear needs is who is on the clock and what it costs +// them. These are the sentences. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { ActionBar, Betting, HoldemState, makeTable } = require("./load"); + +const barOn = (state) => { + ActionBar.state = state; + ActionBar.lastAnnouncement = undefined; + return ActionBar; +}; + +describe("before the cards are out", () => { + test("a table that could be dealt says so", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] }); + assert.equal(barOn(state).announcement(), "Ready to deal."); + }); + + test("and an empty table says what it is, rather than nothing", () => { + // This used to announce "" — silence being the honest answer when the bar was + // hidden on a table nobody was sitting at. The bar isn't hidden any more: an + // empty table is the one state a dealer meets before they know how any of this + // works, so it says so and offers the next step. + const state = HoldemState.createDefault(); + assert.equal(barOn(state).announcement(), "Nobody's at the table yet."); + }); + + test("and a table with one named player has moved on from that", () => { + const state = HoldemState.createDefault(); + state.seats[0].name = "Ann"; + assert.notEqual(barOn(state).announcement(), "Nobody's at the table yet."); + }); +}); + +describe("while a hand is running", () => { + const dealt = () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 }); + state.seats.forEach((p, i) => { p.name = ["Ann", "Ben", "Cal", "Dee"][i] || ""; }); + Betting.beginHand(state); + return state; + }; + + test("names who is on the clock, what it costs, and the pot", () => { + const state = dealt(); + const said = barOn(state).announcement(); + assert.match(said, /to act/); + assert.match(said, /\$2 to call/); + assert.match(said, /Pot \$3\./); + }); + + test("it is the seat that is actually acting, not the one after it", () => { + const state = dealt(); + const name = HoldemState.displayName(state.seats[state.actingSeatIndex], state.actingSeatIndex); + assert.ok(barOn(state).announcement().startsWith(name + " to act")); + }); + + test("says checked to them rather than $0 to call", () => { + // A number nobody owes is a number a dealer has to stop and parse. + const state = dealt(); + state.currentBet = 0; + assert.match(barOn(state).announcement(), /checked to them/); + assert.doesNotMatch(barOn(state).announcement(), /\$0 to call/); + }); + + test("a street with nobody left to act says the street is done", () => { + const state = dealt(); + state.actingSeatIndex = null; + state.street = "flop"; + assert.equal(barOn(state).announcement(), "Flop betting is done."); + }); + + test("and a finished hand points at the pot", () => { + const state = dealt(); + state.seats.forEach((p, i) => { if (i !== 0) p.folded = true; }); + assert.equal(Betting.isHandOver(state), true); + assert.equal(barOn(state).announcement(), "Hand over. Resolve the pot."); + }); +}); + +describe("how often it speaks", () => { + // A live region that is handed the same sentence twice reads it out twice, and + // render() runs for a great many reasons that have nothing to do with whose turn + // it is — a resize, a panel opening, a note being typed. + const stubTarget = () => { + const el = { textContent: "" }; + global.document = { getElementById: id => (id === "actionAnnouncer" ? el : null) }; + return el; + }; + + test("says nothing new when nothing changed", () => { + const el = stubTarget(); + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 }); + Betting.beginHand(state); + const bar = barOn(state); + + bar.announce(); + const first = el.textContent; + assert.ok(first.length > 0); + + el.textContent = "CLEARED"; // if announce() writes again, this comes back + bar.announce(); + assert.equal(el.textContent, "CLEARED", "the same sentence was not written twice"); + delete global.document; + }); + + test("but does when the action moves", () => { + const el = stubTarget(); + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 }); + Betting.beginHand(state); + const bar = barOn(state); + + bar.announce(); + const first = el.textContent; + Betting.call(state, state.actingSeatIndex); + bar.announce(); + + assert.notEqual(el.textContent, first); + assert.match(el.textContent, /to act/); + delete global.document; + }); +}); 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(`(? { + 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..8a80799 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -793,8 +793,98 @@ 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 }; + }, + + // An open dialog's buttons have to be on screen without hunting for them. + // + // The pot wizard — the one dialog in this tool that divides money — had its Confirm + // below a scroll at every size: measured with six players and a side pot, 244px off + // the bottom on an iPhone 14, 417 on a Galaxy S8, 583 on a phone held sideways, 172 + // on a desktop. Nothing on screen said there was anything below the list of names, + // so the only control that finishes the hand was invisible with the table waiting. + // + // Checked by hit-testing rather than by geometry alone: a button can be inside the + // viewport and still be under something. Only dialogs that are actually open are + // considered, and only their buttons — a dialog with no buttons has nothing to find. + dialogActionsReachable() { + const name = "an open dialog's buttons are on screen"; + const dialogs = [...document.querySelectorAll("[role='dialog'], [role='alertdialog']")] + .filter(d => { + 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(),