Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d66abda05a | ||
|
|
6fa4532781 |
@@ -25,7 +25,7 @@
|
||||
<section id="tableSection">
|
||||
<div id="pokerTableWrap">
|
||||
<div id="pokerTable">
|
||||
<div id="tableWatermark">Texas Hold'em</div>
|
||||
<div id="tableWatermark" aria-hidden="true">Texas Hold'em</div>
|
||||
<div id="potBadge">
|
||||
<span id="potBadgeLabel">Pot</span>
|
||||
<span id="potBadgeAmount">$0</span>
|
||||
|
||||
@@ -582,6 +582,23 @@ button,
|
||||
z-index: 2;
|
||||
opacity: 0.32;
|
||||
transition: opacity 0.15s ease;
|
||||
/* A dealer puck should look like a puck — small, sitting on the rim of its seat.
|
||||
But tapping it hands the button to that seat and re-posts the blinds, and 22px
|
||||
is half the 44px a fingertip needs. The hit area grows without the chip doing
|
||||
so: the visual stays 22px and an invisible pad around it catches the tap.
|
||||
Deliberately not centred on the chip — it's pushed down and left, away from the
|
||||
neighbouring seat the chip juts towards, so the bigger target can't start
|
||||
stealing taps meant for the seat next door. */
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.dealerChip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
right: -7px;
|
||||
bottom: -15px;
|
||||
left: -14px;
|
||||
}
|
||||
|
||||
.dealerChip:focus-visible {
|
||||
@@ -621,16 +638,25 @@ button,
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Both badges failed WCAG AA against their own gradients — measured at the worst
|
||||
stop, the small blind was 3.52:1 and the big blind 2.15:1, in 9px bold text a
|
||||
dealer is meant to read at a glance across a table.
|
||||
|
||||
The small blind keeps its blue and just darkens (4.77:1 at its worst stop). The
|
||||
big blind flips to dark text on brighter gold (5.84:1), which is how a gold chip
|
||||
is marked anyway and has the side benefit of making the two badges far easier to
|
||||
tell apart at speed — light-on-blue against dark-on-gold, rather than two pale
|
||||
labels differing only in hue. */
|
||||
.sbBadge {
|
||||
left: -6px;
|
||||
background: linear-gradient(180deg, #3f82d1, #2b5fa0);
|
||||
background: linear-gradient(180deg, #2f6cb5, #22508c);
|
||||
color: #eaf3ff;
|
||||
}
|
||||
|
||||
.bbBadge {
|
||||
left: 22px;
|
||||
background: linear-gradient(180deg, #dba13f, #b7791f);
|
||||
color: #fff7e6;
|
||||
background: linear-gradient(180deg, #e6ad4d, #c98a2a);
|
||||
color: #241a05;
|
||||
}
|
||||
|
||||
/* A blind the dealer named by hand rather than one the button handed out. Without
|
||||
@@ -2146,6 +2172,10 @@ body.hasActionBar #toastHost {
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
/* Same treatment as the dealer puck: a 30px dismiss button in a toast a dealer is
|
||||
swatting away mid-hand, given a fingertip's worth of hit area without becoming
|
||||
a bigger piece of furniture. */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
@@ -2863,3 +2893,9 @@ textarea:focus-visible {
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.toastClose::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -7px;
|
||||
}
|
||||
|
||||
@@ -516,6 +516,15 @@ button,
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* This footnote names a control the dealer is being told to reach for, so the name
|
||||
itself has to be readable — it was 3.39:1 at its worst, inheriting the muted grey
|
||||
the surrounding sentence is deliberately set in. Emphasised text carrying the
|
||||
instruction gets its own colour rather than the aside's. */
|
||||
.splitFootnote strong {
|
||||
color: #a8a8a8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.splitFootnote a {
|
||||
color: #a9782f;
|
||||
}
|
||||
|
||||
+15
-1
@@ -31,7 +31,7 @@ in a browser: whether a dealer can actually reach every control.
|
||||
node tests/holdem/runUiAudit.js # needs Playwright and the site running
|
||||
```
|
||||
|
||||
It checks touch-action and tap highlights, that fields stay selectable and are large
|
||||
It checks WCAG AA contrast, touch target sizes, touch-action and tap highlights, that fields stay selectable and are large
|
||||
enough not to trigger iOS zoom, that nothing important is covered or off-screen with
|
||||
no way to scroll to it, that no grid column has collapsed to nothing, and that no
|
||||
text is painted outside its box. It runs over the table, all five overlays and Quick
|
||||
@@ -43,6 +43,20 @@ this one needs a browser and a running site. Run it after any change that adds o
|
||||
resizes a control. With no Playwright to hand, paste `uiAudit.js` into devtools and
|
||||
call `LayoutAudit.runAll()`.
|
||||
|
||||
The contrast check is built so it cannot report a false positive: backgrounds
|
||||
composite through alpha, and where a gradient is involved every colour stop is tried
|
||||
and the most flattering one used. A finding means the text fails even under the
|
||||
reading most generous to it. Two earlier attempts at this got it wrong in both
|
||||
directions — one read only `background-color` and scored text on a gradient against
|
||||
nothing, the other treated low-alpha stops as opaque — so `uiAudit.js` is checked
|
||||
against known-answer cases before it is believed.
|
||||
|
||||
Touch targets are measured by probing outward with `elementFromPoint` rather than by
|
||||
reading the element box, because the box lies in both directions: a small control can
|
||||
be padded out with a transparent `::after` and respond well beyond its painted size,
|
||||
and a checkbox inside a label is hit by tapping the label. The 44px rule is only
|
||||
enforced where `hover: none` says there's no mouse.
|
||||
|
||||
It exists because these checks were being re-typed into a console every time
|
||||
something changed, and each time they only covered whatever had just been touched.
|
||||
That is how the standings table shipped with a player-name column zero pixels wide
|
||||
|
||||
+229
-8
@@ -231,18 +231,239 @@ const LayoutAudit = {
|
||||
return { name: "no field small enough to trigger iOS zoom", offenders };
|
||||
},
|
||||
|
||||
// ---- Contrast ----
|
||||
//
|
||||
// This check has been got wrong twice before, both times by being too clever about
|
||||
// backgrounds. The first version read only `background-color`, so every sheet
|
||||
// painted with a gradient was measured against transparency and the dealer puck's
|
||||
// dark "D" was reported at 1.2:1 against nothing. The second over-corrected and
|
||||
// treated low-alpha gradient stops as opaque, scoring white-on-dark at 1.0:1.
|
||||
//
|
||||
// So it is built to be incapable of false positives instead of trying to be exact.
|
||||
// Backgrounds composite properly through alpha, and where a gradient is involved
|
||||
// every one of its colour stops is tried and the *most flattering* one is used. A
|
||||
// finding therefore means the text fails even under the reading most generous to
|
||||
// it, which is a claim worth making. Text that can't be resolved to any opaque
|
||||
// background is reported separately as unmeasured rather than guessed at.
|
||||
|
||||
parseColour(value) {
|
||||
const match = String(value).match(/rgba?\(([^)]+)\)/);
|
||||
if (!match) return null;
|
||||
const parts = match[1].split(/[,\s/]+/).filter(Boolean).map(Number);
|
||||
if (parts.length < 3 || parts.some(Number.isNaN)) return null;
|
||||
return { r: parts[0], g: parts[1], b: parts[2], a: parts.length > 3 ? parts[3] : 1 };
|
||||
},
|
||||
|
||||
// Colour stops out of any gradient in a background-image.
|
||||
gradientStops(backgroundImage) {
|
||||
if (!backgroundImage || backgroundImage === "none") return [];
|
||||
return (backgroundImage.match(/rgba?\([^)]+\)/g) || [])
|
||||
.map(c => this.parseColour(c))
|
||||
.filter(Boolean);
|
||||
},
|
||||
|
||||
over(top, bottom) {
|
||||
const a = top.a;
|
||||
return {
|
||||
r: top.r * a + bottom.r * (1 - a),
|
||||
g: top.g * a + bottom.g * (1 - a),
|
||||
b: top.b * a + bottom.b * (1 - a),
|
||||
a: 1,
|
||||
};
|
||||
},
|
||||
|
||||
relativeLuminance({ r, g, b }) {
|
||||
const channel = v => {
|
||||
const s = v / 255;
|
||||
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||||
},
|
||||
|
||||
contrastRatio(fg, bg) {
|
||||
const a = this.relativeLuminance(fg);
|
||||
const b = this.relativeLuminance(bg);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
},
|
||||
|
||||
// Every background this text could actually be sitting on.
|
||||
//
|
||||
// Layers are collected from the element outwards and stop at the first fully
|
||||
// opaque one, because nothing behind that is visible. Getting this wrong in the
|
||||
// lenient direction is just as bad as the old false positives: an earlier version
|
||||
// kept walking past an opaque gradient and offered the page background as a
|
||||
// candidate, which scored white-on-a-mid-grey-panel as though it were
|
||||
// white-on-near-black and would have waved real problems through.
|
||||
//
|
||||
// A gradient contributes one candidate per colour stop; translucent layers above
|
||||
// the opaque one are composited onto each candidate in order.
|
||||
backgroundCandidates(el) {
|
||||
const translucent = []; // top-down, above the opaque layer
|
||||
let base = null; // candidate colours for the first opaque layer
|
||||
|
||||
for (let node = el; node && !base; node = node.parentElement) {
|
||||
const style = getComputedStyle(node);
|
||||
const stops = this.gradientStops(style.backgroundImage);
|
||||
const colour = this.parseColour(style.backgroundColor);
|
||||
|
||||
// A gradient whose stops are all opaque hides everything behind it.
|
||||
if (stops.length > 0 && stops.every(s => s.a === 1)) {
|
||||
base = stops;
|
||||
break;
|
||||
}
|
||||
stops.forEach(s => { if (s.a > 0) translucent.push(s); });
|
||||
|
||||
if (colour && colour.a === 1) { base = [colour]; break; }
|
||||
if (colour && colour.a > 0) translucent.push(colour);
|
||||
}
|
||||
|
||||
if (!base) {
|
||||
// Nothing opaque anywhere up the tree — fall back to the page's own colour
|
||||
// so a finding is still measured against something real.
|
||||
const page = this.parseColour(getComputedStyle(document.documentElement).backgroundColor);
|
||||
base = [page && page.a === 1 ? page : { r: 10, g: 10, b: 10, a: 1 }];
|
||||
}
|
||||
|
||||
// Fold the translucent layers back on, innermost last so it lands on top.
|
||||
return base.map(candidate => {
|
||||
let composed = candidate;
|
||||
for (let i = translucent.length - 1; i >= 0; i--) composed = this.over(translucent[i], composed);
|
||||
return composed;
|
||||
});
|
||||
},
|
||||
|
||||
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
|
||||
const offenders = [];
|
||||
const unmeasured = [];
|
||||
|
||||
const nodes = selector
|
||||
? [...document.querySelectorAll(selector)]
|
||||
: [...document.querySelectorAll("*")];
|
||||
|
||||
nodes.forEach(el => {
|
||||
if (el.children.length > 0) return; // leaf text only
|
||||
const text = (el.textContent || "").trim();
|
||||
if (!text) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return;
|
||||
const style = getComputedStyle(el);
|
||||
if (style.visibility === "hidden" || style.display === "none") return;
|
||||
if (parseFloat(style.opacity) < 0.99) return; // faded on purpose
|
||||
// Decoration is exempt by definition: the watermark on the felt is meant
|
||||
// to be barely there, and holding it to a reading threshold would mean
|
||||
// making it not a watermark. Marked aria-hidden so screen readers skip it
|
||||
// too, which is the same claim expressed where it also does some good.
|
||||
if (el.closest("[aria-hidden='true']")) return;
|
||||
|
||||
const fgRaw = this.parseColour(style.color);
|
||||
if (!fgRaw) return;
|
||||
|
||||
const candidates = this.backgroundCandidates(el);
|
||||
if (candidates.length === 0) {
|
||||
unmeasured.push(`${this.describe(el)} :: no opaque background found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// The kindest reading available: best contrast across every candidate.
|
||||
let best = 0;
|
||||
candidates.forEach(bg => {
|
||||
const fg = fgRaw.a === 1 ? fgRaw : this.over(fgRaw, bg);
|
||||
best = Math.max(best, this.contrastRatio(fg, bg));
|
||||
});
|
||||
|
||||
const sizePx = parseFloat(style.fontSize);
|
||||
const weight = parseInt(style.fontWeight, 10) || 400;
|
||||
const isLarge = sizePx >= 24 || (sizePx >= 18.66 && weight >= 700);
|
||||
const required = isLarge ? largeMinimum : minimum;
|
||||
|
||||
if (best + 0.05 < required) {
|
||||
offenders.push(`${this.describe(el)} :: ${best.toFixed(2)}:1 (needs ${required}:1) at ${Math.round(sizePx)}px`);
|
||||
}
|
||||
});
|
||||
|
||||
return { name: "text meets WCAG AA contrast", offenders, unmeasured };
|
||||
},
|
||||
|
||||
// ---- Reach (touch target sizes) ----
|
||||
|
||||
touchTargets(minimum = 44) {
|
||||
const offenders = this.visibleControls()
|
||||
// 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 => {
|
||||
.filter(el => !(exempt && el.matches(exempt)))
|
||||
.forEach(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.height < minimum - 0.5 || rect.width < minimum - 0.5;
|
||||
})
|
||||
.map(el => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
return `${this.describe(el)} :: ${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
||||
// 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 };
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user