Twelve rulings in a 460px sheet in the middle of an 844px window: 754px of content in a 334px scrollport, 111 of it spent on a title and a caveat that stand still, 384px of window width unused either side. Four of the twelve names on screen, and the dealer is looking one up with an argument going on at the table. Material's rule for a bottom sheet in landscape is to stop being one, and this tool already does exactly that under this query for the action bar. So the sheet takes the window and the width pays for the height: the twelve go into two columns of six, all of them on screen at 844x390 with nothing to scroll at all, and the title, the caveat and Done share one line instead of standing on three. Two wrappers in the markup rather than one grid of twelve, because a grid pairs its children into shared rows: opening the third ruling on the left would push the ninth down the right-hand side to keep the row square. Two flows means a column only ever moves itself — measured, opening one leaves the other six names exactly where they were. Everywhere but landscape the wrappers are `display: contents` and the twelve are the single column they have always been. And the same sheet upright: the header is stuck to the top of its own scrollport now, the way the seat menu's has been. Measured before, at the bottom of that scroll Done was 221px above the sheet's top edge on a 360x640 phone, and 262 on a 390x844 one with a ruling open — which is the only reason anybody is in here. A dealer who has read the answer had to scroll back past it to leave. The layout sweep's scroll-into-view guard learns the new box, or it finds a sheet whose scrollHeight equals its clientHeight and decides nothing is out of view. 1324 tests pass. The sweep is clean across 428 audits; contrast unchanged. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
1735 lines
92 KiB
JavaScript
1735 lines
92 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";
|
||
});
|
||
},
|
||
|
||
// ---- Every control says what it is ----
|
||
|
||
// WCAG 4.1.2 wants a name on every control, and empty button names are among the
|
||
// most common failures on the web — WebAIM's 2025 sweep found them on a bit over a
|
||
// quarter of home pages. This tool had one: the × that removes a chip colour, five
|
||
// of them in a column, identical, each deleting a different colour, and one click
|
||
// of the wrong one took $22,500 off a table.
|
||
//
|
||
// A symbol counts as no name here even though it is technically text. "×" is
|
||
// announced as "times" and names nothing — the rule is that a name has to contain a
|
||
// letter or a digit, which is the difference between "×" and "Undo" or "+ Add chip
|
||
// color". Anything with aria-hidden is not in the tree at all and is skipped rather
|
||
// than judged.
|
||
controlNames() {
|
||
const named = el => {
|
||
const aria = (el.getAttribute("aria-label") || "").trim();
|
||
if (aria) return aria;
|
||
|
||
const by = el.getAttribute("aria-labelledby");
|
||
if (by) {
|
||
const text = by.split(/\s+/)
|
||
.map(id => document.getElementById(id))
|
||
.filter(Boolean)
|
||
.map(node => (node.textContent || "").trim())
|
||
.join(" ")
|
||
.trim();
|
||
if (text) return text;
|
||
}
|
||
|
||
// A field's name usually comes from its label rather than from itself.
|
||
if (/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) {
|
||
const wrapping = el.closest("label");
|
||
const pointing = el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`) : null;
|
||
const text = [wrapping, pointing]
|
||
.filter(Boolean)
|
||
.map(node => (node.textContent || "").trim())
|
||
.join(" ")
|
||
.trim();
|
||
if (text) return text;
|
||
const placeholder = (el.getAttribute("placeholder") || "").trim();
|
||
if (placeholder) return placeholder;
|
||
}
|
||
|
||
const own = (el.textContent || "").trim();
|
||
if (own) return own;
|
||
|
||
const image = el.querySelector("img[alt]");
|
||
if (image) return (image.getAttribute("alt") || "").trim();
|
||
|
||
return (el.getAttribute("title") || "").trim();
|
||
};
|
||
|
||
const offenders = this.visibleControls()
|
||
.filter(el => !el.closest("[aria-hidden='true']"))
|
||
// A seat puck is in CONTROL_SELECTOR for the touch checks; its own name is
|
||
// the player's, and an empty chair is deliberately unnamed.
|
||
.filter(el => !el.classList.contains("seat"))
|
||
.map(el => ({ el, name: named(el) }))
|
||
.filter(({ name }) => !/[\p{L}\p{N}]/u.test(name))
|
||
.map(({ el, name }) => `${this.describe(el)} :: ${name ? `named only "${name}"` : "no accessible name"}`);
|
||
|
||
return { name: "every control has a name a screen reader can read", offenders };
|
||
},
|
||
|
||
// ---- Focus has somewhere to land ----
|
||
|
||
// Everything fixed to the bottom of the window, as one band. The action bar and the
|
||
// toast stack, so what matters is the top edge of the highest of them.
|
||
//
|
||
// Only things crossing the middle of the screen count. A landscape phone turns the
|
||
// bar into a rail down the right-hand side, which is bottom-anchored and full height
|
||
// — measuring that as "furniture at the bottom" would demand scroll padding the
|
||
// height of the whole window.
|
||
bottomFurniture() {
|
||
const middle = innerWidth / 2;
|
||
// A sticky element is anchored to whatever scrolls it, which is only the window
|
||
// when nothing between it and the root scrolls. The standings table has a sticky
|
||
// header inside a panel that scrolls on its own; it comes to rest against the
|
||
// bottom of the window often enough to be picked up here, and it is not
|
||
// furniture — scroll-padding on the document would do nothing about it.
|
||
const anchoredToWindow = node => {
|
||
if (getComputedStyle(node).position === "fixed") return true;
|
||
for (let up = node.parentElement; up && up !== document.body; up = up.parentElement) {
|
||
if (/auto|scroll|overlay/.test(getComputedStyle(up).overflowY)) return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
return [...document.querySelectorAll("*")].filter(node => {
|
||
const cs = getComputedStyle(node);
|
||
if (cs.position !== "fixed" && cs.position !== "sticky") return false;
|
||
if (cs.visibility === "hidden" || cs.display === "none" || node.hidden) return false;
|
||
const b = node.getBoundingClientRect();
|
||
if (b.width === 0 || b.height === 0) return false;
|
||
if (b.bottom < innerHeight - 2 || b.left > middle || b.right < middle) return false;
|
||
return anchoredToWindow(node);
|
||
});
|
||
},
|
||
|
||
// WCAG 2.2 SC 2.4.11: a focused control must not be completely hidden by content the
|
||
// page put on top of it.
|
||
//
|
||
// A fixed bar is not in the flow, so the browser does not know it is there: Tab to a
|
||
// control near the bottom and it is scrolled flush against the edge of the window,
|
||
// under the bar, and the browser considers it done. Tabbing the whole page on a
|
||
// 390x844 phone found 16 stops resting completely covered and 27 more partly.
|
||
//
|
||
// Checked as the fix rather than the symptom, because the symptom needs a keyboard
|
||
// 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: [],
|
||
skipped: "nothing is fixed to the bottom here" };
|
||
}
|
||
|
||
const highest = Math.min(...furniture.map(node => node.getBoundingClientRect().top));
|
||
const needed = Math.round(innerHeight - highest);
|
||
const declared = parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom);
|
||
const reserved = Number.isFinite(declared) ? Math.round(declared) : 0;
|
||
|
||
const offenders = reserved >= needed ? [] : [
|
||
`${furniture.map(n => this.describe(n)).join(" + ")} stands ${needed}px tall, ` +
|
||
`and scroll-padding-bottom reserves ${reserved}px — a control tabbed to down there ` +
|
||
`comes to rest ${needed - reserved}px inside it`,
|
||
];
|
||
return { name: "focus has room under the fixed bar", offenders };
|
||
},
|
||
|
||
// ---- The furniture is not allowed to eat the table ----
|
||
|
||
// How much of the window the fixed bottom band is taking.
|
||
//
|
||
// The action bar's height is not a constant: it carries the pot breakdown, which had
|
||
// one row per all-in and no ceiling. Measured on a 360x640 phone — five players
|
||
// idle, 151px; six all-in, 303px; twelve all-in, 411px, which is 64% of the screen,
|
||
// leaving 96px of felt to draw twelve seats in. Nothing in the sweep could see it,
|
||
// because every check here asks whether a thing is covered or clipped and none of
|
||
// them asked how much room the covering thing had taken.
|
||
//
|
||
// Two lines, because there are two different claims. Half the window is the one
|
||
// worth failing on: furniture bigger than the content it serves is indefensible
|
||
// whatever it is showing, and it is a shape fault rather than a matter of taste.
|
||
// Thirty percent is the published guidance for persistent chrome and it is a warning
|
||
// here, because the shortest phone in the list does not meet it at rest — the bar is
|
||
// 151px of a 640px screen before anything happens — and a sweep that is always red
|
||
// 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" };
|
||
}
|
||
|
||
const highest = Math.min(...furniture.map(node => node.getBoundingClientRect().top));
|
||
const tall = Math.round(innerHeight - highest);
|
||
const share = Math.round((100 * tall) / innerHeight);
|
||
const what = furniture.map(n => this.describe(n)).join(" + ");
|
||
|
||
return {
|
||
name,
|
||
offenders: share > 50
|
||
? [`${what} stands ${tall}px of a ${innerHeight}px window — ${share}%, ` +
|
||
`leaving the felt less room than the furniture`]
|
||
: [],
|
||
warnings: share > 30
|
||
? [`${name}: ${what} is ${share}% of the window (${tall}px of ${innerHeight}px); ` +
|
||
`persistent chrome is usually kept under 30%`]
|
||
: [],
|
||
};
|
||
},
|
||
|
||
// The other half: a control that scrolling can never reach at all.
|
||
//
|
||
// The seat editor is a bottom sheet on a phone, and it was put away with
|
||
// `transform: translateY(105%)` — which moves it and nothing else. Parked below an
|
||
// 844px window at y=850, its Done button was still in the tab order and still on the
|
||
// accessibility tree, and because a fixed element does not move when the page does,
|
||
// no amount of scrolling could bring it back. visibility:hidden puts it away; a
|
||
// transform only moves it.
|
||
// A fixed panel that scrolls is a different thing from a fixed panel that doesn't,
|
||
// and this check could not tell them apart. The landscape rail is `overflow-y: auto`
|
||
// on purpose — the raise pad outgrows a 360px-tall screen the moment the text is
|
||
// turned up, and the stylesheet chose overflow over drawing controls on top of one
|
||
// another — so a button below its fold is one flick away, not lost. Reported as
|
||
// unreachable it is simply untrue, and a sweep that cries wolf is a sweep nobody
|
||
// reads.
|
||
//
|
||
// The ancestor has to be on screen itself for this to hold, which the rail is: it
|
||
// is `top: 0; bottom: 0`. If a scrolling panel is ever parked off the side, the
|
||
// thing to report is the panel, not the button inside it.
|
||
scrollableAncestor(el) {
|
||
for (let node = el.parentElement; node; node = node.parentElement) {
|
||
const overflow = getComputedStyle(node).overflowY;
|
||
if ((overflow === "auto" || overflow === "scroll") &&
|
||
node.scrollHeight > node.clientHeight + 1) return node;
|
||
}
|
||
return null;
|
||
},
|
||
|
||
focusablesReachable() {
|
||
const offenders = this.visibleControls()
|
||
.filter(el => {
|
||
for (let node = el; node; node = node.parentElement) {
|
||
if (getComputedStyle(node).position === "fixed") return true;
|
||
}
|
||
return false;
|
||
})
|
||
.filter(el => {
|
||
const b = el.getBoundingClientRect();
|
||
return b.top >= innerHeight || b.bottom <= 0 || b.left >= innerWidth || b.right <= 0;
|
||
})
|
||
.filter(el => !this.scrollableAncestor(el))
|
||
.map(el => `${this.describe(el)} :: fixed and off-screen, so nothing can scroll to it`);
|
||
|
||
return { name: "no control is parked where scrolling can't reach it", offenders };
|
||
},
|
||
|
||
// ---- 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 };
|
||
},
|
||
|
||
// A control that is on the screen and cannot act has to say why, on the device it is
|
||
// being used on. `disabled` cannot: it drops the control out of the tab order, so a
|
||
// screen reader never lands on it to hear the `title`, and it eats the tap on a touch
|
||
// screen, where there is no hover to show a `title` anyway. That left the card picker
|
||
// answering "who has the ace of spades?" only to a mouse, and Export CSV answering
|
||
// nobody at all.
|
||
//
|
||
// So a control that is dimmed is either marked `aria-disabled` with a reason on it —
|
||
// which the one listener in main.js turns into a sentence when it is pressed — or it
|
||
// is `disabled` and there is genuinely nothing to say, which is the seat sheet's
|
||
// fields and a stepper sitting on its own end. Having a `title` and being `disabled`
|
||
// is the combination that cannot work, and it is the one this looks for.
|
||
dimmedControlsExplain() {
|
||
const offenders = this.visibleControls()
|
||
.filter(el => el.disabled && (el.getAttribute("title") || "").trim())
|
||
.map(el => `${this.describe(el)} :: disabled, and its only explanation is a title`);
|
||
return { name: "a control that can't act says why where it can be read", offenders };
|
||
},
|
||
|
||
// 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 down = node === document.body
|
||
? document.documentElement.scrollHeight - innerHeight - scrollY
|
||
: node.scrollHeight - node.clientHeight - node.scrollTop;
|
||
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;
|
||
},
|
||
|
||
// The scrolling ancestor whose box this element has fallen outside of, if any.
|
||
// Wholly outside — an element straddling the edge is still half on show, and the
|
||
// half that is showing is worth hit-testing.
|
||
clippedOutBy(el, rect) {
|
||
for (let node = el.parentElement; node; node = node.parentElement) {
|
||
const style = getComputedStyle(node);
|
||
if (!/auto|scroll|hidden|overlay|clip/.test(style.overflowY + style.overflowX)) continue;
|
||
const box = node.getBoundingClientRect();
|
||
if (rect.top >= box.bottom - 0.5 || rect.bottom <= box.top + 0.5 ||
|
||
rect.left >= box.right - 0.5 || rect.right <= box.left + 0.5) return node;
|
||
}
|
||
return null;
|
||
},
|
||
|
||
// 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;
|
||
},
|
||
|
||
// 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
|
||
// 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;
|
||
|
||
// 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) {
|
||
if (!this.reachableByScrolling(el)) {
|
||
offenders.push(`${this.describe(el)} :: off-screen and cannot be scrolled to`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Still on screen, but clipped away by a panel that scrolls. Hit-testing it
|
||
// is meaningless: the point lands on whatever the panel's own edge has next
|
||
// to it, and the answer comes back as a diagnosis of something that isn't
|
||
// happening. The pot wizard's third winner, below the fold in a list that
|
||
// scrolls, was reported for four viewports running as "fully covered by
|
||
// button Confirm Winner(s)" — the button is simply what is painted at that
|
||
// height. Whether it is a fault is the same question as for anything else
|
||
// out of view: can it be scrolled to.
|
||
const clip = this.clippedOutBy(el, rect);
|
||
if (clip) {
|
||
if (!this.reachableByScrolling(el)) {
|
||
offenders.push(`${this.describe(el)} :: clipped out of ${this.describe(clip)}, which cannot be scrolled to it`);
|
||
}
|
||
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;
|
||
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
|
||
// 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); blocker = 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); 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 };
|
||
},
|
||
|
||
// ---- 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),
|
||
};
|
||
},
|
||
|
||
// Two things this check cannot do, both found by measuring against real pixels in
|
||
// runContrastAudit.js and neither of them fixable from inside a DOM walk:
|
||
//
|
||
// 1. It reads `background-color`, so anything painted by a gradient is invisible to
|
||
// it. The chair number on an empty seat sat on the felt's brown rim at 2.65:1 and
|
||
// the pot's label on green at 2.66:1 — two plain AA failures this reported clean,
|
||
// for years, because it never saw either background.
|
||
// 2. Where an element has several candidate ancestors it takes the best contrast of
|
||
// any of them ("the kindest reading available", below). That is the right instinct
|
||
// for a fade, and it is what let (1) through: the flattering candidate was the
|
||
// dark page behind the felt, which is not what the text is sitting on.
|
||
//
|
||
// Both are why the sibling audit screenshots the page with its text made transparent
|
||
// and samples the pixels instead. This one is kept because AA is the number an
|
||
// accessibility audit will ask for, and because it runs against a live DOM with no
|
||
// screenshot to decode — but a clean result here is not a claim that the page is
|
||
// readable. That claim belongs to the APCA sweep.
|
||
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. The seat menu carries the same
|
||
// action at full size, under "Give this seat", so the puck is a shortcut rather
|
||
// than 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.
|
||
// `.rulingsList` before `#rulingsSheet`, and `closest` takes the nearer
|
||
// of the two: on a phone held sideways the sheet fills the window and
|
||
// stops scrolling, and the twelve rulings scroll inside the list instead.
|
||
// Naming only the sheet there finds a box whose scrollHeight equals its
|
||
// clientHeight, decides nothing is out of view, and puts the check back
|
||
// in the silence this guard exists to break.
|
||
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, " +
|
||
".rulingsList, #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.
|
||
//
|
||
// It then made the same mistake one level down. `seat.contains(hit)` waved through
|
||
// anything inside the seat, and the dealer chip is inside the seat: when its hit
|
||
// pad was moved *inward* to stop it reaching the neighbours, it covered its own
|
||
// seat's middle instead and this check called that fine. The chip's handler calls
|
||
// stopPropagation, so those taps reached nothing — on a fresh table the button is
|
||
// on seat 1, and seating the first player was the one thing on the felt that did
|
||
// not work. A control inside the seat is a rival for the tap, not part of the seat.
|
||
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) return;
|
||
|
||
// The nearest control at that point, if any. A control is a rival for the
|
||
// tap wherever it lives — the seat's own chip swallows the event as surely
|
||
// as the seat next door's does.
|
||
const CONTROLS = "button, a, input, select, textarea, [role='button']";
|
||
const rival = hit.closest ? hit.closest(CONTROLS) : null;
|
||
|
||
if (rival && seat.contains(rival)) {
|
||
offenders.push(
|
||
`seat ${index + 1}'s centre goes to its own ${this.describe(rival)}`
|
||
);
|
||
return;
|
||
}
|
||
|
||
// Its own name, stack or felt. That is the seat, and a tap there is a tap
|
||
// on the seat.
|
||
if (!rival && (hit === seat || seat.contains(hit))) return;
|
||
|
||
const other = hit.closest(".seat");
|
||
const who = other ? `seat ${seats.indexOf(other) + 1}` : this.describe(hit);
|
||
offenders.push(
|
||
`seat ${index + 1}'s centre goes to ${who}${rival ? "'s " + this.describe(rival) : ""}`
|
||
);
|
||
});
|
||
|
||
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 mark on the felt that stands for nothing.
|
||
//
|
||
// The amount pill inside every seat puck has a background, a pill radius and side
|
||
// padding, so a player who had not put chips in yet got an 8x11px green lozenge
|
||
// where their bet would go — measured on seven of nine seats at an ordinary table.
|
||
// One of those is a component holding its place; nine of them is a rash, and a
|
||
// dealer reading the felt has to learn to ignore a colour that on three other seats
|
||
// means "this player has money out".
|
||
//
|
||
// It cost room as well as attention. The ring is sized from the tallest puck on it,
|
||
// so a row nobody had filled was setting how far apart the seats had to sit.
|
||
//
|
||
// The rule is about paint, not about space: an element may hold its place invisibly,
|
||
// and several here do. What it may not do is draw a background or a border with
|
||
// nothing inside it.
|
||
//
|
||
// One real exemption, and it is on the list because the check reported it rather
|
||
// than because it was predicted: .betChipDisc is a 13x13px circle filled with the
|
||
// denomination colour of the chip beside it, published per chip from render.js. It
|
||
// has no text because the colour *is* the content. Anything carrying its own
|
||
// picture in a background-image is exempt on the same grounds, without needing to be
|
||
// named. Nothing else here needs an entry — three others were guessed at first and
|
||
// matched no element on the page, which is its own warning about writing this list
|
||
// from memory instead of from a run.
|
||
emptyDecoration() {
|
||
const DECORATIVE = new Set(["betChipDisc"]);
|
||
const opaque = value => value && value !== "none" && !/rgba\([^)]*,\s*0\s*\)/.test(value)
|
||
&& value !== "rgba(0, 0, 0, 0)" && value !== "transparent";
|
||
|
||
const offenders = [...document.querySelectorAll("#pokerTableWrap *")]
|
||
.filter(el => {
|
||
if (el.children.length || (el.textContent || "").trim()) return false;
|
||
if ([...el.classList].some(c => DECORATIVE.has(c))) return false;
|
||
const box = el.getBoundingClientRect();
|
||
if (box.width < 2 || box.height < 2) return false;
|
||
const cs = getComputedStyle(el);
|
||
if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return false;
|
||
if (opaque(cs.backgroundImage)) return false;
|
||
return opaque(cs.backgroundColor) ||
|
||
["Top", "Right", "Bottom", "Left"].some(side =>
|
||
parseFloat(cs[`border${side}Width`]) > 0 && opaque(cs[`border${side}Color`]));
|
||
})
|
||
.map(el => {
|
||
const box = el.getBoundingClientRect();
|
||
return `${this.describe(el)} :: nothing in it, painted ` +
|
||
`${Math.round(box.width)}x${Math.round(box.height)}px`;
|
||
});
|
||
|
||
return { name: "nothing on the felt is painted for nothing", offenders };
|
||
},
|
||
|
||
// One green, and it is the thing to do next.
|
||
//
|
||
// The accent is a promise: this is the step. It only works while it is spent on one
|
||
// control at a time, and it was being spent on two. Measured over a whole hand,
|
||
// seventeen steps, before this went in: two accent controls at thirteen of them —
|
||
// the bar's Call and the header's Resolve Pot, the identical gradient at opposite
|
||
// ends of a phone — and at the other four the header alone, while the bar held the
|
||
// only button there was to press. At showdown the bar's own "Showdown — resolve
|
||
// pot" was blue-grey and the corner shortcut was green.
|
||
//
|
||
// A modal is its own view, so while one is open only what is inside it is judged —
|
||
// a wizard's confirm is the primary action of the wizard, and the bar underneath it
|
||
// is not on screen in any sense that matters. That is the same scoping rule the
|
||
// occlusion check uses, and it comes from the same helper.
|
||
//
|
||
// Zero is not a failure. Between hands with nobody seated there is genuinely no next
|
||
// step, and inventing one to keep a colour company would be worse than the silence.
|
||
//
|
||
// Six broken versions were run at it and six were caught, but two of them only after
|
||
// the injection itself was fixed, and both failures were the same mistake: painting
|
||
// a control green is not the same as putting a green control in front of anybody.
|
||
// The first attempt turned every sidebar button green with the panels shut, so the
|
||
// rule landed on nothing that was rendered — `getComputedStyle` reported the accent
|
||
// gradient and `offsetParent` was still null. The second opened the panels first,
|
||
// which rendered the button and left it four hundred pixels below the fold, where
|
||
// this check is meant to ignore it. Scrolled into view, the same green "Add" is
|
||
// named in the same breath as the bar's Call, and taking the green off goes clean
|
||
// again. So: read back the computed paint AND the box, on both counts, before
|
||
// believing a clean result means the check is looking.
|
||
ACCENT_FILL: [22, 120, 58],
|
||
ACCENT_TOLERANCE: 24,
|
||
|
||
wearsTheAccent(el) {
|
||
const cs = getComputedStyle(el);
|
||
const paint = `${cs.backgroundColor} ${cs.backgroundImage}`;
|
||
return [...paint.matchAll(/rgba?\(([^)]+)\)/g)]
|
||
.map(m => m[1].split(",").map(Number))
|
||
.some(([r, g, b, a]) => (a === undefined || a > 0.5) &&
|
||
Math.abs(r - this.ACCENT_FILL[0]) <= this.ACCENT_TOLERANCE &&
|
||
Math.abs(g - this.ACCENT_FILL[1]) <= this.ACCENT_TOLERANCE &&
|
||
Math.abs(b - this.ACCENT_FILL[2]) <= this.ACCENT_TOLERANCE);
|
||
},
|
||
|
||
oneNextStep() {
|
||
const scope = this.modalScope();
|
||
const root = scope || document;
|
||
const onScreen = el => {
|
||
const r = el.getBoundingClientRect();
|
||
if (!r.width || !r.height) return false;
|
||
const cs = getComputedStyle(el);
|
||
if (cs.visibility === "hidden" || +cs.opacity === 0) return false;
|
||
return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth;
|
||
};
|
||
|
||
const green = [...root.querySelectorAll("button, a.holdemBtn")]
|
||
.filter(el => el.offsetParent !== null && onScreen(el) && this.wearsTheAccent(el));
|
||
|
||
if (green.length <= 1) {
|
||
return { name: "one green, and it is the next step", offenders: [] };
|
||
}
|
||
return {
|
||
name: "one green, and it is the next step",
|
||
offenders: [`${green.length} controls wear the accent at once` +
|
||
`${scope ? " inside the open sheet" : ""}: ` +
|
||
green.map(el => `"${(el.textContent || "").trim().slice(0, 24)}" in ` +
|
||
`${el.closest("#holdemHeader") ? "the header"
|
||
: el.closest("#actionBar") ? "the action bar"
|
||
: el.closest("#holdemSidebar") ? "the sidebar" : "the page"}`).join(", ")],
|
||
};
|
||
},
|
||
|
||
// The status a shut sidebar panel wears, and the three ways it can go wrong.
|
||
//
|
||
// Each of the eight <details> in the sidebar now carries the one fact you would
|
||
// have opened it for — "Balanced", "2 waiting", "5 colours · $1–$500". They are the
|
||
// sidebar's whole navigation, and what makes them scannable is that they are eight
|
||
// identical slabs: a glance that pushes one row taller than the rest costs more in
|
||
// rhythm than the fact it added is worth. Two of the strings aren't ours to keep
|
||
// short — a table note is whatever the dealer typed, and the amounts grow with the
|
||
// stakes — so this is checked on the page rather than argued about in the markup.
|
||
//
|
||
// Four questions, and they fail in different directions. A row that grew is the
|
||
// layout one. A glance sitting on its own label is the narrow-screen one, and it is
|
||
// the one that will come back: it takes a long label and a long glance in the same
|
||
// panel, so it depends on the state and the viewport together. And a glance
|
||
// ellipsised down to "Sam…" is worth less than no glance at all — it has spent a
|
||
// row's width to say that a note exists, which the panel's own presence already
|
||
// said.
|
||
GLANCE_MIN_READABLE_PX: 60,
|
||
|
||
glancesFitTheirRows() {
|
||
// Rows that are actually drawn. The Keyboard panel is display: none under
|
||
// `pointer: coarse` — a touch device has no keys to press — and a panel that
|
||
// isn't rendered measures 0px, which the shortest-row rule below happily took
|
||
// as its idea of a bare row. Every real row then read as grown: caught by the
|
||
// sweep on its first clean run, 91 offenders, seven rows across each of the
|
||
// thirteen viewports visited with a thumb.
|
||
const rows = [...document.querySelectorAll(".sidebarPanel > summary")]
|
||
.filter(row => row.getBoundingClientRect().height > 0);
|
||
if (!rows.length) return { name: "panel glances fit their rows", offenders: [], skipped: "no sidebar here" };
|
||
|
||
// The shortest row in the sidebar is what a row with nothing on it measures,
|
||
// whichever one that happens to be — taken rather than declared, so a change to
|
||
// the header's padding doesn't quietly make this check pass by moving the bar.
|
||
const heights = rows.map(row => row.getBoundingClientRect().height);
|
||
const bare = Math.min(...heights);
|
||
|
||
const offenders = [];
|
||
rows.forEach((row, index) => {
|
||
const glance = row.querySelector(".panelGlance");
|
||
const label = row.querySelector("h2");
|
||
const rowBox = row.getBoundingClientRect();
|
||
const name = (label && label.textContent.trim()) || `panel ${index + 1}`;
|
||
|
||
if (heights[index] > bare + 1) {
|
||
offenders.push(`${name} :: row is ${Math.round(heights[index])}px against ` +
|
||
`${Math.round(bare)}px for a row with nothing on it`);
|
||
}
|
||
if (!glance || glance.hidden || !glance.textContent.trim()) return;
|
||
|
||
const box = glance.getBoundingClientRect();
|
||
if (label) {
|
||
const labelBox = label.getBoundingClientRect();
|
||
if (box.left < labelBox.right) {
|
||
offenders.push(`${name} :: "${glance.textContent}" sits on the label, ` +
|
||
`${Math.round(labelBox.right - box.left)}px into it`);
|
||
}
|
||
}
|
||
// Both axes, and the vertical one is the half that matters. The row's own
|
||
// height cannot be trusted to report this: the first version of this check
|
||
// asked only whether a row had grown, and a glance floated back into flow
|
||
// and allowed to wrap sailed straight through it — a float doesn't expand
|
||
// the box it sits in, so two lines of note hung 9px clear of a row that
|
||
// still measured exactly 44px. Asking where the glance actually
|
||
// ended up is the question that has an answer.
|
||
const out = [
|
||
box.right > rowBox.right + 1 ? "off the right" : null,
|
||
box.left < rowBox.left - 1 ? "off the left" : null,
|
||
box.top < rowBox.top - 1 ? `${Math.round(rowBox.top - box.top)}px out of the top` : null,
|
||
box.bottom > rowBox.bottom + 1 ? `${Math.round(box.bottom - rowBox.bottom)}px out of the bottom` : null,
|
||
].filter(Boolean);
|
||
if (out.length) {
|
||
offenders.push(`${name} :: "${glance.textContent}" hangs ${out.join(" and ")} of its row`);
|
||
}
|
||
if (box.width < this.GLANCE_MIN_READABLE_PX &&
|
||
glance.scrollWidth > glance.clientWidth + 1) {
|
||
offenders.push(`${name} :: "${glance.textContent}" is cut down to ` +
|
||
`${Math.round(box.width)}px, which says less than nothing`);
|
||
}
|
||
});
|
||
|
||
return { name: "panel glances fit their rows", 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.
|
||
// Measured against the lines of text as they were painted, not against the box they
|
||
// live in. This used to take the box and subtract its padding, on the reasoning that
|
||
// "padding is room the text has already been kept out of" — which is true, and is
|
||
// not the only way to keep text out of somewhere. A right float does it too, and it
|
||
// does it per line, which is the point of using one: the name on the button seat is
|
||
// now indented past the chip on the line beside it and full width on the line below.
|
||
// Box-minus-padding cannot see that, and called every seat in the sweep an offender
|
||
// while the text sat exactly where it was supposed to.
|
||
//
|
||
// Range rects are the direct question — where did this text actually end up — so
|
||
// padding, floats, centring, wrapping and a short label in a wide box all come out
|
||
// right without any of them being modelled. Line boxes rather than one bounding box,
|
||
// too: a two-line name that clears the chip on the line beside it is not covered
|
||
// because its *other* line happens to sit under where the chip is.
|
||
//
|
||
// Two things a puck wears were missing from this for eleven rounds, and between them
|
||
// they are why the chair number could sit on the felt covered up without the sweep
|
||
// ever saying so.
|
||
//
|
||
// The first is the chair number itself. This check has always asked whether the
|
||
// button or a blind badge sits on a name, and the mark that was sitting on names was
|
||
// neither of those: measured across 348 seats on 36 tables, 51 of them had the number
|
||
// printed on the first letter of the player's own name.
|
||
//
|
||
// The second is that marks were only ever tested against the *writing*, never against
|
||
// each other — so a badge painted straight over the chair number, which is what all
|
||
// 66 seats posting a blind were doing, was invisible to a check whose whole subject
|
||
// is a puck covering itself up.
|
||
seatLabelsClear() {
|
||
const offenders = [];
|
||
// What a puck wears, as opposed to what it writes.
|
||
const MARKS = ".dealerChip, .blindBadge, .seatChairNumber";
|
||
const painted = mark => {
|
||
const box = mark.getBoundingClientRect();
|
||
if (!box.width || !box.height) return null;
|
||
if (!(mark.textContent || "").trim()) return null;
|
||
const style = getComputedStyle(mark);
|
||
if (style.display === "none" || style.visibility === "hidden" ||
|
||
parseFloat(style.opacity) < 0.05) return null;
|
||
return box;
|
||
};
|
||
const across = (a, b) => {
|
||
const wide = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
||
const deep = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
||
return wide > 1 && deep > 1 ? wide : 0;
|
||
};
|
||
|
||
document.querySelectorAll(".seat").forEach(seat => {
|
||
const marks = [...seat.querySelectorAll(MARKS)]
|
||
.map(mark => ({ mark, box: painted(mark) }))
|
||
.filter(entry => entry.box);
|
||
|
||
const name = seat.querySelector(".seatPuckName");
|
||
if (name && name.textContent.trim()) {
|
||
const range = document.createRange();
|
||
range.selectNodeContents(name);
|
||
const lines = [...range.getClientRects()].filter(line => line.width > 1 && line.height > 1);
|
||
marks.forEach(({ mark, box }) => {
|
||
const worst = lines.reduce((most, line) => Math.max(most, across(line, box)), 0);
|
||
if (worst > 1) {
|
||
offenders.push(`${this.describe(name)} :: ${Math.round(worst)}px under ${this.describe(mark)}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// And the marks against each other. The rim holds three of them — the blinds
|
||
// at one end, the button at the other — and there is not room for a third.
|
||
marks.forEach((a, i) => marks.slice(i + 1).forEach(b => {
|
||
const worst = across(a.box, b.box);
|
||
if (worst > 1) {
|
||
offenders.push(`${this.describe(a.mark)} :: ${Math.round(worst)}px across ${this.describe(b.mark)}`);
|
||
}
|
||
}));
|
||
});
|
||
return { name: "nothing a seat wears covers its name or another mark", offenders };
|
||
},
|
||
|
||
// A seat's writing, covered by something belonging to a different seat.
|
||
//
|
||
// seatLabelsClear() above asks whether a puck's own decoration sits on its own name.
|
||
// This asks the other half, and the other half was where the tool was actually
|
||
// failing: measured across nine viewports and six table sizes before this went in,
|
||
// seventeen of fifty-four tables had one seat's name or stack painted under a
|
||
// neighbour. On a Galaxy S8 at the most ordinary table there is — six players at a
|
||
// nine-handed table — seat 2's stack was under seat 3's puck, and five pairs of
|
||
// seats overlapped, the worst by 61px.
|
||
//
|
||
// Two ways in, and it takes both:
|
||
// - the pucks themselves overlapping, which the layout used to permit on the
|
||
// reasoning that rounded corners forgive a little; what they were forgiving was
|
||
// a four-figure stack.
|
||
// - a blind badge hanging off its puck onto the seat next door, which no amount
|
||
// of space between the pucks fixes on its own.
|
||
//
|
||
// Hit-tested against the glyph run rather than the element box. A name is centred,
|
||
// so its box is wider than its ink and testing the box would fail on empty air.
|
||
//
|
||
// The glyph run is then clipped back to what is actually painted, which is not the
|
||
// same thing and cost this check its first honest-looking failure. A puck's name is
|
||
// a `-webkit-box` with `line-clamp`, and getClientRects() hands back a rect for every
|
||
// line the text *would* have taken — including the two that the clamp throws away.
|
||
// Measured at 640x360 with twelve players: "Mike from work" reported a line 30px
|
||
// below its own puck, which duly hit-tested onto the seat underneath, and the seats
|
||
// were not touching at all. Nothing is painted there, so nothing can cover it.
|
||
seatsKeepTheirText() {
|
||
const name = "no seat covers another seat's writing";
|
||
if (this.modalScope()) {
|
||
return { name, offenders: [], skipped: "a sheet is open over the table" };
|
||
}
|
||
const seats = [...document.querySelectorAll(".seat")];
|
||
if (!seats.length) return { name, offenders: [], skipped: "no seats on this page" };
|
||
|
||
const offenders = [];
|
||
seats.forEach((seat, index) => {
|
||
seat.querySelectorAll(".seatPuckName, .seatPuckStack, .seatPuckTotal").forEach(label => {
|
||
const text = label.textContent.trim();
|
||
if (!text) return;
|
||
const painted = label.getBoundingClientRect();
|
||
if (!painted.width || !painted.height) return;
|
||
const range = document.createRange();
|
||
range.selectNodeContents(label);
|
||
[...range.getClientRects()].forEach(raw => {
|
||
const line = {
|
||
left: Math.max(raw.left, painted.left),
|
||
right: Math.min(raw.right, painted.right),
|
||
top: Math.max(raw.top, painted.top),
|
||
bottom: Math.min(raw.bottom, painted.bottom),
|
||
};
|
||
if (line.right - line.left <= 1 || line.bottom - line.top <= 1) return;
|
||
// Along the line rather than at its middle: a badge takes the start
|
||
// of it and a neighbouring puck the end, and a single centre probe
|
||
// walks past both.
|
||
//
|
||
// Every probe, not the first one that hits. How much of the line is
|
||
// gone is the difference between a stack a dealer cannot read and a
|
||
// hairline where a badge's rounded corner grazes the last digit, and
|
||
// reporting both as "is under" gave no way to tell them apart — the
|
||
// one residual this sweep has carried for three rounds turns out to
|
||
// be one probe of seven.
|
||
let hits = 0;
|
||
let culprit = null;
|
||
let probes = 0;
|
||
for (let across = 0.08; across <= 0.92; across += 0.14) {
|
||
const x = line.left + (line.right - line.left) * across;
|
||
const y = (line.top + line.bottom) / 2;
|
||
probes++;
|
||
const over = document.elementFromPoint(x, y);
|
||
if (!over || seat.contains(over) || over.contains(seat)) continue;
|
||
hits++;
|
||
if (!culprit) culprit = over;
|
||
}
|
||
if (culprit) {
|
||
offenders.push(`seat ${index + 1} "${text}" is ${hits}/${probes} `
|
||
+ `under ${this.describe(culprit)}`);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
return { name, offenders };
|
||
},
|
||
|
||
// The other thing on the felt that carries a number.
|
||
//
|
||
// A chip says what a player has put in this street, which is what a call costs. Two
|
||
// of them on top of each other is two figures a dealer can't read, and one behind a
|
||
// puck is a figure that isn't there at all.
|
||
//
|
||
// Geometry rather than hit-testing, which is the opposite of how every other check
|
||
// here works and is forced: #betsLayer is `pointer-events: none`, so elementFromPoint
|
||
// never returns a chip — it returns whatever is behind it, for every chip, always.
|
||
// A first version of this probe hit-tested and duly reported all forty-five
|
||
// configurations as broken, including the ones that were fine. Paint order is known
|
||
// and fixed instead: #betsLayer comes before #seatsLayer in the document and a seat
|
||
// carries `z-index: 1`, so a puck a chip overlaps is a puck that chip is behind.
|
||
chipsKeepTheirAmounts() {
|
||
const name = "no bet chip is hidden behind a seat or another chip";
|
||
if (this.modalScope()) {
|
||
return { name, offenders: [], skipped: "a sheet is open over the table" };
|
||
}
|
||
const chips = [...document.querySelectorAll(".betChip")]
|
||
.filter(c => { const r = c.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
||
if (!chips.length) return { name, offenders: [], skipped: "no bets on the felt" };
|
||
|
||
const overlap = (A, B) =>
|
||
Math.min(A.right, B.right) - Math.max(A.left, B.left) > 0.5 &&
|
||
Math.min(A.bottom, B.bottom) - Math.max(A.top, B.top) > 0.5;
|
||
const offenders = [];
|
||
const seats = [...document.querySelectorAll(".seat")];
|
||
|
||
chips.forEach(chip => {
|
||
const label = chip.textContent.trim() || "a chip";
|
||
const box = chip.getBoundingClientRect();
|
||
seats.forEach((seat, index) => {
|
||
// Its own puck is the one thing it is allowed to touch: on a narrow felt
|
||
// the chip clings to the seat rather than taking a ring of its own.
|
||
if (seat.contains(chip)) return;
|
||
if (overlap(box, seat.getBoundingClientRect())) {
|
||
offenders.push(`chip ${label} is behind seat ${index + 1}`);
|
||
}
|
||
});
|
||
// A chip wider than the puck it is clinging to is on its neighbours by
|
||
// construction, whatever the ring does.
|
||
const host = chip.closest(".seat");
|
||
if (host && chip.classList.contains("betChipAnchored")) {
|
||
const puck = host.getBoundingClientRect();
|
||
if (box.width > puck.width + 0.5) {
|
||
offenders.push(`chip ${label} is ${Math.round(box.width - puck.width)}px ` +
|
||
`wider than its own puck`);
|
||
}
|
||
}
|
||
});
|
||
|
||
for (let a = 0; a < chips.length; a++) {
|
||
for (let b = a + 1; b < chips.length; b++) {
|
||
if (overlap(chips[a].getBoundingClientRect(), chips[b].getBoundingClientRect())) {
|
||
offenders.push(`chip ${chips[a].textContent.trim()} and chip ` +
|
||
`${chips[b].textContent.trim()} are on top of each other`);
|
||
}
|
||
}
|
||
}
|
||
|
||
return { name, offenders: [...new Set(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) {
|
||
// 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}, and nothing scrolls to it`);
|
||
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.controlNames(),
|
||
this.focusHasRoom(),
|
||
this.focusablesReachable(),
|
||
this.furnitureBudget(),
|
||
this.touchAction(),
|
||
this.tapHighlight(),
|
||
this.dimmedControlsExplain(),
|
||
this.textSelectable(),
|
||
this.occlusion(options.occlusionSelector),
|
||
this.collapsedColumns(),
|
||
this.textSpill(),
|
||
this.horizontalOverflow(),
|
||
this.fieldFontSize(),
|
||
this.seatOwnership(),
|
||
this.oneDealerButton(),
|
||
this.hiddenButPainted(),
|
||
this.emptyDecoration(),
|
||
this.oneNextStep(),
|
||
this.glancesFitTheirRows(),
|
||
this.seatLabelsClear(),
|
||
this.seatsKeepTheirText(),
|
||
this.chipsKeepTheirAmounts(),
|
||
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;
|
||
}
|