Merge pull request 'Hold'em dealer: a reachable Confirm, a table that fits, offline, and a quieter first screen' (#117) from polish/holdem-offline-and-render into master
Build and Deploy / build-deploy (push) Successful in 1m42s
Build and Deploy / build-deploy (push) Successful in 1m42s
Reviewed-on: #117
This commit was merged in pull request #117.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
})());
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user