// 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"; }); }, // ---- Every control says what it is ---- // WCAG 4.1.2 wants a name on every control, and empty button names are among the // most common failures on the web — WebAIM's 2025 sweep found them on a bit over a // quarter of home pages. This tool had one: the × that removes a chip colour, five // of them in a column, identical, each deleting a different colour, and one click // of the wrong one took $22,500 off a table. // // A symbol counts as no name here even though it is technically text. "×" is // announced as "times" and names nothing — the rule is that a name has to contain a // letter or a digit, which is the difference between "×" and "Undo" or "+ Add chip // color". Anything with aria-hidden is not in the tree at all and is skipped rather // than judged. controlNames() { const named = el => { const aria = (el.getAttribute("aria-label") || "").trim(); if (aria) return aria; const by = el.getAttribute("aria-labelledby"); if (by) { const text = by.split(/\s+/) .map(id => document.getElementById(id)) .filter(Boolean) .map(node => (node.textContent || "").trim()) .join(" ") .trim(); if (text) return text; } // A field's name usually comes from its label rather than from itself. if (/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) { const wrapping = el.closest("label"); const pointing = el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`) : null; const text = [wrapping, pointing] .filter(Boolean) .map(node => (node.textContent || "").trim()) .join(" ") .trim(); if (text) return text; const placeholder = (el.getAttribute("placeholder") || "").trim(); if (placeholder) return placeholder; } const own = (el.textContent || "").trim(); if (own) return own; const image = el.querySelector("img[alt]"); if (image) return (image.getAttribute("alt") || "").trim(); return (el.getAttribute("title") || "").trim(); }; const offenders = this.visibleControls() .filter(el => !el.closest("[aria-hidden='true']")) // A seat puck is in CONTROL_SELECTOR for the touch checks; its own name is // the player's, and an empty chair is deliberately unnamed. .filter(el => !el.classList.contains("seat")) .map(el => ({ el, name: named(el) })) .filter(({ name }) => !/[\p{L}\p{N}]/u.test(name)) .map(({ el, name }) => `${this.describe(el)} :: ${name ? `named only "${name}"` : "no accessible name"}`); return { name: "every control has a name a screen reader can read", offenders }; }, // ---- Focus has somewhere to land ---- // Everything fixed to the bottom of the window, as one band. The action bar and the // toast stack, so what matters is the top edge of the highest of them. // // Only things crossing the middle of the screen count. A landscape phone turns the // bar into a rail down the right-hand side, which is bottom-anchored and full height // — measuring that as "furniture at the bottom" would demand scroll padding the // height of the whole window. bottomFurniture() { const middle = innerWidth / 2; // A sticky element is anchored to whatever scrolls it, which is only the window // when nothing between it and the root scrolls. The standings table has a sticky // header inside a panel that scrolls on its own; it comes to rest against the // bottom of the window often enough to be picked up here, and it is not // furniture — scroll-padding on the document would do nothing about it. const anchoredToWindow = node => { if (getComputedStyle(node).position === "fixed") return true; for (let up = node.parentElement; up && up !== document.body; up = up.parentElement) { if (/auto|scroll|overlay/.test(getComputedStyle(up).overflowY)) return false; } return true; }; return [...document.querySelectorAll("*")].filter(node => { const cs = getComputedStyle(node); if (cs.position !== "fixed" && cs.position !== "sticky") return false; if (cs.visibility === "hidden" || cs.display === "none" || node.hidden) return false; const b = node.getBoundingClientRect(); if (b.width === 0 || b.height === 0) return false; if (b.bottom < innerHeight - 2 || b.left > middle || b.right < middle) return false; return anchoredToWindow(node); }); }, // WCAG 2.2 SC 2.4.11: a focused control must not be completely hidden by content the // page put on top of it. // // A fixed bar is not in the flow, so the browser does not know it is there: Tab to a // control near the bottom and it is scrolled flush against the edge of the window, // under the bar, and the browser considers it done. Tabbing the whole page on a // 390x844 phone found 16 stops resting completely covered and 27 more partly. // // Checked as the fix rather than the symptom, because the symptom needs a keyboard // and this file has no automation of its own: scroll-padding-bottom on the scroll // container has to reserve at least as much room as the furniture standing there. focusHasRoom() { const furniture = this.bottomFurniture(); if (furniture.length === 0) { return { name: "focus has room under the fixed bar", offenders: [], skipped: "nothing is fixed to the bottom here" }; } const highest = Math.min(...furniture.map(node => node.getBoundingClientRect().top)); const needed = Math.round(innerHeight - highest); const declared = parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom); const reserved = Number.isFinite(declared) ? Math.round(declared) : 0; const offenders = reserved >= needed ? [] : [ `${furniture.map(n => this.describe(n)).join(" + ")} stands ${needed}px tall, ` + `and scroll-padding-bottom reserves ${reserved}px — a control tabbed to down there ` + `comes to rest ${needed - reserved}px inside it`, ]; return { name: "focus has room under the fixed bar", offenders }; }, // ---- The furniture is not allowed to eat the table ---- // How much of the window the fixed bottom band is taking. // // The action bar's height is not a constant: it carries the pot breakdown, which had // one row per all-in and no ceiling. Measured on a 360x640 phone — five players // idle, 151px; six all-in, 303px; twelve all-in, 411px, which is 64% of the screen, // leaving 96px of felt to draw twelve seats in. Nothing in the sweep could see it, // because every check here asks whether a thing is covered or clipped and none of // them asked how much room the covering thing had taken. // // Two lines, because there are two different claims. Half the window is the one // worth failing on: furniture bigger than the content it serves is indefensible // whatever it is showing, and it is a shape fault rather than a matter of taste. // Thirty percent is the published guidance for persistent chrome and it is a warning // here, because the shortest phone in the list does not meet it at rest — the bar is // 151px of a 640px screen before anything happens — and a sweep that is always red // stops being read. furnitureBudget() { const name = "the fixed bar leaves the table its room"; const furniture = this.bottomFurniture(); if (furniture.length === 0) { return { name, offenders: [], skipped: "nothing is fixed to the bottom here" }; } const highest = Math.min(...furniture.map(node => node.getBoundingClientRect().top)); const tall = Math.round(innerHeight - highest); const share = Math.round((100 * tall) / innerHeight); const what = furniture.map(n => this.describe(n)).join(" + "); return { name, offenders: share > 50 ? [`${what} stands ${tall}px of a ${innerHeight}px window — ${share}%, ` + `leaving the felt less room than the furniture`] : [], warnings: share > 30 ? [`${name}: ${what} is ${share}% of the window (${tall}px of ${innerHeight}px); ` + `persistent chrome is usually kept under 30%`] : [], }; }, // The other half: a control that scrolling can never reach at all. // // The seat editor is a bottom sheet on a phone, and it was put away with // `transform: translateY(105%)` — which moves it and nothing else. Parked below an // 844px window at y=850, its Done button was still in the tab order and still on the // accessibility tree, and because a fixed element does not move when the page does, // no amount of scrolling could bring it back. visibility:hidden puts it away; a // transform only moves it. focusablesReachable() { const offenders = this.visibleControls() .filter(el => { for (let node = el; node; node = node.parentElement) { if (getComputedStyle(node).position === "fixed") return true; } return false; }) .filter(el => { const b = el.getBoundingClientRect(); return b.top >= innerHeight || b.bottom <= 0 || b.left >= innerWidth || b.right <= 0; }) .map(el => `${this.describe(el)} :: fixed and off-screen, so nothing can scroll to it`); return { name: "no control is parked where scrolling can't reach it", offenders }; }, // ---- Touch (see the mobile round) ---- // Without touch-action: manipulation a mobile browser holds every tap for ~300ms // deciding whether it was a double-tap-to-zoom. This tool uses double-tap on a // seat as its fold shortcut, so that wait is on the critical path. touchAction() { const offenders = this.visibleControls() .filter(el => getComputedStyle(el).touchAction === "auto") .map(el => this.describe(el)); return { name: "touch-action", offenders, checked: this.visibleControls().length }; }, tapHighlight() { const offenders = this.visibleControls() .filter(el => { const colour = getComputedStyle(el).webkitTapHighlightColor; return colour && !/rgba\(0,\s*0,\s*0,\s*0\)|transparent/.test(colour); }) .map(el => this.describe(el)); return { name: "tap-highlight", offenders, checked: this.visibleControls().length }; }, // Text selection must stay ON for anything a dealer types into. textSelectable() { const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")] .filter(el => el.offsetParent !== null) .filter(el => { const style = getComputedStyle(el); return (style.webkitUserSelect || style.userSelect) === "none"; }) .map(el => this.describe(el)); return { name: "fields stay selectable", offenders }; }, // ---- Reachability (see the occlusion round) ---- // A control covered by something else is unreachable however good it looks. // Sampling several points matters: a button you can only hit in one corner is // covered for practical purposes. // Can this element be brought into view by scrolling? Something below the fold on // a page that scrolls is perfectly reachable; something below the fold in a panel // that *doesn't* scroll is lost. The first version of this check didn't make the // distinction and reported every link under the fold as a fault. reachableByScrolling(el) { const rect = el.getBoundingClientRect(); if (getComputedStyle(el).position === "fixed") return false; for (let node = el.parentElement; node; node = node.parentElement) { const style = getComputedStyle(node); const scrolls = /auto|scroll|overlay/.test(style.overflowY) || node === document.body; if (!scrolls) continue; const box = node === document.body ? { top: 0, bottom: innerHeight } : node.getBoundingClientRect(); const room = node === document.body ? document.documentElement.scrollHeight - innerHeight - scrollY : node.scrollHeight - node.clientHeight - node.scrollTop; if (rect.top >= box.bottom && room > 0) return true; if (rect.bottom <= box.top && (node === document.body ? scrollY > 0 : node.scrollTop > 0)) return true; } return false; }, // The scrolling ancestor whose box this element has fallen outside of, if any. // Wholly outside — an element straddling the edge is still half on show, and the // half that is showing is worth hit-testing. clippedOutBy(el, rect) { for (let node = el.parentElement; node; node = node.parentElement) { const style = getComputedStyle(node); if (!/auto|scroll|hidden|overlay|clip/.test(style.overflowY + style.overflowX)) continue; const box = node.getBoundingClientRect(); if (rect.top >= box.bottom - 0.5 || rect.bottom <= box.top + 0.5 || rect.left >= box.right - 0.5 || rect.right <= box.left + 0.5) return node; } return null; }, // The modal that is currently on top, if any. // // Every check below asks "is this thing reachable", and while a sheet or dialog is // open the answer for everything behind it is legitimately no — that is what a // modal is for. Without knowing that, the audit reported the pot wizard covering // the seats as ten faults and the rulings sheet as seven, which is the noise that // made it useless in exactly the states nobody had audited. Now it scopes to what // is actually on top and says so. MODAL_SELECTOR: "#potWizardOverlay, #rulingsOverlay, #seatMenuOverlay, #cardPickerOverlay, " + "#confirmOverlay, #addChipsOverlay, #seatEditorOverlay", openModal() { const open = [...document.querySelectorAll(this.MODAL_SELECTOR)] .filter(el => !el.hidden && getComputedStyle(el).display !== "none"); // Last in the DOM wins, which is how they stack. return open.length > 0 ? open[open.length - 1] : null; }, // A seat sheet on a phone isn't an overlay element, it's a body class — but it // covers the felt just the same. modalScope() { const overlay = this.openModal(); if (overlay) return overlay; if (document.body.classList.contains("seatSheetOpen")) { return document.getElementById("seatEditorPanel") || null; } return null; }, occlusion(selector = "#actionBar button, #holdemHeader button, .seat") { const offenders = []; const warnings = []; // While something is open on top, only what is inside it can be judged. The // rest of the page is meant to be unreachable. const scope = this.modalScope(); const candidates = [...document.querySelectorAll(selector)] .filter(el => !scope || scope.contains(el)); candidates.forEach(el => { const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const outsideViewport = rect.bottom <= 0 || rect.top >= innerHeight || rect.right <= 0 || rect.left >= innerWidth; if (outsideViewport) { if (!this.reachableByScrolling(el)) { offenders.push(`${this.describe(el)} :: off-screen and cannot be scrolled to`); } return; } // Still on screen, but clipped away by a panel that scrolls. Hit-testing it // is meaningless: the point lands on whatever the panel's own edge has next // to it, and the answer comes back as a diagnosis of something that isn't // happening. The pot wizard's third winner, below the fold in a list that // scrolls, was reported for four viewports running as "fully covered by // button Confirm Winner(s)" — the button is simply what is painted at that // height. Whether it is a fault is the same question as for anything else // out of view: can it be scrolled to. const clip = this.clippedOutBy(el, rect); if (clip) { if (!this.reachableByScrolling(el)) { offenders.push(`${this.describe(el)} :: clipped out of ${this.describe(clip)}, which cannot be scrolled to it`); } return; } const points = [ [rect.left + rect.width / 2, rect.top + rect.height / 2], [rect.left + rect.width * 0.25, rect.top + rect.height * 0.3], [rect.left + rect.width * 0.75, rect.top + rect.height * 0.3], [rect.left + rect.width * 0.25, rect.top + rect.height * 0.7], [rect.left + rect.width * 0.75, rect.top + rect.height * 0.7], ].filter(([x, y]) => x >= 0 && y >= 0 && x < innerWidth && y < innerHeight); if (points.length === 0) return; let blocked = 0; let testable = 0; let blockedBy = null; points.forEach(([x, y]) => { const top = document.elementFromPoint(x, y); // A null result means the point isn't hit-testable — typically it sits // inside the element but outside a scrollable ancestor's clip, i.e. the // control is scrolled out of view rather than covered by anything. // Counting that as "covered by null" was reporting a perfectly // reachable button in a sheet that scrolls. if (!top) return; testable++; if (el.contains(top) || top === el || top.contains(el)) return; // Seats ring a round table and overlap each other at the rim by // design; that is crowding, not an overlay hiding a control. // // But only the seat's *body* is harmless. A neighbouring seat's own // controls sitting on top of this one is a control being stolen, and // exempting the whole seat subtree hid exactly that: an enlarged // dealer-chip hit area was taking the centre of the seat three // places round the table, and this check called it crowding and // moved on. const neighbourSeat = top.closest && top.closest(".seat"); if (neighbourSeat) { const isControl = top.matches && top.matches("button, a, input, select, textarea, [role='button']"); if (!isControl) return; } blocked++; if (!blockedBy) blockedBy = this.describe(top); }); if (testable === 0) { if (!this.reachableByScrolling(el)) { offenders.push(`${this.describe(el)} :: not hit-testable anywhere and cannot be scrolled to`); } return; } if (blocked === 0) return; const note = `${this.describe(el)} :: ${blocked === testable ? "fully" : `${blocked}/${testable}`} covered by ${blockedBy}`; // A toast is transient by design: it dismisses itself and carries a close // button, and on a desktop it necessarily lands over a table that fills the // content column. Partial overlap from one is a fact of the layout, not a // fault. Being *fully* covered by a toast is still a failure though — that // was a real bug once, where a seat sat exactly under the Undo button and a // tap meant for a player took back the last action instead. const transient = blockedBy && /toast/i.test(blockedBy); if (transient && blocked < testable) warnings.push(note); else offenders.push(note); }); return { name: "nothing important is covered", offenders, warnings }; }, // ---- Layout (the check that would have caught the standings regression) ---- // A grid column squeezed to nothing doesn't truncate its text, it erases it. // This is the one that was missing: the player-name column sat at 0px through // two rounds because nothing ever asked whether a column had survived. collapsedColumns() { const offenders = []; [...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => { // Nothing inside something the page has hidden. A hidden element's children // all measure zero wide, so the standings header — which is hidden along // with its table until somebody has bought in — was reported as three // collapsed columns on every empty table. A column nobody can see hasn't // collapsed; it isn't there. if (row.closest("[hidden]")) return; if (getComputedStyle(row).display !== "grid") return; [...row.children].forEach(cell => { if (getComputedStyle(cell).display === "none") return; const hasText = (cell.textContent || "").trim().length > 0; if (hasText && cell.getBoundingClientRect().width < 4) { offenders.push(`${this.describe(row)} > ${this.describe(cell)} :: ${Math.round(cell.getBoundingClientRect().width)}px wide`); } }); }); return { name: "no column collapsed to nothing", offenders }; }, // Text actually painted outside the box meant to contain it — which on a table of // overlapping seats means painted on top of a neighbour. // // The clipping check is the whole subtlety, and the first version of this got it // wrong. A Range's bounding rect measures the *laid out* text, which happily runs // past the element when `overflow: hidden` is quietly clipping it — so an // ellipsised name was reported as spilling when nothing was drawn outside at all. // Only overflow that is actually visible can paint over a neighbour. textSpill(selector = ".seatPuckStack, .seatPuckTotal, .seatPuckName") { const offenders = []; [...document.querySelectorAll(selector)].forEach(el => { const text = (el.textContent || "").trim(); if (!text) return; const style = getComputedStyle(el); const clipsItself = style.overflow !== "visible" || style.overflowX !== "visible"; if (clipsItself) return; const container = el.closest(".seat") || el.parentElement; if (!container) return; const range = document.createRange(); range.selectNodeContents(el); const drawn = range.getBoundingClientRect(); const bounds = container.getBoundingClientRect(); if (drawn.left < bounds.left - 1 || drawn.right > bounds.right + 1) { offenders.push(`${this.describe(el)} "${text}" paints outside ${this.describe(container)}`); } }); return { name: "no text painted outside its box", offenders }; }, horizontalOverflow() { const offenders = document.documentElement.scrollWidth > innerWidth + 1 ? [`page scrolls sideways: ${document.documentElement.scrollWidth}px in a ${innerWidth}px viewport`] : []; return { name: "page never scrolls sideways", offenders }; }, // iOS zooms the whole page when a field under 16px takes focus, and the dealer // is then pinching back out mid-hand. fieldFontSize() { const offenders = [...document.querySelectorAll("input, textarea, select")] .filter(el => el.offsetParent !== null) .filter(el => ["text", "number", "search", "tel", "url", "email", "password", ""].includes(el.type || "")) .filter(el => parseFloat(getComputedStyle(el).fontSize) < 16) .map(el => `${this.describe(el)} at ${getComputedStyle(el).fontSize}`); return { name: "no field small enough to trigger iOS zoom", offenders }; }, // ---- Contrast ---- // // This check has been got wrong twice before, both times by being too clever about // backgrounds. The first version read only `background-color`, so every sheet // painted with a gradient was measured against transparency and the dealer puck's // dark "D" was reported at 1.2:1 against nothing. The second over-corrected and // treated low-alpha gradient stops as opaque, scoring white-on-dark at 1.0:1. // // So it is built to be incapable of false positives instead of trying to be exact. // Backgrounds composite properly through alpha, and where a gradient is involved // every one of its colour stops is tried and the *most flattering* one is used. A // finding therefore means the text fails even under the reading most generous to // it, which is a claim worth making. Text that can't be resolved to any opaque // background is reported separately as unmeasured rather than guessed at. parseColour(value) { const match = String(value).match(/rgba?\(([^)]+)\)/); if (!match) return null; const parts = match[1].split(/[,\s/]+/).filter(Boolean).map(Number); if (parts.length < 3 || parts.some(Number.isNaN)) return null; return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 }; }, // Colour stops out of any gradient in a background-image. gradientStops(backgroundImage) { if (!backgroundImage || backgroundImage === "none") return []; return (backgroundImage.match(/rgba?\([^)]+\)/g) || []) .map(c => this.parseColour(c)) .filter(Boolean); }, over(top, bottom) { const a = top.a; return { r: top.r * a + bottom.r * (1 - a), g: top.g * a + bottom.g * (1 - a), b: top.b * a + bottom.b * (1 - a), a: 1, }; }, relativeLuminance({ r, g, b }) { const channel = v => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }; return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); }, contrastRatio(fg, bg) { const a = this.relativeLuminance(fg); const b = this.relativeLuminance(bg); return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); }, // Every background this text could actually be sitting on. // // Layers are collected from the element outwards and stop at the first fully // opaque one, because nothing behind that is visible. Getting this wrong in the // lenient direction is just as bad as the old false positives: an earlier version // kept walking past an opaque gradient and offered the page background as a // candidate, which scored white-on-a-mid-grey-panel as though it were // white-on-near-black and would have waved real problems through. // // A gradient contributes one candidate per colour stop; translucent layers above // the opaque one are composited onto each candidate in order. backgroundCandidates(el) { const translucent = []; // top-down, above the opaque layer let base = null; // candidate colours for the first opaque layer for (let node = el; node && !base; node = node.parentElement) { const style = getComputedStyle(node); const stops = this.gradientStops(style.backgroundImage); const colour = this.parseColour(style.backgroundColor); // A gradient whose stops are all opaque hides everything behind it. if (stops.length > 0 && stops.every(s => s.a === 1)) { base = stops; break; } stops.forEach(s => { if (s.a > 0) translucent.push(s); }); if (colour && colour.a === 1) { base = [colour]; break; } if (colour && colour.a > 0) translucent.push(colour); } if (!base) { // Nothing opaque anywhere up the tree — fall back to the page's own colour // so a finding is still measured against something real. const page = this.parseColour(getComputedStyle(document.documentElement).backgroundColor); base = [page && page.a === 1 ? page : { r: 10, g: 10, b: 10, a: 1 }]; } // Fold the translucent layers back on, innermost last so it lands on top. return base.map(candidate => { let composed = candidate; for (let i = translucent.length - 1; i >= 0; i--) composed = this.over(translucent[i], composed); return composed; }); }, // Every element between this one and the document, so an opacity set on a // container can be seen from the text inside it. *ancestors(el) { let node = el.parentElement; while (node) { yield node; node = node.parentElement; } }, // How much of this element actually reaches the screen, and what is behind the // thing doing the fading. // // `opacity` on a box fades the whole box — its text and its own background together // — onto whatever sits behind that box. So a dimmed seat is not "grey text on a // dark puck"; it is a faded puck and faded text both landing on the felt. Both // halves have to be composited the same way or the number means nothing. fading(el) { let alpha = 1; let outermost = null; for (let node = el; node; node = node.parentElement) { const own = parseFloat(getComputedStyle(node).opacity); if (own < 0.99) { alpha *= own; outermost = node; } } if (!outermost) return { alpha: 1, behind: null }; return { alpha, behind: this.backgroundCandidates(outermost.parentElement || document.body)[0] || null }; }, // The two colours as the screen actually shows them, once the fading is applied. asPainted(fgRaw, bg, fade) { const solid = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg); if (fade.alpha >= 0.99 || !fade.behind) return { fg: solid, bg }; return { fg: this.over({ ...solid, a: solid.a * fade.alpha }, fade.behind), bg: this.over({ ...bg, a: bg.a * fade.alpha }, fade.behind), }; }, // Two things this check cannot do, both found by measuring against real pixels in // runContrastAudit.js and neither of them fixable from inside a DOM walk: // // 1. It reads `background-color`, so anything painted by a gradient is invisible to // it. The chair number on an empty seat sat on the felt's brown rim at 2.65:1 and // the pot's label on green at 2.66:1 — two plain AA failures this reported clean, // for years, because it never saw either background. // 2. Where an element has several candidate ancestors it takes the best contrast of // any of them ("the kindest reading available", below). That is the right instinct // for a fade, and it is what let (1) through: the flattering candidate was the // dark page behind the felt, which is not what the text is sitting on. // // Both are why the sibling audit screenshots the page with its text made transparent // and samples the pixels instead. This one is kept because AA is the number an // accessibility audit will ask for, and because it runs against a live DOM with no // screenshot to decode — but a clean result here is not a claim that the page is // readable. That claim belongs to the APCA sweep. 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 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. // // It then made the same mistake one level down. `seat.contains(hit)` waved through // anything inside the seat, and the dealer chip is inside the seat: when its hit // pad was moved *inward* to stop it reaching the neighbours, it covered its own // seat's middle instead and this check called that fine. The chip's handler calls // stopPropagation, so those taps reached nothing — on a fresh table the button is // on seat 1, and seating the first player was the one thing on the felt that did // not work. A control inside the seat is a rival for the tap, not part of the seat. 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) return; // The nearest control at that point, if any. A control is a rival for the // tap wherever it lives — the seat's own chip swallows the event as surely // as the seat next door's does. const CONTROLS = "button, a, input, select, textarea, [role='button']"; const rival = hit.closest ? hit.closest(CONTROLS) : null; if (rival && seat.contains(rival)) { offenders.push( `seat ${index + 1}'s centre goes to its own ${this.describe(rival)}` ); return; } // Its own name, stack or felt. That is the seat, and a tap there is a tap // on the seat. if (!rival && (hit === seat || seat.contains(hit))) return; const other = hit.closest(".seat"); const who = other ? `seat ${seats.indexOf(other) + 1}` : this.describe(hit); offenders.push( `seat ${index + 1}'s centre goes to ${who}${rival ? "'s " + this.describe(rival) : ""}` ); }); 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 mark on the felt that stands for nothing. // // The amount pill inside every seat puck has a background, a pill radius and side // padding, so a player who had not put chips in yet got an 8x11px green lozenge // where their bet would go — measured on seven of nine seats at an ordinary table. // One of those is a component holding its place; nine of them is a rash, and a // dealer reading the felt has to learn to ignore a colour that on three other seats // means "this player has money out". // // It cost room as well as attention. The ring is sized from the tallest puck on it, // so a row nobody had filled was setting how far apart the seats had to sit. // // The rule is about paint, not about space: an element may hold its place invisibly, // and several here do. What it may not do is draw a background or a border with // nothing inside it. // // One real exemption, and it is on the list because the check reported it rather // than because it was predicted: .betChipDisc is a 13x13px circle filled with the // denomination colour of the chip beside it, published per chip from render.js. It // has no text because the colour *is* the content. Anything carrying its own // picture in a background-image is exempt on the same grounds, without needing to be // named. Nothing else here needs an entry — three others were guessed at first and // matched no element on the page, which is its own warning about writing this list // from memory instead of from a run. emptyDecoration() { const DECORATIVE = new Set(["betChipDisc"]); const opaque = value => value && value !== "none" && !/rgba\([^)]*,\s*0\s*\)/.test(value) && value !== "rgba(0, 0, 0, 0)" && value !== "transparent"; const offenders = [...document.querySelectorAll("#pokerTableWrap *")] .filter(el => { if (el.children.length || (el.textContent || "").trim()) return false; if ([...el.classList].some(c => DECORATIVE.has(c))) return false; const box = el.getBoundingClientRect(); if (box.width < 2 || box.height < 2) return false; const cs = getComputedStyle(el); if (cs.display === "none" || cs.visibility === "hidden" || Number(cs.opacity) === 0) return false; if (opaque(cs.backgroundImage)) return false; return opaque(cs.backgroundColor) || ["Top", "Right", "Bottom", "Left"].some(side => parseFloat(cs[`border${side}Width`]) > 0 && opaque(cs[`border${side}Color`])); }) .map(el => { const box = el.getBoundingClientRect(); return `${this.describe(el)} :: nothing in it, painted ` + `${Math.round(box.width)}x${Math.round(box.height)}px`; }); return { name: "nothing on the felt is painted for nothing", offenders }; }, // One green, and it is the thing to do next. // // The accent is a promise: this is the step. It only works while it is spent on one // control at a time, and it was being spent on two. Measured over a whole hand, // seventeen steps, before this went in: two accent controls at thirteen of them — // the bar's Call and the header's Resolve Pot, the identical gradient at opposite // ends of a phone — and at the other four the header alone, while the bar held the // only button there was to press. At showdown the bar's own "Showdown — resolve // pot" was blue-grey and the corner shortcut was green. // // A modal is its own view, so while one is open only what is inside it is judged — // a wizard's confirm is the primary action of the wizard, and the bar underneath it // is not on screen in any sense that matters. That is the same scoping rule the // occlusion check uses, and it comes from the same helper. // // Zero is not a failure. Between hands with nobody seated there is genuinely no next // step, and inventing one to keep a colour company would be worse than the silence. // // Six broken versions were run at it and six were caught, but two of them only after // the injection itself was fixed, and both failures were the same mistake: painting // a control green is not the same as putting a green control in front of anybody. // The first attempt turned every sidebar button green with the panels shut, so the // rule landed on nothing that was rendered — `getComputedStyle` reported the accent // gradient and `offsetParent` was still null. The second opened the panels first, // which rendered the button and left it four hundred pixels below the fold, where // this check is meant to ignore it. Scrolled into view, the same green "Add" is // named in the same breath as the bar's Call, and taking the green off goes clean // again. So: read back the computed paint AND the box, on both counts, before // believing a clean result means the check is looking. ACCENT_FILL: [22, 120, 58], ACCENT_TOLERANCE: 24, wearsTheAccent(el) { const cs = getComputedStyle(el); const paint = `${cs.backgroundColor} ${cs.backgroundImage}`; return [...paint.matchAll(/rgba?\(([^)]+)\)/g)] .map(m => m[1].split(",").map(Number)) .some(([r, g, b, a]) => (a === undefined || a > 0.5) && Math.abs(r - this.ACCENT_FILL[0]) <= this.ACCENT_TOLERANCE && Math.abs(g - this.ACCENT_FILL[1]) <= this.ACCENT_TOLERANCE && Math.abs(b - this.ACCENT_FILL[2]) <= this.ACCENT_TOLERANCE); }, oneNextStep() { const scope = this.modalScope(); const root = scope || document; const onScreen = el => { const r = el.getBoundingClientRect(); if (!r.width || !r.height) return false; const cs = getComputedStyle(el); if (cs.visibility === "hidden" || +cs.opacity === 0) return false; return r.bottom > 0 && r.top < innerHeight && r.right > 0 && r.left < innerWidth; }; const green = [...root.querySelectorAll("button, a.holdemBtn")] .filter(el => el.offsetParent !== null && onScreen(el) && this.wearsTheAccent(el)); if (green.length <= 1) { return { name: "one green, and it is the next step", offenders: [] }; } return { name: "one green, and it is the next step", offenders: [`${green.length} controls wear the accent at once` + `${scope ? " inside the open sheet" : ""}: ` + green.map(el => `"${(el.textContent || "").trim().slice(0, 24)}" in ` + `${el.closest("#holdemHeader") ? "the header" : el.closest("#actionBar") ? "the action bar" : el.closest("#holdemSidebar") ? "the sidebar" : "the page"}`).join(", ")], }; }, // The status a shut sidebar panel wears, and the three ways it can go wrong. // // Each of the eight
in the sidebar now carries the one fact you would // have opened it for — "Balanced", "2 waiting", "5 colours · $1–$500". They are the // sidebar's whole navigation, and what makes them scannable is that they are eight // identical slabs: a glance that pushes one row taller than the rest costs more in // rhythm than the fact it added is worth. Two of the strings aren't ours to keep // short — a table note is whatever the dealer typed, and the amounts grow with the // stakes — so this is checked on the page rather than argued about in the markup. // // Four questions, and they fail in different directions. A row that grew is the // layout one. A glance sitting on its own label is the narrow-screen one, and it is // the one that will come back: it takes a long label and a long glance in the same // panel, so it depends on the state and the viewport together. And a glance // ellipsised down to "Sam…" is worth less than no glance at all — it has spent a // row's width to say that a note exists, which the panel's own presence already // said. GLANCE_MIN_READABLE_PX: 60, glancesFitTheirRows() { // Rows that are actually drawn. The Keyboard panel is display: none under // `pointer: coarse` — a touch device has no keys to press — and a panel that // isn't rendered measures 0px, which the shortest-row rule below happily took // as its idea of a bare row. Every real row then read as grown: caught by the // sweep on its first clean run, 91 offenders, seven rows across each of the // thirteen viewports visited with a thumb. const rows = [...document.querySelectorAll(".sidebarPanel > summary")] .filter(row => row.getBoundingClientRect().height > 0); if (!rows.length) return { name: "panel glances fit their rows", offenders: [], skipped: "no sidebar here" }; // The shortest row in the sidebar is what a row with nothing on it measures, // whichever one that happens to be — taken rather than declared, so a change to // the header's padding doesn't quietly make this check pass by moving the bar. const heights = rows.map(row => row.getBoundingClientRect().height); const bare = Math.min(...heights); const offenders = []; rows.forEach((row, index) => { const glance = row.querySelector(".panelGlance"); const label = row.querySelector("h2"); const rowBox = row.getBoundingClientRect(); const name = (label && label.textContent.trim()) || `panel ${index + 1}`; if (heights[index] > bare + 1) { offenders.push(`${name} :: row is ${Math.round(heights[index])}px against ` + `${Math.round(bare)}px for a row with nothing on it`); } if (!glance || glance.hidden || !glance.textContent.trim()) return; const box = glance.getBoundingClientRect(); if (label) { const labelBox = label.getBoundingClientRect(); if (box.left < labelBox.right) { offenders.push(`${name} :: "${glance.textContent}" sits on the label, ` + `${Math.round(labelBox.right - box.left)}px into it`); } } // Both axes, and the vertical one is the half that matters. The row's own // height cannot be trusted to report this: the first version of this check // asked only whether a row had grown, and a glance floated back into flow // and allowed to wrap sailed straight through it — a float doesn't expand // the box it sits in, so two lines of note hung 9px clear of a row that // still measured exactly 44px. Asking where the glance actually // ended up is the question that has an answer. const out = [ box.right > rowBox.right + 1 ? "off the right" : null, box.left < rowBox.left - 1 ? "off the left" : null, box.top < rowBox.top - 1 ? `${Math.round(rowBox.top - box.top)}px out of the top` : null, box.bottom > rowBox.bottom + 1 ? `${Math.round(box.bottom - rowBox.bottom)}px out of the bottom` : null, ].filter(Boolean); if (out.length) { offenders.push(`${name} :: "${glance.textContent}" hangs ${out.join(" and ")} of its row`); } if (box.width < this.GLANCE_MIN_READABLE_PX && glance.scrollWidth > glance.clientWidth + 1) { offenders.push(`${name} :: "${glance.textContent}" is cut down to ` + `${Math.round(box.width)}px, which says less than nothing`); } }); return { name: "panel glances fit their rows", 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. // Measured against the lines of text as they were painted, not against the box they // live in. This used to take the box and subtract its padding, on the reasoning that // "padding is room the text has already been kept out of" — which is true, and is // not the only way to keep text out of somewhere. A right float does it too, and it // does it per line, which is the point of using one: the name on the button seat is // now indented past the chip on the line beside it and full width on the line below. // Box-minus-padding cannot see that, and called every seat in the sweep an offender // while the text sat exactly where it was supposed to. // // Range rects are the direct question — where did this text actually end up — so // padding, floats, centring, wrapping and a short label in a wide box all come out // right without any of them being modelled. Line boxes rather than one bounding box, // too: a two-line name that clears the chip on the line beside it is not covered // because its *other* line happens to sit under where the chip is. seatLabelsClear() { const offenders = []; document.querySelectorAll(".seat").forEach(seat => { const name = seat.querySelector(".seatPuckName"); if (!name || !name.textContent.trim()) return; const range = document.createRange(); range.selectNodeContents(name); const lines = [...range.getClientRects()].filter(line => line.width > 1 && line.height > 1); if (!lines.length) return; 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; let worst = 0; lines.forEach(line => { const across = Math.min(line.right, over.right) - Math.max(line.left, over.left); const down = Math.min(line.bottom, over.bottom) - Math.max(line.top, over.top); if (across > 1 && down > 1) worst = Math.max(worst, across); }); if (worst > 1) { offenders.push(`${this.describe(name)} :: ${Math.round(worst)}px under ${this.describe(mark)}`); } }); }); return { name: "seat names aren't painted under their own badges", offenders }; }, // A seat's writing, covered by something belonging to a different seat. // // seatLabelsClear() above asks whether a puck's own decoration sits on its own name. // This asks the other half, and the other half was where the tool was actually // failing: measured across nine viewports and six table sizes before this went in, // seventeen of fifty-four tables had one seat's name or stack painted under a // neighbour. On a Galaxy S8 at the most ordinary table there is — six players at a // nine-handed table — seat 2's stack was under seat 3's puck, and five pairs of // seats overlapped, the worst by 61px. // // Two ways in, and it takes both: // - the pucks themselves overlapping, which the layout used to permit on the // reasoning that rounded corners forgive a little; what they were forgiving was // a four-figure stack. // - a blind badge hanging off its puck onto the seat next door, which no amount // of space between the pucks fixes on its own. // // Hit-tested against the glyph run rather than the element box. A name is centred, // so its box is wider than its ink and testing the box would fail on empty air. // // The glyph run is then clipped back to what is actually painted, which is not the // same thing and cost this check its first honest-looking failure. A puck's name is // a `-webkit-box` with `line-clamp`, and getClientRects() hands back a rect for every // line the text *would* have taken — including the two that the clamp throws away. // Measured at 640x360 with twelve players: "Mike from work" reported a line 30px // below its own puck, which duly hit-tested onto the seat underneath, and the seats // were not touching at all. Nothing is painted there, so nothing can cover it. seatsKeepTheirText() { const name = "no seat covers another seat's writing"; if (this.modalScope()) { return { name, offenders: [], skipped: "a sheet is open over the table" }; } const seats = [...document.querySelectorAll(".seat")]; if (!seats.length) return { name, offenders: [], skipped: "no seats on this page" }; const offenders = []; seats.forEach((seat, index) => { seat.querySelectorAll(".seatPuckName, .seatPuckStack, .seatPuckTotal").forEach(label => { const text = label.textContent.trim(); if (!text) return; const painted = label.getBoundingClientRect(); if (!painted.width || !painted.height) return; const range = document.createRange(); range.selectNodeContents(label); [...range.getClientRects()].forEach(raw => { const line = { left: Math.max(raw.left, painted.left), right: Math.min(raw.right, painted.right), top: Math.max(raw.top, painted.top), bottom: Math.min(raw.bottom, painted.bottom), }; if (line.right - line.left <= 1 || line.bottom - line.top <= 1) return; // Along the line rather than at its middle: a badge takes the start // of it and a neighbouring puck the end, and a single centre probe // walks past both. // // Every probe, not the first one that hits. How much of the line is // gone is the difference between a stack a dealer cannot read and a // hairline where a badge's rounded corner grazes the last digit, and // reporting both as "is under" gave no way to tell them apart — the // one residual this sweep has carried for three rounds turns out to // be one probe of seven. let hits = 0; let culprit = null; let probes = 0; for (let across = 0.08; across <= 0.92; across += 0.14) { const x = line.left + (line.right - line.left) * across; const y = (line.top + line.bottom) / 2; probes++; const over = document.elementFromPoint(x, y); if (!over || seat.contains(over) || over.contains(seat)) continue; hits++; if (!culprit) culprit = over; } if (culprit) { offenders.push(`seat ${index + 1} "${text}" is ${hits}/${probes} ` + `under ${this.describe(culprit)}`); } }); }); }); return { name, offenders }; }, // The other thing on the felt that carries a number. // // A chip says what a player has put in this street, which is what a call costs. Two // of them on top of each other is two figures a dealer can't read, and one behind a // puck is a figure that isn't there at all. // // Geometry rather than hit-testing, which is the opposite of how every other check // here works and is forced: #betsLayer is `pointer-events: none`, so elementFromPoint // never returns a chip — it returns whatever is behind it, for every chip, always. // A first version of this probe hit-tested and duly reported all forty-five // configurations as broken, including the ones that were fine. Paint order is known // and fixed instead: #betsLayer comes before #seatsLayer in the document and a seat // carries `z-index: 1`, so a puck a chip overlaps is a puck that chip is behind. chipsKeepTheirAmounts() { const name = "no bet chip is hidden behind a seat or another chip"; if (this.modalScope()) { return { name, offenders: [], skipped: "a sheet is open over the table" }; } const chips = [...document.querySelectorAll(".betChip")] .filter(c => { const r = c.getBoundingClientRect(); return r.width > 0 && r.height > 0; }); if (!chips.length) return { name, offenders: [], skipped: "no bets on the felt" }; const overlap = (A, B) => Math.min(A.right, B.right) - Math.max(A.left, B.left) > 0.5 && Math.min(A.bottom, B.bottom) - Math.max(A.top, B.top) > 0.5; const offenders = []; const seats = [...document.querySelectorAll(".seat")]; chips.forEach(chip => { const label = chip.textContent.trim() || "a chip"; const box = chip.getBoundingClientRect(); seats.forEach((seat, index) => { // Its own puck is the one thing it is allowed to touch: on a narrow felt // the chip clings to the seat rather than taking a ring of its own. if (seat.contains(chip)) return; if (overlap(box, seat.getBoundingClientRect())) { offenders.push(`chip ${label} is behind seat ${index + 1}`); } }); // A chip wider than the puck it is clinging to is on its neighbours by // construction, whatever the ring does. const host = chip.closest(".seat"); if (host && chip.classList.contains("betChipAnchored")) { const puck = host.getBoundingClientRect(); if (box.width > puck.width + 0.5) { offenders.push(`chip ${label} is ${Math.round(box.width - puck.width)}px ` + `wider than its own puck`); } } }); for (let a = 0; a < chips.length; a++) { for (let b = a + 1; b < chips.length; b++) { if (overlap(chips[a].getBoundingClientRect(), chips[b].getBoundingClientRect())) { offenders.push(`chip ${chips[a].textContent.trim()} and chip ` + `${chips[b].textContent.trim()} are on top of each other`); } } } return { name, offenders: [...new Set(offenders)] }; }, // ---- Runner ---- // Every check this file knows how to make, every time. // // Two of them used not to be here. Contrast was simply missing from the list, and // touch targets sat behind an opt-in flag nobody passed — so "the audit is clean" // has meant nine checks rather than eleven, and the two left out are the two whose // faults a dealer meets in a dim room with their thumb. Both were written, tested // and then never run: the contrast checker was built across two rounds and every // sweep since has been silent about colour. // // A check that decides it can't apply here says so out loud rather than reporting // an empty offenders list, which reads as a pass. touchTargets stands itself down // on a mouse-driven browser — that is right, a 24px control is fine with a cursor // on it — but "not measured" and "measured and fine" are different answers and the // runner now prints which one it got. // The table has to be big enough for the game around it. // // The seat ring sat at a flat 44% by 42% of the seat layer at every size — two // numbers picked against a phone. A fixed pair can only be right for one ratio of // puck to felt, and measured on the running page it was wrong nearly everywhere: // at 1280x900 nine-handed, five of nine pucks finished past the outer edge of the // table, the worst by 49px; on a phone held sideways all six did. Seats floating // off the felt onto the page read as the table being too small for the game rather // than as players at a rail — and it was a different amount of wrong on every // screen, which is what made it look like a mistake instead of a choice. // // Checked against the table's outer edge, border included: sitting *on* the rail is // fine, hanging off the far side of it is not. The bet chips are held to the same // rule — a chip is meant to be on the felt between its player and the pot. seatsOnTheTable(slackPx = 1) { const name = "seats and chips stay on the table"; if (this.modalScope()) { return { name, offenders: [], skipped: "a sheet is open over the table" }; } const table = document.getElementById("pokerTable"); if (!table) return { name, offenders: [], skipped: "no table on this page" }; const felt = table.getBoundingClientRect(); if (!felt.width || !felt.height) return { name, offenders: [], skipped: "table not laid out" }; const offenders = []; const check = (el, what) => { const r = el.getBoundingClientRect(); if (!r.width || !r.height) return; const over = Math.max( felt.left - r.left, r.right - felt.right, felt.top - r.top, r.bottom - felt.bottom); if (over > slackPx) offenders.push(`${what} hangs ${Math.round(over)}px past the rail`); }; [...document.querySelectorAll(".seat")].forEach((seat, i) => check(seat, `seat ${i + 1}`)); [...document.querySelectorAll("#betsLayer > *")].forEach((chip, i) => check(chip, `bet chip ${i + 1}`)); return { name, offenders }; }, // An open dialog's buttons have to be on screen without hunting for them. // // The pot wizard — the one dialog in this tool that divides money — had its Confirm // below a scroll at every size: measured with six players and a side pot, 244px off // the bottom on an iPhone 14, 417 on a Galaxy S8, 583 on a phone held sideways, 172 // on a desktop. Nothing on screen said there was anything below the list of names, // so the only control that finishes the hand was invisible with the table waiting. // // Checked by hit-testing rather than by geometry alone: a button can be inside the // viewport and still be under something. Only dialogs that are actually open are // considered, and only their buttons — a dialog with no buttons has nothing to find. dialogActionsReachable() { const name = "an open dialog's buttons are on screen"; const dialogs = [...document.querySelectorAll("[role='dialog'], [role='alertdialog']")] .filter(d => { const rect = d.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 && getComputedStyle(d).visibility !== "hidden"; }); if (dialogs.length === 0) return { name, offenders: [], skipped: "no dialog is open" }; const offenders = []; dialogs.forEach(dialog => { const buttons = [...dialog.querySelectorAll("button")].filter(b => { const r = b.getBoundingClientRect(); return r.width > 0 && r.height > 0; }); buttons.forEach(button => { const r = button.getBoundingClientRect(); const label = this.describe(button); if (r.bottom > innerHeight || r.top < 0) { const off = r.bottom > innerHeight ? Math.round(r.bottom - innerHeight) + "px below the fold" : Math.round(-r.top) + "px above it"; offenders.push(`${label} is ${off}`); return; } const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2); if (hit && hit !== button && !button.contains(hit)) { offenders.push(`${label} is covered by ${this.describe(hit)}`); } }); }); return { name, offenders }; }, runAll(options = {}) { const checks = [ this.seatsOnTheTable(options.seatSlack), this.dialogActionsReachable(), this.controlNames(), this.focusHasRoom(), this.focusablesReachable(), this.furnitureBudget(), 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.emptyDecoration(), this.oneNextStep(), this.glancesFitTheirRows(), this.seatLabelsClear(), this.seatsKeepTheirText(), this.chipsKeepTheirAmounts(), this.contrast(options.contrast), this.touchTargets(options.minimumTarget, options.touchTargets), ]; return { viewport: `${innerWidth}x${innerHeight}`, failed: checks.filter(c => c.offenders.length > 0), // Things worth knowing that aren't faults — see occlusion() on toasts. // Controls a check couldn't probe go here too: not a fault, but not a pass // either, and a silent skip is how faults hide. warnings: checks.flatMap(c => [ ...(c.warnings || []), ...(c.unmeasured || []), ...(c.skipped ? [`${c.name}: not run here — ${c.skipped}`] : []), ]), passed: checks.filter(c => !c.skipped && c.offenders.length === 0).map(c => c.name), skipped: checks.filter(c => c.skipped).map(c => c.name), clean: checks.every(c => c.offenders.length === 0), }; }, }; if (typeof module !== "undefined" && module.exports) { module.exports = LayoutAudit; }