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
257 lines
13 KiB
JavaScript
257 lines
13 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]], false],
|
|
["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]]],
|
|
// The half-full table again, with the board turned on and five cards on it. The
|
|
// board is furniture in the middle of the felt that nothing else was ever measured
|
|
// against — and where the middle cannot hold it, it moves to a strip above the
|
|
// table and takes the felt's height with it, which is the seats' problem too.
|
|
//
|
|
// Deliberately the same five players in the same nine chairs as the shape above, so
|
|
// the only thing that differs between them is the board. A seventh player would have
|
|
// told us that a seven-handed table crowds, which is true, already known, and not
|
|
// what this row is here to find out.
|
|
["board", 9, [["Christopher", 1200], ["Mike from work", 800], ["Alexandra", 1500],
|
|
["Bob", 250], ["Dee", 600]], true],
|
|
];
|
|
|
|
function seedTable([seats, players, board]) {
|
|
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;
|
|
if (board) {
|
|
state.trackBoard = true;
|
|
state.board = ["KD", "9C", "4S", "AH", "2H"];
|
|
}
|
|
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, board] of TABLES) {
|
|
const label = `${rawLabel} · ${shape} · ${hand}`;
|
|
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
|
await page.evaluate(seedTable, [seats, players, board]);
|
|
await page.goto(`${BASE}/Casino/TexasHoldem`);
|
|
await page.click("#nextHandBtn");
|
|
await page.waitForTimeout(300);
|
|
// Deal it, and get chips in front of a few players. The sweep used to
|
|
// audit a table that had never had a bet on it, so the bets layer was
|
|
// empty at every viewport it visited and nothing it drew was ever looked
|
|
// at — which is a third of what is on the felt during a hand.
|
|
await page.evaluate(() => {
|
|
const deal = [...document.querySelectorAll("#actionBar button")]
|
|
.find(b => /post blinds/i.test(b.textContent));
|
|
if (deal) deal.click();
|
|
});
|
|
await page.waitForTimeout(220);
|
|
for (let i = 0; i < 3; i++) {
|
|
const acted = await page.evaluate(() => {
|
|
const btn = [...document.querySelectorAll("#actionBar button")]
|
|
.find(b => /^(Call|Check)/.test(b.textContent.trim()) && !b.disabled);
|
|
if (!btn) return false;
|
|
btn.click();
|
|
return true;
|
|
});
|
|
if (!acted) break;
|
|
await page.waitForTimeout(120);
|
|
}
|
|
if (board) {
|
|
await page.evaluate(() => {
|
|
const s = HoldemState.load();
|
|
s.street = "river";
|
|
s.board = ["KD", "9C", "4S", "AH", "2H"];
|
|
s.seats.filter(p => p.inHand).forEach((p, i) => {
|
|
p.cards = [["AS", "KC"], ["AC", "3D"], ["9H", "9S"],
|
|
["7C", "5D"], ["JD", "TS"]][i] || ["", ""];
|
|
});
|
|
HoldemState.save(s);
|
|
});
|
|
await page.reload();
|
|
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 raise pad, which is part of the bar rather than an overlay and so
|
|
// was never once measured by any of this. It is the tallest thing the
|
|
// bar ever holds and the only place in the tool a dealer types an
|
|
// amount, and it had a 36px-tall field on every phone in the list: the
|
|
// `hover: none` rule that sizes fields for a thumb names the settings
|
|
// panels and the seat form, and the one field on the thumb surface was
|
|
// not in the list. Nothing here could see that, because nothing here
|
|
// ever opened it.
|
|
if (await page.evaluate(() => {
|
|
const btn = [...document.querySelectorAll("#actionBar button")]
|
|
.find(b => /^Raise|^Bet/.test(b.textContent.trim()) && !b.disabled);
|
|
if (!btn) return false;
|
|
btn.click();
|
|
return true;
|
|
})) {
|
|
await page.waitForTimeout(200);
|
|
checks++;
|
|
const pad = await page.evaluate(() => LayoutAudit.runAll({
|
|
occlusionSelector: "#actionBar button, #actionBar input",
|
|
}));
|
|
if (!pad.clean) {
|
|
failures.push({
|
|
where: `raise pad · ${label} ${width}x${height}`,
|
|
offenders: pad.failed.flatMap(f => f.offenders),
|
|
});
|
|
}
|
|
await page.evaluate(() => {
|
|
const cancel = [...document.querySelectorAll("#actionBar button")]
|
|
.find(b => /^Cancel/.test(b.textContent.trim()));
|
|
if (cancel) cancel.click();
|
|
});
|
|
await page.waitForTimeout(150);
|
|
}
|
|
|
|
// 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();
|