Take the glass off the middle of the table

The board — the five community cards a dealer taps to enter the flop, the
turn and the river — could not be pressed at all. #seatsLayer is an
`inset: 0` sibling of the table that holds the seat pucks, and an element
with no background still hit-tests, so the empty middle of that layer took
every tap aimed at what was underneath it. Measured at 1280x900 with five
cards out: all five hit-tested to `div#seatsLayer`, on every screen wide
enough to keep the board in the middle rather than on the strip. #betsLayer,
the other sheet over the felt, has been pointer-events: none since it was
written.

Underneath that, the cards were drawing at a little over half their size.
placeBoard measures how much middle the felt has and writes it to the
centre's max-width — but max-width caps a box and does not claim anything,
and the centre is a flex column that sizes to its content. So with 463px of
measured room it went on being 136px wide, the row inside it is width: 100%
of that, and five cards sharing 136px come to rest on their own min-width:
24px, against the 44px floor every other control on the felt is held to.

Neither was visible to anything. A scripted probe calling .click() on the
node skips hit-testing entirely, and the sweep's occlusion check had only
ever been shown three selectors' worth of the page — the action bar, the
header and the seats. It asks about every control now, which took three
refinements to be honest: a control that set pointer-events: none has opted
out rather than been covered; a shut <details> hands out a real rect for
everything in it, and checkVisibility is the browser's own answer; and flow
content under a fixed layer is a position rather than a fault, so it is said
rather than failed.

The five sheets a dealer spends half the night in were getting one rule of
twenty-five. They get all of them now. Three rules had to learn what a modal
is first: two ask about the fixed bar, which is not a question while a scrim
is covering it, and the third called the bottom four rows of a sheet that
scrolls unreachable buttons. The helper all three lean on had its own blind
spot — it asked whether an element had left its box entirely, which is true
of something well below the fold and false of something hanging eight pixels
over the edge of it.

That turned up two controls that were a glyph and nothing else: the pot
wizard's close, on the one dialog that divides money, and the cross that
removes a custom field, in a column of identical crosses.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-14 00:01:37 -06:00
co-authored by Claude Opus 5
parent c1d0105d12
commit 2e372d4677
6 changed files with 377 additions and 14 deletions
+90 -8
View File
@@ -143,6 +143,15 @@ const LayoutAudit = {
// and this file has no automation of its own: scroll-padding-bottom on the scroll
// container has to reserve at least as much room as the furniture standing there.
focusHasRoom() {
// Not a question that means anything while a sheet is open. The page behind is
// inert, and the sheet's own scrim is `position: fixed; inset: 0` — bottom-anchored
// and full height — so this read it as furniture standing 100% of the window tall
// and demanded scroll padding for all of it. Every overlay failed, every viewport.
if (this.modalScope()) {
return { name: "focus has room under the fixed bar", offenders: [],
skipped: "a sheet is open — the page behind it is not being tabbed" };
}
const furniture = this.bottomFurniture();
if (furniture.length === 0) {
return { name: "focus has room under the fixed bar", offenders: [],
@@ -182,6 +191,13 @@ const LayoutAudit = {
// stops being read.
furnitureBudget() {
const name = "the fixed bar leaves the table its room";
// Same reason as focusHasRoom above: a scrim is not furniture, it is the whole
// point of the thing on top of it, and "the felt has less room than the furniture"
// is not a complaint anyone can act on while a sheet is deliberately covering it.
if (this.modalScope()) {
return { name, offenders: [], skipped: "a sheet is open — it is meant to cover the table" };
}
const furniture = this.bottomFurniture();
if (furniture.length === 0) {
return { name, offenders: [], skipped: "nothing is fixed to the bottom here" };
@@ -322,11 +338,25 @@ const LayoutAudit = {
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
const down = 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;
const up = node === document.body ? scrollY : node.scrollTop;
// How far past each edge the element sits, and whether there is that much
// scrolling left in this direction. Partly past counts.
//
// This used to ask whether the element had left the box *entirely* —
// `rect.top >= box.bottom` — which is true of something well below the fold
// and false of something hanging eight pixels over the edge of it. So a card
// half-visible at the bottom of a sheet with 254px of scroll still in it came
// back "cannot be scrolled to", and the row above it, fully out, came back
// fine. Verified in a browser before believing either: the sheet scrolls, the
// two of clubs comes fully into view and hit-tests as itself.
const below = rect.bottom - box.bottom;
const above = box.top - rect.top;
if (below > 0.5 && down >= below - 0.5) return true;
if (above > 0.5 && up >= above - 0.5) return true;
}
return false;
},
@@ -374,7 +404,15 @@ const LayoutAudit = {
return null;
},
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
// Everything a dealer can press, not three selectors' worth.
//
// This asked about the action bar, the header and the seats, and nothing else on the
// page had ever been checked for being covered — which is how the toast came to sit
// on 98% of "Common rulings" and 98% of "Quick Split" on every phone, every hand,
// for eight seconds. Both are controls, both sit below the felt, and neither was in
// any of the three groups, so the sweep never looked.
occlusion(selector = null) {
selector = selector || this.CONTROL_SELECTOR;
const offenders = [];
const warnings = [];
// While something is open on top, only what is inside it can be judged. The
@@ -386,6 +424,21 @@ const LayoutAudit = {
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
// Inside a disclosure nobody has opened. A shut `<details>` still hands out
// a real rect for everything in it — the rake fields measure 54x28 at a
// sensible place on the page while House take is closed — so a size check
// alone lets them through and they hit-test to the next summary along.
// `checkVisibility` is the browser's own answer to the same question.
if (typeof el.checkVisibility === "function" &&
!el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true })) return;
// A control that has opted out of the pointer is not being covered — it is
// a marker that happens to be a button, and the hit test lands on whatever
// is behind it because that is what it asked for. The dealer button on the
// seat that holds it is exactly this: `pointer-events: none`, deliberately,
// because giving the button to the seat that has it is not a thing to do.
if (getComputedStyle(el).pointerEvents === "none") return;
const outsideViewport = rect.bottom <= 0 || rect.top >= innerHeight ||
rect.right <= 0 || rect.left >= innerWidth;
if (outsideViewport) {
@@ -423,6 +476,7 @@ const LayoutAudit = {
let blocked = 0;
let testable = 0;
let blockedBy = null;
let blocker = null;
points.forEach(([x, y]) => {
const top = document.elementFromPoint(x, y);
// A null result means the point isn't hit-testable — typically it sits
@@ -448,7 +502,7 @@ const LayoutAudit = {
if (!isControl) return;
}
blocked++;
if (!blockedBy) blockedBy = this.describe(top);
if (!blockedBy) { blockedBy = this.describe(top); blocker = top; }
});
if (testable === 0) {
@@ -468,8 +522,29 @@ const LayoutAudit = {
// 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);
if (transient && blocked < testable) { warnings.push(note); return; }
// Content in the flow, under something fixed, on a page that scrolls.
//
// The fixed thing stays where it is and the content moves, so this is a
// position rather than a state: scroll, and the control comes out from
// under. Worth saying — a dealer reaching for Common rulings while a toast
// is up finds a toast — but it is not the same fault as a control that no
// amount of scrolling will free, which is what the rest of this reports.
// Widening the check from three selectors to every control put forty of
// these in front of a rule that had only ever been shown the felt.
const pinned = node => {
for (let up = node; up && up !== document.body; up = up.parentElement) {
if (getComputedStyle(up).position === "fixed") return true;
}
return false;
};
const canScroll = document.documentElement.scrollHeight > innerHeight + 1;
if (blockedBy && !pinned(el) && pinned(blocker) && canScroll) {
warnings.push(`${note} — scrolls clear of it`);
return;
}
offenders.push(note);
});
return { name: "nothing important is covered", offenders, warnings };
},
@@ -1576,10 +1651,17 @@ const LayoutAudit = {
const r = button.getBoundingClientRect();
const label = this.describe(button);
if (r.bottom > innerHeight || r.top < 0) {
// Below the fold is only a fault if it is also out of reach. A sheet
// that scrolls is allowed to be taller than the window — the card
// picker is fifty-two cards and always will be, and this reported the
// bottom four rows of it as unreachable buttons on every phone there
// is. What the rule is actually for is a dialog whose Confirm has
// been pushed off a short screen with no way to get to it.
if (this.reachableByScrolling(button)) return;
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}`);
offenders.push(`${label} is ${off}, and nothing scrolls to it`);
return;
}
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);