Files
JoshHeaps.Net/tests/holdem/uiAudit.js
T
jheapsandClaude Opus 5 c052ee32c8 Put the Confirm button back on screen
Opened the pot wizard — the one dialog in this tool that divides money — and its
Confirm button was below a scroll. Not on a tall table, not on one screen: at
every size I measured, with six players and a side pot.

  iPhone 14 390x844   244px of the dialog below the fold
  Galaxy S8 360x640   417px
  844x390 landscape   583px
  desktop 1280x900    172px

Nothing on screen said there was anything under the list of names, so a dealer
with the table waiting had to scroll a list of players to find the only control
that finishes the hand.

Two goes at this, and the first was wrong. A sticky footer is the obvious
answer and it cannot work here: the actions were the last child of the
scrolling content, so their containing block ended exactly where they did and
`bottom` had no room to lift them. Sticky on the *header* did work, for the
mirror-image reason — its container extends below it — which is what made the
failure confusing to read. Capping the player list wasn't enough either; the
pot breakdown and the log still overflowed on a short screen.

So the dialog is a proper column now: a title that doesn't move, a body that
scrolls, and a footer outside the scroll. potWizard.js appends its buttons to
the footer instead of into the body, and clears both together so a step can't
inherit the previous step's buttons. Measured after: Confirm on screen and
hit-testable at all six configurations, twelve-handed on a Galaxy S8 included,
and the dialog itself no longer scrolls at all.

The checkboxes are painted now, too. `accent-color` only ever coloured the tick
— the box around it stayed the platform's own bright white square, which on a
near-black dialog was the loudest thing on screen and the last raw control in a
tool where everything else had been drawn. Same surface ramp, same corner
radius, and a tick drawn with clip-path so it can't pick up a substituted font.

uiAudit gains "an open dialog's buttons are on screen", which hit-tests rather
than trusting geometry — a button can be inside the viewport and still be under
something. Fault-injected: putting the footer back inside the scrolling body
reports Confirm 105px below the fold.

Two things checked and left alone, because they turned out to be right: the
standings table hides three columns on narrow screens through a container query
keyed to the sidebar rather than the viewport, which is why a desktop shows
fewer columns than an iPad — the sidebar is the narrower box there. And In/Won/
Net read $0 for hands settled outside the wizard, which the comment above them
already explains.

Caught by my own guard on the way past: the token test read `scroll-padding` as
`padding` and failed the spacing scale. Correct by its rules, wrong in substance
— those values are a sticky bar's measured height, not a step in the rhythm — so
the lookbehind now rejects a hyphen as well as a word character. Re-fault-
injected to confirm it still catches a real ad-hoc gap.

619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes and
35 across the five overlays. The whole wizard walked end to end: side pot
resolved, hand logged, $1,560 on the table before and after.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-03 12:20:02 -06:00

925 lines
47 KiB
JavaScript

// Layout and touch audits for the dealer, written once so they stop being written
// again every round.
//
// These checks have been re-typed into a browser console at least six times over
// the course of building this thing, and each time they only covered whatever had
// just been changed. That is exactly how the standings table shipped with a
// zero-pixel player-name column and survived two rounds: the audit that would have
// caught it existed only in a previous session's scrollback.
//
// This file is deliberately dependency-free and browser-side. It is injected into
// the page (see runUiAudit.js) rather than imported, so it needs no build step, no
// package.json, and no browser automation of its own — anyone can also paste it
// into devtools and call LayoutAudit.runAll().
const LayoutAudit = {
// Everything a dealer has to be able to hit.
CONTROL_SELECTOR: "button, a, input, select, textarea, [role='button'], .seat",
describe(el) {
if (!el) return "(nothing)";
const id = el.id ? `#${el.id}` : "";
const cls = (el.className || "").toString().trim().split(/\s+/).filter(Boolean).slice(0, 2).join(".");
const text = (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 24);
return `${el.tagName.toLowerCase()}${id}${cls ? "." + cls : ""}${text ? ` "${text}"` : ""}`;
},
visibleControls() {
return [...document.querySelectorAll(this.CONTROL_SELECTOR)].filter(el => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
const style = getComputedStyle(el);
return style.visibility !== "hidden" && style.display !== "none";
});
},
// ---- Touch (see the mobile round) ----
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
// deciding whether it was a double-tap-to-zoom. This tool uses double-tap on a
// seat as its fold shortcut, so that wait is on the critical path.
touchAction() {
const offenders = this.visibleControls()
.filter(el => getComputedStyle(el).touchAction === "auto")
.map(el => this.describe(el));
return { name: "touch-action", offenders, checked: this.visibleControls().length };
},
tapHighlight() {
const offenders = this.visibleControls()
.filter(el => {
const colour = getComputedStyle(el).webkitTapHighlightColor;
return colour && !/rgba\(0,\s*0,\s*0,\s*0\)|transparent/.test(colour);
})
.map(el => this.describe(el));
return { name: "tap-highlight", offenders, checked: this.visibleControls().length };
},
// Text selection must stay ON for anything a dealer types into.
textSelectable() {
const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")]
.filter(el => el.offsetParent !== null)
.filter(el => {
const style = getComputedStyle(el);
return (style.webkitUserSelect || style.userSelect) === "none";
})
.map(el => this.describe(el));
return { name: "fields stay selectable", offenders };
},
// ---- Reachability (see the occlusion round) ----
// A control covered by something else is unreachable however good it looks.
// Sampling several points matters: a button you can only hit in one corner is
// covered for practical purposes.
// Can this element be brought into view by scrolling? Something below the fold on
// a page that scrolls is perfectly reachable; something below the fold in a panel
// that *doesn't* scroll is lost. The first version of this check didn't make the
// distinction and reported every link under the fold as a fault.
reachableByScrolling(el) {
const rect = el.getBoundingClientRect();
if (getComputedStyle(el).position === "fixed") return false;
for (let node = el.parentElement; node; node = node.parentElement) {
const style = getComputedStyle(node);
const scrolls = /auto|scroll|overlay/.test(style.overflowY) || node === document.body;
if (!scrolls) continue;
const box = node === document.body ? { top: 0, bottom: innerHeight } : node.getBoundingClientRect();
const room = node === document.body
? document.documentElement.scrollHeight - innerHeight - scrollY
: node.scrollHeight - node.clientHeight - node.scrollTop;
if (rect.top >= box.bottom && room > 0) return true;
if (rect.bottom <= box.top && (node === document.body ? scrollY > 0 : node.scrollTop > 0)) return true;
}
return false;
},
// The modal that is currently on top, if any.
//
// Every check below asks "is this thing reachable", and while a sheet or dialog is
// open the answer for everything behind it is legitimately no — that is what a
// modal is for. Without knowing that, the audit reported the pot wizard covering
// the seats as ten faults and the rulings sheet as seven, which is the noise that
// made it useless in exactly the states nobody had audited. Now it scopes to what
// is actually on top and says so.
MODAL_SELECTOR: "#potWizardOverlay, #rulingsOverlay, #seatMenuOverlay, #cardPickerOverlay, " +
"#confirmOverlay, #addChipsOverlay, #seatEditorOverlay",
openModal() {
const open = [...document.querySelectorAll(this.MODAL_SELECTOR)]
.filter(el => !el.hidden && getComputedStyle(el).display !== "none");
// Last in the DOM wins, which is how they stack.
return open.length > 0 ? open[open.length - 1] : null;
},
// A seat sheet on a phone isn't an overlay element, it's a body class — but it
// covers the felt just the same.
modalScope() {
const overlay = this.openModal();
if (overlay) return overlay;
if (document.body.classList.contains("seatSheetOpen")) {
return document.getElementById("seatEditorPanel") || null;
}
return null;
},
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
const offenders = [];
const warnings = [];
// While something is open on top, only what is inside it can be judged. The
// rest of the page is meant to be unreachable.
const scope = this.modalScope();
const candidates = [...document.querySelectorAll(selector)]
.filter(el => !scope || scope.contains(el));
candidates.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const outsideViewport = rect.bottom <= 0 || rect.top >= innerHeight ||
rect.right <= 0 || rect.left >= innerWidth;
if (outsideViewport) {
if (!this.reachableByScrolling(el)) {
offenders.push(`${this.describe(el)} :: off-screen and cannot be scrolled to`);
}
return;
}
const points = [
[rect.left + rect.width / 2, rect.top + rect.height / 2],
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.3],
[rect.left + rect.width * 0.75, rect.top + rect.height * 0.3],
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.7],
[rect.left + rect.width * 0.75, rect.top + rect.height * 0.7],
].filter(([x, y]) => x >= 0 && y >= 0 && x < innerWidth && y < innerHeight);
if (points.length === 0) return;
let blocked = 0;
let testable = 0;
let blockedBy = null;
points.forEach(([x, y]) => {
const top = document.elementFromPoint(x, y);
// A null result means the point isn't hit-testable — typically it sits
// inside the element but outside a scrollable ancestor's clip, i.e. the
// control is scrolled out of view rather than covered by anything.
// Counting that as "covered by null" was reporting a perfectly
// reachable button in a sheet that scrolls.
if (!top) return;
testable++;
if (el.contains(top) || top === el || top.contains(el)) return;
// Seats ring a round table and overlap each other at the rim by
// design; that is crowding, not an overlay hiding a control.
//
// But only the seat's *body* is harmless. A neighbouring seat's own
// controls sitting on top of this one is a control being stolen, and
// exempting the whole seat subtree hid exactly that: an enlarged
// dealer-chip hit area was taking the centre of the seat three
// places round the table, and this check called it crowding and
// moved on.
const neighbourSeat = top.closest && top.closest(".seat");
if (neighbourSeat) {
const isControl = top.matches && top.matches("button, a, input, select, textarea, [role='button']");
if (!isControl) return;
}
blocked++;
if (!blockedBy) blockedBy = this.describe(top);
});
if (testable === 0) {
if (!this.reachableByScrolling(el)) {
offenders.push(`${this.describe(el)} :: not hit-testable anywhere and cannot be scrolled to`);
}
return;
}
if (blocked === 0) return;
const note = `${this.describe(el)} :: ${blocked === testable ? "fully" : `${blocked}/${testable}`} covered by ${blockedBy}`;
// A toast is transient by design: it dismisses itself and carries a close
// button, and on a desktop it necessarily lands over a table that fills the
// content column. Partial overlap from one is a fact of the layout, not a
// fault. Being *fully* covered by a toast is still a failure though — that
// was a real bug once, where a seat sat exactly under the Undo button and a
// tap meant for a player took back the last action instead.
const transient = blockedBy && /toast/i.test(blockedBy);
if (transient && blocked < testable) warnings.push(note);
else offenders.push(note);
});
return { name: "nothing important is covered", offenders, warnings };
},
// ---- Layout (the check that would have caught the standings regression) ----
// A grid column squeezed to nothing doesn't truncate its text, it erases it.
// This is the one that was missing: the player-name column sat at 0px through
// two rounds because nothing ever asked whether a column had survived.
collapsedColumns() {
const offenders = [];
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
// Nothing inside something the page has hidden. A hidden element's children
// all measure zero wide, so the standings header — which is hidden along
// with its table until somebody has bought in — was reported as three
// collapsed columns on every empty table. A column nobody can see hasn't
// collapsed; it isn't there.
if (row.closest("[hidden]")) return;
if (getComputedStyle(row).display !== "grid") return;
[...row.children].forEach(cell => {
if (getComputedStyle(cell).display === "none") return;
const hasText = (cell.textContent || "").trim().length > 0;
if (hasText && cell.getBoundingClientRect().width < 4) {
offenders.push(`${this.describe(row)} > ${this.describe(cell)} :: ${Math.round(cell.getBoundingClientRect().width)}px wide`);
}
});
});
return { name: "no column collapsed to nothing", offenders };
},
// Text actually painted outside the box meant to contain it — which on a table of
// overlapping seats means painted on top of a neighbour.
//
// The clipping check is the whole subtlety, and the first version of this got it
// wrong. A Range's bounding rect measures the *laid out* text, which happily runs
// past the element when `overflow: hidden` is quietly clipping it — so an
// ellipsised name was reported as spilling when nothing was drawn outside at all.
// Only overflow that is actually visible can paint over a neighbour.
textSpill(selector = ".seatPuckStack, .seatPuckTotal, .seatPuckName") {
const offenders = [];
[...document.querySelectorAll(selector)].forEach(el => {
const text = (el.textContent || "").trim();
if (!text) return;
const style = getComputedStyle(el);
const clipsItself = style.overflow !== "visible" || style.overflowX !== "visible";
if (clipsItself) return;
const container = el.closest(".seat") || el.parentElement;
if (!container) return;
const range = document.createRange();
range.selectNodeContents(el);
const drawn = range.getBoundingClientRect();
const bounds = container.getBoundingClientRect();
if (drawn.left < bounds.left - 1 || drawn.right > bounds.right + 1) {
offenders.push(`${this.describe(el)} "${text}" paints outside ${this.describe(container)}`);
}
});
return { name: "no text painted outside its box", offenders };
},
horizontalOverflow() {
const offenders = document.documentElement.scrollWidth > innerWidth + 1
? [`page scrolls sideways: ${document.documentElement.scrollWidth}px in a ${innerWidth}px viewport`]
: [];
return { name: "page never scrolls sideways", offenders };
},
// iOS zooms the whole page when a field under 16px takes focus, and the dealer
// is then pinching back out mid-hand.
fieldFontSize() {
const offenders = [...document.querySelectorAll("input, textarea, select")]
.filter(el => el.offsetParent !== null)
.filter(el => ["text", "number", "search", "tel", "url", "email", "password", ""].includes(el.type || ""))
.filter(el => parseFloat(getComputedStyle(el).fontSize) < 16)
.map(el => `${this.describe(el)} at ${getComputedStyle(el).fontSize}`);
return { name: "no field small enough to trigger iOS zoom", offenders };
},
// ---- Contrast ----
//
// This check has been got wrong twice before, both times by being too clever about
// backgrounds. The first version read only `background-color`, so every sheet
// painted with a gradient was measured against transparency and the dealer puck's
// dark "D" was reported at 1.2:1 against nothing. The second over-corrected and
// treated low-alpha gradient stops as opaque, scoring white-on-dark at 1.0:1.
//
// So it is built to be incapable of false positives instead of trying to be exact.
// Backgrounds composite properly through alpha, and where a gradient is involved
// every one of its colour stops is tried and the *most flattering* one is used. A
// finding therefore means the text fails even under the reading most generous to
// it, which is a claim worth making. Text that can't be resolved to any opaque
// background is reported separately as unmeasured rather than guessed at.
parseColour(value) {
const match = String(value).match(/rgba?\(([^)]+)\)/);
if (!match) return null;
const parts = match[1].split(/[,\s/]+/).filter(Boolean).map(Number);
if (parts.length < 3 || parts.some(Number.isNaN)) return null;
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 };
},
// Colour stops out of any gradient in a background-image.
gradientStops(backgroundImage) {
if (!backgroundImage || backgroundImage === "none") return [];
return (backgroundImage.match(/rgba?\([^)]+\)/g) || [])
.map(c => this.parseColour(c))
.filter(Boolean);
},
over(top, bottom) {
const a = top.a;
return {
r: top.r * a + bottom.r * (1 - a),
g: top.g * a + bottom.g * (1 - a),
b: top.b * a + bottom.b * (1 - a),
a: 1,
};
},
relativeLuminance({ r, g, b }) {
const channel = v => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
},
contrastRatio(fg, bg) {
const a = this.relativeLuminance(fg);
const b = this.relativeLuminance(bg);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
},
// Every background this text could actually be sitting on.
//
// Layers are collected from the element outwards and stop at the first fully
// opaque one, because nothing behind that is visible. Getting this wrong in the
// lenient direction is just as bad as the old false positives: an earlier version
// kept walking past an opaque gradient and offered the page background as a
// candidate, which scored white-on-a-mid-grey-panel as though it were
// white-on-near-black and would have waved real problems through.
//
// A gradient contributes one candidate per colour stop; translucent layers above
// the opaque one are composited onto each candidate in order.
backgroundCandidates(el) {
const translucent = []; // top-down, above the opaque layer
let base = null; // candidate colours for the first opaque layer
for (let node = el; node && !base; node = node.parentElement) {
const style = getComputedStyle(node);
const stops = this.gradientStops(style.backgroundImage);
const colour = this.parseColour(style.backgroundColor);
// A gradient whose stops are all opaque hides everything behind it.
if (stops.length > 0 && stops.every(s => s.a === 1)) {
base = stops;
break;
}
stops.forEach(s => { if (s.a > 0) translucent.push(s); });
if (colour && colour.a === 1) { base = [colour]; break; }
if (colour && colour.a > 0) translucent.push(colour);
}
if (!base) {
// Nothing opaque anywhere up the tree — fall back to the page's own colour
// so a finding is still measured against something real.
const page = this.parseColour(getComputedStyle(document.documentElement).backgroundColor);
base = [page && page.a === 1 ? page : { r: 10, g: 10, b: 10, a: 1 }];
}
// Fold the translucent layers back on, innermost last so it lands on top.
return base.map(candidate => {
let composed = candidate;
for (let i = translucent.length - 1; i >= 0; i--) composed = this.over(translucent[i], composed);
return composed;
});
},
// Every element between this one and the document, so an opacity set on a
// container can be seen from the text inside it.
*ancestors(el) {
let node = el.parentElement;
while (node) {
yield node;
node = node.parentElement;
}
},
// 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 = [];
const nodes = selector
? [...document.querySelectorAll(selector)]
: [...document.querySelectorAll("*")];
nodes.forEach(el => {
// An element's *own* text, not its descendants' — they get measured on
// their own turn, against their own colour.
//
// This used to be "leaf elements only", which quietly stopped measuring
// anything the moment a child was added to it. The undo control went that
// way the day a <kbd> was appended to it: its label became invisible to
// this check, with no sign that anything had changed.
const text = [...el.childNodes]
.filter(node => node.nodeType === 3)
.map(node => node.textContent)
.join(" ")
.trim();
if (!text) return;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const style = getComputedStyle(el);
if (style.visibility === "hidden" || style.display === "none") return;
// Faded text used to be skipped outright as "faded on purpose", and that
// exemption hid a real fault: the folded and sat-out seats dimmed their
// whole box with opacity, so a folded player's stack measured 2.22:1 at
// 11.5px and this check never looked at it. Something invisible is still
// 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 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
// marked for screen readers was decoration for everyone. Those are two
// different questions and the answer differs: the bet chips on the felt are
// aria-hidden because their amounts are already announced by the seat and
// the action bar, and repeating them would be noise — but they are exactly
// what a sighted dealer reads the felt for, and this check had stopped
// looking at them. Hiding something from one audience is not a claim about
// whether the other audience can read it.
//
// Nothing is exempt now. If something genuinely decorative turns up, it
// should say so in its own words rather than borrowing an attribute that
// means something else.
const fgRaw = this.parseColour(style.color);
if (!fgRaw) return;
const candidates = this.backgroundCandidates(el);
if (candidates.length === 0) {
unmeasured.push(`${this.describe(el)} :: no opaque background found`);
return;
}
// The kindest reading available: best contrast across every candidate.
let best = 0;
candidates.forEach(bg => {
const painted = this.asPainted(fgRaw, bg, fade);
best = Math.max(best, this.contrastRatio(painted.fg, painted.bg));
});
const sizePx = parseFloat(style.fontSize);
const weight = parseInt(style.fontWeight, 10) || 400;
const isLarge = sizePx >= 24 || (sizePx >= 18.66 && weight >= 700);
const required = isLarge ? largeMinimum : minimum;
if (best + 0.05 < required) {
offenders.push(`${this.describe(el)} :: ${best.toFixed(2)}:1 (needs ${required}:1) at ${Math.round(sizePx)}px`);
}
});
return { name: "text meets WCAG AA contrast", offenders, unmeasured };
},
// ---- Reach (touch target sizes) ----
// What a fingertip can actually hit, which is not the same as the element's box.
//
// Two things make the box misleading, and the first version of this check was
// fooled by both. A control can be padded out with a transparent ::after, so it
// responds well beyond its painted size — that's how a 22px dealer puck should
// stay 22px and still take a thumb. And a checkbox wrapped in a label is hit by
// tapping anywhere on the label, so a 24px box inside a 320x44 label is a
// perfectly good target. Measuring the box reported both as faults.
//
// So it probes outwards with elementFromPoint until the point stops landing on the
// control, which is the thing a finger actually experiences.
effectiveTarget(el, limit = 48) {
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
// The probe walks outward a fixed distance, so it can only ever report
// 2 x limit. Anything already drawn larger than that reads as exactly the cap
// — which had every header button reporting a suspiciously round 80px. Give
// the reach room for the element itself plus the padding being looked for.
limit = Math.max(limit, rect.width / 2 + 24, rect.height / 2 + 24);
// A wrapping or associated label forwards the tap, so it counts as the target.
const label = el.closest("label") || (el.labels && el.labels[0]);
const lands = (x, y) => {
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return false;
const hit = document.elementFromPoint(x, y);
if (!hit) return false;
if (hit === el || el.contains(hit)) return true;
return !!(label && (hit === label || label.contains(hit)));
};
if (!lands(cx, cy)) return null; // centre isn't even hittable; occlusion's problem
const reach = (dx, dy) => {
let far = 0;
for (let d = 1; d <= limit; d++) {
if (!lands(cx + dx * d, cy + dy * d)) break;
far = d;
}
return far;
};
return { width: reach(-1, 0) + reach(1, 0), height: reach(0, -1) + reach(0, 1) };
},
// The 44px guideline is about fingertips, so it only bites where there's no mouse.
// Enforcing it everywhere flagged the header buttons at 42px tall on a desktop,
// which is not a defect — the same buttons are 64px tall on a phone, where it
// matters. Same reasoning as the field sizing: `hover: none` is what actually
// distinguishes a finger from a pointer, not viewport width.
//
// `exempt` is for controls that are deliberately small *and* have a full-size path
// to the same action. The dealer puck is the only one: it's a 22px chip sitting on
// the rim of a seat, and growing it to 44px would take that area from the seat
// itself — which a dealer taps far more often. "Give the button" is in the seat
// menu at full size, so the puck is a shortcut, not the only way through.
touchTargets(minimum = 44, { exempt = ".dealerChip", enforceOnPointerDevices = false } = {}) {
const offenders = [];
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
}
const unmeasured = [];
this.visibleControls()
.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
// check used to pass it in silence — which is how "Confirm Winner(s)",
// the button that pays the pot out, went unmeasured for 36 rounds.
// Bring it into view before probing.
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel");
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
const box = scroller.getBoundingClientRect();
const own = el.getBoundingClientRect();
if (own.bottom > box.bottom || own.top < box.top) {
el.scrollIntoView({ block: "center" });
}
}
const rect = el.getBoundingClientRect();
// Drawn big enough is big enough — no need to probe, and probing wide
// elements is where the cap above used to produce nonsense. Seats sit
// here: they're 76x72 and overlap each other at the rim by design, so
// measuring their reach would report the crowding as a fault.
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
// A checkbox inside a label is tapped by tapping the label, so the
// label is the target. Measured rather than probed, because probing
// fails on anything scrolled off the viewport — which is how a 24px
// checkbox with a 335x44 label around it came up as unprobeable
// instead of as the perfectly good target it is.
const label = el.closest("label");
if (label) {
const lr = label.getBoundingClientRect();
if (lr.width >= minimum - 1 && lr.height >= minimum - 1) return;
}
const size = this.effectiveTarget(el);
// Still can't measure it. That is not a pass — it is the check failing
// to run, and quietly returning here is exactly the kind of exemption
// that hides real faults. Say so instead.
if (!size) {
unmeasured.push(
`${this.describe(el)} :: could not be probed` +
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
);
return;
}
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
offenders.push(
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
);
});
return { name: `touch targets at least ${minimum}px`, offenders, unmeasured };
},
// Tapping the middle of a seat must give you that seat.
//
// Added after a dealer-chip hit area, meant to make a 22px puck thumb-sized, grew
// outward from a chip that already hung over its seat's corner — and therefore
// over the seat next to it. On a nine-seat table, tapping the centre of seat 1
// handed the button to seat 9. The occlusion check missed it because it treated
// anything inside a `.seat` as harmless crowding.
//
// Reported separately from occlusion because the cause is different: not an
// overlay on top of the table, but two pieces of the table fighting over a tap.
seatOwnership() {
// Seats behind an open sheet aren't fighting each other for taps — nothing is
// meant to reach them at all. Asking the question there produced six offenders
// per modal, every one of them the modal doing its job.
if (this.modalScope()) {
return { name: "a seat's centre belongs to that seat", offenders: [],
skipped: "a sheet is open over the table" };
}
const seats = [...document.querySelectorAll(".seat")];
const offenders = [];
seats.forEach((seat, index) => {
const rect = seat.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return;
const hit = document.elementFromPoint(x, y);
if (!hit || seat.contains(hit) || hit === seat) return;
const other = hit.closest(".seat");
const who = other ? `seat ${seats.indexOf(other) + 1}` : this.describe(hit);
const control = hit.matches && hit.matches("button, a, input, select, [role='button']");
offenders.push(
`seat ${index + 1}'s centre goes to ${who}${control ? "'s " + this.describe(hit) : ""}`
);
});
return { name: "a seat's centre belongs to that seat", offenders };
},
// A poker table has one dealer button. Nine pucks all showing "D", one of them
// gold, turns the most-read marker on the felt into a spot-the-difference — and
// a screen reader heard nine buttons named "D" with nothing to tell them apart.
// Both halves are checked here: what's drawn, and what it announces itself as.
oneDealerButton() {
const chips = [...document.querySelectorAll(".dealerChip")];
const shown = chips.filter(chip => {
const style = getComputedStyle(chip);
if (style.display === "none" || style.visibility === "hidden") return false;
if (parseFloat(style.opacity) <= 0.05) return false;
// A hovered seat deliberately fades its empty slot in — that's the "put the
// button here" preview, and it is on screen because a pointer is sitting on
// that seat. Counting it reports two buttons whenever the mouse happens to
// rest on the table, which is most of the time a mouse exists.
const seat = chip.closest(".seat");
if (seat && seat.matches(":hover") && !chip.classList.contains("active")) return false;
return true;
});
const offenders = [];
if (shown.length > 1) {
offenders.push(`${shown.length} dealer buttons drawn on the felt at once`);
}
const labels = chips.map(chip => chip.getAttribute("aria-label") || chip.textContent.trim());
const duplicated = labels.filter((label, i) => labels.indexOf(label) !== i);
if (duplicated.length) {
offenders.push(
`${duplicated.length + 1} dealer buttons announce themselves as "${duplicated[0]}"`
);
}
return { name: "the felt shows one dealer button", offenders };
},
// Anything that says it is hidden and is drawn anyway.
//
// `hidden` gets its `display: none` from the browser's own stylesheet, the weakest
// rule in the cascade, so any class that sets a display beats it — and in this tool
// every button class does. The result is an element the code believes is gone,
// sitting on the screen: found as a 21x44 empty box under the bank check, and this
// file's own touch check had been calling it "could not be probed" rather than
// saying the plainer thing, which is that it should not be there at all.
hiddenButPainted() {
const offenders = [...document.querySelectorAll("[hidden]")]
.filter(el => {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
const style = getComputedStyle(el);
return style.display !== "none" && style.visibility !== "hidden";
})
.map(el => `${this.describe(el)} :: hidden, but drawn ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`);
return { name: "hidden means hidden", offenders };
},
// A seat's name, painted under the decoration on its own puck.
//
// The chip that marks the button sits in the puck's top-right corner on a layer
// above the text, and the name is centred across the whole width. A long one
// therefore ran underneath it and stopped — measured, "Christopher" on the button
// lost 20px of its 64px box and showed no ellipsis, because as far as the text was
// concerned it had all the room it needed. Nothing else here would have caught it:
// the occlusion check is about controls a thumb has to reach, and this is a label
// a dealer has to read.
seatLabelsClear() {
const offenders = [];
document.querySelectorAll(".seat").forEach(seat => {
const name = seat.querySelector(".seatPuckName");
if (!name || !name.textContent.trim()) return;
const box = name.getBoundingClientRect();
if (!box.width || !box.height) return;
// Padding is room the text has already been kept out of, so it is not
// somewhere the text can be covered.
const style = getComputedStyle(name);
const textLeft = box.left + parseFloat(style.paddingLeft || 0);
const textRight = box.right - parseFloat(style.paddingRight || 0);
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
const over = mark.getBoundingClientRect();
if (!over.width || !over.height) return;
const marked = getComputedStyle(mark);
if (marked.display === "none" || marked.visibility === "hidden" ||
parseFloat(marked.opacity) < 0.05) return;
const covered = Math.min(textRight, over.right) - Math.max(textLeft, over.left);
const vertical = Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
if (covered > 1 && vertical > 1) {
offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`);
}
});
});
return { name: "seat names aren't painted under their own badges", offenders };
},
// ---- 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.
// The table has to be big enough for the game around it.
//
// The seat ring sat at a flat 44% by 42% of the seat layer at every size — two
// numbers picked against a phone. A fixed pair can only be right for one ratio of
// puck to felt, and measured on the running page it was wrong nearly everywhere:
// at 1280x900 nine-handed, five of nine pucks finished past the outer edge of the
// table, the worst by 49px; on a phone held sideways all six did. Seats floating
// off the felt onto the page read as the table being too small for the game rather
// than as players at a rail — and it was a different amount of wrong on every
// screen, which is what made it look like a mistake instead of a choice.
//
// Checked against the table's outer edge, border included: sitting *on* the rail is
// fine, hanging off the far side of it is not. The bet chips are held to the same
// rule — a chip is meant to be on the felt between its player and the pot.
seatsOnTheTable(slackPx = 1) {
const name = "seats and chips stay on the table";
if (this.modalScope()) {
return { name, offenders: [], skipped: "a sheet is open over the table" };
}
const table = document.getElementById("pokerTable");
if (!table) return { name, offenders: [], skipped: "no table on this page" };
const felt = table.getBoundingClientRect();
if (!felt.width || !felt.height) return { name, offenders: [], skipped: "table not laid out" };
const offenders = [];
const check = (el, what) => {
const r = el.getBoundingClientRect();
if (!r.width || !r.height) return;
const over = Math.max(
felt.left - r.left, r.right - felt.right,
felt.top - r.top, r.bottom - felt.bottom);
if (over > slackPx) offenders.push(`${what} hangs ${Math.round(over)}px past the rail`);
};
[...document.querySelectorAll(".seat")].forEach((seat, i) => check(seat, `seat ${i + 1}`));
[...document.querySelectorAll("#betsLayer > *")].forEach((chip, i) =>
check(chip, `bet chip ${i + 1}`));
return { name, offenders };
},
// An open dialog's buttons have to be on screen without hunting for them.
//
// The pot wizard — the one dialog in this tool that divides money — had its Confirm
// below a scroll at every size: measured with six players and a side pot, 244px off
// the bottom on an iPhone 14, 417 on a Galaxy S8, 583 on a phone held sideways, 172
// on a desktop. Nothing on screen said there was anything below the list of names,
// so the only control that finishes the hand was invisible with the table waiting.
//
// Checked by hit-testing rather than by geometry alone: a button can be inside the
// viewport and still be under something. Only dialogs that are actually open are
// considered, and only their buttons — a dialog with no buttons has nothing to find.
dialogActionsReachable() {
const name = "an open dialog's buttons are on screen";
const dialogs = [...document.querySelectorAll("[role='dialog'], [role='alertdialog']")]
.filter(d => {
const rect = d.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && getComputedStyle(d).visibility !== "hidden";
});
if (dialogs.length === 0) return { name, offenders: [], skipped: "no dialog is open" };
const offenders = [];
dialogs.forEach(dialog => {
const buttons = [...dialog.querySelectorAll("button")].filter(b => {
const r = b.getBoundingClientRect();
return r.width > 0 && r.height > 0;
});
buttons.forEach(button => {
const r = button.getBoundingClientRect();
const label = this.describe(button);
if (r.bottom > innerHeight || r.top < 0) {
const off = r.bottom > innerHeight
? Math.round(r.bottom - innerHeight) + "px below the fold"
: Math.round(-r.top) + "px above it";
offenders.push(`${label} is ${off}`);
return;
}
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
if (hit && hit !== button && !button.contains(hit)) {
offenders.push(`${label} is covered by ${this.describe(hit)}`);
}
});
});
return { name, offenders };
},
runAll(options = {}) {
const checks = [
this.seatsOnTheTable(options.seatSlack),
this.dialogActionsReachable(),
this.touchAction(),
this.tapHighlight(),
this.textSelectable(),
this.occlusion(options.occlusionSelector),
this.collapsedColumns(),
this.textSpill(),
this.horizontalOverflow(),
this.fieldFontSize(),
this.seatOwnership(),
this.oneDealerButton(),
this.hiddenButPainted(),
this.seatLabelsClear(),
this.contrast(options.contrast),
this.touchTargets(options.minimumTarget, options.touchTargets),
];
return {
viewport: `${innerWidth}x${innerHeight}`,
failed: checks.filter(c => c.offenders.length > 0),
// 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 || []),
...(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),
};
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = LayoutAudit;
}