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:
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user