Hold'em dealer: a reachable Confirm, a table that fits, offline, and a quieter first screen #117
@@ -580,7 +580,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div id="toastHost" aria-live="polite"></div>
|
||||||
|
|
||||||
@@ -602,13 +612,43 @@
|
|||||||
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
|
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="potWizardBody"></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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
<link href="/css/casino/holdem.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
<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 {
|
@section Scripts {
|
||||||
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
|
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
|
||||||
<script src="~/js/casino/holdem/feedback.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.Hubs;
|
||||||
using JoshHeaps.Net.Services.Implementations;
|
using JoshHeaps.Net.Services.Implementations;
|
||||||
using JoshHeaps.Net.Services.Interfaces;
|
using JoshHeaps.Net.Services.Interfaces;
|
||||||
|
using Microsoft.AspNetCore.StaticFiles;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -52,8 +53,16 @@ if (!app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
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
|
app.UseStaticFiles(new StaticFileOptions
|
||||||
{
|
{
|
||||||
|
ContentTypeProvider = contentTypes,
|
||||||
OnPrepareResponse = ctx =>
|
OnPrepareResponse = ctx =>
|
||||||
{
|
{
|
||||||
ctx.Context.Response.Headers.Append("Cache-Control", "no-cache, no-store, must-revalidate");
|
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;
|
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
|
/* 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
|
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
|
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
|
controls couldn't be tapped. Leaving the bar's own height out of the budget
|
||||||
keeps the whole dialog above it. */
|
keeps the whole dialog above it. */
|
||||||
max-height: calc(85vh - var(--actionBarHeight, 0px));
|
max-height: calc(85vh - var(--actionBarHeight, 0px));
|
||||||
overflow-y: auto;
|
/* A column of three: the title, the part that scrolls, and the buttons. The modal
|
||||||
overscroll-behavior: contain;
|
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);
|
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 {
|
.potWizardHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
flex: none;
|
||||||
margin-bottom: var(--sp-3);
|
margin-bottom: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3205,6 +3263,19 @@ body.hasActionBar #toastHost {
|
|||||||
margin-bottom: var(--sp-3);
|
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 {
|
.potWizardChoices {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.potWizardChoice input {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 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
|
/* Selected has to be obvious at a glance — a dealer confirms this with players
|
||||||
@@ -4052,6 +4164,14 @@ textarea:focus-visible {
|
|||||||
min-width: 7rem;
|
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
|
/* The quieter half of the deal prompt: things the deal is about to do that a dealer
|
||||||
would otherwise only discover afterwards. */
|
would otherwise only discover afterwards. */
|
||||||
.dealPromptLine.dealPromptAside {
|
.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;
|
if (!host) return;
|
||||||
host.innerHTML = "";
|
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()) {
|
if (!this.isRunning()) {
|
||||||
host.hidden = true;
|
host.hidden = false;
|
||||||
document.body.classList.remove("hasActionBar");
|
document.body.classList.add("hasActionBar");
|
||||||
document.documentElement.style.setProperty("--actionBarHeight", "0px");
|
host.appendChild(this.renderEmptyTable());
|
||||||
this.stopTankTimer();
|
this.stopTankTimer();
|
||||||
this.tankSeatIndex = null;
|
this.tankSeatIndex = null;
|
||||||
|
this.announce();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
host.hidden = false;
|
host.hidden = false;
|
||||||
@@ -142,6 +149,79 @@ const ActionBar = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.syncTankTimer();
|
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() {
|
renderStatus() {
|
||||||
|
|||||||
@@ -317,6 +317,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
rerender: hooks.rerender,
|
rerender: hooks.rerender,
|
||||||
resolvePot: openPotWizard,
|
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 => {
|
beginAction: label => {
|
||||||
actionHistory.push({ label, snapshot: snapshotState() });
|
actionHistory.push({ label, snapshot: snapshotState() });
|
||||||
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
|
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.",
|
"The saved table couldn't be read, so this one started empty.",
|
||||||
{ duration: 12000 });
|
{ 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");
|
const body = document.getElementById("potWizardBody");
|
||||||
body.innerHTML = "";
|
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());
|
if (this.log.length === 0) body.appendChild(this.renderBreakdown());
|
||||||
body.appendChild(this.renderLog());
|
body.appendChild(this.renderLog());
|
||||||
|
|
||||||
@@ -191,9 +196,13 @@ const PotWizard = {
|
|||||||
this.entries = result.updatedEntries;
|
this.entries = result.updatedEntries;
|
||||||
this.renderStep();
|
this.renderStep();
|
||||||
});
|
});
|
||||||
round.appendChild(confirmBtn);
|
|
||||||
|
|
||||||
body.appendChild(round);
|
body.appendChild(round);
|
||||||
|
footer.appendChild(confirmBtn);
|
||||||
|
},
|
||||||
|
|
||||||
|
// The row of buttons that never scrolls away.
|
||||||
|
footer() {
|
||||||
|
return document.getElementById("potWizardFooter");
|
||||||
},
|
},
|
||||||
|
|
||||||
recordRound(result) {
|
recordRound(result) {
|
||||||
@@ -331,12 +340,10 @@ const PotWizard = {
|
|||||||
const note = document.createElement("p");
|
const note = document.createElement("p");
|
||||||
note.className = "fieldHint potWizardPendingNote";
|
note.className = "fieldHint potWizardPendingNote";
|
||||||
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
|
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
|
||||||
wrap.appendChild(actions);
|
|
||||||
wrap.appendChild(note);
|
wrap.appendChild(note);
|
||||||
return wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wrap.appendChild(actions);
|
this.footer().appendChild(actions);
|
||||||
return wrap;
|
return wrap;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,6 +71,32 @@ const Render = {
|
|||||||
const misdeal = document.getElementById("misdealBtn");
|
const misdeal = document.getElementById("misdealBtn");
|
||||||
if (!misdeal) return;
|
if (!misdeal) return;
|
||||||
misdeal.hidden = !this.state.bettingOpen;
|
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
|
// 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) ----
|
// ---- Seats around the table (compact pucks) ----
|
||||||
|
|
||||||
seatPosition(index, count, aspect = this.feltAspect()) {
|
// The widest the ring has ever been allowed to be. These are the numbers the ring
|
||||||
return this.positionOnRing(index, count, 44, 42, aspect);
|
// 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
|
// 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
|
// 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",
|
// anything visible — it is just the honest way to say "on that player's spoke",
|
||||||
// and it saves the second walk.
|
// and it saves the second walk.
|
||||||
betPosition(index, count, aspect = this.feltAspect()) {
|
betPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii()) {
|
||||||
const seat = this.seatPosition(index, count, aspect);
|
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 {
|
return {
|
||||||
left: 50 + (seat.left - 50) * (27 / 44),
|
left: 50 + (seat.left - 50) * (27 / 44),
|
||||||
top: 50 + (seat.top - 50) * (25 / 42),
|
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
|
// 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
|
// 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.
|
// 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;
|
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,
|
x: (seat.left / 100) * box.width,
|
||||||
y: (seat.top / 100) * box.height,
|
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
|
// 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
|
// growing mid-hand shortens the table, and the seats have to answer that without
|
||||||
// waiting for the next render to come along.
|
// 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");
|
const layer = document.getElementById("seatsLayer");
|
||||||
if (!layer || !this.state) return;
|
if (!layer) return { radii: this.seatRingRadii() };
|
||||||
layer.classList.toggle("seatsTight",
|
|
||||||
this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX);
|
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.
|
// The angles that cut the rim into `count` equal lengths, starting at the top.
|
||||||
@@ -359,18 +535,23 @@ const Render = {
|
|||||||
layer.innerHTML = "";
|
layer.innerHTML = "";
|
||||||
|
|
||||||
const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
|
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) => {
|
this.state.seats.forEach((player, index) => {
|
||||||
const bet = Betting.committed(player);
|
const bet = Betting.committed(player);
|
||||||
if (bet <= 0) return;
|
if (bet <= 0) return;
|
||||||
|
|
||||||
const chip = this.makeBetChip(bet);
|
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) {
|
if (anchorToSeat) {
|
||||||
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
|
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
|
||||||
if (!seat) return;
|
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");
|
chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
|
||||||
seat.appendChild(chip);
|
seat.appendChild(chip);
|
||||||
return;
|
return;
|
||||||
@@ -386,11 +567,59 @@ const Render = {
|
|||||||
return HoldemState.displayName(player, seatIndex);
|
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() {
|
renderSeats() {
|
||||||
const layer = document.getElementById("seatsLayer");
|
const layer = document.getElementById("seatsLayer");
|
||||||
layer.innerHTML = "";
|
const count = this.state.seats.length;
|
||||||
this.updateSeatDensity();
|
if (layer.childElementCount !== count) this.buildSeatNodes(layer, count);
|
||||||
const template = document.getElementById("seatTemplate");
|
// 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);
|
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
|
||||||
// A button on an empty chair normally means the table has drifted and wants
|
// 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
|
// 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[dealerIndex].inHand;
|
||||||
|
|
||||||
this.state.seats.forEach((player, index) => {
|
this.state.seats.forEach((player, index) => {
|
||||||
const node = template.content.firstElementChild.cloneNode(true);
|
const node = layer.children[index];
|
||||||
const pos = this.seatPosition(index, this.state.seatCount);
|
if (!node) return;
|
||||||
|
const pos = this.seatPosition(index, this.state.seatCount, aspect, radii);
|
||||||
node.style.left = `${pos.left}%`;
|
node.style.left = `${pos.left}%`;
|
||||||
node.style.top = `${pos.top}%`;
|
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
|
// 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
|
// 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.
|
// telling the dealer who the big blind is was simply gone.
|
||||||
node.classList.toggle("seatBelowCentre", pos.top > 50);
|
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 dealerChip = node.querySelector(".dealerChip");
|
||||||
const hasButton = index === dealerIndex;
|
const hasButton = index === dealerIndex;
|
||||||
@@ -440,13 +670,6 @@ const Render = {
|
|||||||
: `Give the dealer button to ${who}`;
|
: `Give the dealer button to ${who}`;
|
||||||
dealerChip.setAttribute("aria-label", chipLabel);
|
dealerChip.setAttribute("aria-label", chipLabel);
|
||||||
dealerChip.title = 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 sbBadge = node.querySelector(".sbBadge");
|
||||||
const bbBadge = node.querySelector(".bbBadge");
|
const bbBadge = node.querySelector(".bbBadge");
|
||||||
@@ -471,11 +694,6 @@ const Render = {
|
|||||||
|
|
||||||
this.updatePuckContent(node, player, index);
|
this.updatePuckContent(node, player, index);
|
||||||
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
|
||||||
|
|
||||||
this.wireSeatGestures(node, index);
|
|
||||||
this.wireDragAndDrop(node, index);
|
|
||||||
|
|
||||||
layer.appendChild(node);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.renderBets();
|
this.renderBets();
|
||||||
@@ -517,8 +735,24 @@ const Render = {
|
|||||||
updatePuckContent(node, player, index) {
|
updatePuckContent(node, player, index) {
|
||||||
const empty = this.isEmptySeat(player);
|
const empty = this.isEmptySeat(player);
|
||||||
const nameEl = node.querySelector(".seatPuckName");
|
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);
|
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");
|
const cardsEl = node.querySelector(".seatPuckCards");
|
||||||
cardsEl.innerHTML = "";
|
cardsEl.innerHTML = "";
|
||||||
@@ -780,6 +1014,12 @@ const Render = {
|
|||||||
// Safe to do on every keystroke: the bar is small, it's rebuilt on every action
|
// 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.
|
// anyway, and nothing inside it can hold focus while the sidebar has it.
|
||||||
if (typeof ActionBar !== "undefined" && ActionBar.state) ActionBar.render();
|
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) {
|
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 valuesIn = (properties, source = body) => {
|
||||||
const found = [];
|
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;
|
let m;
|
||||||
while ((m = re.exec(source)) !== null) {
|
while ((m = re.exec(source)) !== null) {
|
||||||
const [, prop, value] = m;
|
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 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
|
// on it — but "not measured" and "measured and fine" are different answers and the
|
||||||
// runner now prints which one it got.
|
// 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 = {}) {
|
runAll(options = {}) {
|
||||||
const checks = [
|
const checks = [
|
||||||
|
this.seatsOnTheTable(options.seatSlack),
|
||||||
|
this.dialogActionsReachable(),
|
||||||
this.touchAction(),
|
this.touchAction(),
|
||||||
this.tapHighlight(),
|
this.tapHighlight(),
|
||||||
this.textSelectable(),
|
this.textSelectable(),
|
||||||
|
|||||||
Reference in New Issue
Block a user