Hold'em dealer: making the table and the bar fit the screen they are on #119
@@ -86,6 +86,54 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
() => HoldemState.removeFromWaitingList(state, id));
|
||||
},
|
||||
onGiveButton: index => seatMenuHooks.makeDealer(index),
|
||||
|
||||
// Removing a chip colour, which is only sometimes a small thing.
|
||||
//
|
||||
// A colour nobody has counted is a row in a legend, and taking it out should
|
||||
// cost a click and nothing else. A colour seats are holding is money: the
|
||||
// legend is what prices their chips, so the row leaving takes their chips off
|
||||
// the table with it — and it cannot be put back, because a colour added again
|
||||
// is a new one and the counts belong to the old.
|
||||
//
|
||||
// So the friction is on the second case only. A confirmation that fires every
|
||||
// time is one nobody reads by the third night, and the one that matters here
|
||||
// would go the same way as the four before it that didn't.
|
||||
removeChipColor: async id => {
|
||||
const usage = HoldemState.chipColorUsage(state, id);
|
||||
if (!usage.chip) return;
|
||||
const name = Number(usage.chip.value) > 0
|
||||
? `${Money.format(usage.chip.value)} chip`
|
||||
: "chip colour";
|
||||
|
||||
if (usage.total !== 0) {
|
||||
const seatCount = `${usage.seats.length} ${usage.seats.length === 1 ? "seat" : "seats"}`;
|
||||
const held = usage.seats.map(seat => `${seat.name} ${seat.count}`).join(", ");
|
||||
const ok = await Dialog.confirm({
|
||||
// Both directions are named rather than only the loss. Going up is
|
||||
// the odd one — seats falling back to the table's standing ante once
|
||||
// their chips are gone — and a dealer who is told the total will move
|
||||
// and then finds it moved the other way has been told something false.
|
||||
title: usage.total > 0
|
||||
? `Remove the ${name} and ${Money.format(usage.total)} with it?`
|
||||
: `Remove the ${name} and put the table up ${Money.format(-usage.total)}?`,
|
||||
message:
|
||||
`${usage.chips} of them are counted onto ${seatCount} — ${held} — and the table ` +
|
||||
`${usage.total > 0 ? "loses" : "gains"} ${Money.format(Math.abs(usage.total))} ` +
|
||||
`when the colour goes. Undo will put it back; adding the colour again will not.`,
|
||||
confirmLabel: "Remove it",
|
||||
danger: usage.total > 0,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
runUndoable(
|
||||
usage.total > 0
|
||||
? `${name} removed — ${Money.format(usage.total)} off the table.`
|
||||
: usage.total < 0
|
||||
? `${name} removed — table up ${Money.format(-usage.total)}.`
|
||||
: `${name} removed.`,
|
||||
() => HoldemState.removeChipColor(state, id));
|
||||
},
|
||||
};
|
||||
|
||||
const seatMenuHooks = {
|
||||
|
||||
@@ -2006,25 +2006,38 @@ const Render = {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
const colorInput = row.querySelector(".chipColorInput");
|
||||
const valueInput = row.querySelector(".chipValueInput");
|
||||
const removeBtn = row.querySelector(".removeChipColorBtn");
|
||||
colorInput.value = chip.color;
|
||||
valueInput.value = chip.value;
|
||||
|
||||
// "×" is the whole of this button, so without a name it is announced as a
|
||||
// button and nothing else — and there are five of them in a column, all
|
||||
// identical, each doing something different and none of them small. The
|
||||
// value is what tells them apart, and it changes as it is typed, so the
|
||||
// name is kept up with it rather than set once.
|
||||
const nameRemove = () => {
|
||||
const worth = Number(valueInput.value) || 0;
|
||||
removeBtn.setAttribute("aria-label", worth > 0
|
||||
? `Remove the ${Money.format(worth)} chip`
|
||||
: "Remove this chip colour");
|
||||
};
|
||||
nameRemove();
|
||||
|
||||
colorInput.addEventListener("input", () => {
|
||||
chip.color = colorInput.value;
|
||||
this.hooks.save(this.state);
|
||||
});
|
||||
valueInput.addEventListener("input", () => {
|
||||
chip.value = Number(valueInput.value) || 0;
|
||||
nameRemove();
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeats();
|
||||
this.renderSeatEditor();
|
||||
this.updatePotBadge();
|
||||
});
|
||||
row.querySelector(".removeChipColorBtn").addEventListener("click", () => {
|
||||
this.state.chipLegend = this.state.chipLegend.filter(c => c.id !== chip.id);
|
||||
this.hooks.save(this.state);
|
||||
this.renderAll();
|
||||
});
|
||||
// Through the hook rather than done here, because what this costs depends on
|
||||
// what the seats are holding — see removeChipColor in main.js.
|
||||
removeBtn.addEventListener("click", () => this.hooks.removeChipColor(chip.id));
|
||||
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
@@ -1139,6 +1139,84 @@ const HoldemState = {
|
||||
return Math.max(0, Money.round(Betting.stackOf(state, player)));
|
||||
},
|
||||
|
||||
// What a chip colour is holding up.
|
||||
//
|
||||
// Removing one is not a change to a legend. computeContribution() prices a seat by
|
||||
// running the legend over that seat's chip counts, so a colour that leaves the
|
||||
// legend takes every chip counted in it off the table. Measured on a three-handed
|
||||
// table counting real chips: deleting the $500 row took $22,500 of $26,874 with it,
|
||||
// on one click of a 24px button, with nothing said and nothing shown.
|
||||
//
|
||||
// It does not come back, either. A colour added again is a new colour with a new
|
||||
// id, while the counts stay keyed to the one that left — so "put it back" restores
|
||||
// the row and none of the money.
|
||||
//
|
||||
// Hence this: the cost is answerable before the click rather than discovered at the
|
||||
// end of the night, when the box is short and nobody knows why.
|
||||
// The number here is the *difference*, asked by pricing every seat twice — once with
|
||||
// the colour and once without — rather than by multiplying the chips by their value.
|
||||
//
|
||||
// Those two are not the same, and the multiplication is the wrong one, because
|
||||
// computeContribution() does not always reach the chips. A seat with a typed amount
|
||||
// is priced from that and its chip counts are ignored, so a colour it holds is worth
|
||||
// nothing to it; and a seat whose chips all leave falls through to the table's base
|
||||
// pot — the standing ante — rather than to zero, so it loses less than its chips
|
||||
// were worth.
|
||||
// Both would put a number in the confirmation that the table then failed to match,
|
||||
// which is worse than no number at all — this is a warning whose whole job is to be
|
||||
// the truth.
|
||||
chipColorUsage(state, chipId) {
|
||||
const chip = (state.chipLegend || []).find(entry => entry.id === chipId);
|
||||
if (!chip) return { chip: null, total: 0, chips: 0, seats: [] };
|
||||
|
||||
const legend = state.chipLegend || [];
|
||||
const without = legend.filter(entry => entry.id !== chipId);
|
||||
|
||||
const seats = state.seats
|
||||
.map((player, index) => {
|
||||
const before = this.computeContribution(player, legend, state.basePotAmount);
|
||||
const after = this.computeContribution(player, without, state.basePotAmount);
|
||||
return {
|
||||
index,
|
||||
name: this.displayName(player, index),
|
||||
count: Number((player.chipCounts || {})[chipId]) || 0,
|
||||
amount: Money.round(before - after),
|
||||
};
|
||||
})
|
||||
// Signed, and not filtered to losses, because removing a colour can put the
|
||||
// table *up*. A seat whose chips are worth less than the table's standing
|
||||
// ante falls through to the ante once they are gone: found by the randomised
|
||||
// check at 14 chips of a $1 colour against a $200 base pot, where deleting it
|
||||
// moved the table by +186. Rare, but a warning that said "nothing changes"
|
||||
// there would be as wrong as one that named the wrong number.
|
||||
.filter(row => row.amount !== 0);
|
||||
|
||||
return {
|
||||
chip,
|
||||
seats,
|
||||
chips: seats.reduce((sum, row) => sum + row.count, 0),
|
||||
total: Money.round(seats.reduce((sum, row) => sum + row.amount, 0)),
|
||||
};
|
||||
},
|
||||
|
||||
// Take a colour out, and take its counts with it.
|
||||
//
|
||||
// Leaving them behind is what the old code did — the seats kept counts against an
|
||||
// id no legend entry answers to any more, so the file carried money that nothing
|
||||
// could price and nothing could show. Clearing them makes the saved table say what
|
||||
// the screen says. The usage is measured before anything moves, so a caller can
|
||||
// report what it cost.
|
||||
removeChipColor(state, chipId) {
|
||||
const usage = this.chipColorUsage(state, chipId);
|
||||
if (!usage.chip) return usage;
|
||||
|
||||
state.chipLegend = (state.chipLegend || []).filter(entry => entry.id !== chipId);
|
||||
state.seats.forEach(player => {
|
||||
if (player.chipCounts && chipId in player.chipCounts) delete player.chipCounts[chipId];
|
||||
});
|
||||
return usage;
|
||||
},
|
||||
|
||||
// Seats playing with chips the box has no cash against.
|
||||
//
|
||||
// Two kinds, and they are not the same problem. A seat holding the table's starting
|
||||
|
||||
@@ -26,6 +26,7 @@ other in the page, in the load order from `TexasHoldem.cshtml`.
|
||||
| `feltShape.test.js` | What shape the felt is at each phone size, resolved through the media queries in source order (see below) |
|
||||
| `potBreakdown.test.js` | How each pot says who can win it, and the nesting property that wording rests on (see below) |
|
||||
| `sidebarPanels.test.js` | What the sidebar's panels are called and what lives in each (see below) |
|
||||
| `chipLegend.test.js` | Removing a chip colour, and whether the warning's number is the one the table loses (see below) |
|
||||
| `invariants.test.js` | 1000 randomised hands per property (see below) |
|
||||
|
||||
## The layout audit
|
||||
@@ -245,6 +246,52 @@ The panels are split on the opening tag rather than parsed, which is sound only
|
||||
sidebar panel never contains another one; Table Setup's sub-panels carry a different
|
||||
class. That assumption is worth knowing about if one ever does.
|
||||
|
||||
## A warning is only worth having if its number is right
|
||||
|
||||
`chipLegend.test.js` covers removing a chip colour, which was one click of an unlabelled
|
||||
24px button and 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, with 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.
|
||||
|
||||
Most of the file is about the number in the warning rather than the removal, because a
|
||||
warning that overstates is worse than none. The obvious way to work it out — chips times
|
||||
value — is wrong in **both** directions:
|
||||
|
||||
- a seat with a typed amount is priced from that and its counts are never read, so a
|
||||
colour it holds is worth nothing to it;
|
||||
- a seat whose chips all leave falls through to the table's standing ante rather than to
|
||||
zero, so it loses less than its chips were worth.
|
||||
|
||||
So the figure is the difference between pricing every seat with the colour and without
|
||||
it, and a randomised check across 2,000 tables asserts that what the warning claims is
|
||||
exactly what the table then does. That check found a third case nobody would have written
|
||||
by hand: 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 friction is conditional on purpose. A colour nobody has counted deletes on one click;
|
||||
only one that is holding money asks. A confirmation that fires every time is one nobody
|
||||
reads by the third night, and this is a tool where four other confirmations already
|
||||
compete for that attention.
|
||||
|
||||
## Every control says what it is
|
||||
|
||||
`uiAudit.js` gained `controlNames()`, which is the general form of the bug above: the ×
|
||||
had no accessible name, so it was announced as a button and nothing else — five of them
|
||||
in a column, identical, each deleting a different colour.
|
||||
|
||||
It treats a symbol as no name even though `×` is technically text. The rule is that a
|
||||
name has to contain a letter or a digit, which is the difference between "×" and "Undo".
|
||||
Names are resolved the way the accessibility tree does it — `aria-label`, then
|
||||
`aria-labelledby`, then a field's wrapping or pointing `<label>`, then placeholder, then
|
||||
text content, then an image's `alt`, then `title` — and anything under `aria-hidden` is
|
||||
skipped rather than judged, since it is not in the tree to begin with.
|
||||
|
||||
Removing the one `aria-label` this round added makes it report all five buttons, which is
|
||||
how it was checked.
|
||||
|
||||
## Why the hand evaluator is tested the way it is
|
||||
|
||||
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Taking a chip colour out of the legend.
|
||||
//
|
||||
// This was one click of an unlabelled 24px button, and it was not 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, with 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
|
||||
// that nothing could price and nothing could show.
|
||||
//
|
||||
// What is tested here is mostly the number in the warning, because a warning that
|
||||
// overstates is worse than none: it is the one thing a dealer would have to trust at
|
||||
// the exact moment they are about to lose track of the box.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { HoldemState, Money } = require("./load");
|
||||
|
||||
const tableWith = ({ counts = [], overrides = [], basePot = null } = {}) => {
|
||||
const state = HoldemState.createDefault();
|
||||
HoldemState.resizeSeats(state, 9);
|
||||
state.basePotAmount = basePot;
|
||||
["Christopher", "Mike from work", "Alexandra"].forEach((name, i) => {
|
||||
state.seats[i].name = name;
|
||||
state.seats[i].inHand = true;
|
||||
state.seats[i].buyIns = 500;
|
||||
state.seats[i].chipCounts = {};
|
||||
});
|
||||
counts.forEach(([seat, chipIndex, n]) => {
|
||||
state.seats[seat].chipCounts[state.chipLegend[chipIndex].id] = n;
|
||||
});
|
||||
overrides.forEach(([seat, amount]) => { state.seats[seat].amountOverride = amount; });
|
||||
return state;
|
||||
};
|
||||
|
||||
const onTable = state => state.seats.reduce(
|
||||
(sum, p) => sum + HoldemState.computeContribution(p, state.chipLegend, state.basePotAmount), 0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what a chip colour is holding up", () => {
|
||||
test("is the money, named seat by seat", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10]] }); // the $25 chip
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[2].id);
|
||||
|
||||
assert.equal(usage.total, 350);
|
||||
assert.equal(usage.chips, 14);
|
||||
assert.deepEqual(usage.seats.map(s => [s.name, s.count, s.amount]), [
|
||||
["Christopher", 4, 100],
|
||||
["Mike from work", 10, 250],
|
||||
]);
|
||||
});
|
||||
|
||||
test("is nothing at all for a colour nobody counted", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[4].id);
|
||||
assert.equal(usage.total, 0);
|
||||
assert.deepEqual(usage.seats, []);
|
||||
assert.ok(usage.chip, "the colour itself is still found, so it can be named");
|
||||
});
|
||||
|
||||
test("and nothing for a colour that isn't there", () => {
|
||||
const state = tableWith();
|
||||
assert.deepEqual(HoldemState.chipColorUsage(state, "no-such-chip"),
|
||||
{ chip: null, total: 0, chips: 0, seats: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the number in the warning is the number the table loses", () => {
|
||||
// The whole point. Multiplying the chips by their value would have been the obvious
|
||||
// way to work this out and it is wrong in both directions — the two tests below are
|
||||
// the two ways it is wrong.
|
||||
const dropFromRemoving = (state, chipId) => {
|
||||
const before = onTable(state);
|
||||
HoldemState.removeChipColor(state, chipId);
|
||||
return Money.round(before - onTable(state));
|
||||
};
|
||||
|
||||
test("a seat with a typed amount ignores its chips, so its colour is worth nothing", () => {
|
||||
// computeContribution() reads amountOverride first and never reaches the counts.
|
||||
// Christopher is holding ten $25 chips on paper and priced at a flat $90.
|
||||
const state = tableWith({ counts: [[0, 2, 10]], overrides: [[0, 90]] });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
const usage = HoldemState.chipColorUsage(state, chipId);
|
||||
assert.equal(usage.total, 0, "$250 of chips, but the seat is not priced from them");
|
||||
assert.equal(dropFromRemoving(state, chipId), 0);
|
||||
});
|
||||
|
||||
test("a seat whose chips all leave falls back to the base pot, not to zero", () => {
|
||||
// The $25s are everything Christopher has counted, so taking them away drops him
|
||||
// to the table's standing ante — a loss of 50, not of 250.
|
||||
const state = tableWith({ counts: [[0, 2, 10]], basePot: 200 });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
const usage = HoldemState.chipColorUsage(state, chipId);
|
||||
assert.equal(usage.total, 50, "250 of chips, but only 50 of it actually leaves");
|
||||
assert.equal(dropFromRemoving(state, chipId), 50);
|
||||
});
|
||||
|
||||
test("and across a spread of tables the two always agree", () => {
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
const random = rng(20260906);
|
||||
let withMoney = 0;
|
||||
|
||||
for (let table = 0; table < 2000; table++) {
|
||||
const counts = [];
|
||||
for (let seat = 0; seat < 3; seat++) {
|
||||
for (let chip = 0; chip < 5; chip++) {
|
||||
if (random() < 0.5) counts.push([seat, chip, 1 + Math.floor(random() * 20)]);
|
||||
}
|
||||
}
|
||||
const overrides = [];
|
||||
for (let seat = 0; seat < 3; seat++) {
|
||||
if (random() < 0.25) overrides.push([seat, Math.floor(random() * 400)]);
|
||||
}
|
||||
const basePot = random() < 0.5 ? 200 : null;
|
||||
|
||||
const chipIndex = Math.floor(random() * 5);
|
||||
const stated = tableWith({ counts, overrides, basePot });
|
||||
const chipId = stated.chipLegend[chipIndex].id;
|
||||
const claimed = HoldemState.chipColorUsage(stated, chipId).total;
|
||||
|
||||
const actual = dropFromRemoving(tableWith({ counts, overrides, basePot }), chipId);
|
||||
if (claimed !== 0) withMoney++;
|
||||
assert.equal(claimed, actual,
|
||||
`table ${table}: warned ${claimed}, table moved ${actual}\n` +
|
||||
JSON.stringify({ counts, overrides, basePot, chipIndex }));
|
||||
}
|
||||
|
||||
// Guards the generator: shapes that never put money on a colour would make every
|
||||
// assertion above compare zero with zero.
|
||||
assert.ok(withMoney > 500, `only ${withMoney} of 2000 tables had money on the colour`);
|
||||
});
|
||||
|
||||
test("and it can be a gain, which is said as one", () => {
|
||||
// Fourteen $1 chips against a $200 standing ante: once the colour goes, the seat
|
||||
// stops being priced from its chips and falls through to the ante, so the table
|
||||
// goes *up* by 186. Found by the randomised check above, not by hand.
|
||||
const state = tableWith({ counts: [[1, 0, 14]], basePot: 200 });
|
||||
const usage = HoldemState.chipColorUsage(state, state.chipLegend[0].id);
|
||||
assert.equal(usage.total, -186);
|
||||
assert.equal(dropFromRemoving(state, state.chipLegend[0].id), -186);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removing it", () => {
|
||||
test("takes the counts with it, so nothing is left priced by a colour that is gone", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4], [1, 2, 10], [1, 0, 3]] });
|
||||
const chipId = state.chipLegend[2].id;
|
||||
|
||||
HoldemState.removeChipColor(state, chipId);
|
||||
|
||||
assert.equal(state.chipLegend.filter(c => c.id === chipId).length, 0);
|
||||
state.seats.forEach(player => assert.ok(!(chipId in (player.chipCounts || {})),
|
||||
"a count against a colour no legend entry answers to is money nothing can show"));
|
||||
// The other colour on that seat is untouched.
|
||||
assert.equal(state.seats[1].chipCounts[state.chipLegend[0].id], 3);
|
||||
});
|
||||
|
||||
test("reports what it cost, measured before anything moved", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const usage = HoldemState.removeChipColor(state, state.chipLegend[2].id);
|
||||
assert.equal(usage.total, 100);
|
||||
assert.equal(usage.seats.length, 1);
|
||||
});
|
||||
|
||||
test("and a colour that isn't there changes nothing", () => {
|
||||
const state = tableWith({ counts: [[0, 2, 4]] });
|
||||
const before = onTable(state);
|
||||
const usage = HoldemState.removeChipColor(state, "no-such-chip");
|
||||
assert.equal(usage.chip, null);
|
||||
assert.equal(state.chipLegend.length, 5);
|
||||
assert.equal(onTable(state), before);
|
||||
});
|
||||
});
|
||||
@@ -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