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
@@ -16,10 +16,13 @@
danger is on the label rather than at the end of a scroll — Quick Split down
with Common rulings where the other "open something else" link already lives,
and the way back to the site up top where a back link goes. *@
@* The word is its own element so a phone can drop it and keep the arrow, and the
aria-label is what a screen reader gets either way. It 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. *@
<div id="holdemHeaderLeft">
<a href="/casino" class="holdemBackLink">← Casino</a>
<a href="/casino" class="holdemBackLink" aria-label="Back to Casino">←<span class="backLinkWord"> Casino</span></a>
<h1>Texas Hold'em Dealer</h1>
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
</div>
<div id="holdemHeaderActions">
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
+108 -19
View File
@@ -422,16 +422,10 @@ button,
}
}
.handCounterBadge {
display: inline-block;
margin-top: var(--sp-2);
padding: var(--sp-1) var(--sp-3);
border-radius: var(--r-pill);
background: rgba(216, 180, 106, 0.12);
border: 1px solid rgba(216, 180, 106, 0.4);
color: var(--gold);
font-size: var(--fs-sm);
letter-spacing: 0.04em;
/* The word beside the arrow, which a phone drops. Below 700px the link is the arrow
and a 44px box around it; the name a screen reader gets is on the <a> either way. */
.backLinkWord {
white-space: nowrap;
}
#holdemHeaderActions {
@@ -3103,6 +3097,16 @@ body.hasActionBar #holdemApp {
}
}
/* Which hand this is, at the head of the row that says what hand you are in. Quieter
than the street beside it — the street is read every deal and this is read when
somebody asks — and tabular, because it is a number that ticks over while the dealer
is looking at it. */
.actionHand {
color: var(--text-dim);
font-variant-numeric: tabular-nums;
font-size: var(--fs-xs);
}
.actionStreet {
color: var(--gold);
font-weight: 600;
@@ -4404,26 +4408,92 @@ body.hasActionBar #toastHost {
padding: var(--sp-4) var(--sp-3) calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px));
}
/* ---- One row, the way a phone on its side already had it ----
The header stood 120px tall on a 390px phone and only 46 of that was a control.
The other 74 were a 22px title with a gold rule under it, a back link and a gold
"Hand #1" pill — read once, at the top of a screen whose subject is the green
table underneath. Sixteen percent of the window, before the felt started.
A phone held sideways has solved this since the landscape block went in: one row,
title clipped, arrow and buttons. Upright never got the same answer, and upright
is how this tool is held. It is 44px now, and the felt is 76px taller — 496 to
572, 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 hand number moved into the
action bar's status row, where the street, the pot and the player count already
say what hand you are in. And the arrow keeps its 44px box; only the word "Casino"
is gone, which is what every phone does with a back link. */
#holdemHeader {
flex-wrap: nowrap;
align-items: center;
margin-bottom: var(--sp-4);
gap: var(--sp-2);
}
/* So the arrow is a flex item of the header itself rather than a block inside a
block — there is nothing else left in this group once the title is clipped. */
#holdemHeaderLeft {
display: contents;
}
/* Clipped, not removed: `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. */
#holdemHeader h1 {
font-size: var(--fs-xl);
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.backLinkWord {
display: none;
}
/* 44 square, not 32. 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
layout sweep caught it at 31x43 on every touch viewport there is. It costs
nothing — the arrow and the three buttons come to 323px of the 336 a 360px phone
has, which is the narrowest screen in the sweep. */
.holdemBackLink {
flex: none;
justify-content: center;
min-width: 44px;
/* 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. */
margin: 0;
font-size: var(--fs-md);
}
/* Takes the row rather than shrink-wrapping to the right of it. Sized to their
labels the three buttons left 78px of nothing where the title used to be, and
that gap buys nothing — spent on the buttons instead it is about 26px of extra
target each, on the controls a dealer presses once a hand with a thumb. */
#holdemHeaderActions {
width: 100%;
gap: var(--sp-2);
flex: 1 1 auto;
flex-wrap: nowrap;
min-width: 0;
gap: var(--sp-1);
}
/* Grow into that spare width, never shrink below the label. `flex: 1 1 0` would
divide the row into equal thirds and squeeze "Next Hand ⟳" — 105px of text — into
99, and these are `white-space: nowrap`, so what gives is the text. An `auto`
basis makes the label the floor and the spare width the bonus. */
#holdemHeaderActions .holdemBtn {
flex: 1 1 auto;
justify-content: center;
min-height: 46px;
padding: var(--sp-2) var(--sp-3);
font-size: var(--fs-base);
padding: var(--sp-2) var(--sp-2);
font-size: var(--fs-sm);
white-space: nowrap;
}
/* Keeps every puck inside the viewport: the ring is drawn within this box,
@@ -4842,8 +4912,12 @@ body.hasActionBar #toastHost {
they missed sharing one line by 64px, and at 640x360 by rather more. What goes is
the page title: the one thing on this screen a dealer already knows. It is hidden
from the eye rather than removed, so the page keeps its heading for a screen
reader and for the browser's own outline. The hand number stays — nothing else on
the screen says which hand this is. */
reader and for the browser's own outline.
The upright block above now does all of this too, and the hand pill it used to
make room for has gone to the action bar's status row for both of them. What is
left here is what upright does not need: this query reaches up to 900px wide,
where the 700px block does not, so a phone held sideways still needs to be told. */
#holdemHeader {
margin-bottom: var(--sp-2);
gap: var(--sp-1);
@@ -4851,6 +4925,10 @@ body.hasActionBar #toastHost {
align-items: center;
}
#holdemHeaderLeft {
display: contents;
}
#holdemHeader h1 {
position: absolute;
width: 1px;
@@ -4862,17 +4940,28 @@ body.hasActionBar #toastHost {
white-space: nowrap;
}
.handCounterBadge {
margin-top: 0;
.backLinkWord {
display: none;
}
.holdemBackLink {
flex: none;
justify-content: center;
min-width: 44px;
margin: 0;
font-size: var(--fs-md);
}
/* Tightened across, never down: the buttons keep the 44px a thumb needs. */
#holdemHeaderActions {
flex-wrap: nowrap;
margin-left: auto;
min-width: 0;
gap: var(--sp-1);
}
#holdemHeaderActions .holdemBtn {
flex: 0 1 auto;
padding-left: var(--sp-2);
padding-right: var(--sp-2);
font-size: var(--fs-sm);
@@ -277,10 +277,29 @@ const ActionBar = {
return `${name} to act, ${cost}. Pot ${Money.format(Betting.potSoFar(state))}.`;
},
// Pure, so the wording can be checked without a page to hang it on — the same reason
// Render.panelGlances is. A table that has never dealt is on hand one, not hand
// zero and not hand undefined, and that fallback is the whole of what there is to
// get wrong here.
handLabel(state) {
return `#${(state && state.handNumber) || 1}`;
},
renderStatus() {
const row = document.createElement("div");
row.className = "actionStatus";
// Which hand this is. It used to be a gold pill beside the page title, and it
// was the wrong fact in the wrong place: a counter that changes once a hand,
// read after the fact, holding a line of a 390px phone's header all night. It
// belongs with the street, the pot and the player count — the other three
// things that say what hand you are in — and here it costs nothing, because
// this row was already drawn.
const hand = document.createElement("span");
hand.className = "actionHand";
hand.textContent = this.handLabel(this.state);
row.appendChild(hand);
const street = document.createElement("span");
street.className = "actionStreet";
street.textContent = Betting.streetLabel(this.state.street);
@@ -3031,8 +3031,6 @@ const Render = {
if (!force && signature === this.lastHistorySignature) return;
this.lastHistorySignature = signature;
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
const list = document.getElementById("handHistoryList");
const emptyHint = document.getElementById("handHistoryEmptyHint");
const history = this.state.handHistory || [];
+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/);
});
});