Six players at a nine-handed table is the most ordinary table this tool draws, and on every phone in the sweep one of them had their stack painted under the player next to them. Measured across nine viewports and six table sizes: seventeen of fifty-four tables hid a name or a stack behind something belonging to another seat. Two causes, and the layout only knew about a third of one of them. The felt decided how big a puck could be from a single number — the straight-line distance between the closest two *neighbouring* seats, judged against a fraction of a puck's width. Distance has one number in it and a puck is half again as wide as it is tall, so the same 84px was generous side by side and nowhere near enough on the diagonal. And neighbours are the wrong pairs to ask about: on a phone held sideways the felt is 528x87, the ring is very nearly a straight line, and the seat that lands on top of you is the one across the table. Ten of the tables in the sweep had a collision that a neighbours-only check cannot see — seats 1 and 7, overlapping by 68px, while every adjacent pair had room. seatsCollide() asks the real question instead: do any two of these boxes want the same pixels. Two equal axis-aligned boxes overlap exactly when their centres are closer than one box in both axes, so it is an answer rather than a proxy, and it looks at every pair. The second cause was the small blind badge, sitting at `left: -6px` — hanging off its own puck's left corner onto whatever was beside it. On an iPhone Pro Max at six seats, with the pucks not touching at all, "SB" was painted across the next player's $1,137. All three badges now sit inside the puck's width, and in the same place as each other: a seat only ever wears one, so two positions bought nothing except making the dealer's eye find which of two spots this seat was using before it could read which blind it is. Then the layout had to be able to act on the answer, and it couldn't. It chose between two widths — 96px or 58px — and if neither fitted it widened the ring and let the seats overlap, on the reasoning that a dealer can see an overhang but cannot see a tap landing on the wrong player. That trade is real; it was also a false choice, because the tables that overlapped did not want either number. At nine seats a Galaxy S8 had room for a 61px puck, an iPhone 14 for 68, a Pixel 7 for 74, an iPhone Pro Max for 79. Three media queries declared three more of these numbers — 78px, 64px, 62px — at three breakpoints, guessing at something a media query cannot see: the same 390px phone has room for a 90px puck at four seats and 61 at nine. So the width is searched for now, not chosen. render.js walks candidates from the top, applies each one, measures what the browser actually drew, and takes the widest that leaves every seat its own box. There was a width that worked on every table in the sweep; the code was never asking for it. Ten candidates is ten forced layouts, so the result is cached against the felt's size, the seat count and the names — measured 12.5ms cold, 0.8ms warm, and renderSeats runs after every tap. Three things this got wrong on the way, all caught by measuring rather than reading: The badge overhang was folded into the space between two seats. That collapsed the ring on a phone held sideways — 87px of height, and 18px of badge is most of the room the ring has to move in — until a six-handed landscape table was unsatisfiable at any width. It didn't belong there: the badge points *away* from the table at both ends, so two seats facing each other across the felt wear them back to back. box-sizing: border-box, which the puck needed so the searched number means the width it is drawn at, quietly turned every min-height from a content height into a drawn one. An empty chair went from a 52px tap target to a 42px one on every phone in the sweep. The audit found it; the four min-heights have each gained the padding and border they used to be quoted without. The new audit check reported a name under a neighbour on a table whose seats were 9px apart. A puck's name is a `-webkit-box` with `line-clamp`, and getClientRects() hands back a rect for every line the text *would* have taken, including the ones the clamp throws away — so the probe was hit-testing 30px below the puck, where nothing is painted. It clips to what is painted now. uiAudit gains "no seat covers another seat's writing", the complement of the check already there for a puck's own decoration on its own name — which is why nothing caught this: seatLabelsClear() only ever looked inside one seat. Fault-injected against the previous version: eighteen configurations, naming both causes. And the sweep itself now runs two table shapes. It had run one for its whole life — five players at a nine-handed table — and how crowded the felt is depends on how many people are sitting down far more than on anything a viewport can report. A full table was never once looked at. 632 tests, 0 failures. 190 audits across thirteen viewports at two table shapes, with a mouse and with a thumb. Every table from two seats to twelve now keeps its own text on every viewport in the sweep. One configuration still reports: a dozen players on a 640x360 phone, where the felt is 87px tall and two rows of 55px pucks cannot both fit and stay on the rail — the ring grows 8px past the edge to keep the seats off each other, which is the trade the old code named and got right. Left reported rather than tuned away. Names on a crowded phone table truncate harder than they did, because the puck is now sized to fit instead of allowed to overlap: "Christopher" is "Christ…" at nine seats on an iPhone 14. The full name is in the DOM, in the seat's form and in the sidebar. The next lever on that is the seat layer's inset, which is still a fixed 44px of "half a puck" for a puck that is no longer 96px wide — worth 54px to 60px where it was measured. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
173 lines
8.1 KiB
JavaScript
173 lines
8.1 KiB
JavaScript
// Drives uiAudit.js over the dealer's surfaces at every viewport worth caring about.
|
|
//
|
|
// node tests/holdem/runUiAudit.js [baseUrl]
|
|
//
|
|
// Needs Playwright available (`npx playwright install chromium` once). It is
|
|
// deliberately NOT wired into the unit-test suite or CI, because that suite is
|
|
// dependency-free and runs in a second — this one needs a browser and a running
|
|
// site. Run it after any change that adds or resizes a control.
|
|
//
|
|
// No Playwright to hand? Open the page, paste tests/holdem/uiAudit.js into devtools,
|
|
// and call LayoutAudit.runAll(). Same checks, one viewport.
|
|
const path = require("path");
|
|
|
|
const BASE = process.argv[2] || "http://localhost:5299";
|
|
const AUDIT = path.join(__dirname, "uiAudit.js");
|
|
|
|
// Portrait phones, tablets, desktops, then the same phones on their side — landscape
|
|
// is where this layout has broken most often, because the table is sized from width
|
|
// and the action bar is fixed to a very short viewport.
|
|
const VIEWPORTS = [
|
|
[360, 640, "Galaxy S8"], [375, 667, "iPhone SE"], [390, 844, "iPhone 14"],
|
|
[412, 915, "Pixel 7"], [430, 932, "iPhone Pro Max"],
|
|
[768, 1024, "iPad"], [834, 1112, "iPad Air"], [1024, 1366, "iPad Pro"],
|
|
[1280, 900, "desktop"], [1600, 1000, "wide desktop"],
|
|
[640, 360, "S8 landscape"], [667, 375, "SE landscape"], [844, 390, "iPhone landscape"],
|
|
];
|
|
|
|
const OVERLAYS = [
|
|
["rulings", () => document.getElementById("openRulingsBtn").click()],
|
|
["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)],
|
|
["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }],
|
|
["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
|
|
["pot wizard", () => document.getElementById("resolvePotBtn").click()],
|
|
];
|
|
|
|
// A table part-way through a real night: long names, uneven stacks, a hand log.
|
|
//
|
|
// Two of them. The sweep ran on one table shape for its whole life — five players at a
|
|
// nine-handed table — and how much room the felt has depends on how many people are
|
|
// sitting down more than on anything a viewport can tell you. A full table is where the
|
|
// seats crowd, and it was never once looked at: measured when it finally was, seventeen
|
|
// of fifty-four tables had one player's name or stack painted under another.
|
|
const TABLES = [
|
|
["half full", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
|
["Bob", 250], ["Dee", 600]]],
|
|
["packed", 12, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
|
["Bob", 250], ["Dee", 600], ["Sam", 900], ["Priya", 1100], ["Jonathan", 480],
|
|
["Al", 2000], ["Katarzyna", 340], ["Ted", 760], ["Wes", 1300]]],
|
|
];
|
|
|
|
function seedTable([seats, players]) {
|
|
const state = HoldemState.createDefault();
|
|
HoldemState.resizeSeats(state, seats);
|
|
players.forEach(([name, cash], i) => {
|
|
state.seats[i].name = name;
|
|
state.seats[i].inHand = true;
|
|
state.seats[i].buyIns = cash;
|
|
state.seats[i].stack = cash;
|
|
});
|
|
state.smallBlind = 5;
|
|
state.bigBlind = 10;
|
|
for (let hand = 1; hand <= 4; hand++) {
|
|
state.handHistory.push({
|
|
handNumber: hand, timestamp: Date.now(), pot: 120,
|
|
payouts: [{ id: state.seats[hand % players.length].id, label: "P", amount: 120 }],
|
|
ledger: state.seats.slice(0, players.length)
|
|
.map(p => ({ id: p.id, name: p.name, in: 30, won: 0 })),
|
|
});
|
|
}
|
|
state.handNumber = 5;
|
|
HoldemState.save(state);
|
|
}
|
|
|
|
async function main() {
|
|
let chromium;
|
|
try {
|
|
({ chromium } = require("playwright"));
|
|
} catch {
|
|
console.error("This script needs Playwright: npm i -D playwright && npx playwright install chromium");
|
|
console.error("Or paste tests/holdem/uiAudit.js into devtools and call LayoutAudit.runAll().");
|
|
process.exit(2);
|
|
}
|
|
|
|
const browser = await chromium.launch();
|
|
const failures = [];
|
|
const warnings = [];
|
|
let checks = 0;
|
|
|
|
// Twice over: once with a mouse, once with a thumb. The 44px rule stands itself
|
|
// down where `hover: hover` says there's a pointer, and a headless desktop browser
|
|
// is a pointer — so a single pass here has never once measured a touch target,
|
|
// however many viewports it visited. The phone-sized ones are exactly the pass that
|
|
// needs it.
|
|
for (const touch of [false, true]) {
|
|
const context = await browser.newContext(touch
|
|
? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 }
|
|
: {});
|
|
const page = await context.newPage();
|
|
const hand = touch ? "touch" : "mouse";
|
|
|
|
for (const [width, height, rawLabel] of VIEWPORTS) {
|
|
await page.setViewportSize({ width, height });
|
|
|
|
for (const [shape, seats, players] of TABLES) {
|
|
const label = `${rawLabel} · ${shape} · ${hand}`;
|
|
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
|
await page.evaluate(seedTable, [seats, players]);
|
|
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
|
await page.click("#nextHandBtn");
|
|
await page.waitForTimeout(300);
|
|
await page.evaluate(() => document.querySelectorAll("details.sidebarPanel").forEach(d => { d.open = true; }));
|
|
await page.addScriptTag({ path: AUDIT });
|
|
|
|
const table = await page.evaluate(() => LayoutAudit.runAll());
|
|
checks++;
|
|
if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) });
|
|
table.warnings.forEach(w => warnings.push(`${label}: ${w}`));
|
|
|
|
// The overlays don't change with the table's shape, so they are only
|
|
// opened over the half-full one — twice the viewports is enough time
|
|
// spent already.
|
|
if (shape !== TABLES[0][0]) continue;
|
|
|
|
for (const [name, open] of OVERLAYS) {
|
|
await page.evaluate(open);
|
|
await page.waitForTimeout(180);
|
|
checks++;
|
|
const result = await page.evaluate(() => {
|
|
const overlay = [...document.querySelectorAll("[id$='Overlay']")].find(o => !o.hidden);
|
|
if (!overlay) return { offenders: [] };
|
|
return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`);
|
|
});
|
|
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
|
if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders });
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
|
|
const label = `${rawLabel} · ${hand}`;
|
|
await page.setViewportSize({ width, height });
|
|
await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`);
|
|
await page.evaluate(() => {
|
|
const button = [...document.querySelectorAll("button")].find(b => /all.?in/i.test(b.textContent));
|
|
if (button) button.click();
|
|
});
|
|
await page.addScriptTag({ path: AUDIT });
|
|
checks++;
|
|
const result = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "main button, main a, main input" }));
|
|
if (!result.clean) failures.push({ where: `quick split · ${label} ${width}x${height}`, offenders: result.failed.flatMap(f => f.offenders) });
|
|
result.warnings.forEach(w => warnings.push(`quick split · ${label}: ${w}`));
|
|
}
|
|
|
|
await context.close();
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`);
|
|
warnings.forEach(w => console.log(` note: ${w}`));
|
|
if (failures.length === 0) {
|
|
console.log("clean");
|
|
return;
|
|
}
|
|
failures.forEach(f => {
|
|
console.log(`\nFAIL ${f.where}`);
|
|
f.offenders.forEach(o => console.log(` ${o}`));
|
|
});
|
|
process.exit(1);
|
|
}
|
|
|
|
main();
|