Last change made a failing save say so. This is the other direction, and it turned out to be worse. load() is well defended -- try/catch, a shape check, sensible fallbacks -- and every one of those paths ends in `return this.createDefault()`. So an unreadable save becomes an empty table. Driven with a save truncated to 70% of its length, the way an interrupted write leaves one: 1762 bytes stored, 1233 after truncating loaded: 0 named seats, hand #1 instead of #34 toast: none. warning: none. anything on the page: no. A dealer mid-session sees an empty felt and cannot tell whether they mis-tapped Reset, whether the browser lost the night, or what. And then the first edit destroys the evidence. I typed a name into a seat and storage went from the 1233 unreadable bytes to 2549 bytes of fresh default state. Whatever a person could have picked out of that JSON -- the names, the buy-ins, the standings -- was gone one tap after the tool decided it could not read it. Now the unreadable value is moved aside to its own key before the fallback, and the table says what happened: toast: "The saved table couldn't be read, so this one started empty." panel: "The saved table couldn't be read, so this one started empty. The unreadable copy has been kept rather than written over." It is deliberately not offered as a restore. Half a JSON document is not a table, and pretending otherwise would be worse than an empty one. It just stops being destroyed. The two warnings share the Session panel line and both can show at once -- a browser having trouble with storage tends to have it in both directions -- with the load message first, because "why is my table empty" is the question the dealer is standing there asking. 8 tests (263 total): nothing stored is a new table rather than a failure, a good save loads clean, a truncated one is reported, valid JSON of the wrong shape counts too, the copy is kept, a later save does not touch it, the listener fires once across three reads, and storage with no room to keep the copy is not a crash -- which is exactly the storage most likely to hand back half a value in the first place. Driven through the UI: warning and toast on a corrupted save, the set-aside copy still 1233 bytes intact after editing a seat, and nothing shown at all on a healthy table. One audit correction rides along. The collapsed-column check measured inside hidden elements, so the standings header -- hidden with its table until somebody buys in -- was reported as three collapsed columns on every empty table. It skips hidden subtrees now, and still catches a real one: squeezing a visible column reports the header and all three rows. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
589 lines
29 KiB
JavaScript
589 lines
29 KiB
JavaScript
// Layout and touch audits for the dealer, written once so they stop being written
|
|
// again every round.
|
|
//
|
|
// These checks have been re-typed into a browser console at least six times over
|
|
// the course of building this thing, and each time they only covered whatever had
|
|
// just been changed. That is exactly how the standings table shipped with a
|
|
// zero-pixel player-name column and survived two rounds: the audit that would have
|
|
// caught it existed only in a previous session's scrollback.
|
|
//
|
|
// This file is deliberately dependency-free and browser-side. It is injected into
|
|
// the page (see runUiAudit.js) rather than imported, so it needs no build step, no
|
|
// package.json, and no browser automation of its own — anyone can also paste it
|
|
// into devtools and call LayoutAudit.runAll().
|
|
const LayoutAudit = {
|
|
// Everything a dealer has to be able to hit.
|
|
CONTROL_SELECTOR: "button, a, input, select, textarea, [role='button'], .seat",
|
|
|
|
describe(el) {
|
|
if (!el) return "(nothing)";
|
|
const id = el.id ? `#${el.id}` : "";
|
|
const cls = (el.className || "").toString().trim().split(/\s+/).filter(Boolean).slice(0, 2).join(".");
|
|
const text = (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 24);
|
|
return `${el.tagName.toLowerCase()}${id}${cls ? "." + cls : ""}${text ? ` "${text}"` : ""}`;
|
|
},
|
|
|
|
visibleControls() {
|
|
return [...document.querySelectorAll(this.CONTROL_SELECTOR)].filter(el => {
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) return false;
|
|
const style = getComputedStyle(el);
|
|
return style.visibility !== "hidden" && style.display !== "none";
|
|
});
|
|
},
|
|
|
|
// ---- Touch (see the mobile round) ----
|
|
|
|
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
|
|
// deciding whether it was a double-tap-to-zoom. This tool uses double-tap on a
|
|
// seat as its fold shortcut, so that wait is on the critical path.
|
|
touchAction() {
|
|
const offenders = this.visibleControls()
|
|
.filter(el => getComputedStyle(el).touchAction === "auto")
|
|
.map(el => this.describe(el));
|
|
return { name: "touch-action", offenders, checked: this.visibleControls().length };
|
|
},
|
|
|
|
tapHighlight() {
|
|
const offenders = this.visibleControls()
|
|
.filter(el => {
|
|
const colour = getComputedStyle(el).webkitTapHighlightColor;
|
|
return colour && !/rgba\(0,\s*0,\s*0,\s*0\)|transparent/.test(colour);
|
|
})
|
|
.map(el => this.describe(el));
|
|
return { name: "tap-highlight", offenders, checked: this.visibleControls().length };
|
|
},
|
|
|
|
// Text selection must stay ON for anything a dealer types into.
|
|
textSelectable() {
|
|
const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")]
|
|
.filter(el => el.offsetParent !== null)
|
|
.filter(el => {
|
|
const style = getComputedStyle(el);
|
|
return (style.webkitUserSelect || style.userSelect) === "none";
|
|
})
|
|
.map(el => this.describe(el));
|
|
return { name: "fields stay selectable", offenders };
|
|
},
|
|
|
|
// ---- Reachability (see the occlusion round) ----
|
|
|
|
// A control covered by something else is unreachable however good it looks.
|
|
// Sampling several points matters: a button you can only hit in one corner is
|
|
// covered for practical purposes.
|
|
// Can this element be brought into view by scrolling? Something below the fold on
|
|
// a page that scrolls is perfectly reachable; something below the fold in a panel
|
|
// that *doesn't* scroll is lost. The first version of this check didn't make the
|
|
// distinction and reported every link under the fold as a fault.
|
|
reachableByScrolling(el) {
|
|
const rect = el.getBoundingClientRect();
|
|
if (getComputedStyle(el).position === "fixed") return false;
|
|
|
|
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
const style = getComputedStyle(node);
|
|
const scrolls = /auto|scroll|overlay/.test(style.overflowY) || node === document.body;
|
|
if (!scrolls) continue;
|
|
const box = node === document.body ? { top: 0, bottom: innerHeight } : node.getBoundingClientRect();
|
|
const room = node === document.body
|
|
? document.documentElement.scrollHeight - innerHeight - scrollY
|
|
: node.scrollHeight - node.clientHeight - node.scrollTop;
|
|
if (rect.top >= box.bottom && room > 0) return true;
|
|
if (rect.bottom <= box.top && (node === document.body ? scrollY > 0 : node.scrollTop > 0)) return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
|
|
const offenders = [];
|
|
const warnings = [];
|
|
[...document.querySelectorAll(selector)].forEach(el => {
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.width === 0 || rect.height === 0) return;
|
|
|
|
const outsideViewport = rect.bottom <= 0 || rect.top >= innerHeight ||
|
|
rect.right <= 0 || rect.left >= innerWidth;
|
|
if (outsideViewport) {
|
|
if (!this.reachableByScrolling(el)) {
|
|
offenders.push(`${this.describe(el)} :: off-screen and cannot be scrolled to`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const points = [
|
|
[rect.left + rect.width / 2, rect.top + rect.height / 2],
|
|
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.3],
|
|
[rect.left + rect.width * 0.75, rect.top + rect.height * 0.3],
|
|
[rect.left + rect.width * 0.25, rect.top + rect.height * 0.7],
|
|
[rect.left + rect.width * 0.75, rect.top + rect.height * 0.7],
|
|
].filter(([x, y]) => x >= 0 && y >= 0 && x < innerWidth && y < innerHeight);
|
|
if (points.length === 0) return;
|
|
|
|
let blocked = 0;
|
|
let testable = 0;
|
|
let blockedBy = null;
|
|
points.forEach(([x, y]) => {
|
|
const top = document.elementFromPoint(x, y);
|
|
// A null result means the point isn't hit-testable — typically it sits
|
|
// inside the element but outside a scrollable ancestor's clip, i.e. the
|
|
// control is scrolled out of view rather than covered by anything.
|
|
// Counting that as "covered by null" was reporting a perfectly
|
|
// reachable button in a sheet that scrolls.
|
|
if (!top) return;
|
|
testable++;
|
|
if (el.contains(top) || top === el || top.contains(el)) return;
|
|
// Seats ring a round table and overlap each other at the rim by
|
|
// design; that is crowding, not an overlay hiding a control.
|
|
//
|
|
// 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;
|
|
});
|
|
},
|
|
|
|
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 };
|
|
},
|
|
|
|
// 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 };
|
|
},
|
|
|
|
// ---- 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(),
|
|
this.oneDealerButton(),
|
|
];
|
|
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;
|
|
}
|