// 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.match(phoneRule, /^var\(--feltRoomPx,/, "something other than the measured room decides how tall the felt may be"); }); test("and what it falls back on is a near guess rather than the whole window", () => { // The fallback is only ever used for the frame before actionBar.js has measured // anything — but that frame is one a dealer sees, because the scripts run at the // end of the body. `100vh` was what stood here, and it says the whole window is // free: measured at 360x640 nine-handed, the table came up 336x504 and dropped to // 336x374 as soon as the bar was measured. See whatTheFeltIsToldFirst. assert.doesNotMatch(phoneRule, /var\(--feltRoomPx,\s*100vh\)/); assert.match(phoneRule, /var\(--actionBarHeight, 9rem\)/); }); 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"); }); });