Make the table big enough for the game around it

Looked at the felt at three times scale and the seats were falling off it. Not
sitting at a rail — hanging past the outer edge of the table into the page,
half a puck on green and half on nothing. Measured:

  1280x900, nine-handed   five of nine seats past the rail, worst 49px
  844x390,  six-handed    all six, worst 20px
  430x932,  six-handed    two, worst 12px
  360x640,  nine-handed   none

The ring sat at a flat 44% by 42% of the seat layer at every size, two numbers
picked against a phone. A fixed pair can only be right for one ratio of puck to
felt, and the felt's ratio changes with every viewport — so it was a different
amount of wrong on each one, which is exactly what makes something look like a
mistake rather than a choice.

The radius is derived now: the widest ring whose outermost seat still finishes
inside the table, worked out from the measured table, seat layer and puck. It
can only ever pull seats in — the old pair is kept as the ceiling — and it falls
back to that pair whenever there is nothing to measure, which is every call from
the unit tests and the first render before the felt is laid out.

Three things this turned up on the way, each caught by the sweep:

  * The seat layer is not centred on the table. Its insets differ top from
    bottom, so the first version — which assumed symmetry and worked from half
    widths — left a phone with 12px still over the rail. It works in absolute
    pixels against the nearer edge now.

  * The radius and the puck size depend on each other: the ring is limited by
    how big a puck is, the puck is sized by the `seatsTight` class, and that
    class was decided from the gaps between seats, measured at a radius that
    hadn't been worked out yet. Fixing the gap measurement then made a
    nine-handed phone overhang that had been fine, because the two were chasing
    each other. It is settled by trying the candidates in preference order
    instead: the roomy layout if everything fits, the tight one if not, and if
    neither fits, tight pucks on a ring widened only as far as it takes to stop
    seats colliding — because two seats fighting over a tap is a worse failure
    than a puck over the rail. A dealer can see an overhang; they cannot see
    that the seat they pressed wasn't the one that answered.

  * The spacing threshold was a flat 84px, the figure a 96px puck was judged
    against. Asking a 68px tight puck to keep a 96px puck's distance is a test
    it can never pass, so every landscape table fell through to the fallback and
    took an overhang it didn't need. It is a ratio now, not a number.

Result: worst overhang anywhere goes 49px to 9px, and 59 of 65 configurations
are completely clean. Desktop, tablet and every phone held upright: nothing over
the rail at all.

Residual, reported rather than tuned away: six configurations still overhang by
1-9px, all of them landscape at 640x360 and 667x375, where the felt is about
110-125px tall and no ring puts readable pucks inside that. The audit check is
left strict so they stay visible.

uiAudit gains "seats and chips stay on the table", which runs at every viewport
in the sweep. Fault-injected: putting the flat 44/42 back reproduces the original
failure exactly, including seat 3 at 49px.

Faster, too, because the measuring got hoisted out of the per-seat loops while I
was in there:

  renderSeats   7.3ms -> 1.8ms
  renderAll    12.4ms -> 6.8ms

619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes.
Offline re-verified: a whole hand with the network cut, $800 conserved.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-03 11:55:51 -06:00
co-authored by Claude Opus 5
parent 3ffce9d7c1
commit 1f3dda180f
3 changed files with 328 additions and 14 deletions
+175 -14
View File
@@ -145,8 +145,67 @@ const Render = {
// ---- Seats around the table (compact pucks) ----
seatPosition(index, count, aspect = this.feltAspect()) {
return this.positionOnRing(index, count, 44, 42, aspect);
// The widest the ring has ever been allowed to be. These are the numbers the ring
// used to be pinned at, kept as a ceiling so nothing gets looser than it was.
RING_MAX_RX: 44,
RING_MAX_RY: 42,
// A little daylight between the outermost puck and the rail.
SEAT_EDGE_MARGIN_PX: 6,
// Below this the ring has collapsed into the pot, which is worse than an overhang.
RING_MIN_R: 18,
// How far out the ring can sit and still keep a whole seat on the table.
//
// It used to be 44% by 42%, flat, at every size — two numbers picked against a
// phone. A fixed pair can only be right for one ratio of puck to felt, and the
// felt's ratio changes with every viewport: measured, at 1280x900 nine-handed,
// five of nine pucks finished past the outer edge of the table, the worst by 49px,
// and on a phone held sideways all six did. Seats floating off the felt onto the
// page read as the table being too small for the game rather than as players at a
// rail — and it is a different amount of wrong on every screen, which is what makes
// it look like a mistake rather than a choice.
//
// So it is derived rather than declared: the widest ring whose outermost seat still
// finishes inside the table. Falls back to the old pair whenever there is nothing to
// measure, which is every call from the unit tests and the first render before the
// felt has been laid out.
seatRingRadii() {
const fallback = { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY };
if (typeof document === "undefined") return fallback;
const box = this.ringBox();
const table = document.getElementById("pokerTable");
const puck = document.querySelector(".seat");
if (!box || !box.width || !box.height || !table || !puck) return fallback;
const tableBox = table.getBoundingClientRect();
const puckBox = puck.getBoundingClientRect();
if (!tableBox.width || !puckBox.width) return fallback;
// Worked in absolute pixels rather than from half-widths, because the seat
// layer is *not* centred on the table: its insets differ top from bottom (the
// landscape rule sets a floor on the top edge and a ceiling on the bottom), so
// assuming symmetry left the shorter side still hanging over. Measured that
// way, a phone still had 12px of puck past the rail after the first attempt.
//
// The reach that matters is to the nearer of the two edges.
const reach = (layerCentre, nearEdge, farEdge, puckSide, boxSide) => {
const room = Math.min(layerCentre - nearEdge, farEdge - layerCentre);
return ((room - puckSide / 2 - this.SEAT_EDGE_MARGIN_PX) / boxSide) * 100;
};
return {
rx: Math.max(this.RING_MIN_R, Math.min(this.RING_MAX_RX,
reach(box.left + box.width / 2, tableBox.left, tableBox.right,
puckBox.width, box.width))),
ry: Math.max(this.RING_MIN_R, Math.min(this.RING_MAX_RY,
reach(box.top + box.height / 2, tableBox.top, tableBox.bottom,
puckBox.height, box.height))),
};
},
seatPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii()) {
return this.positionOnRing(index, count, radii.rx, radii.ry, aspect);
},
// How many times wider than tall the felt actually is, which is the one thing the
@@ -179,8 +238,11 @@ const Render = {
// The two answers are within half a degree of each other, so this is not a fix for
// anything visible — it is just the honest way to say "on that player's spoke",
// and it saves the second walk.
betPosition(index, count, aspect = this.feltAspect()) {
const seat = this.seatPosition(index, count, aspect);
betPosition(index, count, aspect = this.feltAspect(), radii = this.seatRingRadii()) {
const seat = this.seatPosition(index, count, aspect, radii);
// Fractions of the seat's own reach, not of the felt — so when the ring pulls in
// to keep the pucks on the table, the chips come with it and stay on the same
// spoke, the same proportion of the way to the pot.
return {
left: 50 + (seat.left - 50) * (27 / 44),
top: 50 + (seat.top - 50) * (25 / 42),
@@ -279,10 +341,15 @@ const Render = {
// a flat ellipse those are nothing alike. At 640x360 six seats had 193px of rim
// each and sat 56px apart, because at the ends of a long oval the rim doubles back
// and two seats a long walk apart are stacked one above the other.
closestSeatGapPx(count, box = this.ringBox()) {
closestSeatGapPx(count, box = this.ringBox(), radii = this.seatRingRadii()) {
if (count < 2 || !box || !box.width || !box.height) return Infinity;
const points = this.ringPositions(count, 44, 42, box.width / box.height).map(seat => ({
// The ring the seats are actually drawn on, not the widest one they could be.
// This asked about a flat 44/42 while the renderer had pulled the ring in to
// keep the pucks on the table, so it measured gaps that did not exist: in
// landscape it reported plenty of room and the seats were overlapping each
// other's centres, which the audit caught as taps landing on the wrong player.
const points = this.ringPositions(count, radii.rx, radii.ry, box.width / box.height).map(seat => ({
x: (seat.left / 100) * box.width,
y: (seat.top / 100) * box.height,
}));
@@ -300,11 +367,94 @@ const Render = {
// Called on every render and again whenever the felt is resized — the action bar
// growing mid-hand shortens the table, and the seats have to answer that without
// waiting for the next render to come along.
updateSeatDensity() {
// Which layout the felt gets, and how wide its ring is.
//
// These two answers depend on each other, which is what made the first attempt at
// this wobble: the ring's radius is limited by the size of a puck, the puck's size
// is set by the `seatsTight` class, and that class was being decided from the gaps
// between seats — measured at a radius that hadn't been worked out yet. Fixing the
// gap measurement to use the real radius then made a nine-handed phone overhang
// that had been fine, because now the two were chasing each other.
//
// So it is settled by trying the candidates in order of preference rather than by
// solving it in one pass: the roomy layout if everything fits, the tight one if it
// doesn't, and if neither fits, the tight pucks on a ring wide enough that seats
// don't overlap — because two seats fighting over a tap is a worse failure than a
// puck sitting slightly over the rail. A dealer can see an overhang; they cannot
// see that the seat they meant to press wasn't the one that answered.
// How much space two neighbouring seats need between their centres.
//
// This was a flat 84px, which is the figure a 96px-wide puck was judged against —
// seats allowed to sit a little closer than their own width, since the pucks are
// rounded and a small overlap at the corners reads fine. Asking the *tight* layout
// to clear the same 84px is asking a 68px puck to keep a 96px puck's distance: it
// could never pass, so every landscape table fell through to the fallback and took
// an overhang it didn't need. The ratio is what carries over, not the number.
SEAT_ROOM_RATIO: 84 / 96,
seatRoomNeededPx() {
if (typeof document === "undefined") return this.SEAT_ROOM_PX;
const puck = document.querySelector(".seat");
if (!puck) return this.SEAT_ROOM_PX;
const width = puck.getBoundingClientRect().width;
return width ? width * this.SEAT_ROOM_RATIO : this.SEAT_ROOM_PX;
},
resolveSeatLayout(count) {
const layer = document.getElementById("seatsLayer");
if (!layer || !this.state) return;
layer.classList.toggle("seatsTight",
this.closestSeatGapPx(this.state.seatCount) < this.SEAT_ROOM_PX);
if (!layer) return { radii: this.seatRingRadii() };
for (const tight of [false, true]) {
layer.classList.toggle("seatsTight", tight);
const radii = this.seatRingRadii();
const roomy = this.closestSeatGapPx(count, this.ringBox(), radii) >= this.seatRoomNeededPx();
if (roomy && !this.tooTallForTheTable()) return { radii };
}
// Neither fits — a phone held sideways gives the felt about 140px of height and
// there is no ring that puts a dozen readable pucks inside that. Keep the small
// pucks and widen the ring only as far as it takes to stop the seats colliding,
// rather than all the way back out: going straight to the full width bought an
// overhang of 14px where 4 would have done.
layer.classList.add("seatsTight");
const contained = this.seatRingRadii();
const box = this.ringBox();
for (let step = 1; step <= 8; step++) {
const t = step / 8;
const radii = {
rx: contained.rx + (this.RING_MAX_RX - contained.rx) * t,
ry: contained.ry + (this.RING_MAX_RY - contained.ry) * t,
};
if (this.closestSeatGapPx(count, box, radii) >= this.seatRoomNeededPx()) return { radii };
}
return { radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY } };
},
updateSeatDensity() {
if (!this.state) return;
this.resolveSeatLayout(this.state.seatCount);
},
// Room is not only sideways.
//
// "Tight" used to mean one thing: seats close enough together to bump into each
// other. On a phone held sideways the felt is 141px tall and the pucks had grown to
// 88 — two-line names — so six seats had all the horizontal room in the world and
// could not fit top to bottom whatever the ring did. The gap check said everything
// was fine and 20px of every seat hung over the rail.
//
// A puck more than half the table's height is the honest test: two seats on
// opposite sides of the ring would then have to overlap the pot to fit at all, so
// the pucks are what has to give.
tooTallForTheTable() {
if (typeof document === "undefined") return false;
const table = document.getElementById("pokerTable");
const puck = document.querySelector(".seat");
if (!table || !puck) return false;
const tableBox = table.getBoundingClientRect();
const puckBox = puck.getBoundingClientRect();
if (!tableBox.height || !puckBox.height) return false;
return puckBox.height > tableBox.height * 0.5;
},
// The angles that cut the rim into `count` equal lengths, starting at the top.
@@ -385,18 +535,23 @@ const Render = {
layer.innerHTML = "";
const anchorToSeat = window.matchMedia("(max-width: 700px)").matches;
// Measured once for the whole ring. Asked per seat, this reads the layout back
// nine times in a loop that is also writing to it, which is the read-after-write
// pattern that makes the browser recompute layout on every iteration.
const aspect = this.feltAspect();
const radii = this.seatRingRadii();
this.state.seats.forEach((player, index) => {
const bet = Betting.committed(player);
if (bet <= 0) return;
const chip = this.makeBetChip(bet);
const pos = this.betPosition(index, this.state.seatCount);
const pos = this.betPosition(index, this.state.seatCount, aspect, radii);
if (anchorToSeat) {
const seat = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (!seat) return;
const seatPos = this.seatPosition(index, this.state.seatCount);
const seatPos = this.seatPosition(index, this.state.seatCount, aspect, radii);
chip.classList.add("betChipAnchored", seatPos.top > 50 ? "betChipAbove" : "betChipBelow");
seat.appendChild(chip);
return;
@@ -458,7 +613,13 @@ const Render = {
const layer = document.getElementById("seatsLayer");
const count = this.state.seats.length;
if (layer.childElementCount !== count) this.buildSeatNodes(layer, count);
this.updateSeatDensity();
// Settles the tight class and the ring radius together, and hands back the
// radius it settled on — so the loop below doesn't have to ask again. Both of
// these read the layout back, so they are taken once, here, before the loop
// starts writing to it: asked per seat they would force the browser to
// recompute the layout on every iteration.
const { radii } = this.resolveSeatLayout(count);
const aspect = this.feltAspect();
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
// A button on an empty chair normally means the table has drifted and wants
// fixing. Once it's the dead button it means the opposite — it's exactly where
@@ -470,7 +631,7 @@ const Render = {
this.state.seats.forEach((player, index) => {
const node = layer.children[index];
if (!node) return;
const pos = this.seatPosition(index, this.state.seatCount);
const pos = this.seatPosition(index, this.state.seatCount, aspect, radii);
node.style.left = `${pos.left}%`;
node.style.top = `${pos.top}%`;
+110
View File
@@ -0,0 +1,110 @@
// How wide the ring of seats is allowed to be.
//
// It sat at a flat 44% by 42% of the seat layer at every size — two numbers picked
// against a phone, then asked to hold for a 754x543 felt on a desktop and a 686x141
// one on a phone held sideways. A fixed pair can only be right for one ratio of puck
// to felt, so measured on the running page:
//
// 1280x900, nine-handed five of nine pucks past the table's outer edge, worst 49px
// 844x390, six-handed all six, worst 20px
// 430x932, six-handed two, worst 12px
//
// Seats floating off the felt onto the page read as the table being too small for the
// game rather than as players at a rail, and it was a different amount of wrong on
// every screen — which is what made it look like a mistake instead of a choice.
//
// The radius is derived from what has to fit now. Whether it *actually* fits is a
// question about a laid-out browser, and it is checked there: uiAudit's "seats and
// chips stay on the table" runs at every viewport in the sweep. What is checked here
// is the contract that function has to honour, including the two ways it is allowed
// to give up.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
describe("with no browser to measure", () => {
test("it falls back to the ring the tool has always used", () => {
// Every call from this suite has no DOM, and so does the very first render,
// before the felt has been laid out. Neither should throw or return nonsense.
assert.deepEqual(Render.seatRingRadii(), { rx: Render.RING_MAX_RX, ry: Render.RING_MAX_RY });
});
test("so seatPosition still answers", () => {
const pos = Render.seatPosition(0, 9, 1);
assert.ok(Number.isFinite(pos.left) && Number.isFinite(pos.top));
});
test("and nothing is measured for the puck height either", () => {
assert.equal(Render.tooTallForTheTable(), false);
});
});
describe("the ceiling and the floor", () => {
test("the old ring is the widest it can ever be", () => {
// Nothing gets looser than it was; this can only pull seats in.
assert.ok(Render.RING_MAX_RX <= 44);
assert.ok(Render.RING_MAX_RY <= 42);
});
test("and it never collapses the ring into the pot", () => {
// A puck wider than the table it sits on has no honest answer. Overhanging is
// the lesser evil — seats stacked on the pot would be unreadable, and the
// density rule shrinks the pucks before it ever gets this far.
assert.ok(Render.RING_MIN_R > 0);
assert.ok(Render.RING_MIN_R < Render.RING_MAX_RY);
});
test("with a margin that keeps the outermost seat off the rail", () => {
assert.ok(Render.SEAT_EDGE_MARGIN_PX > 0);
});
});
describe("the ring the radii describe", () => {
const spread = (radii, count = 9, aspect = 1.4) =>
Render.ringPositions(count, radii.rx, radii.ry, aspect);
test("a smaller radius puts every seat nearer the middle", () => {
const wide = spread({ rx: 44, ry: 42 });
const tight = spread({ rx: 30, ry: 28 });
wide.forEach((seat, i) => {
const wideOut = Math.hypot(seat.left - 50, seat.top - 50);
const tightOut = Math.hypot(tight[i].left - 50, tight[i].top - 50);
assert.ok(tightOut < wideOut, `seat ${i + 1} should have come in`);
});
});
test("and the seats stay evenly spread however far in they are", () => {
// Pulling the ring in must not bunch them: the spacing rule is arc length, and
// shrinking an ellipse uniformly keeps every gap in proportion.
const gaps = ring => ring.map((p, i) => {
const next = ring[(i + 1) % ring.length];
return Math.hypot(p.left - next.left, p.top - next.top);
});
const tight = gaps(spread({ rx: 30, ry: 28 }));
const smallest = Math.min(...tight), largest = Math.max(...tight);
assert.ok(largest / smallest < 1.35, `gaps ranged ${smallest.toFixed(1)}..${largest.toFixed(1)}`);
});
});
describe("the bets follow the seats", () => {
test("a chip sits on its own player's spoke, proportionally in", () => {
// The chip is a fraction of the seat's reach rather than of the felt, so when
// the ring pulls in the chips come with it instead of being left by the rail.
const radii = { rx: 30, ry: 28 };
const seat = Render.seatPosition(2, 8, 1.4, radii);
const bet = Render.betPosition(2, 8, 1.4, radii);
const seatOut = Math.hypot(seat.left - 50, seat.top - 50);
const betOut = Math.hypot(bet.left - 50, bet.top - 50);
assert.ok(betOut < seatOut, "the chip is nearer the pot than the player is");
assert.ok(betOut > seatOut * 0.4, "but still out on the player's side of the felt");
});
test("and it is the same spoke, not just the same distance", () => {
const radii = { rx: 30, ry: 28 };
const seat = Render.seatPosition(5, 9, 1.2, radii);
const bet = Render.betPosition(5, 9, 1.2, radii);
const angleOf = p => Math.atan2(p.top - 50, p.left - 50);
assert.ok(Math.abs(angleOf(seat) - angleOf(bet)) < 0.01);
});
});
+43
View File
@@ -793,8 +793,51 @@ const LayoutAudit = {
// on a mouse-driven browser — that is right, a 24px control is fine with a cursor
// on it — but "not measured" and "measured and fine" are different answers and the
// runner now prints which one it got.
// The table has to be big enough for the game around it.
//
// The seat ring sat at a flat 44% by 42% of the seat layer at every size — two
// numbers picked against a phone. A fixed pair can only be right for one ratio of
// puck to felt, and measured on the running page it was wrong nearly everywhere:
// at 1280x900 nine-handed, five of nine pucks finished past the outer edge of the
// table, the worst by 49px; on a phone held sideways all six did. Seats floating
// off the felt onto the page read as the table being too small for the game rather
// than as players at a rail — and it was a different amount of wrong on every
// screen, which is what made it look like a mistake instead of a choice.
//
// Checked against the table's outer edge, border included: sitting *on* the rail is
// fine, hanging off the far side of it is not. The bet chips are held to the same
// rule — a chip is meant to be on the felt between its player and the pot.
seatsOnTheTable(slackPx = 1) {
const name = "seats and chips stay on the table";
if (this.modalScope()) {
return { name, offenders: [], skipped: "a sheet is open over the table" };
}
const table = document.getElementById("pokerTable");
if (!table) return { name, offenders: [], skipped: "no table on this page" };
const felt = table.getBoundingClientRect();
if (!felt.width || !felt.height) return { name, offenders: [], skipped: "table not laid out" };
const offenders = [];
const check = (el, what) => {
const r = el.getBoundingClientRect();
if (!r.width || !r.height) return;
const over = Math.max(
felt.left - r.left, r.right - felt.right,
felt.top - r.top, r.bottom - felt.bottom);
if (over > slackPx) offenders.push(`${what} hangs ${Math.round(over)}px past the rail`);
};
[...document.querySelectorAll(".seat")].forEach((seat, i) => check(seat, `seat ${i + 1}`));
[...document.querySelectorAll("#betsLayer > *")].forEach((chip, i) =>
check(chip, `bet chip ${i + 1}`));
return { name, offenders };
},
runAll(options = {}) {
const checks = [
this.seatsOnTheTable(options.seatSlack),
this.touchAction(),
this.tapHighlight(),
this.textSelectable(),