// 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 }; }, // ---- 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; }