Hold'em dealer: 55 polish rounds, one merge #101

Merged
jheaps merged 71 commits from release/casino-holdem-polish into master 2026-09-02 10:00:15 -06:00
8 changed files with 341 additions and 60 deletions
Showing only changes of commit 6df400322e - Show all commits
+43 -8
View File
@@ -586,12 +586,16 @@ button,
box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.5);
}
/* The chair number is 9px, and on an empty seat it is 9px behind a 0.7 fade: #8a8a8a
came out at 3.39:1 once the fade was composited, for the label a dealer reads to
answer "which seat is seat 6?". Lifted until it passes with a little to spare —
measured 4.5:1 at #a5a5a5 exactly, so it sits above that. */
.seatChairNumber {
position: absolute;
top: 2px;
left: 6px;
font-size: 0.58rem;
color: #8a8a8a;
color: #aeaeae;
}
.seatPuckName {
@@ -2906,14 +2910,45 @@ body.hasActionBar #toastHost {
}
@media (max-width: 700px) {
/* The narrow layout already had an answer for the bar: it reserves the bar's height
as scroll room below the table, and a phone is a thing you scroll. Capping the
felt here as well was measured and rejected — at 360x640 it took the table from
321px to 226px, which fixed the six-seat ring but broke the nine-seat one, two
covered seats traded for two seats whose centre landed on a neighbour. The cap is
for the wide layout, which never consulted the bar's height at all. */
/* The cap lives here now, and the reason it didn't is worth keeping.
It was measured and rejected once: at 360x640 it took the table from 321px to
226px, which fixed the six-seat ring but broke the nine-seat one — two covered
seats traded for two seats whose centre landed on a neighbour. The narrow layout
fell back on reserving the bar's height as scroll room, on the grounds that a
phone is a thing you scroll.
That trade was real then and isn't now: seats are spaced by distance around the
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
at 360x640 with the bar grown by a side pot, where the bottom seat sat 38px
inside it: capped, the felt is 240px and the ring clears the bar by 50, with the
audit clean at 6, 9 and 10 seats. Scroll room is not an answer for a seat that is
covered rather than merely below the fold. */
#pokerTableWrap {
max-width: none;
max-width: calc(var(--feltRoomPx, 100vh) / var(--feltAspect));
}
/* A felt small enough that the rim can't give each seat its own puck width. Set
from the measured rim in render.js rather than from the window, because the
question is how much table there is per player, which no media query can see:
nine seats on a 240px felt get 72px of rim each and the puck is 76 across. */
.seatsTight .seat {
width: 58px;
min-height: 38px;
padding: 3px 4px;
}
.seatsTight .seatPuckName {
margin-top: 0.35rem;
font-size: 0.62rem;
}
.seatsTight .seatPuckStack {
font-size: 0.55rem;
}
.seatsTight .seatPuckCards {
font-size: 0.58rem;
}
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
@@ -148,9 +148,13 @@ button,
color: #8f8f8f;
}
/* 37px tall, on the field a dealer types every amount into. The touch rule has been
44px everywhere else since the round that set it; this page never got measured for
it, because the audit only ran the target check when asked and nothing asked. */
.unevenAmount {
flex: 1;
min-width: 0;
min-height: 46px;
background: none;
border: none;
outline: none;
@@ -180,10 +184,12 @@ button,
color: #ffd6d6;
}
/* 30px across, and it deletes a player's entry — the one control on the row where a
near miss costs something. The amount field beside it gives up the 16px. */
.unevenRemoveBtn {
flex: none;
width: 30px;
height: 44px;
width: 46px;
height: 46px;
border: none;
background: none;
/* Was #6a6a6a — 3.66:1, the worst on the page, on a control that deletes a
@@ -89,6 +89,10 @@ const ActionBar = {
document.documentElement.style.setProperty(
"--feltRoomPx",
`${this.feltRoomFor(window.innerHeight, barHeight, topInDocument)}px`);
// The felt has just changed size, so how much rim each seat gets has changed
// with it. Told rather than left to be noticed on the next render, which on a
// bar that grew mid-hand could be several actions away.
if (typeof Render !== "undefined" && Render.updateSeatDensity) Render.updateSeatDensity();
},
// Anything to act on? A hand with nobody dealt in doesn't get a bar — and that
@@ -137,6 +137,34 @@ const Render = {
RING_STEPS: 720,
ringCache: {},
// How much rim each seat gets, in pixels. Ramanujan's approximation for an
// ellipse's perimeter, which is well inside a pixel at these sizes.
rimPerSeat(count) {
const wrap = document.getElementById("pokerTableWrap");
if (!wrap || !count) return Infinity;
const box = wrap.getBoundingClientRect();
const a = (box.width * 44) / 100;
const b = (box.height * 42) / 100;
if (!a || !b) return Infinity;
const perimeter = Math.PI * (3 * (a + b) - Math.sqrt((3 * a + b) * (a + 3 * b)));
return perimeter / count;
},
// A puck is 76px across. Under about that much rim each, neighbours start covering
// each other's middles, and a tap meant for one player lands on the next — so the
// pucks come down a size rather than the table pretending it has room it hasn't.
SEAT_ROOM_PX: 88,
// 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() {
const layer = document.getElementById("seatsLayer");
if (!layer || !this.state) return;
layer.classList.toggle("seatsTight",
this.rimPerSeat(this.state.seatCount) < this.SEAT_ROOM_PX);
},
// The angles that cut the rim into `count` equal lengths, starting at the top.
// Every seat on a ring wants the same answer, so it is worked out once per shape
// and kept — the ring only changes when the table is resized or the window is.
@@ -245,6 +273,7 @@ const Render = {
renderSeats() {
const layer = document.getElementById("seatsLayer");
layer.innerHTML = "";
this.updateSeatDensity();
const template = document.getElementById("seatTemplate");
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
// A button on an empty chair normally means the table has drifted and wants
+16 -1
View File
@@ -36,7 +36,22 @@ enough not to trigger iOS zoom, that nothing important is covered or off-screen
no way to scroll to it, that no grid column has collapsed to nothing, and that no
text is painted outside its box. It runs over the table, all five overlays and Quick
Split, at thirteen viewports from a 360px phone to a 1600px desktop and the same
phones on their side.
phones on their side — each viewport twice, once with a mouse and once with a thumb.
That last part, and two of those checks, are newer than this paragraph claiming them.
`runAll()` did not include the contrast check at all, and touch targets sat behind an
opt-in flag nothing passed, so "the audit is clean" meant nine checks rather than
eleven for a long run of rounds. Both were written and tested and simply never called.
The mouse/thumb pair matters for the same reason: the 44px rule stands itself down
where `hover: hover` says there is a pointer, and a headless desktop browser is a
pointer, so a single pass had never measured a touch target at any viewport. Between
them they found a 9px seat label at 3.39:1, a 37px-tall amount field and a 30px delete
button on Quick Split.
A check that decides it doesn't apply says so in the output rather than reporting no
offenders, which reads identically to a pass. The same goes for the two exemptions
that exist: a disabled control, which WCAG itself excuses, and a link inside a
sentence, whose size is set by the line it sits in.
Deliberately not in CI: the unit suite is dependency-free and runs in a second, while
this one needs a browser and a running site. Run it after any change that adds or
+108
View File
@@ -0,0 +1,108 @@
// The sums the contrast check runs on, tested away from a browser.
//
// The check itself needs a page to look at, but the arithmetic underneath it doesn't —
// and the arithmetic is the part that decides whether a real fault is reported or
// waved through. Faded text used to be filed as "not checked" rather than measured;
// this is the compositing that replaced that.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const LayoutAudit = require("./uiAudit");
const WHITE = { r: 255, g: 255, b: 255, a: 1 };
const BLACK = { r: 0, g: 0, b: 0, a: 1 };
const PAGE = { r: 28, g: 28, b: 28, a: 1 }; // the felt page behind everything
const PUCK = { r: 44, g: 44, b: 44, a: 1 }; // a seat's own background
describe("the ratio itself", () => {
test("black on white is the full 21:1, either way round", () => {
assert.ok(Math.abs(LayoutAudit.contrastRatio(BLACK, WHITE) - 21) < 0.01);
assert.ok(Math.abs(LayoutAudit.contrastRatio(WHITE, BLACK) - 21) < 0.01);
});
test("a colour against itself is 1:1", () => {
assert.ok(Math.abs(LayoutAudit.contrastRatio(PUCK, PUCK) - 1) < 0.001);
});
});
describe("text on a box that is being faded", () => {
// opacity on a box fades the box and its text together onto whatever is behind
// it — so a dimmed seat is not grey text on a dark puck, it is a faded puck and
// faded text both landing on the page.
const fade = alpha => ({ alpha, behind: PAGE });
test("at full strength nothing is composited", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 1, behind: PAGE });
assert.deepEqual(painted.fg, WHITE);
assert.deepEqual(painted.bg, PUCK);
});
test("fading moves both the text and its background, not just the text", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
assert.ok(painted.fg.r < WHITE.r, "the text dims");
assert.ok(painted.bg.r < PUCK.r, "and so does what it sits on");
assert.ok(painted.bg.r > PAGE.r, "but not all the way to the page");
});
test("which is what stops a fade being reported as a fault it isn't", () => {
// Dimming light text on a dark puck over a dark page costs contrast, but far
// less than measuring the faded text against the *unfaded* puck would claim.
const honest = LayoutAudit.asPainted(WHITE, PUCK, fade(0.7));
const wrong = { fg: LayoutAudit.over({ ...WHITE, a: 0.7 }, PUCK), bg: PUCK };
assert.ok(LayoutAudit.contrastRatio(honest.fg, honest.bg) >
LayoutAudit.contrastRatio(wrong.fg, wrong.bg));
});
test("a fade all the way out lands everything on the page behind", () => {
const painted = LayoutAudit.asPainted(WHITE, PUCK, fade(0));
assert.deepEqual(painted.fg, PAGE);
assert.deepEqual(painted.bg, PAGE);
});
test("with nothing known to be behind it, the fade is left alone", () => {
// Better to measure the unfaded pair than to invent a backdrop.
const painted = LayoutAudit.asPainted(WHITE, PUCK, { alpha: 0.5, behind: null });
assert.deepEqual(painted.fg, WHITE);
assert.deepEqual(painted.bg, PUCK);
});
});
describe("a fade makes small grey text worse, which is the fault it went looking for", () => {
// Deliberately not asserting a particular CSS colour here: the page's own greys are
// measured in the browser, against the real gradients, where they live. What this
// pins is the direction — that dimming a box costs its text contrast, which is why
// filing faded text as "not checked" hid faults rather than avoiding false ones.
test("the same grey scores lower once its box is dimmed", () => {
const grey = LayoutAudit.parseColour("rgb(138, 138, 138)");
const solid = LayoutAudit.asPainted(grey, PUCK, { alpha: 1, behind: PAGE });
const dimmed = LayoutAudit.asPainted(grey, PUCK, { alpha: 0.7, behind: PAGE });
assert.ok(LayoutAudit.contrastRatio(dimmed.fg, dimmed.bg) <
LayoutAudit.contrastRatio(solid.fg, solid.bg));
});
test("and a lighter grey scores higher at the same fade", () => {
const at = colour => {
const painted = LayoutAudit.asPainted(LayoutAudit.parseColour(colour), PUCK, { alpha: 0.7, behind: PAGE });
return LayoutAudit.contrastRatio(painted.fg, painted.bg);
};
assert.ok(at("rgb(174, 174, 174)") > at("rgb(138, 138, 138)"));
});
});
describe("reading a colour off the page", () => {
test("rgb and rgba both come back as numbers", () => {
assert.deepEqual(LayoutAudit.parseColour("rgb(1, 2, 3)"), { r: 1, g: 2, b: 3, a: 1 });
assert.deepEqual(LayoutAudit.parseColour("rgba(1, 2, 3, 0.5)"), { r: 1, g: 2, b: 3, a: 0.5 });
});
test("a gradient gives up every stop, because text could be over any of them", () => {
const stops = LayoutAudit.gradientStops("linear-gradient(rgb(203, 170, 240), rgb(169, 127, 214))");
assert.equal(stops.length, 2);
assert.deepEqual(stops[0], { r: 203, g: 170, b: 240, a: 1 });
assert.deepEqual(stops[1], { r: 169, g: 127, b: 214, a: 1 });
});
test("no background image, no stops", () => {
assert.deepEqual(LayoutAudit.gradientStops("none"), []);
});
});
+55 -38
View File
@@ -68,57 +68,74 @@ async function main() {
}
const browser = await chromium.launch();
const page = await browser.newPage();
const failures = [];
const warnings = [];
let checks = 0;
for (const [width, height, label] of VIEWPORTS) {
await page.setViewportSize({ width, height });
// 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";
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable);
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 });
for (const [width, height, rawLabel] of VIEWPORTS) {
const label = `${rawLabel} · ${hand}`;
await page.setViewportSize({ width, height });
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}`));
await page.goto(`${BASE}/Casino/TexasHoldem`);
await page.evaluate(seedTable);
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 });
for (const [name, open] of OVERLAYS) {
await page.evaluate(open);
await page.waitForTimeout(180);
const table = await page.evaluate(() => LayoutAudit.runAll());
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 });
}
}
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}`));
for (const [width, height, label] of VIEWPORTS.filter((_, i) => i % 4 === 0)) {
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) });
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`);
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");
+78 -11
View File
@@ -393,6 +393,37 @@ const LayoutAudit = {
}
},
// How much of this element actually reaches the screen, and what is behind the
// thing doing the fading.
//
// `opacity` on a box fades the whole box — its text and its own background together
// — onto whatever sits behind that box. So a dimmed seat is not "grey text on a
// dark puck"; it is a faded puck and faded text both landing on the felt. Both
// halves have to be composited the same way or the number means nothing.
fading(el) {
let alpha = 1;
let outermost = null;
for (let node = el; node; node = node.parentElement) {
const own = parseFloat(getComputedStyle(node).opacity);
if (own < 0.99) {
alpha *= own;
outermost = node;
}
}
if (!outermost) return { alpha: 1, behind: null };
return { alpha, behind: this.backgroundCandidates(outermost.parentElement || document.body)[0] || null };
},
// The two colours as the screen actually shows them, once the fading is applied.
asPainted(fgRaw, bg, fade) {
const solid = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
if (fade.alpha >= 0.99 || !fade.behind) return { fg: solid, bg };
return {
fg: this.over({ ...solid, a: solid.a * fade.alpha }, fade.behind),
bg: this.over({ ...bg, a: bg.a * fade.alpha }, fade.behind),
};
},
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
const offenders = [];
const unmeasured = [];
@@ -426,12 +457,17 @@ const LayoutAudit = {
// exempt — there's nothing to read at zero — but anything in between is
// reported as unmeasured rather than passed over in silence, because a
// number nobody checked is not the same as a number that's fine.
const opacity = parseFloat(style.opacity);
if (opacity <= 0.02) return;
const faded = opacity < 0.99 ||
[...this.ancestors(el)].some(a => parseFloat(getComputedStyle(a).opacity) < 0.99);
if (faded) {
unmeasured.push(`${this.describe(el)} :: faded (opacity ${opacity}), contrast not checked`);
const fade = this.fading(el);
if (fade.alpha <= 0.02) return;
// A control that is switched off is the one exemption WCAG itself grants —
// an inactive component has no contrast requirement, which is why every
// toolkit dims them. Said out loud rather than skipped in silence: this is
// the only exemption in here, and the moment a dimmed thing turns out to be
// live it stops applying.
const off = el.closest("[disabled], [aria-disabled='true']");
if (off) {
unmeasured.push(`${this.describe(el)} :: disabled control, exempt from AA`);
return;
}
// aria-hidden used to exempt text here, on the grounds that decoration
@@ -459,8 +495,8 @@ const LayoutAudit = {
// The kindest reading available: best contrast across every candidate.
let best = 0;
candidates.forEach(bg => {
const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
best = Math.max(best, this.contrastRatio(fg, bg));
const painted = this.asPainted(fgRaw, bg, fade);
best = Math.max(best, this.contrastRatio(painted.fg, painted.bg));
});
const sizePx = parseFloat(style.fontSize);
@@ -543,6 +579,17 @@ const LayoutAudit = {
.filter(el => el.type !== "color")
.filter(el => !(exempt && el.matches(exempt)))
.forEach(el => {
// A link inside a sentence is the one target WCAG itself carves out:
// its size is set by the line it sits in, and padding a word out to
// 44px would break the paragraph around it. The rule is about controls
// a thumb goes hunting for, not words in prose. Said out loud, and
// narrow — display:inline, and real text either side of it.
if (el.tagName === "A" && getComputedStyle(el).display === "inline" &&
el.parentElement &&
[...el.parentElement.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) {
unmeasured.push(`${this.describe(el)} :: link inside a sentence, sized by the line`);
return;
}
// A control inside a scrolling sheet can sit below its container's
// visible area while still having a viewport rect. elementFromPoint at
// its centre then returns the sheet, effectiveTarget gives up, and the
@@ -674,6 +721,20 @@ const LayoutAudit = {
// ---- Runner ----
// Every check this file knows how to make, every time.
//
// Two of them used not to be here. Contrast was simply missing from the list, and
// touch targets sat behind an opt-in flag nobody passed — so "the audit is clean"
// has meant nine checks rather than eleven, and the two left out are the two whose
// faults a dealer meets in a dim room with their thumb. Both were written, tested
// and then never run: the contrast checker was built across two rounds and every
// sweep since has been silent about colour.
//
// A check that decides it can't apply here says so out loud rather than reporting
// an empty offenders list, which reads as a pass. touchTargets stands itself down
// 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.
runAll(options = {}) {
const checks = [
this.touchAction(),
@@ -686,8 +747,9 @@ const LayoutAudit = {
this.fieldFontSize(),
this.seatOwnership(),
this.oneDealerButton(),
this.contrast(options.contrast),
this.touchTargets(options.minimumTarget, options.touchTargets),
];
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
return {
viewport: `${innerWidth}x${innerHeight}`,
@@ -695,8 +757,13 @@ const LayoutAudit = {
// Things worth knowing that aren't faults — see occlusion() on toasts.
// Controls a check couldn't probe go here too: not a fault, but not a pass
// either, and a silent skip is how faults hide.
warnings: checks.flatMap(c => [...(c.warnings || []), ...(c.unmeasured || [])]),
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
warnings: checks.flatMap(c => [
...(c.warnings || []),
...(c.unmeasured || []),
...(c.skipped ? [`${c.name}: not run here — ${c.skipped}`] : []),
]),
passed: checks.filter(c => !c.skipped && c.offenders.length === 0).map(c => c.name),
skipped: checks.filter(c => c.skipped).map(c => c.name),
clean: checks.every(c => c.offenders.length === 0),
};
},