Contrast is the one check I've got wrong twice, both times by being clever about backgrounds. The first version read only background-color, so text on any of the gradient-painted sheets 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 this one is built to be incapable of false positives rather than exact. Backgrounds composite through alpha; where a gradient is involved every colour stop is tried and the most flattering one used. A finding means the text fails even under the reading most generous to it. It's checked against known answers before being believed: black on white at 21:1, white on white at 1:1, #767676 on white at the 4.54:1 AA boundary, white over a dark gradient, dark text on that same gradient, white over a 6% white wash on a near-black page. Nine cases, all correct — and building them found a real bug in the check, which walked past an opaque gradient and offered the page background as a candidate. That made it too lenient, which is as bad as the old false positives. What it found: Both blind badges failed against their own gradients, in 9px bold text a dealer reads at a glance across a table. At the worst stop: small blind 3.52:1, big blind 2.15:1. The small blind keeps its blue and darkens to 4.77:1. The big blind flips to dark text on brighter gold, 5.84:1 — how a gold chip is marked anyway, and it makes the two badges far easier to tell apart at speed. Quick Split's footnote names a control the dealer is told to reach for, and that name inherited the muted grey of the surrounding aside: 3.39:1. Emphasised text carrying an instruction gets its own colour now. The felt watermark came back at 1.19:1 and is meant to. It's decoration; holding it to a reading threshold would mean making it not a watermark. It's aria-hidden now, which says the same thing where it also does some good, and the audit skips aria-hidden subtrees. 32 contrast passes over the table, all five overlays and Quick Split at five viewports: zero findings, zero unmeasured. Layout audit still clean. 137 tests pass. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
432 lines
20 KiB
JavaScript
432 lines
20 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;
|
|
},
|
|
|
|
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
|
|
const offenders = [];
|
|
const warnings = [];
|
|
[...document.querySelectorAll(selector)].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.
|
|
if (top.closest && top.closest(".seat")) 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 => {
|
|
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;
|
|
});
|
|
},
|
|
|
|
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
|
|
const offenders = [];
|
|
const unmeasured = [];
|
|
|
|
const nodes = selector
|
|
? [...document.querySelectorAll(selector)]
|
|
: [...document.querySelectorAll("*")];
|
|
|
|
nodes.forEach(el => {
|
|
if (el.children.length > 0) return; // leaf text only
|
|
const text = (el.textContent || "").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;
|
|
if (parseFloat(style.opacity) < 0.99) return; // faded on purpose
|
|
// Decoration is exempt by definition: the watermark on the felt is meant
|
|
// to be barely there, and holding it to a reading threshold would mean
|
|
// making it not a watermark. Marked aria-hidden so screen readers skip it
|
|
// too, which is the same claim expressed where it also does some good.
|
|
if (el.closest("[aria-hidden='true']")) return;
|
|
|
|
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 fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
|
|
best = Math.max(best, this.contrastRatio(fg, 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) ----
|
|
|
|
touchTargets(minimum = 44) {
|
|
const offenders = this.visibleControls()
|
|
.filter(el => el.type !== "color")
|
|
.filter(el => {
|
|
const rect = el.getBoundingClientRect();
|
|
return rect.height < minimum - 0.5 || rect.width < minimum - 0.5;
|
|
})
|
|
.map(el => {
|
|
const rect = el.getBoundingClientRect();
|
|
return `${this.describe(el)} :: ${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
|
});
|
|
return { name: `touch targets at least ${minimum}px`, offenders };
|
|
},
|
|
|
|
// ---- Runner ----
|
|
|
|
runAll(options = {}) {
|
|
const checks = [
|
|
this.touchAction(),
|
|
this.tapHighlight(),
|
|
this.textSelectable(),
|
|
this.occlusion(options.occlusionSelector),
|
|
this.collapsedColumns(),
|
|
this.textSpill(),
|
|
this.horizontalOverflow(),
|
|
this.fieldFontSize(),
|
|
];
|
|
if (options.includeTouchTargets) checks.push(this.touchTargets(options.minimumTarget));
|
|
|
|
return {
|
|
viewport: `${innerWidth}x${innerHeight}`,
|
|
failed: checks.filter(c => c.offenders.length > 0),
|
|
// Things worth knowing that aren't faults — see occlusion() on toasts.
|
|
warnings: checks.flatMap(c => c.warnings || []),
|
|
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
|
|
clean: checks.every(c => c.offenders.length === 0),
|
|
};
|
|
},
|
|
};
|
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
|
module.exports = LayoutAudit;
|
|
}
|