Hold'em dealer: a reachable Confirm, a table that fits, offline, and a quieter first screen #117

Merged
jheaps merged 4 commits from polish/holdem-offline-and-render into master 2026-09-03 12:24:00 -06:00
17 changed files with 1095 additions and 45 deletions
+41 -1
View File
@@ -580,7 +580,17 @@
</div>
</div>
<div id="actionBar" aria-live="polite" hidden></div>
@* 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. *@
<div id="actionBar" hidden></div>
@* 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. *@
<div id="actionAnnouncer" role="status" aria-live="polite" class="visuallyHidden"></div>
<div id="toastHost" aria-live="polite"></div>
@@ -602,13 +612,43 @@
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
</div>
<div id="potWizardBody"></div>
@* 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. *@
<div id="potWizardFooter"></div>
</div>
</div>
@section Styles {
<link href="/css/casino/holdem.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
@* 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. *@
<link rel="manifest" href="/casino/holdem.webmanifest" />
@* iOS reads none of the manifest's icons and only this. *@
<link rel="apple-touch-icon" href="/images/casino/holdem-apple-touch.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Hold'em" />
@* 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. *@
<meta name="theme-color" content="#141414" />
}
@* 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 {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
+9
View File
@@ -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");
@@ -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"
}
]
}
+125
View File
@@ -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);
})());
});
+123 -3
View File
@@ -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 {
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -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() {
@@ -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 });
});
@@ -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;
},
};
+272 -32
View File
@@ -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) {
+135
View File
@@ -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;
});
});
+6 -1
View File
@@ -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(`(?<![\\w-])(${properties.join("|")})\\s*:([^;}]*)`, "g");
let m;
while ((m = re.exec(source)) !== null) {
const [, prop, value] = m;
+110
View File
@@ -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);
});
});
+90
View File
@@ -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(),