Hold'em dealer: making the table and the bar fit the screen they are on #119

Merged
jheaps merged 11 commits from polish/holdem-round-next into master 2026-09-08 19:08:13 -06:00
3 changed files with 255 additions and 7 deletions
Showing only changes of commit 13d704301d - Show all commits
+61 -7
View File
@@ -3876,9 +3876,57 @@ body.hasActionBar #toastHost {
max-height leaves the width alone and lets the aspect ratio give way, so a short
screen gets a wide oval, which is the shape a poker table is anyway. Measured at
360x640: 336x240 rather than 240x240, same clearance, and the twelve-seat ring
that was crowded at 240 comes out clean. Where the room isn't the binding
constraint — 390x844 and up — nothing changes and the table stays round. */
that was crowded at 240 comes out clean.
"Where the room isn't the binding constraint — 390x844 and up — nothing changes
and the table stays round" is what this used to say, and it was true and it was
the bug. A square felt on a portrait phone can never be taller than it is wide,
so on every phone with room to spare the surplus was measured, published, and
dropped: 522px of room under a 366px felt on an iPhone 14, 638 under 406 on a Pro
Max. Nothing was wrong with the arithmetic. The shape simply refused the answer.
So the preferred shape is an oval standing up, and the cap above stays to stop it.
A phone is a portrait screen and this is a table seen from above — the wide layout
has been an oval lying down (0.72) all along, and the two are the same decision
made twice. What it buys is rim: twelve seats at 390x844 went from a 44px puck
with five names clipped to 66px with one, and five seats went from three clipped
to none.
1.5 is where the sweep stops paying. 1.6 and 1.7 measured byte-identical to each
other on every phone, because by then the room is the binding constraint again;
between 1.35 and 1.5 the twelve-seat ring on a Pixel 7 goes from one clipped name
to none. The three smaller phones are room-bound at any cap at all and measured
identical from 1.15 to 1.7, so this cannot regress them.
It was tempting to pick a lower number and claim it bought stillness — a felt
whose height *is* --feltRoomPx moves every time the bar does. That claim was
written here and it was wrong: the bar's growth was measured at 125px resting,
233px with six players all in, and 341px with twelve and their nine side pots
listed. 216px of growth is more slack than any phone has at any aspect ratio, so
there is no number here that buys stillness. renderSeats() is
already told when the room changes, which is the thing that actually holds the
chips on their seats, and this rule stops trying to.
A floor under the max-height was tried here and taken out again, which is worth
writing down because it looks obviously right. The worry was that max-height has
no lower bound, so a tall enough bar presses the felt flat; the fix was to floor
it at 72cqw, the wide layout's own oval, so the worst a phone could do was arrive
at the shape the desktop already has.
It made things worse. Twelve players all in with nine side pots is the tallest the
bar gets — 341px — and at 360x640 that leaves 142px of room. Floored to 242 the
felt keeps its shape and puts five of the twelve seats behind the bar; unfloored
at 142 it is a flat table with all twelve of them visible. Same on an iPhone SE,
three seats against none. A covered seat is the fault this whole measurement
exists to prevent, and a flat table is only ugly.
The slit the floor was defending against was never reachable either. It came from
forcing --feltRoomPx to 10px by hand; the real floor is FELT_FLOOR_PX at 96, and
the worst any of these phones actually publishes is 142, which lays out fine.
Landscape overrides all of this outright, further down the file. */
#pokerTableWrap {
--feltAspect: 1.5;
max-width: none;
max-height: var(--feltRoomPx, 100vh);
}
@@ -3898,11 +3946,6 @@ body.hasActionBar #toastHost {
grid-template-columns: 1fr;
}
/* One column, so the table has the full width and can afford to be round. */
#pokerTableWrap {
--feltAspect: 1;
}
/* No width here any more. This was 78px, and two blocks below it were 64 and 62 —
three guesses, at three breakpoints, at the one number that depends on something
a media query cannot see: how many people are sitting down. The same 390px phone
@@ -3912,6 +3955,17 @@ body.hasActionBar #toastHost {
}
}
/* One column, and still wide enough that a round table fits. Below 701px the block
further up stands the oval up instead, which is why this has to be written as a
range rather than as another max-width: both queries match a phone, they set the
same custom property at the same specificity, and the one lower in the file wins.
It did win, for a while, and the upright oval above was dead text. */
@media (min-width: 701px) and (max-width: 900px) {
#pokerTableWrap {
--feltAspect: 1;
}
}
/* ---------- Phones ----------
Two things break on a narrow screen: seat pucks ring the table on a radius
measured from the wrap, so the left- and right-most ones hang off the edge of
+29
View File
@@ -23,6 +23,7 @@ other in the page, in the load order from `TexasHoldem.cshtml`.
| `handRank.test.js` | What beats what: categories, the wheel, kickers, best five of seven |
| `readingTheShowdown.test.js` | The board, the picker that fills it, and who wins a side pot |
| `theRail.test.js` | A phone on its side: which shape the bar is in, what it leaves under the felt, how a name is shortened, how the ring is pulled back onto the table |
| `feltShape.test.js` | What shape the felt is at each phone size, resolved through the media queries in source order (see below) |
| `invariants.test.js` | 1000 randomised hands per property (see below) |
## The layout audit
@@ -155,6 +156,34 @@ something changed, and each time they only covered whatever had just been touche
That is how the standings table shipped with a player-name column zero pixels wide
and kept it for two rounds.
## Which rule wins
`feltShape.test.js` is here because of a change that measured as correct and was a
no-op. The felt on a phone was made an upright oval by setting `--feltAspect` inside
`@media (max-width: 700px)`, and forty lines below it `@media (max-width: 900px)` set
the same property back to 1. Both queries match a phone, the declarations have the same
specificity, and the later one wins — so the property was 1 on every phone on the site
while the rule that was supposed to change it sat there reading perfectly.
Nothing in the suite could have caught it and nothing in review did. A stylesheet is not
a list of rules for viewports; it is a list of rules in an order, and the question "what
does a 390px phone get?" has an answer that no single rule knows. So the test walks the
media queries the way the cascade does — every declaration of a property on a selector,
in source order, keeping the last one whose query matches — and asks what each of five
real phone sizes actually ends up with. It carries just enough of a media-query engine
for the forms this stylesheet uses, and refuses loudly rather than guessing when it
meets one it doesn't know.
The same file records a second thing, which is a floor that was added and removed. The
`max-height` that keeps the felt above the action bar has no lower bound, so a tall
enough bar presses the table flat; flooring it at the desktop oval looked obviously
right. Measured, it was worse: twelve players all in with nine side pots takes the bar
to 341px, which at 360x640 leaves 142px of room, and a felt floored to 242px keeps its
shape by putting five of the twelve seats behind the bar. Unfloored it is flat and all
twelve are visible. The slit the floor defended against turned out to be unreachable —
it came from forcing the custom property by hand. A test now asserts the floor is
*absent*, with the numbers, so it doesn't get re-added by someone reasoning the same way.
## Why the hand evaluator is tested the way it is
`handRank.js` is the only module here that can pay the wrong person without anybody
+165
View File
@@ -0,0 +1,165 @@
// What shape the felt ends up, which is a question about which rule wins.
//
// feltRoom.test.js covers how much room there is. This covers what the stylesheet does
// with it, and it exists because of a bug that no amount of reading caught: the upright
// oval for phones was written into the `max-width: 700px` block, and the `max-width:
// 900px` block below it set the same custom property back to 1. Both queries match a
// phone, the two declarations have identical specificity, and the later one wins. The
// rule was correct, the arithmetic was correct, and the property was 1 on every phone
// on the site. It measured as a no-op and would have shipped as one.
//
// So this walks the media queries the way the cascade does — in source order, keeping
// the last one that matches — instead of trusting that a rule written for a viewport is
// the rule that viewport gets.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
// Comments carry example numbers and at least one worked-through `--feltAspect: 1`, so
// they come out before anything here counts declarations.
const SOURCE = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
// --- just enough of a media query engine to answer "does this viewport match?" -------
//
// Only the forms this stylesheet actually uses: width and height bounds, orientation,
// and `and` between them. Anything else is refused loudly rather than guessed at, so a
// new kind of query can't quietly start matching everything.
const matches = (condition, view) => {
if (!condition) return true;
return condition.split(/\s+and\s+/).every(part => {
const m = part.trim().match(/^\(\s*([a-z-]+)\s*:\s*([^)]+?)\s*\)$/);
assert.ok(m, `feltShape can't read the query "${part}" — teach it or simplify the CSS`);
const [, feature, raw] = m;
const px = parseFloat(raw);
switch (feature) {
case "min-width": return view.width >= px;
case "max-width": return view.width <= px;
case "min-height": return view.height >= px;
case "max-height": return view.height <= px;
case "orientation":
return raw === (view.width > view.height ? "landscape" : "portrait");
default:
assert.fail(`feltShape doesn't know the media feature "${feature}"`);
}
});
};
// Every style rule in the file, in source order, with the media condition it sits
// under. Written as a brace walk rather than a regex because a regex for `max-height`
// also finds it in `@media (orientation: landscape) and (max-height: 560px)`, and a
// media feature is not a declaration.
const rules = () => {
const out = [];
const open = [];
let prelude = "", i = 0;
while (i < SOURCE.length) {
const c = SOURCE[i];
if (c === "{") {
const head = prelude.trim();
prelude = "";
if (head.startsWith("@")) { open.push(head); i++; continue; }
let depth = 1, j = i + 1;
while (j < SOURCE.length && depth > 0) {
if (SOURCE[j] === "{") depth++;
else if (SOURCE[j] === "}") depth--;
j++;
}
const media = open.filter(h => h.startsWith("@media"))
.map(h => h.replace(/^@media\s*/, "")).join(" and ");
out.push({ selector: head, condition: media || null, body: SOURCE.slice(i + 1, j - 1) });
i = j;
continue;
}
if (c === "}") { open.pop(); prelude = ""; i++; continue; }
prelude += c;
i++;
}
return out;
};
const ALL_RULES = rules();
// The cascade, for equal-specificity declarations of one property on one selector: the
// last one whose query matches is the one the viewport gets.
const winnerAt = (selector, prop, view) => {
const winning = ALL_RULES
.filter(r => r.selector === selector && matches(r.condition, view))
.map(r => r.body.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`)))
.filter(Boolean)
.pop();
assert.ok(winning, `nothing sets ${prop} on ${selector} at ${view.width}x${view.height}`);
return winning[1].trim();
};
const PHONES = [
{ width: 360, height: 640, name: "Galaxy S8" },
{ width: 375, height: 667, name: "iPhone SE" },
{ width: 390, height: 844, name: "iPhone 14" },
{ width: 412, height: 915, name: "Pixel 7" },
{ width: 430, height: 932, name: "Pro Max" },
];
// ---------------------------------------------------------------------------
describe("the shape a phone gets", () => {
test("is an oval standing up, on every one of them", () => {
PHONES.forEach(phone => {
const aspect = parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", phone));
assert.ok(aspect > 1.2,
`${phone.name} lands on --feltAspect: ${aspect}. A portrait phone is ` +
`tall and this is what uses it; anything at or under 1 is the square ` +
`felt that threw the room away.`);
});
});
test("and a wide screen still gets one lying down", () => {
// The desktop felt is the shape it always was. Whatever the phones do, this is
// the one a poker table looks like when there is room for it.
assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", { width: 1280, height: 900 })), 0.72);
});
test("with the middle widths in between left round", () => {
// 701-900px is one column and wide enough that neither oval is called for. It
// has to be written as a range: a bare max-width would match the phones too and,
// sitting lower in the file, would win there.
[{ width: 768, height: 1024 }, { width: 900, height: 700 }].forEach(view => {
assert.equal(parseFloat(winnerAt("#pokerTableWrap", "--feltAspect", view)), 1);
});
});
});
describe("what stops the oval", () => {
const phoneRule = winnerAt("#pokerTableWrap", "max-height", { width: 390, height: 844 });
test("is the room and nothing but the room", () => {
// The oval is a preference and the room is a fact. Whatever shape is asked for
// above, the felt gives way to the measurement rather than the other way round,
// because the thing on the other side of the cap is a seat behind the action bar.
assert.equal(phoneRule, "var(--feltRoomPx, 100vh)");
});
test("and it is not floored, however tempting that is", () => {
// A floor at the desktop oval — max(var(--feltRoomPx), 72cqw) — was written here
// and measured out again. Twelve players all in with nine side pots is the
// tallest the bar gets, 341px, and at 360x640 that leaves 142px of room. Floored
// to 242 the felt keeps its shape and hides five of the twelve seats behind the
// bar. Unfloored at 142 it is flat and all twelve are visible.
//
// The slit it was defending against wasn't reachable: that came from forcing
// --feltRoomPx to 10px by hand, and the real floor is FELT_FLOOR_PX at 96.
assert.doesNotMatch(phoneRule, /max\(/,
"a floor here trades a flat table for a covered seat, which is the worse of " +
"the two — see the comment above the rule");
});
test("and the width is let go of, so the height can be the one that binds", () => {
// The wide layout sizes the felt from its width and caps that. On a phone the
// width is the column and the height is what's scarce, so the cap moves.
assert.equal(winnerAt("#pokerTableWrap", "max-width", { width: 390, height: 844 }), "none");
});
});