The chips were the last thing on the table nobody had measured, and the sweep had never seen one: it audited a table that had never had a bet placed on it, so a third of what is on the felt during a hand went unlooked at for the tool's whole life. It posts blinds, deals and calls three times now before it measures anything. What that found, and what this fixes: - Chips accumulated. Seats are kept and updated in place rather than rebuilt, so clearing the bets layer stopped clearing the ones anchored inside a seat and nothing else ever did. An iPhone 14 had one chip per seat after the deal and five by the third render; a dealer's every tap left another disc on the felt. - The ring-or-cling decision asked how wide the window was. A phone held sideways is 844px across and gives the felt 117px: it passed the width test, took the ring, and every chip on it was painted under a puck — the seats carry z-index 1 and the bets layer carries none, so a chip that lands under one is gone, and with it the only number saying what that player has in. Measured now, and only measured. - Getting clear of a box was `edgeReach(puck) + edgeReach(chip)`, which is right only straight up, down or sideways. Two axis-aligned boxes are clear when they are apart on one axis, so the travel is the reach of the box you get by adding their widths and their heights. At 768x1024 a $10 leaving a seat on the diagonal wanted 88px and got 77, and came to rest 11px inside its own player's puck — every seat off the four compass points, which is most of them. - Every chip was laid out at the first chip's size. A chip is as wide as the number on it: "$5" is 21px and "$10" is 27px on a phone, so each $10 was placed as if it were 6px narrower than it is. The widest is no better: at 768x1024 one 47px chip made the whole table give up a ring the other four had room for. - Anchored chips settled where they landed. At 640x360 the felt is 528x83 and a puck is 55 of those pixels tall: clearing it takes 41px, the players facing each other are 70px apart, and both chips set off 41px into a gap that is 70 wide. Three overlapping pairs on a twelve-handed table. Each chip now gives ground only to something it is actually standing on, a pixel at a time — a midline rule or a single scale factor both punish the seats at the ends of a rail, which were never going to hit anything. - Nothing knew the pot total was there. Seat 1's $10 was sitting on "Pot $65". Two things off the felt, from the same sweep: - The toast outlived its news. "Next hand — button on seat 2. [Undo]" stayed up while the hand was dealt and three players called: an offer to move the button, wired to a snapshot that predates the hand, so taking it would have thrown the hand away without saying so. It was also in the way of thirty small screens' seats and controls. The bar drops it as it takes the next action; the bar's own Undo names what it would actually take back. - The pot dialog cut a name off. Three players in the pot on a Galaxy S8, two rows visible, the third below a hairline that reads as the end of the list — a dealer ticking the two they could see would have paid two thirds of a three-way pot. The 85vh budget was still subtracting the action bar's height from a dialog that has sat above the bar since its z-index was fixed, and a player's ceiling moves beside their name on a short screen. All three fit now, and where a list does run on, it runs on under a fade rather than stopping dead on a row boundary. The pot badge goes on a phone held sideways: 86x66 of a 692x113 felt, for a number the action bar prints three inches below it, standing in the only band a bet chip has. The audit stopped calling one thing another. An element clipped away by a panel that scrolls is not covered by whatever is painted at that height — the wizard's third winner came back for four viewports as "fully covered by button Confirm Winner(s)". 190 audits: 25 failing configurations to 7, and every one of those seven is the seat ring hanging past the rail on a landscape phone, which is the known residual from the last round. 663 unit tests, 44 of them new. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
1091 lines
56 KiB
JavaScript
1091 lines
56 KiB
JavaScript
// Layout and touch audits for the dealer, written once so they stop being written
|
|
// again every round.
|
|
//
|
|
// These checks have been re-typed into a browser console at least six times over
|
|
// the course of building this thing, and each time they only covered whatever had
|
|
// just been changed. That is exactly how the standings table shipped with a
|
|
// zero-pixel player-name column and survived two rounds: the audit that would have
|
|
// caught it existed only in a previous session's scrollback.
|
|
//
|
|
// This file is deliberately dependency-free and browser-side. It is injected into
|
|
// the page (see runUiAudit.js) rather than imported, so it needs no build step, no
|
|
// package.json, and no browser automation of its own — anyone can also paste it
|
|
// into devtools and call LayoutAudit.runAll().
|
|
const LayoutAudit = {
|
|
// Everything a dealer has to be able to hit.
|
|
CONTROL_SELECTOR: "button, a, input, select, textarea, [role='button'], .seat",
|
|
|
|
describe(el) {
|
|
if (!el) return "(nothing)";
|
|
const id = el.id ? `#${el.id}` : "";
|
|
const cls = (el.className || "").toString().trim().split(/\s+/).filter(Boolean).slice(0, 2).join(".");
|
|
const text = (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 24);
|
|
return `${el.tagName.toLowerCase()}${id}${cls ? "." + cls : ""}${text ? ` "${text}"` : ""}`;
|
|
},
|
|
|
|
visibleControls() {
|
|
return [...document.querySelectorAll(this.CONTROL_SELECTOR)].filter(el => {
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) return false;
|
|
const style = getComputedStyle(el);
|
|
return style.visibility !== "hidden" && style.display !== "none";
|
|
});
|
|
},
|
|
|
|
// ---- Touch (see the mobile round) ----
|
|
|
|
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
|
|
// deciding whether it was a double-tap-to-zoom. This tool uses double-tap on a
|
|
// seat as its fold shortcut, so that wait is on the critical path.
|
|
touchAction() {
|
|
const offenders = this.visibleControls()
|
|
.filter(el => getComputedStyle(el).touchAction === "auto")
|
|
.map(el => this.describe(el));
|
|
return { name: "touch-action", offenders, checked: this.visibleControls().length };
|
|
},
|
|
|
|
tapHighlight() {
|
|
const offenders = this.visibleControls()
|
|
.filter(el => {
|
|
const colour = getComputedStyle(el).webkitTapHighlightColor;
|
|
return colour && !/rgba\(0,\s*0,\s*0,\s*0\)|transparent/.test(colour);
|
|
})
|
|
.map(el => this.describe(el));
|
|
return { name: "tap-highlight", offenders, checked: this.visibleControls().length };
|
|
},
|
|
|
|
// Text selection must stay ON for anything a dealer types into.
|
|
textSelectable() {
|
|
const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")]
|
|
.filter(el => el.offsetParent !== null)
|
|
.filter(el => {
|
|
const style = getComputedStyle(el);
|
|
return (style.webkitUserSelect || style.userSelect) === "none";
|
|
})
|
|
.map(el => this.describe(el));
|
|
return { name: "fields stay selectable", offenders };
|
|
},
|
|
|
|
// ---- Reachability (see the occlusion round) ----
|
|
|
|
// A control covered by something else is unreachable however good it looks.
|
|
// Sampling several points matters: a button you can only hit in one corner is
|
|
// covered for practical purposes.
|
|
// Can this element be brought into view by scrolling? Something below the fold on
|
|
// a page that scrolls is perfectly reachable; something below the fold in a panel
|
|
// that *doesn't* scroll is lost. The first version of this check didn't make the
|
|
// distinction and reported every link under the fold as a fault.
|
|
reachableByScrolling(el) {
|
|
const rect = el.getBoundingClientRect();
|
|
if (getComputedStyle(el).position === "fixed") return false;
|
|
|
|
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
const style = getComputedStyle(node);
|
|
const scrolls = /auto|scroll|overlay/.test(style.overflowY) || node === document.body;
|
|
if (!scrolls) continue;
|
|
const box = node === document.body ? { top: 0, bottom: innerHeight } : node.getBoundingClientRect();
|
|
const room = node === document.body
|
|
? document.documentElement.scrollHeight - innerHeight - scrollY
|
|
: node.scrollHeight - node.clientHeight - node.scrollTop;
|
|
if (rect.top >= box.bottom && room > 0) return true;
|
|
if (rect.bottom <= box.top && (node === document.body ? scrollY > 0 : node.scrollTop > 0)) return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
// The 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),
|
|
};
|
|
},
|
|
|
|
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
|
|
const offenders = [];
|
|
const unmeasured = [];
|
|
|
|
const nodes = selector
|
|
? [...document.querySelectorAll(selector)]
|
|
: [...document.querySelectorAll("*")];
|
|
|
|
nodes.forEach(el => {
|
|
// An element's *own* text, not its descendants' — they get measured on
|
|
// their own turn, against their own colour.
|
|
//
|
|
// This used to be "leaf elements only", which quietly stopped measuring
|
|
// anything the moment a child was added to it. The undo control went that
|
|
// way the day a <kbd> was appended to it: its label became invisible to
|
|
// this check, with no sign that anything had changed.
|
|
const text = [...el.childNodes]
|
|
.filter(node => node.nodeType === 3)
|
|
.map(node => node.textContent)
|
|
.join(" ")
|
|
.trim();
|
|
if (!text) return;
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) return;
|
|
const style = getComputedStyle(el);
|
|
if (style.visibility === "hidden" || style.display === "none") return;
|
|
// Faded text used to be skipped outright as "faded on purpose", and that
|
|
// exemption hid a real fault: the folded and sat-out seats dimmed their
|
|
// whole box with opacity, so a folded player's stack measured 2.22:1 at
|
|
// 11.5px and this check never looked at it. Something invisible is still
|
|
// exempt — there's nothing to read at zero — but anything in between is
|
|
// reported as unmeasured rather than passed over in silence, because a
|
|
// number nobody checked is not the same as a number that's fine.
|
|
const fade = this.fading(el);
|
|
if (fade.alpha <= 0.02) return;
|
|
|
|
// A control that is switched off is the one exemption WCAG itself grants —
|
|
// an inactive component has no contrast requirement, which is why every
|
|
// toolkit dims them. Said out loud rather than skipped in silence: this is
|
|
// the only exemption in here, and the moment a dimmed thing turns out to be
|
|
// live it stops applying.
|
|
const off = el.closest("[disabled], [aria-disabled='true']");
|
|
if (off) {
|
|
unmeasured.push(`${this.describe(el)} :: disabled control, exempt from AA`);
|
|
return;
|
|
}
|
|
// aria-hidden used to exempt text here, on the grounds that decoration
|
|
// marked for screen readers was decoration for everyone. Those are two
|
|
// different questions and the answer differs: the bet chips on the felt are
|
|
// aria-hidden because their amounts are already announced by the seat and
|
|
// the action bar, and repeating them would be noise — but they are exactly
|
|
// what a sighted dealer reads the felt for, and this check had stopped
|
|
// looking at them. Hiding something from one audience is not a claim about
|
|
// whether the other audience can read it.
|
|
//
|
|
// Nothing is exempt now. If something genuinely decorative turns up, it
|
|
// should say so in its own words rather than borrowing an attribute that
|
|
// means something else.
|
|
|
|
const fgRaw = this.parseColour(style.color);
|
|
if (!fgRaw) return;
|
|
|
|
const candidates = this.backgroundCandidates(el);
|
|
if (candidates.length === 0) {
|
|
unmeasured.push(`${this.describe(el)} :: no opaque background found`);
|
|
return;
|
|
}
|
|
|
|
// The kindest reading available: best contrast across every candidate.
|
|
let best = 0;
|
|
candidates.forEach(bg => {
|
|
const painted = this.asPainted(fgRaw, bg, fade);
|
|
best = Math.max(best, this.contrastRatio(painted.fg, painted.bg));
|
|
});
|
|
|
|
const sizePx = parseFloat(style.fontSize);
|
|
const weight = parseInt(style.fontWeight, 10) || 400;
|
|
const isLarge = sizePx >= 24 || (sizePx >= 18.66 && weight >= 700);
|
|
const required = isLarge ? largeMinimum : minimum;
|
|
|
|
if (best + 0.05 < required) {
|
|
offenders.push(`${this.describe(el)} :: ${best.toFixed(2)}:1 (needs ${required}:1) at ${Math.round(sizePx)}px`);
|
|
}
|
|
});
|
|
|
|
return { name: "text meets WCAG AA contrast", offenders, unmeasured };
|
|
},
|
|
|
|
// ---- Reach (touch target sizes) ----
|
|
|
|
// What a fingertip can actually hit, which is not the same as the element's box.
|
|
//
|
|
// Two things make the box misleading, and the first version of this check was
|
|
// fooled by both. A control can be padded out with a transparent ::after, so it
|
|
// responds well beyond its painted size — that's how a 22px dealer puck should
|
|
// stay 22px and still take a thumb. And a checkbox wrapped in a label is hit by
|
|
// tapping anywhere on the label, so a 24px box inside a 320x44 label is a
|
|
// perfectly good target. Measuring the box reported both as faults.
|
|
//
|
|
// So it probes outwards with elementFromPoint until the point stops landing on the
|
|
// control, which is the thing a finger actually experiences.
|
|
effectiveTarget(el, limit = 48) {
|
|
const rect = el.getBoundingClientRect();
|
|
const cx = rect.left + rect.width / 2;
|
|
const cy = rect.top + rect.height / 2;
|
|
// The probe walks outward a fixed distance, so it can only ever report
|
|
// 2 x limit. Anything already drawn larger than that reads as exactly the cap
|
|
// — which had every header button reporting a suspiciously round 80px. Give
|
|
// the reach room for the element itself plus the padding being looked for.
|
|
limit = Math.max(limit, rect.width / 2 + 24, rect.height / 2 + 24);
|
|
|
|
// A wrapping or associated label forwards the tap, so it counts as the target.
|
|
const label = el.closest("label") || (el.labels && el.labels[0]);
|
|
const lands = (x, y) => {
|
|
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return false;
|
|
const hit = document.elementFromPoint(x, y);
|
|
if (!hit) return false;
|
|
if (hit === el || el.contains(hit)) return true;
|
|
return !!(label && (hit === label || label.contains(hit)));
|
|
};
|
|
|
|
if (!lands(cx, cy)) return null; // centre isn't even hittable; occlusion's problem
|
|
|
|
const reach = (dx, dy) => {
|
|
let far = 0;
|
|
for (let d = 1; d <= limit; d++) {
|
|
if (!lands(cx + dx * d, cy + dy * d)) break;
|
|
far = d;
|
|
}
|
|
return far;
|
|
};
|
|
return { width: reach(-1, 0) + reach(1, 0), height: reach(0, -1) + reach(0, 1) };
|
|
},
|
|
|
|
// The 44px guideline is about fingertips, so it only bites where there's no mouse.
|
|
// Enforcing it everywhere flagged the header buttons at 42px tall on a desktop,
|
|
// which is not a defect — the same buttons are 64px tall on a phone, where it
|
|
// matters. Same reasoning as the field sizing: `hover: none` is what actually
|
|
// distinguishes a finger from a pointer, not viewport width.
|
|
//
|
|
// `exempt` is for controls that are deliberately small *and* have a full-size path
|
|
// to the same action. The dealer puck is the only one: it's a 22px chip sitting on
|
|
// the rim of a seat, and growing it to 44px would take that area from the seat
|
|
// itself — which a dealer taps far more often. "Give the button" is in the seat
|
|
// menu at full size, so the puck is a shortcut, not the only way through.
|
|
touchTargets(minimum = 44, { exempt = ".dealerChip", enforceOnPointerDevices = false } = {}) {
|
|
const offenders = [];
|
|
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
|
|
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
|
|
}
|
|
const unmeasured = [];
|
|
this.visibleControls()
|
|
.filter(el => el.type !== "color")
|
|
.filter(el => !(exempt && el.matches(exempt)))
|
|
.forEach(el => {
|
|
// A link inside a sentence is the one target WCAG itself carves out:
|
|
// its size is set by the line it sits in, and padding a word out to
|
|
// 44px would break the paragraph around it. The rule is about controls
|
|
// a thumb goes hunting for, not words in prose. Said out loud, and
|
|
// narrow — display:inline, and real text either side of it.
|
|
if (el.tagName === "A" && getComputedStyle(el).display === "inline" &&
|
|
el.parentElement &&
|
|
[...el.parentElement.childNodes].some(n => n.nodeType === 3 && n.textContent.trim())) {
|
|
unmeasured.push(`${this.describe(el)} :: link inside a sentence, sized by the line`);
|
|
return;
|
|
}
|
|
// A control inside a scrolling sheet can sit below its container's
|
|
// visible area while still having a viewport rect. elementFromPoint at
|
|
// its centre then returns the sheet, effectiveTarget gives up, and the
|
|
// check used to pass it in silence — which is how "Confirm Winner(s)",
|
|
// the button that pays the pot out, went unmeasured for 36 rounds.
|
|
// Bring it into view before probing.
|
|
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel");
|
|
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
|
|
const box = scroller.getBoundingClientRect();
|
|
const own = el.getBoundingClientRect();
|
|
if (own.bottom > box.bottom || own.top < box.top) {
|
|
el.scrollIntoView({ block: "center" });
|
|
}
|
|
}
|
|
const rect = el.getBoundingClientRect();
|
|
// Drawn big enough is big enough — no need to probe, and probing wide
|
|
// elements is where the cap above used to produce nonsense. Seats sit
|
|
// here: they're 76x72 and overlap each other at the rim by design, so
|
|
// measuring their reach would report the crowding as a fault.
|
|
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
|
|
|
|
// A checkbox inside a label is tapped by tapping the label, so the
|
|
// label is the target. Measured rather than probed, because probing
|
|
// fails on anything scrolled off the viewport — which is how a 24px
|
|
// checkbox with a 335x44 label around it came up as unprobeable
|
|
// instead of as the perfectly good target it is.
|
|
const label = el.closest("label");
|
|
if (label) {
|
|
const lr = label.getBoundingClientRect();
|
|
if (lr.width >= minimum - 1 && lr.height >= minimum - 1) return;
|
|
}
|
|
|
|
const size = this.effectiveTarget(el);
|
|
// Still can't measure it. That is not a pass — it is the check failing
|
|
// to run, and quietly returning here is exactly the kind of exemption
|
|
// that hides real faults. Say so instead.
|
|
if (!size) {
|
|
unmeasured.push(
|
|
`${this.describe(el)} :: could not be probed` +
|
|
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
|
);
|
|
return;
|
|
}
|
|
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
|
|
offenders.push(
|
|
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
|
|
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
|
);
|
|
});
|
|
return { name: `touch targets at least ${minimum}px`, offenders, unmeasured };
|
|
},
|
|
|
|
// Tapping the middle of a seat must give you that seat.
|
|
//
|
|
// Added after a dealer-chip hit area, meant to make a 22px puck thumb-sized, grew
|
|
// outward from a chip that already hung over its seat's corner — and therefore
|
|
// over the seat next to it. On a nine-seat table, tapping the centre of seat 1
|
|
// handed the button to seat 9. The occlusion check missed it because it treated
|
|
// anything inside a `.seat` as harmless crowding.
|
|
//
|
|
// Reported separately from occlusion because the cause is different: not an
|
|
// overlay on top of the table, but two pieces of the table fighting over a tap.
|
|
seatOwnership() {
|
|
// Seats behind an open sheet aren't fighting each other for taps — nothing is
|
|
// meant to reach them at all. Asking the question there produced six offenders
|
|
// per modal, every one of them the modal doing its job.
|
|
if (this.modalScope()) {
|
|
return { name: "a seat's centre belongs to that seat", offenders: [],
|
|
skipped: "a sheet is open over the table" };
|
|
}
|
|
const seats = [...document.querySelectorAll(".seat")];
|
|
const offenders = [];
|
|
|
|
seats.forEach((seat, index) => {
|
|
const rect = seat.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) return;
|
|
const x = rect.left + rect.width / 2;
|
|
const y = rect.top + rect.height / 2;
|
|
if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return;
|
|
|
|
const hit = document.elementFromPoint(x, y);
|
|
if (!hit || seat.contains(hit) || hit === seat) return;
|
|
|
|
const other = hit.closest(".seat");
|
|
const who = other ? `seat ${seats.indexOf(other) + 1}` : this.describe(hit);
|
|
const control = hit.matches && hit.matches("button, a, input, select, [role='button']");
|
|
offenders.push(
|
|
`seat ${index + 1}'s centre goes to ${who}${control ? "'s " + this.describe(hit) : ""}`
|
|
);
|
|
});
|
|
|
|
return { name: "a seat's centre belongs to that seat", offenders };
|
|
},
|
|
|
|
// A poker table has one dealer button. Nine pucks all showing "D", one of them
|
|
// gold, turns the most-read marker on the felt into a spot-the-difference — and
|
|
// a screen reader heard nine buttons named "D" with nothing to tell them apart.
|
|
// Both halves are checked here: what's drawn, and what it announces itself as.
|
|
oneDealerButton() {
|
|
const chips = [...document.querySelectorAll(".dealerChip")];
|
|
const shown = chips.filter(chip => {
|
|
const style = getComputedStyle(chip);
|
|
if (style.display === "none" || style.visibility === "hidden") return false;
|
|
if (parseFloat(style.opacity) <= 0.05) return false;
|
|
// A hovered seat deliberately fades its empty slot in — that's the "put the
|
|
// button here" preview, and it is on screen because a pointer is sitting on
|
|
// that seat. Counting it reports two buttons whenever the mouse happens to
|
|
// rest on the table, which is most of the time a mouse exists.
|
|
const seat = chip.closest(".seat");
|
|
if (seat && seat.matches(":hover") && !chip.classList.contains("active")) return false;
|
|
return true;
|
|
});
|
|
const offenders = [];
|
|
|
|
if (shown.length > 1) {
|
|
offenders.push(`${shown.length} dealer buttons drawn on the felt at once`);
|
|
}
|
|
|
|
const labels = chips.map(chip => chip.getAttribute("aria-label") || chip.textContent.trim());
|
|
const duplicated = labels.filter((label, i) => labels.indexOf(label) !== i);
|
|
if (duplicated.length) {
|
|
offenders.push(
|
|
`${duplicated.length + 1} dealer buttons announce themselves as "${duplicated[0]}"`
|
|
);
|
|
}
|
|
|
|
return { name: "the felt shows one dealer button", offenders };
|
|
},
|
|
|
|
// Anything that says it is hidden and is drawn anyway.
|
|
//
|
|
// `hidden` gets its `display: none` from the browser's own stylesheet, the weakest
|
|
// rule in the cascade, so any class that sets a display beats it — and in this tool
|
|
// every button class does. The result is an element the code believes is gone,
|
|
// sitting on the screen: found as a 21x44 empty box under the bank check, and this
|
|
// file's own touch check had been calling it "could not be probed" rather than
|
|
// saying the plainer thing, which is that it should not be there at all.
|
|
hiddenButPainted() {
|
|
const offenders = [...document.querySelectorAll("[hidden]")]
|
|
.filter(el => {
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) return false;
|
|
const style = getComputedStyle(el);
|
|
return style.display !== "none" && style.visibility !== "hidden";
|
|
})
|
|
.map(el => `${this.describe(el)} :: hidden, but drawn ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`);
|
|
return { name: "hidden means hidden", offenders };
|
|
},
|
|
|
|
// A seat's name, painted under the decoration on its own puck.
|
|
//
|
|
// The chip that marks the button sits in the puck's top-right corner on a layer
|
|
// above the text, and the name is centred across the whole width. A long one
|
|
// therefore ran underneath it and stopped — measured, "Christopher" on the button
|
|
// lost 20px of its 64px box and showed no ellipsis, because as far as the text was
|
|
// concerned it had all the room it needed. Nothing else here would have caught it:
|
|
// the occlusion check is about controls a thumb has to reach, and this is a label
|
|
// a dealer has to read.
|
|
seatLabelsClear() {
|
|
const offenders = [];
|
|
document.querySelectorAll(".seat").forEach(seat => {
|
|
const name = seat.querySelector(".seatPuckName");
|
|
if (!name || !name.textContent.trim()) return;
|
|
const box = name.getBoundingClientRect();
|
|
if (!box.width || !box.height) return;
|
|
// Padding is room the text has already been kept out of, so it is not
|
|
// somewhere the text can be covered.
|
|
const style = getComputedStyle(name);
|
|
const textLeft = box.left + parseFloat(style.paddingLeft || 0);
|
|
const textRight = box.right - parseFloat(style.paddingRight || 0);
|
|
|
|
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
|
|
const over = mark.getBoundingClientRect();
|
|
if (!over.width || !over.height) return;
|
|
const marked = getComputedStyle(mark);
|
|
if (marked.display === "none" || marked.visibility === "hidden" ||
|
|
parseFloat(marked.opacity) < 0.05) return;
|
|
const covered = Math.min(textRight, over.right) - Math.max(textLeft, over.left);
|
|
const vertical = Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
|
|
if (covered > 1 && vertical > 1) {
|
|
offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`);
|
|
}
|
|
});
|
|
});
|
|
return { name: "seat names aren't painted under their own badges", offenders };
|
|
},
|
|
|
|
// 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.
|
|
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;
|
|
const over = document.elementFromPoint(x, y);
|
|
if (!over || seat.contains(over) || over.contains(seat)) continue;
|
|
offenders.push(
|
|
`seat ${index + 1} "${text}" is under ${this.describe(over)}`);
|
|
return;
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
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.touchAction(),
|
|
this.tapHighlight(),
|
|
this.textSelectable(),
|
|
this.occlusion(options.occlusionSelector),
|
|
this.collapsedColumns(),
|
|
this.textSpill(),
|
|
this.horizontalOverflow(),
|
|
this.fieldFontSize(),
|
|
this.seatOwnership(),
|
|
this.oneDealerButton(),
|
|
this.hiddenButPainted(),
|
|
this.seatLabelsClear(),
|
|
this.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;
|
|
}
|