Stop the table resizing under the thumb that is reaching for it

Two things moved the felt, and neither of them was the felt's business.

The raise pad. It is 57px taller than the row it replaces, and the table was sized
against it — so pressing Raise shrank the whole thing, re-ran the ten-candidate width
search, redrew every seat at a different size, and undid all of it when the dealer
pressed Raise to. Measured, five players nine-handed:

  1280x900   felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px
  360x640    felt 283x329 -> 283x279, pucks  44 ->  48, worst seat moved 50px
  390x844    felt 309x463 -> 309x451,                   worst seat moved 11px

Every raise, all night, at the one moment the dealer is reading the felt to decide
what to type. A phone held sideways already scored 0px: the rail is a column down the
side, and its height never was the table's business. So the felt is sized against the
bar at rest and the pad floats up over the bottom of it, which is what a transient
surface is supposed to do — the context it is over stays where it was. --actionBarHeight
is untouched and still says what is really covering the window, because that is what the
toast has to clear.

And the status row. It wraps, and flex settles its line breaks before it decides what
to shrink, so a name too long for the room left beside the clock and the undo pill did
not give way — it pushed them onto a third line. That is 22px off a felt underneath a
bar fixed to the bottom. Measured at 360x640 mid-street: "Dee · $600 behind" left the
table 283x329 and "Christopher · $1,200 behind" made it 283x310, with every seat moving
24px between one player and the next. Whose turn it was decided how big the table was.

An earlier round saw this and reserved two lines for the row; three is what it takes.
Reserving three would spend the 22px on every table for the sake of some of them. So
the actor, the clock and the undo pill get a line of their own that cannot wrap, and
the name shrinks into it — with the stack held out of the shrink, because a number read
wrong is a worse failure than a name read short. The row is 66px whoever is acting.

  It claims that line only when there is somebody on it. Claiming it unconditionally
  cost one in the state that can least afford it — a board running out has nobody to
  act, so the row is the undo pill alone, and it used to sit up beside "All-in —
  running out the board".

  The pill gives width back to the name on a phone: 10rem is 160px, and at 360 it took
  half the row, leaving "Christopher" cut to 23px of its 75. A media query, not
  `min(10rem, 34%)` — that was tried, and a percentage against a row sized to its own
  contents chases itself: the sweep caught the pill drawn 38px wide at ten viewports.

Verified by walking a hand at five viewports: one felt size, one puck width, and not
one seat moving, across a raise pad opened and shut and a full street of action.

1003 unit tests. 320 layout audits across 13 viewports with a mouse and a thumb, and
this is the first round in a while with nothing in them — the chip behind seat 11 on
the twelve-handed Galaxy S8, which master has carried for rounds, clears too.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-10 11:48:04 -06:00
co-authored by Claude Opus 5
parent 4a2ec1295b
commit ab04c43ffd
4 changed files with 332 additions and 11 deletions
+13 -3
View File
@@ -156,9 +156,19 @@ describe("controls that must not resize under a thumb", () => {
test("the undo pill has a fixed width, not a content width", () => {
// It names the last action, so its label changes on every tap. Measured across
// one hand before this: 148, 110, 109, 108, 109, 110, 120.
const rule = css.match(/\.actionUndo\s*\{[^}]*\}/);
assert.ok(rule, ".actionUndo rule not found");
assert.match(rule[0], /width:\s*[0-9.]+rem/);
//
// A length at every breakpoint, never a percentage: `min(10rem, 34%)` was tried
// and the percentage resolves against the row it sits in, which is sized to its
// own contents — the two chase each other, and the sweep caught the pill drawn
// 38px wide at ten viewports. A phone gets a narrower one through a media query
// instead, which is still one width at a time.
const rules = [...css.matchAll(/\.actionUndo\s*\{([^}]*)\}/g)].map(m => m[1]);
assert.ok(rules.length, ".actionUndo rule not found");
rules.filter(body => /(?<![\w-])width:/.test(body)).forEach(body =>
assert.match(body, /(?<![\w-])width:\s*([0-9.]+rem|100%)/,
"a content width would let the pill resize under the dealer's thumb"));
assert.ok(rules.some(body => /(?<![\w-])width:\s*[0-9.]+rem/.test(body)),
"the pill should still state a fixed width somewhere");
});
test("and its label is the part that ellipsizes", () => {
+134
View File
@@ -15,6 +15,8 @@ const { ActionBar, Render } = require("./load");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
const CSS_JS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"), "utf8");
// ---------------------------------------------------------------------------
@@ -160,6 +162,138 @@ describe("the button seat's name", () => {
// ---------------------------------------------------------------------------
describe("the table is sized against the bar, not against a pad over it", () => {
// The raise pad is 57px taller than the row it replaces, and the felt was sized
// against it. Measured with five players nine-handed, pressing Raise:
//
// 1280x900 felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px
// 360x640 felt 283x329 -> 283x279, pucks 44 -> 48, worst seat moved 50px
//
// The whole table jumped, re-ran the ten-candidate width search, and undid all of it
// when the dealer pressed Raise to. Every raise, all night, at the one moment they
// are reading the felt to decide what to type.
//
// A phone held sideways already scored 0px, because the rail is a column down the
// side and its height never was the table's business. This is the upright layout
// being held to what the sideways one already does.
test.afterEach(() => { ActionBar.raiseOpen = false; delete ActionBar.barHeightAtRest; });
test("a closed bar is measured, and remembered", () => {
ActionBar.raiseOpen = false;
assert.equal(ActionBar.restingBarHeight(151), 151);
assert.equal(ActionBar.barHeightAtRest, 151);
});
test("and an open pad gets the height the bar had before it opened", () => {
ActionBar.raiseOpen = false;
ActionBar.restingBarHeight(151);
ActionBar.raiseOpen = true;
assert.equal(ActionBar.restingBarHeight(208), 151,
"the felt would resize under the dealer's thumb mid-raise");
});
test("and gets it back the moment the pad closes", () => {
ActionBar.raiseOpen = false;
ActionBar.restingBarHeight(151);
ActionBar.raiseOpen = true;
ActionBar.restingBarHeight(208);
ActionBar.raiseOpen = false;
assert.equal(ActionBar.restingBarHeight(172), 172,
"a bar that really did grow — a side pot to show — still takes its room");
});
test("a pad open before anything was ever measured takes what it can see", () => {
// Nothing has been remembered yet, so the measurement is the only answer there
// is. Better a felt sized for the pad than one sized for undefined.
ActionBar.raiseOpen = true;
delete ActionBar.barHeightAtRest;
assert.equal(ActionBar.restingBarHeight(208), 208);
});
test("the bar that is actually covering the window is still reported honestly", () => {
// Only the felt's question is answered from the resting height. The toast has to
// clear whatever is really there, and so does the page's scroll padding, so
// --actionBarHeight stays a measurement.
const publish = CSS_JS.slice(CSS_JS.indexOf("const publish = () => {"));
const body = publish.slice(0, publish.indexOf("};"));
assert.match(body, /--actionBarHeight[^\n]*Math\.round\(height\)/,
"--actionBarHeight should still be the measured height");
assert.match(body, /publishFeltRoom\(.*restingBarHeight\(height\)/,
"the felt should be the one asking for the resting height");
});
});
// ---------------------------------------------------------------------------
describe("the status block is the same number of lines whoever is acting", () => {
// The bar is fixed to the bottom, so a line added to this block comes straight off
// the felt. The block wraps, and flex settles its line breaks before it decides what
// to shrink — so a name too long for the space left beside the clock and the undo
// pill did not give way, it pushed them onto a third line.
//
// Measured at 360x640 mid-street: "Dee · $600 behind" left the table 283x329 and
// "Christopher · $1,200 behind" made it 283x310, with every seat moving 24px between
// one player and the next. Whose turn it was decided how big the table was.
const acting = CSS.match(/\.actionActing \{([^}]*)\}/);
const who = CSS.match(/\n\.actionWho \{([^}]*)\}/);
const name = CSS.match(/\.actionWhoName \{([^}]*)\}/);
const stack = CSS.match(/\.actionWhoStack \{([^}]*)\}/);
test("the acting row takes a line of its own, once there is somebody on it", () => {
assert.ok(acting, ".actionActing rule not found");
assert.match(acting[1], /flex:\s*0 1 auto/,
"the pill on its own should not claim a line");
const withActor = CSS.match(/\.actionActing:has\(\.actionWho\) \{([^}]*)\}/);
assert.ok(withActor, "nothing gives the acting row its own line");
assert.match(withActor[1], /flex:\s*1 1 100%/,
"without a full basis it shares whatever line the wrap left it");
});
test("and only then — a run-out has nobody to act and cannot spare the line", () => {
// While a board is running out there is nobody to act, so this row is the undo
// pill alone. Claiming a line for it is 22px off the felt on the twelve-handed
// 360x640 table where the pucks are already at their 44px floor, and the sweep
// caught that as a seat's "ALL IN" going back under the blind badge next door.
assert.doesNotMatch(acting[1], /flex:\s*1 1 100%/);
});
test("and it shrinks rather than wrapping", () => {
// Left wrapping, this row reproduced the fault one level down: the undo pill is
// a fixed width, so a long name pushed it onto a line of its own and the block
// was 84px for some players and 66 for others.
assert.match(acting[1], /flex-wrap:\s*nowrap/);
});
test("the name is what gives way", () => {
// `min-width` on a flex item defaults to `auto` — "never smaller than your own
// content" — which is why the ellipsis this was written with never once appeared.
assert.ok(who && name, "the actor should be a name and an amount, not one string");
assert.match(who[1], /min-width:\s*0/);
assert.match(name[1], /min-width:\s*0/);
assert.match(name[1], /text-overflow:\s*ellipsis/);
});
test("and the stack is what does not", () => {
// The same rule the seat pucks keep: a number read wrong is a worse failure than
// a name read short, so the stack keeps every digit.
assert.ok(stack, ".actionWhoStack rule not found");
assert.match(stack[1], /flex:\s*none/);
});
test("except in the rail, which is a column and has no line to shrink into", () => {
// 190px wide: every one of these readings is on its own line there regardless,
// and squeezing a name into a fifth of the column would be shrinking to fit a
// row that isn't there.
const rules = [...CSS.matchAll(/\.actionActing \{([^}]*)\}/g)].map(m => m[1]);
assert.equal(rules.length, 2,
"one rule for the bar and one for the rail — no more, no fewer");
assert.ok(rules.some(body => /flex-wrap:\s*wrap\s*;/.test(body)),
"the rail should say what it wants instead");
});
});
// ---------------------------------------------------------------------------
describe("how much of the bottom is spoken for", () => {
// feltRoomFor asks one question — what is under the felt — and gets two different
// answers depending on the layout. Under the bar it is the bar. Under the rail it is