diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index db0fa37..ffdc411 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -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 diff --git a/tests/holdem/README.md b/tests/holdem/README.md index 8df8b08..9968a4f 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -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 diff --git a/tests/holdem/feltShape.test.js b/tests/holdem/feltShape.test.js new file mode 100644 index 0000000..d9c9bf9 --- /dev/null +++ b/tests/holdem/feltShape.test.js @@ -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"); + }); +});