A phone held sideways is 640x360: short of height and swimming in width. The layout spent the whole of that shortage on one column — 70px of header, 85px of felt, 205px of action bar. Two rows of seat pucks 55px tall have to fit inside 85px of felt, which they cannot, so seven of nine finished past the rail with up to 17px of puck floating on the page. That single fault was 96 of the 98 offenders the layout audit could find anywhere. No amount of shrinking fixes it, because the shortage was never in the table. The bar is 205px tall and 529 wide to hold three buttons and a status line, and it was taking that height from the one element that needed height. So in landscape it moves: the bar becomes a rail down the right-hand side, the table takes the column beside it, and the felt goes from 529x85 to 409x205 — an actual oval, with room for two rows of pucks and a gap between them. It also puts Fold, Call and Raise under the right thumb rather than along the bottom edge, where a landscape grip has the palm. Which layout the bar is in is declared by the stylesheet in a custom property and read back by actionBar.js, because the one thing that must not exist is a second copy of that media query written in JavaScript. Then the ring stopped predicting itself. seatRingRadii works out the widest ring whose outermost puck still finishes inside the rail, and it is right about a puck it measured before the width search settled and before a name wrapped or was shortened — each worth up to 15px of height arriving after the answer. Four viewports finished with the top seat 5-6px over. render.js now checks the drawn result the way the audit judges it: if the worst puck hangs 6px past the rail, the ring comes in by 6px and they are placed again. A layout that predicts itself has to be right about the future; one that measures itself only has to be right about the present. Also in this round: - Names shorten at a word instead of being chopped mid-one. "Mike from work" on a nine-handed phone was "Mike f…"; he is Mike. A name never ends on a joining word, because "Mike from" looks cut off rather than shortened, and the whole name is on the tooltip either way. - The blind badge is counted in what a seat needs. It always points away from the table, which was taken to mean it costs nothing between two seats — true of a straight rail and false of a ring, where neighbours sit at different heights. Three tables had a player's stack painted under the next player's SB or BB, and that badge outranks everything else on the felt. It also got shorter, 15px to 12: clearing its own name at 5px of overhang instead of 9 hands every chair in the ring four pixels back, which at 640x360 is the difference between a 66px puck and a 48px one. - Border-box, inherited, for the whole app rather than nine elements opting in one at a time. Both elements that hadn't went wrong in the same afternoon: the rail overran the page's padding by 25px and put Resolve Pot underneath itself, and the raise pad's amount field pushed a horizontal scrollbar across a 640px phone. - The raise amount field is thumb-sized. The rule that sizes fields for a finger names the settings panels and the seat form, so the one field on the one surface built for thumbs was 36px on every phone in the list. The sweep never saw it because it had no step that opened the raise pad; it has one now, and that step also caught the rail squeezing its own status line under the pad. - mobile-web-app-capable alongside Apple's older name, which Chrome has been calling deprecated in this page's console on every load. The audit goes from 19 failing configurations and 98 offenders to 2 and 3, across 294 audits — thirteen viewports, three table shapes, a mouse and a thumb. What is left is twelve players on a 360px phone with the raise pad open, where twelve pucks at the 44px minimum genuinely do not fit on the felt; the search reports that rather than papering over it. Each new mechanism was checked by breaking it on purpose: removing the settle pass puts 26 offenders back, dropping the badge from the footprint 5, and un-sizing the raise field is caught by the step that now opens it. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
227 lines
12 KiB
JavaScript
227 lines
12 KiB
JavaScript
// Whether two seats can end up wanting the same pixels.
|
|
//
|
|
// The felt used to decide this from one number: the straight-line distance between the
|
|
// closest two *neighbouring* seats, judged against a fraction of a puck's width. Both
|
|
// halves of that were wrong, and the running page showed it. Measured across nine
|
|
// viewports and six table sizes:
|
|
//
|
|
// seventeen of fifty-four tables had one seat's name or stack painted under another
|
|
// ten of them had the collision between two seats that are not neighbours
|
|
//
|
|
// The second number is the interesting one. On a phone held sideways the felt is 528px
|
|
// wide and 87 tall, the ring is very nearly a straight line, and the seat that lands on
|
|
// top of you is the one across the table — seats 1 and 7, overlapping by 68px, while
|
|
// every neighbouring pair was fine and the check said the table was clean.
|
|
//
|
|
// The first is what a distance test can't fix even looking at every pair: distance has
|
|
// one number in it, and a puck is half again as wide as it is tall. 84px between centres
|
|
// is generous side by side and nowhere near enough on the diagonal.
|
|
const test = require("node:test");
|
|
const { describe } = require("node:test");
|
|
const assert = require("node:assert");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { Render } = require("./load");
|
|
|
|
const CSS = fs.readFileSync(
|
|
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
|
|
|
describe("two boxes on a ring", () => {
|
|
// A phone held sideways, once the action bar has taken its share: a felt far wider
|
|
// than it is tall, on which the ring is very nearly a straight line.
|
|
const felt = { width: 400, height: 70 };
|
|
const flat = { rx: 44, ry: 42 };
|
|
|
|
test("a pair that is not neighbours still counts", () => {
|
|
// Eight seats, a 48x60 puck. Every neighbouring pair is clear. Seats 1 and 5 are
|
|
// on top of each other, and so are 2 and 4, and 6 and 8 — the seat that lands on
|
|
// you here is the one across the table, because the rim doubles back at the ends
|
|
// of a shape this flat and a long walk apart is no distance at all.
|
|
assert.equal(Render.seatsCollide(8, felt, flat, 48, 60), true);
|
|
|
|
const neighbours = (count, w, h) => {
|
|
const points = Render.ringPositions(count, flat.rx, flat.ry, felt.width / felt.height)
|
|
.map(s => ({ x: (s.left / 100) * felt.width, y: (s.top / 100) * felt.height }));
|
|
return points.some((point, i) => {
|
|
const next = points[(i + 1) % points.length];
|
|
return Math.abs(point.x - next.x) < w && Math.abs(point.y - next.y) < h;
|
|
});
|
|
};
|
|
assert.equal(neighbours(8, 48 + Render.SEAT_CLEARANCE_PX, 60 + Render.SEAT_CLEARANCE_PX), false,
|
|
"no neighbouring pair overlaps, which is exactly why this had to look at all of them");
|
|
});
|
|
|
|
test("and the answer is the box, not a radius", () => {
|
|
// Two seats 60px apart across and 35px down, with a 64x40 puck. The distance
|
|
// between them is 69px, so the test this replaces — "are these two further apart
|
|
// than a puck is wide?" — called it clear. The boxes overlap by 4px by 5px.
|
|
const boxes = { width: 200, height: 100 };
|
|
const twoSeats = (dx, dy) => {
|
|
const original = Render.ringPositions;
|
|
Render.ringPositions = () => [
|
|
{ left: 50 - (dx / 2 / boxes.width) * 100, top: 50 - (dy / 2 / boxes.height) * 100 },
|
|
{ left: 50 + (dx / 2 / boxes.width) * 100, top: 50 + (dy / 2 / boxes.height) * 100 },
|
|
];
|
|
try {
|
|
return Render.seatsCollide(2, boxes, flat, 64, 40, 0);
|
|
} finally {
|
|
Render.ringPositions = original;
|
|
}
|
|
};
|
|
assert.ok(Math.hypot(60, 35) > 64, "and a distance test would have passed it");
|
|
assert.equal(twoSeats(60, 35), true, "60 across and 35 down, with a 64x40 puck");
|
|
assert.equal(twoSeats(60, 45), false, "far enough apart vertically");
|
|
assert.equal(twoSeats(66, 10), false, "far enough apart horizontally");
|
|
});
|
|
|
|
test("with daylight, so rounded corners don't read as touching", () => {
|
|
assert.ok(Render.SEAT_CLEARANCE_PX > 0);
|
|
});
|
|
|
|
test("a table nobody is sitting at can't collide with itself", () => {
|
|
assert.equal(Render.seatsCollide(1, felt, flat, 96, 60), false);
|
|
assert.equal(Render.seatsCollide(9, null, flat, 96, 60), false);
|
|
});
|
|
});
|
|
|
|
describe("the widths the felt may choose from", () => {
|
|
test("run widest first, because the widest that fits is the one wanted", () => {
|
|
const widths = Render.seatWidthCandidates();
|
|
widths.slice(1).forEach((w, i) => assert.ok(w < widths[i], `${widths[i]} then ${w}`));
|
|
});
|
|
|
|
test("and span the whole declared range", () => {
|
|
const widths = Render.seatWidthCandidates();
|
|
assert.equal(widths[0], Render.SEAT_WIDTH_MAX);
|
|
assert.equal(widths[widths.length - 1], Render.SEAT_WIDTH_MIN);
|
|
});
|
|
|
|
test("the floor is still a name, not a smudge", () => {
|
|
// At 44px the tight layout shows about five characters and an ellipsis. Below
|
|
// that a puck stops telling the dealer who is sitting there, which is the one
|
|
// thing on the felt it exists to say.
|
|
assert.ok(Render.SEAT_WIDTH_MIN >= 44);
|
|
assert.ok(Render.SEAT_TIGHT_BELOW_PX > Render.SEAT_WIDTH_MIN);
|
|
assert.ok(Render.SEAT_TIGHT_BELOW_PX < Render.SEAT_WIDTH_MAX);
|
|
});
|
|
|
|
test("and there are few enough of them to measure after every tap", () => {
|
|
// Each one costs a forced layout. Ten is the budget; the cache carries the rest.
|
|
assert.ok(Render.seatWidthCandidates().length <= 12);
|
|
});
|
|
});
|
|
|
|
describe("what the stylesheet has to keep holding up", () => {
|
|
test("the puck's width comes from the property, not from a breakpoint", () => {
|
|
// Three media queries used to declare it — 78px, 64px and 62px — three guesses
|
|
// at a number that depends on how many people are sitting down, which no media
|
|
// query can see. The same 390px phone has room for 90px at four seats and 61 at
|
|
// nine.
|
|
const declarations = CSS.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
const seatRules = [...declarations.matchAll(/(^|[,}])\s*(\.seat)\s*\{([^}]*)\}/g)]
|
|
.map(m => m[3]);
|
|
const hardWidths = seatRules
|
|
.flatMap(body => body.match(/(?<![\w-])width:\s*[^;]+/g) || [])
|
|
.filter(decl => !decl.includes("var(--seatPuckW"));
|
|
assert.deepEqual(hardWidths, []);
|
|
});
|
|
|
|
test("the puck measures what it says it measures", () => {
|
|
// As content-box a 64px puck came out 74 across once its padding and border were
|
|
// counted, and the search compares that number against distances on the felt.
|
|
const rule = CSS.match(/\n\.seat \{[^}]*\}/);
|
|
assert.ok(rule, ".seat rule not found");
|
|
assert.match(rule[0], /box-sizing:\s*border-box/);
|
|
});
|
|
|
|
test("and a blind badge never reaches sideways off its own puck", () => {
|
|
// This is what makes the footprint the puck and nothing more. The badge's
|
|
// remaining overhang is vertical, and it points away from the table at both ends
|
|
// — `top` on the upper arc, `bottom` on the lower — so two seats facing each
|
|
// other across the felt wear their badges back to back.
|
|
//
|
|
// It was not always so: at `left: -6px` the small blind hung off the puck's left
|
|
// corner, and on an iPhone Pro Max at six seats — pucks not touching at all —
|
|
// "SB" was painted across the next player's $1,137.
|
|
const badges = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)];
|
|
assert.equal(badges.length, 3, "expected all three blind badges");
|
|
badges.forEach(([, which, body]) => {
|
|
const left = body.match(/(?<![\w-])left:\s*(-?[\d.]+)px/);
|
|
assert.ok(left, `.${which}Badge should place itself horizontally`);
|
|
assert.ok(Number(left[1]) >= 0,
|
|
`.${which}Badge sits at ${left[1]}px, off the left of its own puck`);
|
|
});
|
|
});
|
|
|
|
test("and all three sit in the same place, so there is one spot to look at", () => {
|
|
// Two badges in two positions means the dealer's eye has to find which of them
|
|
// this seat is using before it can read which blind it is, forty times a night.
|
|
// A seat only ever wears one: the blinds are different players by construction,
|
|
// and the straddle chain starts left of the big blind and stops short of the
|
|
// small one.
|
|
const lefts = [...CSS.matchAll(/\.(sb|bb|str)Badge\s*\{([^}]*)\}/g)]
|
|
.map(([, , body]) => (body.match(/(?<![\w-])left:\s*(-?[\d.]+px)/) || [])[1]);
|
|
assert.equal(new Set(lefts).size, 1, `badges sit at ${lefts.join(", ")}`);
|
|
});
|
|
});
|
|
|
|
describe("the blind badge counts", () => {
|
|
// It didn't, for a while, and the reasoning had one hole in it. The badge hangs 9px
|
|
// off the puck and always points away from the table — `top: -9px` on the upper arc,
|
|
// `bottom: -9px` on the lower — so two seats facing each other across the felt wear
|
|
// them back to back and it costs nothing. That much is true.
|
|
//
|
|
// What was also claimed is that two seats on the *same* arc "wear them at the same
|
|
// height, offset by exactly as much as the pucks are". True of a straight rail,
|
|
// false of a ring: neighbours on a curve sit at different heights, so the badge
|
|
// pointing away from the table on one of them points at the corner of the other.
|
|
// Measured on the running page, three tables in the sweep had a player's stack
|
|
// painted under the next player's SB or BB — and the badge outranks everything else
|
|
// on the felt, so what the dealer reads in that spot is the wrong number entirely.
|
|
test("a seat needs room for the badge it might be wearing", () => {
|
|
const puck = { width: 64, height: 40 };
|
|
assert.deepEqual(Render.seatFootprint(puck),
|
|
{ width: 64, height: 40 + Render.SEAT_BADGE_OVERHANG_PX });
|
|
});
|
|
|
|
test("sideways it costs nothing, because the badge doesn't go sideways", () => {
|
|
// It sits inside the puck's own width — see "all three sit in the same place"
|
|
// above, which holds the stylesheet to it.
|
|
assert.equal(Render.seatFootprint({ width: 64, height: 40 }).width, 64);
|
|
});
|
|
|
|
test("the number here is the number the stylesheet draws", () => {
|
|
// Two files have to agree about one distance: holdem.css hangs the badge off the
|
|
// puck by it, render.js charges every chair in the ring for it. They are wired
|
|
// together by nothing but this test. Drift either way is silent — too small and
|
|
// badges land on neighbours again, too large and every puck on a nine-handed
|
|
// phone gives up a size step for clearance nothing needs.
|
|
const top = CSS.match(/\.blindBadge \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
|
|
const bottom = CSS.match(/\.seat\.seatBelowCentre \.blindBadge \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
|
|
assert.ok(top && bottom, "the badge should hang off both arcs");
|
|
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
|
|
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX,
|
|
"the same distance at both ends, or one arc is charged for the other's badge");
|
|
});
|
|
|
|
test("and it is counted once, not once per seat", () => {
|
|
// seatsCollide is given one box and compares centres against it, so the
|
|
// separation it asks for is one puck plus one badge. That is exactly right for
|
|
// two seats on the same arc each wearing one, and generous for the far commoner
|
|
// case where only one of the two is in the blinds at all. Counting it on both
|
|
// sides is what made a landscape table unsatisfiable the first time this was
|
|
// tried, back when that felt was 87px tall.
|
|
const puck = { width: 64, height: 40 };
|
|
const grew = Render.seatFootprint(puck).height - puck.height;
|
|
assert.equal(grew, Render.SEAT_BADGE_OVERHANG_PX);
|
|
assert.ok(grew < 2 * Render.SEAT_BADGE_OVERHANG_PX);
|
|
});
|
|
});
|
|
|
|
describe("with no browser to measure", () => {
|
|
test("nothing here throws", () => {
|
|
assert.equal(Render.measuredSeatBox(), null);
|
|
assert.equal(Render.seatFootprint(null), null);
|
|
});
|
|
});
|