Give the felt back the sixth of the phone the title was holding

The header stood 120px tall on a 390x844 phone 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
whole subject is the green table underneath. Sixteen percent of the window,
spent before the felt started.

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. It is 44px now, the table starts at 78px instead of 152, and the felt
went from 496px to 570 — about fifteen percent more table on the same screen.

Nothing left the page:

- The title is still the document's <h1>, clipped rather than removed, so it is
  still the heading a screen reader lands on and still what the browser tab and
  the installed app are called.
- The back link keeps a 44px box and loses only the word "Casino", which is what
  every phone does with a back link. The name a screen reader gets is on the <a>
  either way, and it contains the visible word on the sizes that still show it.
- The hand number moved to the action bar's status row, in front of the street.
  That row already says what hand you are in — the street, the pot, the player
  count — and this is the fourth fact of the same kind; a counter read after the
  fact had no business holding a line of the header all night. It costs the bar
  nothing: the five audit scenarios that report the bar's height report the same
  249, 227, 203, 227 and 227 pixels as before.

The three buttons take the width the title gave up rather than shrink-wrapping
to the right of it, which is 26px of extra target each. `flex: 1 1 0` was wrong
here — it divides the row into equal thirds and squeezes "Next Hand ⟳", 105px of
text, into 99, and these are nowrap, so what gives is the label. An `auto` basis
makes the label the floor and the spare width the bonus. On the narrowest phone
in the sweep the arrow and all three buttons come to 323px of 336.

The arrow was 32px on the first pass and the layout sweep caught it at 31x43 on
every touch viewport there is — 56 scenarios, one control. The word had been
carrying the width; with the word gone the arrow is the whole target. 44 square.

The sweep is down from five failures to one. Four of the five were seats and bet
chips colliding on a 360px Galaxy S8, and 76px more felt was all they needed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-09 08:11:21 -06:00
co-authored by Claude Opus 5
parent 0f7213ec2d
commit 171e878262
5 changed files with 307 additions and 23 deletions
+175
View File
@@ -0,0 +1,175 @@
// 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 <h1> 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(/<a href="\/casino"[^>]*>/);
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/);
});
});