The card picker greys out a card that is already dealt and hangs the reason off a `title`. Measured on an iPhone with three hands out: seven of the fifty-two were dead, none of them could take focus, and tapping one changed nothing at all — no toast, no text, not a byte of DOM. The tool's answer to "who has the ace of spades?" existed and was reachable only with a mouse. The header's two buttons were the same, on a new dealer's first screen, and Export CSV was greyed out with no explanation written anywhere. `disabled` is the wrong attribute whenever there is something to say: it drops the control out of the tab order, so a screen reader never lands on it to hear the description, and it eats the tap on a touch screen, where the hover that would have shown the `title` does not exist. `aria-disabled` announces the same state and leaves the control reachable, so one capture listener can turn the press into a sentence — in a toast, or inside a sheet that is standing on top of the toast. It paid for itself in the picker's keyboard too. The arrows hunted past dealt cards because a `disabled` button cannot hold focus, and the hunting did the exact thing the grid's no-wrap rule was written to prevent: Right from A-spades over a dealt A-hearts landed on A-diamonds, so the column shifted and the next press of Down walked a different suit. A cell that can hold focus needs no hunting, and the APG asks for that inside a composite widget anyway. The line that answers is one line by measurement, not by taste. Its box is 318px on a Galaxy S8 at 20px of root text; the first wording drew 332px and wrapped, shoving fifty-two targets 21px down mid-tap, and twenty-four capital Ws draw 434px whatever the wording, so the box is what holds. Two things the sweep found on the way. It opened the card picker by un-hiding an overlay whose grid had never been painted, so for its whole life it audited an empty sheet; it now opens it the way a dealer does, with cards out and the answer showing. And the slot buttons above the grid were named to a screen reader by the em dash they draw when empty. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
1646 lines
87 KiB
JavaScript
1646 lines
87 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() {
|
||
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";
|
||
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 room = node === document.body
|
||
? document.documentElement.scrollHeight - innerHeight - scrollY
|
||
: node.scrollHeight - node.clientHeight - node.scrollTop;
|
||
if (rect.top >= box.bottom && room > 0) return true;
|
||
if (rect.bottom <= box.top && (node === document.body ? scrollY > 0 : node.scrollTop > 0)) return true;
|
||
}
|
||
return false;
|
||
},
|
||
|
||
// The 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;
|
||
},
|
||
|
||
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
|
||
const offenders = [];
|
||
const warnings = [];
|
||
// While something is open on top, only what is inside it can be judged. The
|
||
// rest of the page is meant to be unreachable.
|
||
const scope = this.modalScope();
|
||
const candidates = [...document.querySelectorAll(selector)]
|
||
.filter(el => !scope || scope.contains(el));
|
||
candidates.forEach(el => {
|
||
const rect = el.getBoundingClientRect();
|
||
if (rect.width === 0 || rect.height === 0) return;
|
||
|
||
const outsideViewport = rect.bottom <= 0 || rect.top >= innerHeight ||
|
||
rect.right <= 0 || rect.left >= innerWidth;
|
||
if (outsideViewport) {
|
||
if (!this.reachableByScrolling(el)) {
|
||
offenders.push(`${this.describe(el)} :: off-screen and cannot be scrolled to`);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 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;
|
||
points.forEach(([x, y]) => {
|
||
const top = document.elementFromPoint(x, y);
|
||
// A null result means the point isn't hit-testable — typically it sits
|
||
// inside the element but outside a scrollable ancestor's clip, i.e. the
|
||
// control is scrolled out of view rather than covered by anything.
|
||
// Counting that as "covered by null" was reporting a perfectly
|
||
// reachable button in a sheet that scrolls.
|
||
if (!top) return;
|
||
testable++;
|
||
if (el.contains(top) || top === el || top.contains(el)) return;
|
||
// Seats ring a round table and overlap each other at the rim by
|
||
// design; that is crowding, not an overlay hiding a control.
|
||
//
|
||
// But only the seat's *body* is harmless. A neighbouring seat's own
|
||
// controls sitting on top of this one is a control being stolen, and
|
||
// exempting the whole seat subtree hid exactly that: an enlarged
|
||
// dealer-chip hit area was taking the centre of the seat three
|
||
// places round the table, and this check called it crowding and
|
||
// moved on.
|
||
const neighbourSeat = top.closest && top.closest(".seat");
|
||
if (neighbourSeat) {
|
||
const isControl = top.matches && top.matches("button, a, input, select, textarea, [role='button']");
|
||
if (!isControl) return;
|
||
}
|
||
blocked++;
|
||
if (!blockedBy) blockedBy = this.describe(top);
|
||
});
|
||
|
||
if (testable === 0) {
|
||
if (!this.reachableByScrolling(el)) {
|
||
offenders.push(`${this.describe(el)} :: not hit-testable anywhere and cannot be scrolled to`);
|
||
}
|
||
return;
|
||
}
|
||
if (blocked === 0) return;
|
||
|
||
const note = `${this.describe(el)} :: ${blocked === testable ? "fully" : `${blocked}/${testable}`} covered by ${blockedBy}`;
|
||
|
||
// A toast is transient by design: it dismisses itself and carries a close
|
||
// button, and on a desktop it necessarily lands over a table that fills the
|
||
// content column. Partial overlap from one is a fact of the layout, not a
|
||
// fault. Being *fully* covered by a toast is still a failure though — that
|
||
// was a real bug once, where a seat sat exactly under the Undo button and a
|
||
// tap meant for a player took back the last action instead.
|
||
const transient = blockedBy && /toast/i.test(blockedBy);
|
||
if (transient && blocked < testable) warnings.push(note);
|
||
else offenders.push(note);
|
||
});
|
||
return { name: "nothing important is covered", offenders, warnings };
|
||
},
|
||
|
||
// ---- Layout (the check that would have caught the standings regression) ----
|
||
|
||
// A grid column squeezed to nothing doesn't truncate its text, it erases it.
|
||
// This is the one that was missing: the player-name column sat at 0px through
|
||
// two rounds because nothing ever asked whether a column had survived.
|
||
collapsedColumns() {
|
||
const offenders = [];
|
||
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
|
||
// Nothing inside something the page has hidden. A hidden element's children
|
||
// all measure zero wide, so the standings header — which is hidden along
|
||
// with its table until somebody has bought in — was reported as three
|
||
// collapsed columns on every empty table. A column nobody can see hasn't
|
||
// collapsed; it isn't there.
|
||
if (row.closest("[hidden]")) return;
|
||
if (getComputedStyle(row).display !== "grid") return;
|
||
[...row.children].forEach(cell => {
|
||
if (getComputedStyle(cell).display === "none") return;
|
||
const hasText = (cell.textContent || "").trim().length > 0;
|
||
if (hasText && cell.getBoundingClientRect().width < 4) {
|
||
offenders.push(`${this.describe(row)} > ${this.describe(cell)} :: ${Math.round(cell.getBoundingClientRect().width)}px wide`);
|
||
}
|
||
});
|
||
});
|
||
return { name: "no column collapsed to nothing", offenders };
|
||
},
|
||
|
||
// Text actually painted outside the box meant to contain it — which on a table of
|
||
// overlapping seats means painted on top of a neighbour.
|
||
//
|
||
// The clipping check is the whole subtlety, and the first version of this got it
|
||
// wrong. A Range's bounding rect measures the *laid out* text, which happily runs
|
||
// past the element when `overflow: hidden` is quietly clipping it — so an
|
||
// ellipsised name was reported as spilling when nothing was drawn outside at all.
|
||
// Only overflow that is actually visible can paint over a neighbour.
|
||
textSpill(selector = ".seatPuckStack, .seatPuckTotal, .seatPuckName") {
|
||
const offenders = [];
|
||
[...document.querySelectorAll(selector)].forEach(el => {
|
||
const text = (el.textContent || "").trim();
|
||
if (!text) return;
|
||
|
||
const style = getComputedStyle(el);
|
||
const clipsItself = style.overflow !== "visible" || style.overflowX !== "visible";
|
||
if (clipsItself) return;
|
||
|
||
const container = el.closest(".seat") || el.parentElement;
|
||
if (!container) return;
|
||
|
||
const range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
const drawn = range.getBoundingClientRect();
|
||
const bounds = container.getBoundingClientRect();
|
||
if (drawn.left < bounds.left - 1 || drawn.right > bounds.right + 1) {
|
||
offenders.push(`${this.describe(el)} "${text}" paints outside ${this.describe(container)}`);
|
||
}
|
||
});
|
||
return { name: "no text painted outside its box", offenders };
|
||
},
|
||
|
||
horizontalOverflow() {
|
||
const offenders = document.documentElement.scrollWidth > innerWidth + 1
|
||
? [`page scrolls sideways: ${document.documentElement.scrollWidth}px in a ${innerWidth}px viewport`]
|
||
: [];
|
||
return { name: "page never scrolls sideways", offenders };
|
||
},
|
||
|
||
// iOS zooms the whole page when a field under 16px takes focus, and the dealer
|
||
// is then pinching back out mid-hand.
|
||
fieldFontSize() {
|
||
const offenders = [...document.querySelectorAll("input, textarea, select")]
|
||
.filter(el => el.offsetParent !== null)
|
||
.filter(el => ["text", "number", "search", "tel", "url", "email", "password", ""].includes(el.type || ""))
|
||
.filter(el => parseFloat(getComputedStyle(el).fontSize) < 16)
|
||
.map(el => `${this.describe(el)} at ${getComputedStyle(el).fontSize}`);
|
||
return { name: "no field small enough to trigger iOS zoom", offenders };
|
||
},
|
||
|
||
// ---- Contrast ----
|
||
//
|
||
// This check has been got wrong twice before, both times by being too clever about
|
||
// backgrounds. The first version read only `background-color`, so every sheet
|
||
// painted with a gradient was measured against transparency and the dealer puck's
|
||
// dark "D" was reported at 1.2:1 against nothing. The second over-corrected and
|
||
// treated low-alpha gradient stops as opaque, scoring white-on-dark at 1.0:1.
|
||
//
|
||
// So it is built to be incapable of false positives instead of trying to be exact.
|
||
// Backgrounds composite properly through alpha, and where a gradient is involved
|
||
// every one of its colour stops is tried and the *most flattering* one is used. A
|
||
// finding therefore means the text fails even under the reading most generous to
|
||
// it, which is a claim worth making. Text that can't be resolved to any opaque
|
||
// background is reported separately as unmeasured rather than guessed at.
|
||
|
||
parseColour(value) {
|
||
const match = String(value).match(/rgba?\(([^)]+)\)/);
|
||
if (!match) return null;
|
||
const parts = match[1].split(/[,\s/]+/).filter(Boolean).map(Number);
|
||
if (parts.length < 3 || parts.some(Number.isNaN)) return null;
|
||
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 };
|
||
},
|
||
|
||
// Colour stops out of any gradient in a background-image.
|
||
gradientStops(backgroundImage) {
|
||
if (!backgroundImage || backgroundImage === "none") return [];
|
||
return (backgroundImage.match(/rgba?\([^)]+\)/g) || [])
|
||
.map(c => this.parseColour(c))
|
||
.filter(Boolean);
|
||
},
|
||
|
||
over(top, bottom) {
|
||
const a = top.a;
|
||
return {
|
||
r: top.r * a + bottom.r * (1 - a),
|
||
g: top.g * a + bottom.g * (1 - a),
|
||
b: top.b * a + bottom.b * (1 - a),
|
||
a: 1,
|
||
};
|
||
},
|
||
|
||
relativeLuminance({ r, g, b }) {
|
||
const channel = v => {
|
||
const s = v / 255;
|
||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||
};
|
||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||
},
|
||
|
||
contrastRatio(fg, bg) {
|
||
const a = this.relativeLuminance(fg);
|
||
const b = this.relativeLuminance(bg);
|
||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||
},
|
||
|
||
// Every background this text could actually be sitting on.
|
||
//
|
||
// Layers are collected from the element outwards and stop at the first fully
|
||
// opaque one, because nothing behind that is visible. Getting this wrong in the
|
||
// lenient direction is just as bad as the old false positives: an earlier version
|
||
// kept walking past an opaque gradient and offered the page background as a
|
||
// candidate, which scored white-on-a-mid-grey-panel as though it were
|
||
// white-on-near-black and would have waved real problems through.
|
||
//
|
||
// A gradient contributes one candidate per colour stop; translucent layers above
|
||
// the opaque one are composited onto each candidate in order.
|
||
backgroundCandidates(el) {
|
||
const translucent = []; // top-down, above the opaque layer
|
||
let base = null; // candidate colours for the first opaque layer
|
||
|
||
for (let node = el; node && !base; node = node.parentElement) {
|
||
const style = getComputedStyle(node);
|
||
const stops = this.gradientStops(style.backgroundImage);
|
||
const colour = this.parseColour(style.backgroundColor);
|
||
|
||
// A gradient whose stops are all opaque hides everything behind it.
|
||
if (stops.length > 0 && stops.every(s => s.a === 1)) {
|
||
base = stops;
|
||
break;
|
||
}
|
||
stops.forEach(s => { if (s.a > 0) translucent.push(s); });
|
||
|
||
if (colour && colour.a === 1) { base = [colour]; break; }
|
||
if (colour && colour.a > 0) translucent.push(colour);
|
||
}
|
||
|
||
if (!base) {
|
||
// Nothing opaque anywhere up the tree — fall back to the page's own colour
|
||
// so a finding is still measured against something real.
|
||
const page = this.parseColour(getComputedStyle(document.documentElement).backgroundColor);
|
||
base = [page && page.a === 1 ? page : { r: 10, g: 10, b: 10, a: 1 }];
|
||
}
|
||
|
||
// Fold the translucent layers back on, innermost last so it lands on top.
|
||
return base.map(candidate => {
|
||
let composed = candidate;
|
||
for (let i = translucent.length - 1; i >= 0; i--) composed = this.over(translucent[i], composed);
|
||
return composed;
|
||
});
|
||
},
|
||
|
||
// Every element between this one and the document, so an opacity set on a
|
||
// container can be seen from the text inside it.
|
||
*ancestors(el) {
|
||
let node = el.parentElement;
|
||
while (node) {
|
||
yield node;
|
||
node = node.parentElement;
|
||
}
|
||
},
|
||
|
||
// How much of this element actually reaches the screen, and what is behind the
|
||
// thing doing the fading.
|
||
//
|
||
// `opacity` on a box fades the whole box — its text and its own background together
|
||
// — onto whatever sits behind that box. So a dimmed seat is not "grey text on a
|
||
// dark puck"; it is a faded puck and faded text both landing on the felt. Both
|
||
// halves have to be composited the same way or the number means nothing.
|
||
fading(el) {
|
||
let alpha = 1;
|
||
let outermost = null;
|
||
for (let node = el; node; node = node.parentElement) {
|
||
const own = parseFloat(getComputedStyle(node).opacity);
|
||
if (own < 0.99) {
|
||
alpha *= own;
|
||
outermost = node;
|
||
}
|
||
}
|
||
if (!outermost) return { alpha: 1, behind: null };
|
||
return { alpha, behind: this.backgroundCandidates(outermost.parentElement || document.body)[0] || null };
|
||
},
|
||
|
||
// The two colours as the screen actually shows them, once the fading is applied.
|
||
asPainted(fgRaw, bg, fade) {
|
||
const solid = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
|
||
if (fade.alpha >= 0.99 || !fade.behind) return { fg: solid, bg };
|
||
return {
|
||
fg: this.over({ ...solid, a: solid.a * fade.alpha }, fade.behind),
|
||
bg: this.over({ ...bg, a: bg.a * fade.alpha }, fade.behind),
|
||
};
|
||
},
|
||
|
||
// 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.
|
||
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel");
|
||
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
|
||
const box = scroller.getBoundingClientRect();
|
||
const own = el.getBoundingClientRect();
|
||
if (own.bottom > box.bottom || own.top < box.top) {
|
||
el.scrollIntoView({ block: "center" });
|
||
}
|
||
}
|
||
const rect = el.getBoundingClientRect();
|
||
// Drawn big enough is big enough — no need to probe, and probing wide
|
||
// elements is where the cap above used to produce nonsense. Seats sit
|
||
// here: they're 76x72 and overlap each other at the rim by design, so
|
||
// measuring their reach would report the crowding as a fault.
|
||
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
|
||
|
||
// A checkbox inside a label is tapped by tapping the label, so the
|
||
// label is the target. Measured rather than probed, because probing
|
||
// fails on anything scrolled off the viewport — which is how a 24px
|
||
// checkbox with a 335x44 label around it came up as unprobeable
|
||
// instead of as the perfectly good target it is.
|
||
const label = el.closest("label");
|
||
if (label) {
|
||
const lr = label.getBoundingClientRect();
|
||
if (lr.width >= minimum - 1 && lr.height >= minimum - 1) return;
|
||
}
|
||
|
||
const size = this.effectiveTarget(el);
|
||
// Still can't measure it. That is not a pass — it is the check failing
|
||
// to run, and quietly returning here is exactly the kind of exemption
|
||
// that hides real faults. Say so instead.
|
||
if (!size) {
|
||
unmeasured.push(
|
||
`${this.describe(el)} :: could not be probed` +
|
||
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
||
);
|
||
return;
|
||
}
|
||
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
|
||
offenders.push(
|
||
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
|
||
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
||
);
|
||
});
|
||
return { name: `touch targets at least ${minimum}px`, offenders, unmeasured };
|
||
},
|
||
|
||
// Tapping the middle of a seat must give you that seat.
|
||
//
|
||
// Added after a dealer-chip hit area, meant to make a 22px puck thumb-sized, grew
|
||
// outward from a chip that already hung over its seat's corner — and therefore
|
||
// over the seat next to it. On a nine-seat table, tapping the centre of seat 1
|
||
// handed the button to seat 9. The occlusion check missed it because it treated
|
||
// anything inside a `.seat` as harmless crowding.
|
||
//
|
||
// Reported separately from occlusion because the cause is different: not an
|
||
// overlay on top of the table, but two pieces of the table fighting over a tap.
|
||
//
|
||
// 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) {
|
||
const off = r.bottom > innerHeight
|
||
? Math.round(r.bottom - innerHeight) + "px below the fold"
|
||
: Math.round(-r.top) + "px above it";
|
||
offenders.push(`${label} is ${off}`);
|
||
return;
|
||
}
|
||
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||
if (hit && hit !== button && !button.contains(hit)) {
|
||
offenders.push(`${label} is covered by ${this.describe(hit)}`);
|
||
}
|
||
});
|
||
});
|
||
|
||
return { name, offenders };
|
||
},
|
||
|
||
runAll(options = {}) {
|
||
const checks = [
|
||
this.seatsOnTheTable(options.seatSlack),
|
||
this.dialogActionsReachable(),
|
||
this.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;
|
||
}
|