// 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. // // 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 => { 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) ---- // 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" }; } this.visibleControls() .filter(el => el.type !== "color") .filter(el => !(exempt && el.matches(exempt))) .forEach(el => { 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; const size = this.effectiveTarget(el); if (!size) 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 }; }, // 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() { 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 }; }, // ---- Runner ---- runAll(options = {}) { const checks = [ this.touchAction(), this.tapHighlight(), this.textSelectable(), this.occlusion(options.occlusionSelector), this.collapsedColumns(), this.textSpill(), this.horizontalOverflow(), this.fieldFontSize(), this.seatOwnership(), ]; 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; }