Stop a chip colour taking the table's money with it, quietly

Deleting a chip colour was one click of an unlabelled 24px button, and it
was never a change to a legend. computeContribution() prices a seat by
running the legend over that seat's chip counts, so a colour that leaves
takes every chip counted in it off the table.

Measured in a browser on a three-handed table: deleting the $500 row took
$22,500 of $26,874 with it. No confirmation, nothing said afterwards, and
no way back — a colour added again is a new colour with a new id, while
the counts stay keyed to the one that left. The counts were left behind
too, so the saved file carried money nothing could price and nothing
could show.

Now: a colour nobody has counted still goes on one click, and one that is
holding money asks first, naming the amount, the chips and the seats, and
saying that Undo will put it back while adding the colour again will not.
Conditional on purpose — a confirmation that fires every time is one
nobody reads by the third night, and four others already compete for that
attention here. Removal goes through runUndoable, and the counts go with
the colour so the file matches the screen.

The number in that warning is the difference between pricing every seat
with the colour and without it, not chips times value. The obvious
arithmetic is wrong both ways: a seat with a typed amount is never priced
from its counts, and a seat whose chips all leave falls through to the
table's standing ante rather than to zero. A randomised check over 2,000
tables asserts the warning equals what the table then does, and it found
a third case — fourteen $1 chips against a $200 ante, where removing the
colour puts the table UP $186. The wording says so in that direction too.

The × also had no accessible name, so uiAudit.js gained controlNames():
every control needs a name containing a letter or a digit, resolved the
way the accessibility tree resolves it. A symbol is not a name. Taking
the new aria-label off again makes it report all five buttons.

Audit unchanged at 2 failing configurations and 3 offenders, both
pre-existing. 805 unit tests pass.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-05 22:26:16 -06:00
co-authored by Claude Opus 5
parent 6eb97c8a35
commit cd2db6346e
6 changed files with 439 additions and 5 deletions
+65
View File
@@ -32,6 +32,70 @@ const LayoutAudit = {
});
},
// ---- 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 };
},
// ---- Touch (see the mobile round) ----
// Without touch-action: manipulation a mobile browser holds every tap for ~300ms
@@ -1080,6 +1144,7 @@ const LayoutAudit = {
const checks = [
this.seatsOnTheTable(options.seatSlack),
this.dialogActionsReachable(),
this.controlNames(),
this.touchAction(),
this.tapHighlight(),
this.textSelectable(),