Files
JoshHeaps.Net/tests/holdem/theRulings.test.js
T
jheapsandClaude Opus 5 023228efc0 Give the rulings the window a sideways phone was already holding
Twelve rulings in a 460px sheet in the middle of an 844px window: 754px of
content in a 334px scrollport, 111 of it spent on a title and a caveat that
stand still, 384px of window width unused either side. Four of the twelve
names on screen, and the dealer is looking one up with an argument going on
at the table.

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: the twelve go
into two columns of six, all of them on screen at 844x390 with nothing to
scroll at all, and the title, the caveat and Done share one line instead of
standing on three.

Two wrappers in the markup rather than one grid of twelve, because a grid
pairs its children into shared rows: opening the third ruling on the left
would push the ninth down the right-hand side to keep the row square. Two
flows means a column only ever moves itself — measured, opening one leaves
the other six names exactly where they were. Everywhere but landscape the
wrappers are `display: contents` and the twelve are the single column they
have always been.

And the same sheet upright: the header is stuck to the top of its own
scrollport now, the way the seat menu's has been. Measured before, at the
bottom of that scroll Done was 221px above the sheet's top edge on a 360x640
phone, and 262 on a 390x844 one with a ruling open — which is the only
reason anybody is in here. A dealer who has read the answer had to scroll
back past it to leave.

The layout sweep's scroll-into-view guard learns the new box, or it finds a
sheet whose scrollHeight equals its clientHeight and decides nothing is out
of view.

1324 tests pass. The sweep is clean across 428 audits; contrast unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-15 04:36:36 -06:00

275 lines
14 KiB
JavaScript

// 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('<div id="rulingsOverlay"');
assert.ok(at >= 0, "there is no rulings overlay");
return PAGE.slice(at, PAGE.indexOf('<div id="seatMenuOverlay"', at));
};
const ruleFor = selector => {
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('<details class="ruling"');
return parts.slice(1).map(p => p.slice(0, p.indexOf("</details>")));
};
// ---------------------------------------------------------------------------
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(!/<details class="ruling"[^>]*\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(!/<details class="ruling"[^>]*\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, /<summary><h3>.+<\/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 <summary> 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 <summary> 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\)/);
});
// <details> 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(/<div class="rulingsHeader">[\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('<div class="rulingsColumn">').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 </div>")).match(/<details class="ruling"/g) || []).length;
assert.equal(count, 6, `column ${i + 1} holds ${count} rulings rather than six`);
});
});
test("but everywhere else they are still one column", () => {
// `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/);
});
});