// What stands between the top of a phone and the felt. // // The header was 120px of a 390x844 phone — 16% of the window, spent before the table // started — and only 46 of that was a control. The other 74 were a 22px page title with // a gold rule under it, a back link, and a gold "Hand #1" pill: three things read once, // above a screen whose subject is the green table underneath. // // A phone held sideways had already solved this. The landscape block clips the title and // puts everything on one row, because at 390px tall there was no other option. Upright // never got the same answer, and upright is how this tool is held: measured after, the // header is 44px and the felt went from 496px to 570. // // Two claims are checked here. The stylesheet's, as text — the same way the seat sheet's // and the reduced-motion rules are — because "which row is this on" is a layout decision // that lives in the file. And the action bar's, by running it: the hand number had to go // somewhere, and where it went is a rendering question. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const fs = require("fs"); const path = require("path"); const { ActionBar, makeTable } = require("./load"); const PAGE = fs.readFileSync( path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8"); const CSS = fs.readFileSync( path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8"); // Every @media block written at exactly this condition, concatenated. // // All of them, not the first one — `@media (max-width: 700px)` is opened twice in this // file, the second time to hold the phone layout proper, and a helper that stopped at // the first found a block with none of these rules in it and reported them missing. const mediaBlock = condition => { const found = []; const needle = "@media "; for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) { const open = CSS.indexOf("{", at); if (CSS.slice(at + needle.length, open).trim() !== condition) continue; let depth = 0; for (let i = open; i < CSS.length; i++) { if (CSS[i] === "{") depth++; else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(open + 1, i)); break; } } } assert.ok(found.length > 0, `no @media block written as "${condition}"`); return found.join("\n"); }; const ruleIn = (block, selector) => { const re = new RegExp(`(^|[},])\\s*${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "m"); const found = block.match(re); return found ? found[2] : null; }; const PHONE = () => mediaBlock("(max-width: 700px)"); // The header's sideways rules are in the plain landscape query, not the one that also // caps the width — that later block is the seat sheet's and the action rail's. Both // exist, and picking the wrong one finds a block with no header in it at all. const LANDSCAPE = () => mediaBlock("(orientation: landscape) and (max-height: 560px)"); // --------------------------------------------------------------------------- describe("the header is one row on a phone", () => { test("upright gets the shape a phone on its side already had", () => { // These four together are the whole change: don't wrap, drop the word beside the // arrow, clip the title, and let the buttons size themselves in what is left. const phone = PHONE(); assert.match(ruleIn(phone, "#holdemHeader") || "", /flex-wrap:\s*nowrap/, "a wrapping header is a two-row header"); assert.match(ruleIn(phone, "#holdemHeaderLeft") || "", /display:\s*contents/, "the arrow has to be a flex item of the header, not a block inside a block"); assert.ok(ruleIn(phone, ".backLinkWord"), "the word beside the arrow is not dropped"); assert.ok(ruleIn(phone, "#holdemHeader h1"), "the title is not addressed on a phone"); }); test("both narrow layouts clip the title rather than removing it", () => { // `display: none` would take the page's only

off the accessibility tree, and // a page whose heading disappears below 700px is a page with no heading on the // device it is mostly read on. [["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => { const rule = ruleIn(block, "#holdemHeader h1"); assert.ok(rule, `${which} does not address the title`); assert.doesNotMatch(rule, /display:\s*none/, `${which} removes the page's heading instead of hiding it`); assert.match(rule, /clip-path:\s*inset\(50%\)/, `${which} should clip the heading the way .visuallyHidden does`); }); }); test("the actions row stops claiming a row of its own", () => { // `width: 100%` is what made it a second row: a flex child at full width cannot // share a line with anything, whatever the wrap setting says. const rule = ruleIn(PHONE(), "#holdemHeaderActions") || ""; assert.doesNotMatch(rule, /width:\s*100%/); assert.match(rule, /flex:\s*1/, "it should take the width the title gave up"); }); test("and the buttons grow into that width without ever shrinking below their labels", () => { // `flex: 1 1 0` would divide the row into equal thirds and squeeze "Next Hand ⟳" // — 105px of text — into 99. These are `white-space: nowrap`, so what gives is // the text. const rule = ruleIn(PHONE(), "#holdemHeaderActions .holdemBtn") || ""; assert.match(rule, /flex:\s*1\s+1\s+auto/, "an auto basis makes the label the floor"); assert.match(rule, /white-space:\s*nowrap/); assert.match(rule, /min-height:\s*46px/, "across is the only direction that tightens"); }); test("the arrow keeps a thumb-sized box once it loses its word", () => { // The word used to carry the width, and the padding only had to make up the // height. With the word gone the arrow is the whole target, and the first // attempt at 32px was measured by the layout sweep at 31x43 on every touch // viewport it runs — 56 scenarios, one control. [["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => { const rule = ruleIn(block, ".holdemBackLink") || ""; assert.match(rule, /min-width:\s*44px/, `${which}'s back arrow is under the touch floor`); // The negative margins that gave the hit area back to a two-row header would // now pull the arrow out of a row it is the first item of. assert.match(rule, /margin:\s*0/); }); }); test("and it still says where it goes when the word is gone", () => { // An "←" on its own is announced as "left arrow" and names nothing. The label // contains the visible text on the sizes that still show it, which is what // WCAG 2.5.3 asks of a name that says more than the label does. const link = PAGE.match(/]*>/); assert.ok(link, "the back link is gone"); const label = link[0].match(/aria-label="([^"]+)"/); assert.ok(label, "the back link has no accessible name for when the word is hidden"); assert.match(label[1], /Casino/, "the visible word has to appear in the spoken name"); }); }); describe("the hand number found somewhere to live", () => { test("it is not a pill beside the page title any more", () => { assert.ok(!PAGE.includes("handCounterBadge"), "a counter read after the fact does not earn a line of a phone's header"); }); test("it is in the row that already says what hand you are in", () => { // The street, the pot and the player count are the other three facts about the // current hand, and they are drawn together. This one costs nothing there: the // row was on the screen already. const source = fs.readFileSync( path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"), "utf8"); // Sliced from the top of renderStatus to the first mention of the street, so // finding the hand chip inside that slice is the ordering claim as well as the // presence one: it is appended before the street or it is not in here. const status = source.slice(source.indexOf("renderStatus()"), source.indexOf("actionStreet")); assert.match(status, /actionHand/, "the status row does not say which hand this is, or says it after the street"); }); test("a table that has never dealt says #1, not #0 and not #undefined", () => { const state = makeTable({ seatCount: 4 }); delete state.handNumber; assert.equal(ActionBar.handLabel(state), "#1"); assert.equal(ActionBar.handLabel({ handNumber: 0 }), "#1"); assert.equal(ActionBar.handLabel({ handNumber: 12 }), "#12"); assert.equal(ActionBar.handLabel(null), "#1"); }); test("it is drawn quieter than the street beside it", () => { // The street is read every deal; this is read when somebody asks about a hand // that is already over. Same row, different job, and the colour says so. const rule = CSS.match(/\.actionHand\s*\{([^}]*)\}/); assert.ok(rule, ".actionHand has no rule"); assert.match(rule[1], /color:\s*var\(--text-dim\)/); // A number that ticks over while the dealer is looking at it. assert.match(rule[1], /font-variant-numeric:\s*tabular-nums/); }); });