Let a large felt have larger seats, and the type that goes with them

The felt grows with the window and the pucks did not: 96px was the only
ceiling there was. Nine-handed, the seats covered 19% of the felt on a phone,
11% on a 1280px desktop and 8% at 1440px and beyond — and the stack figure a
dealer reads sat at 10px on a 2560px monitor, the same 10px as on a 360px
phone, well under every published floor for text meant to be read.

Two things stop this costing more than it buys.

Every candidate width is a forced layout. Simply raising the ceiling took the
layout pass from 24ms to 73ms twelve-handed on a desktop, so a screen that
cannot use the wider widths must not measure them: the extra pass is offered
only when the arithmetic already says the seats have room to spare, and a phone
measures exactly the ten widths, in the same order, that it measured before.
Timed with the order alternated so neither carries the warm-up: 10.7ms against
10.4ms on a phone, and +3 to +6ms where the widths are actually used.

And a wide puck on a narrow felt is worse than a small one. The first version
of this let phones through, and a 144px puck on an iPhone's 278px ring is half
the width of the table: six-handed, all six seats sat over the rail by 15px
against 5px before, reading as a stack of cards rather than a table with people
around it. So the ceiling is a fifth of the felt's shorter span, capped at 144.
Five is measured rather than chosen — the shorter span is 233-303px on every
phone in the sweep including sideways and 617-714px on tablets and desktops, so
any divisor between 3.2 and 6.4 separates them — and the test pins both ends.

Above the ordinary ceiling the puck takes the larger type as well, which is the
point of the extra width. Nine-handed: an iPad goes 96px/12px to 134px/15px, a
1440px desktop 96px/12px to 140px/15px, and the seats go from 8% of the felt to
12%. Every phone, at every seat count, is untouched.

The sweep is byte-identical to before this change: same six configurations,
same 63 offenders, empty diff.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-06 00:56:59 -06:00
co-authored by Claude Opus 5
parent f8ed4abc85
commit e7ddff2fed
4 changed files with 296 additions and 2 deletions
@@ -1358,6 +1358,33 @@ button,
font-size: var(--fs-2xs); font-size: var(--fs-2xs);
} }
/* The other end of the same idea. A puck past the ordinary 96px ceiling has room the
type was never given: the felt grows with the window and the text did not, so a
dealer on a 2560px monitor read their stacks at the same 10px as one on a 360px
phone, on a table where the seats had dropped to 8% of the felt.
Two steps up the scale for the two numbers a dealer reads across the table, one for
the chair number in the corner, which is a reference rather than a reading. Not
proportional to the width — 96 to 144 is 1.5x and this is 1.25x — because a name is
a label on a puck, not a headline, and the puck also has to hold it: 15px is the
largest size at which "Mike from work", the longest name the sweep uses, still comes
out on one line at 144px.
Steps on the scale rather than a clamp(), for the same reason the tight set is: the
width is a continuum and the type is a set of deliberate sizes. */
.seatsRoomy .seatPuckName {
font-size: var(--fs-md);
}
.seatsRoomy .seatPuckStack,
.seatsRoomy .seatPuckTotal {
font-size: var(--fs-sm);
}
.seatsRoomy .seatChairNumber {
font-size: var(--fs-xs);
}
/* The hole cards give up their line here. A puck this close to its neighbours is 64px /* The hole cards give up their line here. A puck this close to its neighbours is 64px
tall and the seats at the ends of a landscape felt are 55px apart, so the line has to tall and the seats at the ends of a landscape felt are 55px apart, so the line has to
come from somewhere — and of the four things on a puck, the cards are the one the come from somewhere — and of the four things on a puck, the cards are the one the
@@ -405,6 +405,43 @@ const Render = {
// The step the search walks down in. Six pixels is finer than the difference between // The step the search walks down in. Six pixels is finer than the difference between
// two font sizes and coarse enough to settle in ten tries. // two font sizes and coarse enough to settle in ten tries.
SEAT_WIDTH_STEP: 6, SEAT_WIDTH_STEP: 6,
// And the ceiling when the felt has room to spare, which on anything tablet-sized or
// larger it does. 96px was the only ceiling there was, so the felt grew with the
// window and the seats did not: measured nine-handed, the pucks covered 19% of the
// felt on a phone, 11% on a 1280px desktop and 8% at 1440px and beyond, with the
// stack figure a dealer reads sitting at 10px on a 2560px monitor.
//
// 144 rather than further. Past it the extra width buys nothing a dealer can use —
// at 144 a 15px "Mike from work" is already on one line, which is the longest thing
// a name is asked to be — and a puck much wider than its own contents stops reading
// as a seat and starts reading as a card.
SEAT_WIDTH_ROOMY_MAX: 144,
// Coarser above the ordinary ceiling, because up there the difference is 8% of the
// puck rather than the difference between two font sizes, and every candidate width
// costs a forced layout.
SEAT_WIDTH_ROOMY_STEP: 12,
// And the puck may not be more than a fifth of the shorter span of the ring.
//
// The containment arithmetic keeps a puck inside the table's *box*; the rail it
// looks like it should stay inside is an ellipse, and at the top and bottom of the
// ring the ellipse curves away from the box. Seats have always sat a little over
// the rail because of it — measured before any of this, 10px on a desktop and 46px
// on a phone held sideways — and the wider the puck, the wider that gap opens.
//
// Which made the first version of this a bad trade on exactly the screens it was
// supposed to leave alone. A 144px puck on an iPhone's 278px ring is half the width
// of the table: six-handed, all six sat over the rail by 15px against 5px before,
// and it read as a stack of cards rather than a table with people around it.
//
// A fifth is measured, not chosen. The shorter span is 248-318px on every phone in
// the sweep including sideways, and 672-978px on tablets and desktops, so any
// divisor between about 3.3 and 7 splits them; five sits in the middle with room
// either side. Above it the pass is skipped and a phone measures exactly the ten
// widths, and settles on exactly the puck, that it did before.
SEAT_ROOMY_SPAN_RATIO: 5,
// A puck past the ordinary ceiling gets the larger type, which is the point of the
// extra width — see `.seatsRoomy` in holdem.css.
SEAT_ROOMY_FROM_PX: 108,
// Below this a puck cannot hold two lines of name and a row of hole cards, so the // Below this a puck cannot hold two lines of name and a row of hole cards, so the
// typography drops to the tight set in one go. Width scales continuously; type // typography drops to the tight set in one go. Width scales continuously; type
// changes deliberately, which is the split the modern guidance actually draws — // changes deliberately, which is the split the modern guidance actually draws —
@@ -529,11 +566,45 @@ const Render = {
applySeatWidth(layer, width) { applySeatWidth(layer, width) {
layer.style.setProperty("--seatPuckW", `${width}px`); layer.style.setProperty("--seatPuckW", `${width}px`);
layer.classList.toggle("seatsTight", width < this.SEAT_TIGHT_BELOW_PX); layer.classList.toggle("seatsTight", width < this.SEAT_TIGHT_BELOW_PX);
layer.classList.toggle("seatsRoomy", width >= this.SEAT_ROOMY_FROM_PX);
},
// Is it worth asking about pucks wider than the ordinary ceiling?
//
// Arithmetic, not a measurement, and that is the whole point: closestSeatGapPx is
// the tightest centre-to-centre distance the seats will sit at on the widest ring
// they could have, so if that already clears the widest ordinary puck and its
// clearance, the felt has room going spare and the coarse pass above the ceiling
// will find some of it. If it does not — every phone in the sweep, at every seat
// count — the pass is skipped and exactly the ten widths that were measured before
// are measured now.
//
// Worth being strict about, because the candidates are not free. Raising the ceiling
// for everybody took the layout pass from 24ms to 73ms on a twelve-handed desktop,
// and it is the phones, which would gain nothing at all, that can least afford it.
roomForWiderPucks(count, box) {
if (!box) return false;
if (this.roomyWidthCeiling(box) <= this.SEAT_WIDTH_MAX) return false;
const gap = this.closestSeatGapPx(count, box, this.seatRingRadii());
return gap > this.SEAT_WIDTH_MAX + this.SEAT_CLEARANCE_PX;
},
// The widest a puck may be drawn on this felt, whatever the seats would allow.
roomyWidthCeiling(box) {
if (!box || !box.width || !box.height) return this.SEAT_WIDTH_MAX;
const span = Math.min(box.width, box.height) / this.SEAT_ROOMY_SPAN_RATIO;
return Math.min(this.SEAT_WIDTH_ROOMY_MAX, Math.floor(span));
}, },
// Widest first, because the widest that fits is the one we want. // Widest first, because the widest that fits is the one we want.
seatWidthCandidates() { seatWidthCandidates(count, box) {
const widths = []; const widths = [];
if (count != null && this.roomForWiderPucks(count, box)) {
const ceiling = this.roomyWidthCeiling(box);
for (let w = ceiling; w > this.SEAT_WIDTH_MAX; w -= this.SEAT_WIDTH_ROOMY_STEP) {
widths.push(w);
}
}
for (let w = this.SEAT_WIDTH_MAX; w > this.SEAT_WIDTH_MIN; w -= this.SEAT_WIDTH_STEP) { for (let w = this.SEAT_WIDTH_MAX; w > this.SEAT_WIDTH_MIN; w -= this.SEAT_WIDTH_STEP) {
widths.push(w); widths.push(w);
} }
@@ -616,7 +687,7 @@ const Render = {
} }
const candidates = []; const candidates = [];
for (const width of this.seatWidthCandidates()) { for (const width of this.seatWidthCandidates(count, box)) {
this.applySeatWidth(layer, width); this.applySeatWidth(layer, width);
const puck = this.measuredSeatBox(); const puck = this.measuredSeatBox();
if (!puck) { if (!puck) {
+67
View File
@@ -442,6 +442,73 @@ is what makes it a test. Four faults were injected in total; the other three (dr
two flags, letting the count into the key instead of a boolean, and counting only hand two flags, letting the count into the key instead of a boolean, and counting only hand
bets so a table playing antes is missed) fail 3, 2 and 1 of the eight. bets so a table playing antes is missed) fail 3, 2 and 1 of the eight.
## Room the felt had and the type never got
The felt grows with the window and the pucks did not — 96px was the only ceiling there
was. Nine-handed, the seats covered 19% of the felt on a phone, 11% on a 1280px desktop
and **8% at 1440px and beyond**, with the stack figure a dealer reads at **10px on a
2560px monitor**, the same 10px as on a 360px phone.
Two things stop the fix costing more than it buys, and `seatWidthSearch.test.js` checks
both:
- **Every candidate width is a forced layout.** Raising the ceiling for everybody took
the layout pass from 24ms to 73ms twelve-handed on a desktop. A screen that cannot use
the wider widths must not measure them, so a phone is checked to measure *exactly* the
ten widths it measured before — same list, same order — and the measured cost on a
phone is unchanged (10.7ms against 10.4ms, which is noise).
- **A wide puck on a narrow felt is worse than a small one.** The first version let
phones through, and a 144px puck on an iPhone's 278px ring is half the width of the
table: six-handed, all six seats sat over the rail by 15px against 5px before, and it
read as a stack of cards rather than a table with people around it.
So the ceiling is a fifth of the felt's **shorter** span, capped at 144. Five is measured,
not chosen: the shorter span is 233-303px on every phone in the sweep including sideways,
and 617-714px on tablets and desktops, so any divisor between 3.2 and 6.4 separates them.
The test pins both ends, so if the felt ever changes shape enough to bring those together
it says so rather than letting the phones quietly start paying.
Above the ordinary ceiling the puck also takes the larger type — `.seatsRoomy`, two steps
up the scale for the two numbers read across the table and one for the chair number.
Steps rather than a `clamp()`, for the same reason the tight set is: the width is a
continuum and the type is a set of deliberate sizes.
| nine-handed | before | after |
| --- | --- | --- |
| iPad puck / name | 96px / 12px | **134px / 15px** |
| desktop 1440 puck / name | 96px / 12px | **140px / 15px** |
| seats as a share of the felt, 1440 | 8% | **12%** |
| every phone, every seat count | unchanged | unchanged |
### What the rail check is actually measuring, which is not the rail
Found while checking the above and **not fixed** — written down here because the number is
worth having and the fix is not a small one.
`#pokerTable` is `border-radius: 50%`. Both the settle pass in `render.js` and
`seatsOnTheTable()` in the audit compare a puck against the four edges of the table's
**bounding box**, which is a different and much slacker question than the one the message
"hangs Npx past the rail" claims to answer: at the top and bottom of the ring the oval
curves away from its own box, so a puck can be comfortably inside the box and visibly
outside the felt.
Measured against the ellipse instead — a point is outside when `(dx/a)² + (dy/b)² > 1`,
and the ray from the centre crosses the boundary at `1/√t` of the way out — across 35
configurations the check is seeing **303px of overhang as zero**, including **46px on a
phone held sideways** that has been there for as long as the check has.
Settling against the ellipse was tried and reverted, because it is not the local fix it
looks like. Pulling the ring in far enough to clear the oval crowds the seats, and on a
twelve-handed phone with everybody all-in it put seat 3's centre inside seat 4 — the one
trade the width search explicitly refuses to make, since a dealer can see a puck over the
rail but cannot see a tap answering for the wrong player. Guarding against that instead
leaves the ring out and the overhang worse: the sweep went from 6 failing configurations
to 77, with 355 rail reports.
Which is the finding. On a crowded table **keeping the seats off the rail and keeping them
off each other are in genuine conflict**, and resolving it needs the two settled together
rather than one bolted onto the other.
## Why the hand evaluator is tested the way it is ## Why the hand evaluator is tested the way it is
`handRank.js` is the only module here that can pay the wrong person without anybody `handRank.js` is the only module here that can pay the wrong person without anybody
+129
View File
@@ -0,0 +1,129 @@
// How wide a seat puck is allowed to get, and on which screens.
//
// The felt grows with the window and the pucks did not: 96px was the only ceiling there
// was, so nine-handed the seats covered 19% of the felt on a phone, 11% on a 1280px
// desktop and 8% at 1440px and beyond — with the stack figure a dealer reads sitting at
// 10px on a 2560px monitor, the same 10px as on a 360px phone.
//
// Two things stop the fix from costing more than it buys, and both are checked here.
//
// Every candidate width is a forced layout, and raising the ceiling for everybody took
// the layout pass from 24ms to 73ms on a twelve-handed desktop — so a screen that cannot
// use the extra widths must not measure them. And a 144px puck on an iPhone's 278px ring
// is half the width of the table: the first version of this let phones through, and
// six-handed all six seats sat over the rail by 15px against 5px before, reading as a
// stack of cards rather than a table with people around it.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
// Ring boxes read off the running page, not invented. #seatsLayer, nine-handed, at the
// viewports the sweep visits.
const RINGS = {
"Galaxy S8": { width: 233, height: 282 },
"iPhone 14": { width: 263, height: 470 },
"Pro Max": { width: 303, height: 535 },
"phone sideways": { width: 565, height: 257 },
iPad: { width: 689, height: 689 },
desktop: { width: 857, height: 617 },
"desktop 1440": { width: 992, height: 714 },
};
const SMALL = ["Galaxy S8", "iPhone 14", "Pro Max", "phone sideways"];
const LARGE = ["iPad", "desktop", "desktop 1440"];
describe("which felts may have wider pucks at all", () => {
test("no phone does, held either way up", () => {
// The guarantee the phones are owed: they measure the widths they always
// measured and settle on the puck they always settled on.
SMALL.forEach(name => {
assert.ok(Render.roomyWidthCeiling(RINGS[name]) <= Render.SEAT_WIDTH_MAX,
`${name} would be allowed a ${Render.roomyWidthCeiling(RINGS[name])}px puck`);
[2, 4, 6, 9, 12].forEach(count => assert.equal(
Render.roomForWiderPucks(count, RINGS[name]), false,
`${name} ${count}-handed`));
});
});
test("and every tablet and desktop does", () => {
LARGE.forEach(name => {
assert.ok(Render.roomyWidthCeiling(RINGS[name]) > Render.SEAT_WIDTH_MAX, name);
});
});
test("with a comfortable margin either side of the line", () => {
// The rule is a fifth of the shorter span, and the point of five is that it is
// not near either group. If a change to the felt ever moves these together,
// this says so before the phones quietly start paying for it.
const biggestSmall = Math.max(...SMALL.map(n => Math.min(RINGS[n].width, RINGS[n].height)));
const smallestLarge = Math.min(...LARGE.map(n => Math.min(RINGS[n].width, RINGS[n].height)));
assert.ok(smallestLarge > biggestSmall * 1.8,
`${biggestSmall}px and ${smallestLarge}px are too close for one divisor to separate`);
// Every divisor that keeps the phones out is at least the widest phone span over
// the ordinary ceiling; every one that lets the tablets in is below the narrowest
// large span over the same. 303/96 = 3.2 and 617/96 = 6.4, and five is between.
const floor = biggestSmall / Render.SEAT_WIDTH_MAX;
const cap = smallestLarge / Render.SEAT_WIDTH_MAX;
assert.ok(Render.SEAT_ROOMY_SPAN_RATIO >= floor,
`${Render.SEAT_ROOMY_SPAN_RATIO} lets a phone through; it has to be at least ${floor.toFixed(2)}`);
assert.ok(Render.SEAT_ROOMY_SPAN_RATIO < cap,
`${Render.SEAT_ROOMY_SPAN_RATIO} shuts the tablets out; it has to be under ${cap.toFixed(2)}`);
});
test("and a felt with no size at all is not one of them", () => {
assert.equal(Render.roomForWiderPucks(9, null), false);
assert.equal(Render.roomForWiderPucks(9, { width: 0, height: 0 }), false);
});
});
describe("the widths a felt actually measures", () => {
const widthsFor = (name, count) => Render.seatWidthCandidates(count, RINGS[name]);
test("a phone measures exactly the ten it measured before", () => {
const expected = [];
for (let w = Render.SEAT_WIDTH_MAX; w > Render.SEAT_WIDTH_MIN; w -= Render.SEAT_WIDTH_STEP) {
expected.push(w);
}
expected.push(Render.SEAT_WIDTH_MIN);
SMALL.forEach(name => [6, 9, 12].forEach(count =>
assert.deepEqual(widthsFor(name, count), expected, `${name} ${count}-handed`)));
});
test("a desktop measures a handful more, and no more than a handful", () => {
const extra = widthsFor("desktop 1440", 9).length - widthsFor("Galaxy S8", 9).length;
assert.ok(extra > 0, "the wider widths never got offered");
assert.ok(extra <= 6, `${extra} extra forced layouts is more than this is worth`);
});
test("never wider than the felt's own ceiling", () => {
Object.keys(RINGS).forEach(name => [2, 6, 9, 12].forEach(count => {
const widest = Math.max(...widthsFor(name, count));
assert.ok(widest <= Math.max(Render.SEAT_WIDTH_MAX, Render.roomyWidthCeiling(RINGS[name])),
`${name} ${count}-handed offered ${widest}px`);
assert.ok(widest <= Render.SEAT_WIDTH_ROOMY_MAX);
}));
});
test("always widest first, always down to the floor", () => {
// The search takes the first width that fits, so the order is the whole
// algorithm — and the floor has to be on the list or a crowded table has
// nothing left to fall back to.
Object.keys(RINGS).forEach(name => [2, 9, 12].forEach(count => {
const widths = widthsFor(name, count);
widths.forEach((w, i) => {
if (i === 0) return;
assert.ok(w < widths[i - 1], `${name}: ${widths.join(", ")} is not descending`);
});
assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
}));
});
test("and a crowded table on a big felt is told no like any other", () => {
// Room on the felt is necessary, not sufficient: the seats still have to clear
// each other. Enough of them on any ring and the wider widths stop being
// offered, which is the arithmetic doing the refusing rather than the
// measurement having to.
const crowded = [24, 30, 40].map(count => Render.roomForWiderPucks(count, RINGS.desktop));
assert.deepEqual(crowded, [false, false, false]);
});
});