Hold'em dealer: making the table and the bar fit the screen they are on #119
@@ -3876,9 +3876,57 @@ body.hasActionBar #toastHost {
|
|||||||
max-height leaves the width alone and lets the aspect ratio give way, so a short
|
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
|
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
|
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
|
that was crowded at 240 comes out clean.
|
||||||
constraint — 390x844 and up — nothing changes and the table stays round. */
|
|
||||||
|
"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 {
|
#pokerTableWrap {
|
||||||
|
--feltAspect: 1.5;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
max-height: var(--feltRoomPx, 100vh);
|
max-height: var(--feltRoomPx, 100vh);
|
||||||
}
|
}
|
||||||
@@ -3898,11 +3946,6 @@ body.hasActionBar #toastHost {
|
|||||||
grid-template-columns: 1fr;
|
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 —
|
/* 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
|
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
|
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 ----------
|
/* ---------- Phones ----------
|
||||||
Two things break on a narrow screen: seat pucks ring the table on a radius
|
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
|
measured from the wrap, so the left- and right-most ones hang off the edge of
|
||||||
|
|||||||
@@ -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 |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||||||
|
|
||||||
## The layout audit
|
## 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
|
That is how the standings table shipped with a player-name column zero pixels wide
|
||||||
and kept it for two rounds.
|
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
|
## 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
|
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user