// The sheet a dealer opens mid-argument. // // It held twelve rulings with all their prose showing — 2,480px of it on a 390px // phone, of which 1,754 was below the fold — and a row of twelve chips across the top // to jump between them. The chips were an index of the twelve headings printed directly // above the same twelve headings: the same list twice, 172px of navigation before the // first word, and the dealer who used one landed in the middle of a scroll with eleven // rulings they hadn't asked for still wrapped around them. // // The headings are the index now. NN/g's rule for this shape — a longer list of // independent topics somebody opens one of — is an accordion, shut by default, and // half of these headings answer the question without being opened at all: "A short // all-in doesn't reopen the betting" is the ruling, not the topic. Measured after: // 29px below the fold on an iPhone with all twelve rows on screen at once, 51px on a // desktop. Opening the longest of them leaves 244px. // // These are layout and markup decisions, so they are checked as text — except the one // about what the sheet does when it is opened again, which is behaviour and lives in // main.js. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); const fs = require("fs"); const path = require("path"); const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net"); const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8"); const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8"); const MAIN = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "main.js"), "utf8"); const sheet = () => { const at = PAGE.indexOf('
= 0, "there is no rulings overlay"); return PAGE.slice(at, PAGE.indexOf('
{ const at = CSS.indexOf(`\n${selector} {`); assert.ok(at >= 0, `there is no \`${selector}\` rule`); return CSS.slice(at, CSS.indexOf("}", at)); }; // Every @media block written at exactly this condition, concatenated — the same helper // theHeader.test.js uses, and for the same reason: this condition is opened more than // once in the file, and a helper that stops at the first finds a block with none of // these rules in it and reports 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); assert.ok(found, `there is no \`${selector}\` rule in that block`); return found[2]; }; const SIDEWAYS = () => mediaBlock("(orientation: landscape) and (max-height: 560px)"); // Each ruling as its own slice of markup. const rulings = () => { const parts = sheet().split('
p.slice(0, p.indexOf("
"))); }; // --------------------------------------------------------------------------- describe("twelve rulings, shut", () => { test("every one of them is a disclosure, and there are still twelve", () => { assert.equal(rulings().length, 12); }); // NN/g is explicit that opening the first one by default costs the reader the // glance at the whole list, which on this sheet is the entire point. test("none of them is open in the markup", () => { assert.ok(!/
]*\bopen\b/.test(sheet()), "a ruling starts open, so the sheet no longer opens on its own contents"); }); // Native exclusive accordions close the open one when another is opened, which // shifts everything below it under the finger that is reaching. That is the // disorientation the accordion is here to remove, not add. test("opening one does not close another", () => { assert.ok(!/
]*\bname=/.test(sheet()), "the rulings are an exclusive accordion, so content moves when one opens"); }); test("the name of a ruling is the control that opens it", () => { rulings().forEach((r, i) => { assert.match(r, /

.+<\/h3><\/summary>/, `ruling ${i + 1} does not carry its heading in its summary`); }); }); }); describe("the index that was above them is gone, not merely hidden", () => { test("nothing builds a chip row any more", () => { assert.ok(!/rulingsIndex/.test(PAGE), "the index nav is still in the markup"); assert.ok(!/buildRulingsIndex|rulingsIndexBtn/.test(MAIN), "main.js still builds the index"); assert.ok(!/rulingsIndexBtn/.test(CSS), "the index chips still have styles"); }); // The short labels existed only to fit three chips on a line. With the full // headings doing the work there is nothing left to read them. test("and the short labels it read are gone with it", () => { assert.ok(!/data-short=/.test(PAGE)); }); }); describe("what a summary has to be to work", () => { // A is announced as a button and not as a heading, so without this the // twelve names — the whole of this sheet's navigation — are invisible to anybody // moving through it by heading. Same fix as the sidebar's panel names. test("the heading survives being a control", () => { assert.match(ruleFor(".ruling > summary > h3"), /display:\s*inline/); assert.match(ruleFor(".ruling > summary > h3"), /font:\s*inherit/); }); // A is content-box, so a min-height is *added* to its padding — the trap // the sidebar's headers were in, one stylesheet section over. test("a thumb gets its 44px, counted the way the box actually counts", () => { const rule = ruleFor(".ruling > summary"); assert.match(rule, /box-sizing:\s*border-box/); assert.match(rule, /min-height:\s*44px/); }); test("the accent belongs to the ruling being read, not to all twelve", () => { assert.match(ruleFor(".ruling > summary"), /color:\s*var\(--text\)/); assert.match(ruleFor(".ruling[open] > summary"), /color:\s*var\(--gold\)/); }); test("and so does the gold bar down the side", () => { assert.match(ruleFor(".ruling[open]"), /border-left:\s*2px solid/); assert.ok(!/border-left/.test(ruleFor(".ruling")), "every ruling wears the open one's marker"); }); }); describe("reading one of them", () => { // Five of the twelve are two paragraphs and they ran into each other: the sections // were spaced from one another, but nothing separated the halves of one ruling. test("two paragraphs of a ruling are two paragraphs", () => { assert.match(ruleFor(".ruling p + p"), /margin-top:\s*var\(--sp-\d\)/); }); //
keeps its state, so without this a dealer who read the odd-chip rule an // hour ago finds it still hanging open under the one they came back for. test("the sheet opens shut, however it was left", () => { const at = MAIN.indexOf("const openRulings"); assert.ok(at >= 0, "nothing owns what the sheet looks like when it opens"); const body = MAIN.slice(at, MAIN.indexOf("};", at)); assert.match(body, /\.ruling\[open\]/); assert.match(body, /open = false/); }); test("and that is what the button calls", () => { assert.match(MAIN, /getElementById\("openRulingsBtn"\)\.addEventListener\("click", openRulings\)/); }); }); // The dealer who has read the answer has to be able to leave without scrolling back // past it. Measured before this: on a 360x640 phone the twelve shut rulings are 791px of // a 549px sheet, so at the bottom of that scroll Done is 221px above the sheet's top // edge; on a 390x844 phone with one ruling opened — the only reason anybody is in here — // it is 262px. The seat menu's sheet had already been given the fix and this one, the // longer of the two, had not. describe("the way out does not scroll away", () => { test("the header is stuck to the top of the sheet's own scrollport", () => { const rule = ruleFor(".rulingsHeader"); assert.match(rule, /position:\s*sticky/); assert.match(rule, /top:\s*0/); // Out to the sheet's edges and back, or the list shows through either side of it. assert.match(rule, /margin:\s*0 calc\(var\(--sp-4\) \* -1\)/); assert.match(rule, /background:\s*var\(--surface-2\)/); }); test("and nothing is left above it for the list to scroll out through", () => { assert.match(ruleFor("#rulingsSheet"), /padding:\s*0 var\(--sp-4\)/, "the sheet keeps a top padding, which is a gap above a sticky header"); // The grip was the one thing up there. It belongs inside the header now. const header = sheet().match(/
[\s\S]*?<\/div>\s*<\/div>/); assert.ok(header, "the header is not in the markup where it was"); assert.match(header[0], /seatSheetGrip/, "the grip is still outside the header, so it scrolls away above it"); assert.ok(ruleFor(".rulingsHeader .seatSheetGrip"), "the grip has no rule placing it inside a two-column header"); }); test("the scroll inside the sheet stays inside it", () => { assert.match(ruleFor("#rulingsSheet"), /overscroll-behavior:\s*contain/); }); }); // A phone held sideways is 844x390: no height and swimming in width. Measured before // this, the bottom sheet was 460px wide in the middle of that window — 384px of it // unused either side — holding 754px of rulings in a 334px scrollport, 111px of which // went on a title and a caveat that stand still. Four of the twelve names on screen. // // Material's rule for a bottom sheet in landscape is to stop being one, and this tool // already does exactly that under this query for the action bar. So the sheet takes the // window and the width pays for the height: two columns of six, all twelve names on // screen at 844x390 with nothing to scroll at all. describe("a phone on its side gets the whole window", () => { test("the twelve are six and six in the markup", () => { const columns = sheet().split('
').slice(1); assert.equal(columns.length, 2, "there are not exactly two columns to stand side by side"); columns.forEach((c, i) => { const count = (c.slice(0, c.indexOf("\n
")).match(/
{ // `display: contents` hands the rulings straight to the list, so every layout // that isn't landscape sees exactly the markup it saw before the wrappers. assert.match(ruleFor(".rulingsColumn"), /display:\s*contents/); assert.match(ruleIn(SIDEWAYS(), ".rulingsColumn"), /display:\s*block/); }); test("sideways the sheet stops being a sheet and becomes the screen", () => { const block = SIDEWAYS(); assert.match(ruleIn(block, "#rulingsOverlay"), /align-items:\s*stretch/, "the sheet is still anchored to the bottom edge, so it is still short"); const sheetRule = ruleIn(block, "#rulingsSheet"); assert.match(sheetRule, /height:\s*100%/); assert.match(sheetRule, /max-height:\s*none/); // Capped rather than unbounded: this query also matches a short desktop window, // and two columns of a 1400px one would be 680px lines of body text. assert.match(sheetRule, /max-width:\s*62rem/); assert.match(sheetRule, /overflow:\s*hidden/, "the sheet scrolls as well as the list, which is two scrollbars for one list"); }); test("the title, the caveat and the way out share one line", () => { // That line is the whole of the 111px this screen was spending on them, and the // sticky positioning goes with the box being dropped: nothing above the list // scrolls here, so nothing has to stick. const block = SIDEWAYS(); assert.match(ruleIn(block, ".rulingsHeader"), /display:\s*contents/); assert.match(ruleIn(block, ".rulingsHeader .seatSheetGrip"), /display:\s*none/, "a grab handle on a sheet that is not a sheet any more"); assert.match(ruleIn(block, "#rulingsSheet"), /grid-template-areas:\s*\n?\s*"title intro done"/); }); test("the two columns are two flows, not one flow wrapped", () => { // A grid with twelve children of its own pairs them into six shared rows, and // opening the third ruling on the left pushes the ninth down the right-hand side // to keep the row square. Two wrappers means a column only ever moves itself. const rule = ruleIn(SIDEWAYS(), ".rulingsList"); assert.match(rule, /grid-template-columns:\s*1fr 1fr/); assert.match(rule, /overflow-y:\s*auto/); assert.match(rule, /min-height:\s*0/, "a grid item's floor is its content, so the list would push the sheet open"); }); test("and the gutter is reserved before it is needed", () => { // It arrives the moment a ruling is opened. Without the reservation it takes 8px // off the tracks and slides the right-hand column sideways under the eye that is // reading it. assert.match(ruleIn(SIDEWAYS(), ".rulingsList"), /scrollbar-gutter:\s*stable/); }); });