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:
@@ -799,7 +799,12 @@
|
||||
<div id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
|
||||
<div class="potWizardHeader">
|
||||
<h2 id="potWizardTitle" tabindex="-1" data-modal-focus>Resolve the Pot</h2>
|
||||
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
|
||||
@* Named, and named for what leaving does rather than "Close". The glyph is
|
||||
the whole of this control, so unnamed it announced as "button" — on the
|
||||
one dialog in the tool that divides money, where the difference between
|
||||
backing out and confirming is somebody's stack. *@
|
||||
<button id="closePotWizardBtn" type="button" data-modal-close class="holdemBtn holdemBtnGhost"
|
||||
aria-label="Close without paying the pot">✕</button>
|
||||
</div>
|
||||
<div id="potWizardBody"></div>
|
||||
@* The buttons live outside the scrolling body on purpose. This is the one
|
||||
|
||||
@@ -853,9 +853,27 @@ button,
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* A transparent sheet over the whole felt — and until now it took every tap that landed
|
||||
on it.
|
||||
|
||||
The seats are pucks scattered around a ring; the space between them is the middle of
|
||||
the table, and the middle of the table is where the board and the pot badge live. But
|
||||
this layer is `inset: 0` and a later sibling of #pokerTable, so it covers all of it,
|
||||
and an element with no background still hit-tests. Measured at 1280x900 with the board
|
||||
turned on and five cards out: all five community cards — buttons, each one the way the
|
||||
dealer enters or corrects that card — hit-tested to `div#seatsLayer`. Not one of them
|
||||
was pressable, on any screen wide enough to keep the board in the middle.
|
||||
|
||||
Nothing scripted could see it, because a probe that calls `.click()` on the node skips
|
||||
hit-testing entirely; the sweep could not see it either, because its occlusion check
|
||||
only ever asked about the bar, the header and the seats. #betsLayer, the other sheet
|
||||
over the felt, has had `pointer-events: none` all along.
|
||||
|
||||
The pucks take their own taps back below. Everything else in here is inside one. */
|
||||
#seatsLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- Bets on the felt ---------- */
|
||||
@@ -949,6 +967,11 @@ button,
|
||||
did not: a 64px puck measured 74 across once its padding and border were counted,
|
||||
and the search that sets this compares it against distances on the felt. */
|
||||
box-sizing: border-box;
|
||||
/* The layer above is inert so the middle of the felt is reachable; a puck is the
|
||||
thing in it that is not. Its own children inherit this, and the two that want to
|
||||
be untouchable — the ghost dealer chips, the one that is only a marker — say so
|
||||
for themselves further down. */
|
||||
pointer-events: auto;
|
||||
width: var(--seatPuckW, 96px);
|
||||
/* Every min-height on a seat is a drawn height now, border and padding included,
|
||||
because of the box-sizing above. They were content heights before, so each one
|
||||
|
||||
@@ -1843,11 +1843,27 @@ const Render = {
|
||||
if (onStrip) {
|
||||
if (strip && row.parentElement !== strip) strip.appendChild(row);
|
||||
if (strip) strip.hidden = false;
|
||||
if (centre) { centre.style.maxWidth = ""; centre.style.maxHeight = ""; }
|
||||
if (centre) { centre.style.width = ""; centre.style.maxWidth = ""; centre.style.maxHeight = ""; }
|
||||
} else {
|
||||
if (centre && row.parentElement !== centre) centre.insertBefore(row, centre.firstChild);
|
||||
if (strip) strip.hidden = true;
|
||||
if (centre) centre.style.maxWidth = `${Math.round(width)}px`;
|
||||
if (centre) {
|
||||
// Both, and the width is the half that was missing.
|
||||
//
|
||||
// `max-width` caps a box; it does not claim anything. The centre is a
|
||||
// flex column that sizes to its content, so with 463px of measured room
|
||||
// written into its max-width it went on being 136px wide — and the board
|
||||
// row inside it is `width: 100%` of that, which is a loop that comes to
|
||||
// rest on the cards' own `min-width`. Measured with the board on: the
|
||||
// middle had 294px on an iPad, 463px on a desktop and 542px on a wide
|
||||
// one, and in all three the five community cards drew 24px across. On
|
||||
// the strip, where the row simply gets the width of the page, the same
|
||||
// cards draw 44. They are buttons — a tap on one opens the picker for
|
||||
// that card — so 24px was a little over half the floor this sweep holds
|
||||
// every other control on the felt to.
|
||||
centre.style.width = `${Math.round(width)}px`;
|
||||
centre.style.maxWidth = `${Math.round(width)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
// The chips were laid out against whatever was in the middle of the felt a
|
||||
@@ -2530,8 +2546,21 @@ const Render = {
|
||||
const valueInput = row.querySelector(".customFieldValue");
|
||||
labelInput.value = field.label;
|
||||
valueInput.value = field.value;
|
||||
|
||||
// Same as the chip legend's remove button, and for the same reason: "×" is
|
||||
// the whole of this control, so unnamed it announces as a button and nothing
|
||||
// more, in a column of others exactly like it. The label the dealer typed is
|
||||
// what tells them apart, and it changes as it is typed.
|
||||
const removeBtn = row.querySelector(".removeFieldBtn");
|
||||
const nameRemove = () => {
|
||||
const typed = labelInput.value.trim();
|
||||
removeBtn.setAttribute("aria-label", typed ? `Remove "${typed}"` : "Remove this note");
|
||||
};
|
||||
nameRemove();
|
||||
|
||||
labelInput.addEventListener("input", () => {
|
||||
field.label = labelInput.value;
|
||||
nameRemove();
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeatDisclosures(form, player);
|
||||
});
|
||||
@@ -2540,7 +2569,7 @@ const Render = {
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeatDisclosures(form, player);
|
||||
});
|
||||
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
|
||||
removeBtn.addEventListener("click", () => {
|
||||
player.customFields.splice(fieldIdx, 1);
|
||||
this.renderCustomFields(form, player, index);
|
||||
this.hooks.save(this.state);
|
||||
|
||||
@@ -322,17 +322,32 @@ async function main() {
|
||||
// spent already.
|
||||
if (shape !== TABLES[0][0]) continue;
|
||||
|
||||
// The whole audit, not the one check.
|
||||
//
|
||||
// This ran `occlusion()` and nothing else — one rule of twenty-five — so
|
||||
// for the sweep's entire life the five sheets a dealer spends half the
|
||||
// night in had never been measured for touch targets, contrast, control
|
||||
// names, text spill or anything else. The three rules that made that a
|
||||
// reasonable thing to do are modal-aware now: two of them ask about the
|
||||
// fixed bar, which is not a question while a scrim is covering it, and
|
||||
// the third called every card below the fold of a sheet that scrolls an
|
||||
// unreachable button.
|
||||
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`);
|
||||
const sheet = LayoutAudit.runAll({
|
||||
occlusionSelector: overlay
|
||||
? `#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`
|
||||
: undefined,
|
||||
});
|
||||
return { offenders: sheet.failed.flatMap(f => f.offenders), warnings: sheet.warnings };
|
||||
});
|
||||
await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; }));
|
||||
record(`${name} · ${label} ${width}x${height}`, result.offenders);
|
||||
result.warnings.forEach(w => warnings.push(`${name} · ${label}: ${w}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// The five cards in the middle of the felt, and the sheet of glass over them.
|
||||
//
|
||||
// Two faults, one on top of the other, and the sweep could see neither.
|
||||
//
|
||||
// #seatsLayer is a transparent `inset: 0` sibling of the table that holds the seat
|
||||
// pucks. The pucks ring the felt; the middle of it is empty layer — and an element with
|
||||
// no background still hit-tests. So every tap aimed at the community cards landed on the
|
||||
// layer instead. Measured at 1280x900 with the board on and 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. Each one is a button that opens the picker for that
|
||||
// card, and not one of them could be pressed. #betsLayer, the other sheet over the felt,
|
||||
// has been `pointer-events: none` since it was written.
|
||||
//
|
||||
// Underneath that, the cards were a little over half the size they were meant to be.
|
||||
// `placeBoard` measures how much middle the felt has and writes it to the centre's
|
||||
// `max-width` — but a max-width caps a box, it 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 board row inside it is `width: 100%` of that, and five cards
|
||||
// sharing 136px come to rest on their own `min-width`: 24px, against a 44px floor this
|
||||
// sweep holds every other control on the felt to. On the strip, where the row simply
|
||||
// gets the width of the page, the same cards have always drawn 44.
|
||||
//
|
||||
// Neither was visible to a scripted probe — `.click()` on a 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.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
const AUDIT = fs.readFileSync(path.join(__dirname, "uiAudit.js"), "utf8");
|
||||
const DRIVER = fs.readFileSync(path.join(__dirname, "runUiAudit.js"), "utf8");
|
||||
const src = name => fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the sheet over the felt", () => {
|
||||
test("it does not take taps meant for what is under it", () => {
|
||||
assert.match(ruleFor("#seatsLayer"), /pointer-events:\s*none/);
|
||||
});
|
||||
|
||||
// Which is only safe because the thing in it that *is* a control says so itself.
|
||||
test("but a seat still does", () => {
|
||||
assert.match(ruleFor(".seat"), /pointer-events:\s*auto/);
|
||||
});
|
||||
|
||||
// The two chips that want to stay untouchable set their own value and are inside a
|
||||
// seat, so they override what they inherit rather than depending on the layer.
|
||||
test("and the two pucks that are markers rather than controls still are", () => {
|
||||
assert.match(ruleFor(".dealerChip:not(.active)"), /pointer-events:\s*none/);
|
||||
assert.match(ruleFor(".dealerChip.active"), /pointer-events:\s*none/);
|
||||
});
|
||||
|
||||
test("the other sheet is unchanged, and was always right", () => {
|
||||
assert.match(ruleFor("#betsLayer"), /pointer-events:\s*none/);
|
||||
});
|
||||
|
||||
test("what it measured is written beside the rule", () => {
|
||||
const at = CSS.indexOf("/* A transparent sheet over the whole felt");
|
||||
assert.ok(at >= 0, "the rule carries no note saying why");
|
||||
const note = CSS.slice(at, CSS.indexOf("*/", at));
|
||||
assert.match(note, /1280x900/);
|
||||
assert.match(note, /seatsLayer/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("how wide the middle actually is", () => {
|
||||
test("the room it measured is claimed, not only capped", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("placeBoard(row, strip) {");
|
||||
assert.ok(at >= 0, "there is no placeBoard");
|
||||
const body = render.slice(at, render.indexOf("\n },", at));
|
||||
assert.match(body, /centre\.style\.width = `\$\{Math\.round\(width\)\}px`/,
|
||||
"the centre is still given a ceiling and no floor, so it shrink-wraps");
|
||||
assert.match(body, /centre\.style\.maxWidth = `\$\{Math\.round\(width\)\}px`/);
|
||||
});
|
||||
|
||||
// Both come off together when the board leaves the felt, or the centre would keep a
|
||||
// width measured for a board that is no longer in it.
|
||||
test("and both are given back when the board moves to the strip", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("placeBoard(row, strip) {");
|
||||
const body = render.slice(at, render.indexOf("} else {", at));
|
||||
assert.match(body, /centre\.style\.width = ""/);
|
||||
assert.match(body, /centre\.style\.maxWidth = ""/);
|
||||
});
|
||||
|
||||
// The floor is what it always said it was; what changed is that the cards can now
|
||||
// reach past it to the ceiling.
|
||||
test("a card is still allowed to grow to a thumb", () => {
|
||||
const rule = ruleFor(".boardCard");
|
||||
assert.match(rule, /max-width:\s*44px/);
|
||||
assert.match(rule, /min-height:\s*44px/);
|
||||
assert.match(rule, /flex:\s*1 1 0/);
|
||||
});
|
||||
|
||||
test("what it measured is written beside the code", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("// Both, and the width is the half that was missing.");
|
||||
assert.ok(at >= 0, "the fix carries no note saying why");
|
||||
const note = render.slice(at, at + 1400);
|
||||
assert.match(note, /463px/);
|
||||
assert.match(note, /24px/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("why the sweep could not see either of them", () => {
|
||||
// Three selectors: the bar, the header and the seats. Nothing else on the page had
|
||||
// ever been asked whether something was sitting on top of it.
|
||||
test("the occlusion check looks at every control now, not three groups", () => {
|
||||
const at = AUDIT.indexOf("occlusion(selector = null) {");
|
||||
assert.ok(at >= 0, "occlusion still hard-codes its own list");
|
||||
assert.match(AUDIT.slice(at, at + 200), /selector = selector \|\| this\.CONTROL_SELECTOR/);
|
||||
});
|
||||
|
||||
// Widening it put three kinds of non-fault in front of the rule for the first time.
|
||||
test("a control that opted out of the pointer is not reported as covered", () => {
|
||||
const at = AUDIT.indexOf("occlusion(selector = null) {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /getComputedStyle\(el\)\.pointerEvents === "none"/);
|
||||
});
|
||||
|
||||
test("nor is one inside a disclosure nobody has opened", () => {
|
||||
const at = AUDIT.indexOf("occlusion(selector = null) {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /el\.checkVisibility/,
|
||||
"a shut <details> still hands out a real rect, and a size check lets it through");
|
||||
});
|
||||
|
||||
test("and flow content under a fixed layer is said rather than failed", () => {
|
||||
const at = AUDIT.indexOf("occlusion(selector = null) {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /scrolls clear of it/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the rest of what the sweep had never run", () => {
|
||||
// It opened five sheets and asked one question of them.
|
||||
test("an overlay gets the whole audit, not the occlusion check alone", () => {
|
||||
const at = DRIVER.indexOf("for (const [name, open] of OVERLAYS) {");
|
||||
assert.ok(at >= 0);
|
||||
const body = DRIVER.slice(at, DRIVER.indexOf("record(`${name}", at));
|
||||
assert.match(body, /LayoutAudit\.runAll\(/);
|
||||
assert.ok(!/return LayoutAudit\.occlusion\(/.test(body),
|
||||
"the overlay pass still runs one rule");
|
||||
});
|
||||
|
||||
// Two of the rules that made running only one of them reasonable ask about the
|
||||
// fixed bar, which is not a question while a scrim is covering the page.
|
||||
test("the two questions about the fixed bar stand down while a sheet is open", () => {
|
||||
["focusHasRoom() {", "furnitureBudget() {"].forEach(signature => {
|
||||
const at = AUDIT.indexOf(signature);
|
||||
assert.ok(at >= 0, `there is no \`${signature}\``);
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /if \(this\.modalScope\(\)\) \{/,
|
||||
`${signature} still reads a full-screen scrim as furniture`);
|
||||
});
|
||||
});
|
||||
|
||||
// And the third called the bottom four rows of a sheet that scrolls unreachable.
|
||||
test("a dialog's buttons below the fold are only a fault if nothing scrolls to them", () => {
|
||||
const at = AUDIT.indexOf("dialogActionsReachable() {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /if \(this\.reachableByScrolling\(button\)\) return;/);
|
||||
});
|
||||
|
||||
// The helper all three of those lean on had a blind spot of its own: it asked
|
||||
// whether the element had left the box entirely, which is true of something well
|
||||
// below the fold and false of something hanging eight pixels over the edge.
|
||||
test("and reachability counts a control that is only part-way out", () => {
|
||||
const at = AUDIT.indexOf("reachableByScrolling(el) {");
|
||||
const body = AUDIT.slice(at, AUDIT.indexOf("\n },", at));
|
||||
assert.match(body, /const below = rect\.bottom - box\.bottom/);
|
||||
assert.match(body, /const above = box\.top - rect\.top/);
|
||||
// The comment above the fix quotes the old test, so the code is read on its own.
|
||||
const code = body.split("\n").filter(line => !/^\s*\/\//.test(line)).join("\n");
|
||||
assert.ok(!/rect\.top >= box\.bottom/.test(code),
|
||||
"it still asks whether the element has left the box entirely");
|
||||
});
|
||||
});
|
||||
|
||||
describe("two controls that were a glyph and nothing else", () => {
|
||||
test("the pot wizard's close is named for what leaving does", () => {
|
||||
const markup = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const at = markup.indexOf('id="closePotWizardBtn"');
|
||||
assert.ok(at >= 0);
|
||||
assert.match(markup.slice(at, markup.indexOf(">", at)), /aria-label="Close without paying the pot"/);
|
||||
});
|
||||
|
||||
// Named from the label the dealer typed, and kept up with it — the same shape the
|
||||
// chip legend's remove button already used, in a column of identical crosses.
|
||||
test("and a custom field's remove says which field", () => {
|
||||
const render = src("render");
|
||||
const at = render.indexOf("renderCustomFields(form, player, index) {");
|
||||
const body = render.slice(at, render.indexOf("\n },", at));
|
||||
assert.match(body, /removeBtn\.setAttribute\("aria-label"/);
|
||||
assert.match(body, /labelInput\.value\.trim\(\)/);
|
||||
assert.match(body, /nameRemove\(\);/);
|
||||
});
|
||||
});
|
||||
+90
-8
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user