Seat the table properly on a phone held sideways

Landscape is the one shape this layout has never really fitted, and the audit
was calling two separate faults there.

The felt worked its own height out as `100vh - bar - 7rem`, guessing that
everything above it came to 7rem. That is 108px at 640x360, which the guess
covers, and 119px at 844x390, which it doesn't — so on an iPhone on its side the
bottom row of seats sat up to 26px inside the action bar. The measurement it
needed was already being published for the wide layout: the bar's real height,
the wrap's real offset and the bottom seat's overhang, all taken off the window.
It uses that now. The floor under it drops from 220px to 96px, because 220 was
not a size a 390px-tall window has to give — holding out for it was itself how
seats ended up behind the Fold button, which is the one thing the floor exists
to prevent.

That still left the header taking two rows, and the second row is 44px of the
390. The title block and the five buttons came to 829px inside a 765px header,
missing one line by 64px. The page title goes — the one thing on this screen a
dealer already knows — hidden from the eye rather than removed, so the heading
is still there for a screen reader. The buttons tighten across but never down:
they stay a 44px thumb target.

The seats themselves were spaced by angle, which is only even spacing on a
circle. On a 601x126 felt, ten seats at 36° apart put four of them down each
steep end, 20px apart, with pucks 72px tall — a seat's centre belonged to its
neighbour, so tapping a player got the wrong one. They now walk the rim by
distance instead, which puts them along the two long rails the way a real table
seats people. Closest neighbours go from 33px apart to 59px.

Measured at 640x360, 667x375 and 844x390 with 9-, 10- and 12-seat tables: every
combination the audit used to fail is clean, bar a 10-seat table at 640x360,
where two seats at the end of a rim that flat still overlap. Portrait, tablet
and desktop are unchanged — the floor never bound there, and on a rounder felt
walking the rim gives back the same angles it always did.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-02 03:13:00 -06:00
co-authored by Claude Opus 5
parent 419852a1f4
commit b97453fba1
5 changed files with 375 additions and 20 deletions
+73
View File
@@ -0,0 +1,73 @@
// How tall the felt is allowed to be.
//
// The bar is fixed to the bottom of the screen and grows with the hand, so the table
// has to be told what is left. Everything below is the arithmetic that decides it —
// the part that can be wrong without anyone noticing until a seat is behind the Fold
// button.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { ActionBar } = require("./load");
// window height, bar height, and how far down the page the felt starts.
const room = (windowHeight, barHeight, top) => ActionBar.feltRoomFor(windowHeight, barHeight, top);
describe("the room left for the felt", () => {
test("is what the window has after the bar, the header and the bottom seat", () => {
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
assert.equal(room(900, 127, 146), 900 - 127 - 146 - reserved);
});
test("shrinks when the bar grows mid-hand", () => {
// Side pots make the bar taller exactly when the dealer most needs to see who
// is still in.
const quiet = room(900, 117, 146);
const sidePots = room(900, 172, 146);
assert.equal(quiet - sidePots, 55);
});
test("leaves the bottom seat's overhang out of it", () => {
// The seat pucks hang below the felt's edge; the felt has to stop short by that
// much or the ring ends up in the bar even though the felt doesn't.
assert.ok(ActionBar.SEAT_OVERHANG_PX > 30, "half a puck");
assert.ok(room(900, 127, 146) < 900 - 127 - 146);
});
});
describe("a window too short to give what was asked", () => {
// A phone on its side: 390px tall, 127px of that is the bar and 119px is
// everything above the felt. What's left is under 100.
const landscape = () => room(390, 127, 119);
test("gets the measurement, not a comfortable number", () => {
// The floor used to be 220px, which this window hasn't got. Holding out for it
// put the bottom row of seats 26px inside the action bar — the exact fault the
// measurement exists to prevent.
assert.ok(landscape() < 150, `${landscape()}px`);
assert.ok(landscape() + 119 + 127 <= 390, "the felt still ends above the bar");
});
test("still draws a table rather than a line", () => {
assert.equal(ActionBar.feltRoomFor(200, 160, 100), ActionBar.FELT_FLOOR_PX);
assert.equal(ActionBar.feltRoomFor(0, 0, 0), ActionBar.FELT_FLOOR_PX);
});
test("never returns something a browser can't lay out", () => {
[[390, 300, 200], [100, 90, 90], [-10, 0, 0]].forEach(([h, bar, top]) => {
const answer = ActionBar.feltRoomFor(h, bar, top);
assert.ok(Number.isFinite(answer) && answer > 0, `${h}/${bar}/${top} gave ${answer}`);
});
});
});
describe("a window with room to spare", () => {
test("hands over everything that's left, floor or no floor", () => {
const reserved = ActionBar.SEAT_OVERHANG_PX + ActionBar.FELT_GAP_PX;
assert.equal(room(1000, 127, 146), 1000 - 127 - 146 - reserved);
assert.equal(room(1366, 120, 206), 1366 - 120 - 206 - reserved);
});
test("gives a whole number of pixels", () => {
assert.equal(room(844.4, 127.6, 223.2) % 1, 0);
});
});
+147
View File
@@ -0,0 +1,147 @@
// Where the seats sit on the rim.
//
// The ring used to space seats by angle, which is only the same as spacing them evenly
// when the felt is a circle. On a phone held sideways the felt is 601x126, and equal
// angles put four seats down each steep end, 20px apart, with pucks 72px tall — so a
// seat's centre belonged to its neighbour and tapping a player got the wrong one.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render } = require("./load");
// The seats as a dealer sees them: pixels on a felt of a given size.
const ringPixels = (count, width, height, rx = 44, ry = 42) => {
const aspect = width / height;
return Array.from({ length: count }, (_, i) => {
const pos = Render.positionOnRing(i, count, rx, ry, aspect);
return { x: (pos.left / 100) * width, y: (pos.top / 100) * height };
});
};
const gaps = (points) => points.map((p, i) => {
const next = points[(i + 1) % points.length];
return Math.hypot(next.x - p.x, next.y - p.y);
});
// What the ring did before: one turn cut into equal angles.
const byAngle = (index, count, rx, ry) => {
const radians = ((-90 + (360 / count) * index) * Math.PI) / 180;
return { left: 50 + rx * Math.cos(radians), top: 50 + ry * Math.sin(radians) };
};
describe("a round table", () => {
test("still spaces seats by angle, because on a circle that is the same thing", () => {
for (let seat = 0; seat < 9; seat++) {
const now = Render.positionOnRing(seat, 9, 40, 40, 1);
const before = byAngle(seat, 9, 40, 40);
assert.ok(Math.abs(now.left - before.left) < 0.2, `seat ${seat} left`);
assert.ok(Math.abs(now.top - before.top) < 0.2, `seat ${seat} top`);
}
});
test("seat one is at the top, whatever shape the felt is", () => {
[[600, 600], [601, 126], [351, 351], [949, 683]].forEach(([w, h]) => {
const first = Render.positionOnRing(0, 10, 44, 42, w / h);
assert.ok(Math.abs(first.left - 50) < 0.01, `${w}x${h} left`);
assert.ok(first.top < 50, `${w}x${h} is above the middle`);
});
});
});
describe("a felt wider than it is tall", () => {
// 640x360 landscape: the size the audit was failing at.
const wide = () => ringPixels(10, 601, 126);
const byAnglePixels = () => Array.from({ length: 10 }, (_, i) => {
const pos = byAngle(i, 10, 44, 42);
return { x: (pos.left / 100) * 601, y: (pos.top / 100) * 126 };
});
// The two seats at the right-hand end, which is where they crowd.
const endPairApart = (seats) => {
const rightmost = [...seats].sort((a, b) => b.x - a.x).slice(0, 2);
return Math.abs(rightmost[0].y - rightmost[1].y);
};
test("pulls the pair at each end apart", () => {
// They still share the end — ten seats on a rim this flat have nowhere else to
// be — but they stop sitting on top of each other.
const now = endPairApart(wide());
const before = endPairApart(byAnglePixels());
assert.ok(now > before * 1.4, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
});
test("nearly doubles the room between the closest two neighbours", () => {
// Not equal spacing in a straight line — at the ends the rim turns back on
// itself, so the same walk along it covers less ground across. Even there the
// gap goes from a third of a puck to most of one.
const now = Math.min(...gaps(wide()));
const before = Math.min(...gaps(byAnglePixels()));
assert.ok(now > before * 1.7, `${now.toFixed(1)}px now, ${before.toFixed(1)}px before`);
assert.ok(now > 55, `${now.toFixed(1)}px`);
});
test("never sits two seats on the same spot", () => {
const seats = wide();
seats.forEach((seat, i) => {
seats.slice(i + 1).forEach((other, j) => {
const apart = Math.hypot(seat.x - other.x, seat.y - other.y);
assert.ok(apart > 50, `seats ${i + 1} and ${i + j + 2} are ${apart.toFixed(1)}px apart`);
});
});
});
});
describe("the shape of the ring", () => {
test("is a mirror image left to right", () => {
const seats = ringPixels(10, 601, 126);
for (let seat = 1; seat < 5; seat++) {
const mirrored = seats[10 - seat];
assert.ok(Math.abs((seats[seat].x - 300.5) + (mirrored.x - 300.5)) < 0.5, `seat ${seat} across`);
assert.ok(Math.abs(seats[seat].y - mirrored.y) < 0.5, `seat ${seat} down`);
}
});
test("holds for the smallest and largest tables the seat count allows", () => {
[2, 3, 4, 9, 12, 24].forEach(count => {
const seats = ringPixels(count, 601, 126);
assert.equal(seats.length, count);
seats.forEach((seat, i) => {
assert.ok(Number.isFinite(seat.x) && Number.isFinite(seat.y), `${count}-handed seat ${i + 1}`);
assert.ok(seat.x >= 0 && seat.x <= 601, `${count}-handed seat ${i + 1} is on the felt`);
});
assert.ok(Math.min(...gaps(seats)) > 0, `${count}-handed seats are distinct`);
});
});
test("shares the rim out equally on a round table at any size", () => {
[3, 6, 9, 10, 24].forEach(count => {
const spacing = gaps(ringPixels(count, 500, 500, 40, 40));
const shortest = Math.min(...spacing);
const longest = Math.max(...spacing);
assert.ok(longest - shortest < 0.5,
`${count}-handed gaps ran ${shortest.toFixed(1)}-${longest.toFixed(1)}px`);
});
});
test("asked for no seats, offers no positions", () => {
assert.deepEqual(Render.ringAngles(0, 44, 42, 1), []);
});
test("puts each bet between its own player and the pot", () => {
// Where a dealer pushes the chips: on that player's line to the middle, short
// of the seat.
const bearing = (point) => Math.atan2(point.top - 50, point.left - 50);
const outFromMiddle = (point) => Math.hypot(point.left - 50, point.top - 50);
[[601, 126], [810, 583], [351, 351]].forEach(([w, h]) => {
for (let seat = 0; seat < 9; seat++) {
const player = Render.seatPosition(seat, 9, w / h);
const bet = Render.betPosition(seat, 9, w / h);
const offBy = Math.abs(bearing(bet) - bearing(player)) * (180 / Math.PI);
assert.ok(offBy < 1, `${w}x${h} seat ${seat + 1} is ${offBy.toFixed(1)}° off its spoke`);
assert.ok(outFromMiddle(bet) < outFromMiddle(player), `${w}x${h} seat ${seat + 1} is short of the seat`);
}
});
});
});