Light up the seat that's holding the game up #57

Merged
jheaps merged 9 commits from feature/casino-holdem-acting-seat into master 2026-09-01 09:31:52 -06:00
5 changed files with 184 additions and 5 deletions
Showing only changes of commit 6fa4532781 - Show all commits
@@ -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>
+12 -3
View File
@@ -621,16 +621,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
@@ -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;
}
+9 -1
View File
@@ -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-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,14 @@ 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.
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
+153
View File
@@ -231,6 +231,159 @@ 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) {