A phone screenshot, mid-hand, eight-handed: the seat with the button reads "Christop" and then a white D. The name is centred across the whole puck and the chip sits in its top-right corner on a layer above, so a long name runs underneath and stops — measured, "Christopher" lost 20px of its 64px box. The bad part is the silence. The text believed it had the room it was being painted out of, so no ellipsis appeared: nothing on screen said the name went on. On the one seat at the table a dealer looks at most. The name now keeps out of the chip's corner on the seat that has the button, so a long one ends in an ellipsis before it gets there. Only that seat — every other puck has its full width, because the chip on those is invisible. And the audit has a check for it now. Nothing it had would have caught this: the occlusion check is about controls a thumb has to reach, and this is a label a dealer has to read. The new one measures every seat's name against its own chip and badges, and the fix was undone by hand to confirm it fires — "20px under button.dealerChip.active". 182 audits across 13 viewports, both input kinds, plain and straddled tables, every overlay: clean. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
835 lines
42 KiB
JavaScript
835 lines
42 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 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;
|
|
}
|
|
|
|
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 };
|
|
},
|
|
|
|
// ---- 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.
|
|
runAll(options = {}) {
|
|
const checks = [
|
|
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.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;
|
|
}
|