Answer the press instead of eating it

The card picker greys out a card that is already dealt and hangs the reason
off a `title`. Measured on an iPhone with three hands out: seven of the
fifty-two were dead, none of them could take focus, and tapping one changed
nothing at all — no toast, no text, not a byte of DOM. The tool's answer to
"who has the ace of spades?" existed and was reachable only with a mouse.
The header's two buttons were the same, on a new dealer's first screen, and
Export CSV was greyed out with no explanation written anywhere.

`disabled` is the wrong attribute whenever there is something to say: it
drops the control out of the tab order, so a screen reader never lands on it
to hear the description, and it eats the tap on a touch screen, where the
hover that would have shown the `title` does not exist. `aria-disabled`
announces the same state and leaves the control reachable, so one capture
listener can turn the press into a sentence — in a toast, or inside a sheet
that is standing on top of the toast.

It paid for itself in the picker's keyboard too. The arrows hunted past
dealt cards because a `disabled` button cannot hold focus, and the hunting
did the exact thing the grid's no-wrap rule was written to prevent: Right
from A-spades over a dealt A-hearts landed on A-diamonds, so the column
shifted and the next press of Down walked a different suit. A cell that can
hold focus needs no hunting, and the APG asks for that inside a composite
widget anyway.

The line that answers is one line by measurement, not by taste. Its box is
318px on a Galaxy S8 at 20px of root text; the first wording drew 332px and
wrapped, shoving fifty-two targets 21px down mid-tap, and twenty-four capital
Ws draw 434px whatever the wording, so the box is what holds.

Two things the sweep found on the way. It opened the card picker by
un-hiding an overlay whose grid had never been painted, so for its whole
life it audited an empty sheet; it now opens it the way a dealer does, with
cards out and the answer showing. And the slot buttons above the grid were
named to a screen reader by the em dash they draw when empty.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-13 22:41:06 -06:00
co-authored by Claude Opus 5
parent 2d80edd513
commit c1d0105d12
12 changed files with 594 additions and 63 deletions
@@ -740,7 +740,10 @@
</div>
<div id="cardPickerOverlay" hidden>
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
@* `data-why-note` is this sheet claiming the job of saying why a card can't be
picked. The toast that answers everywhere else is fixed to the bottom of the
window, and this sheet stands on top of it. *@
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle" data-why-note="cardPickerNote">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="cardPickerHeader">
@* Not the first control any more. Modal used to land on whatever came
@@ -753,6 +756,10 @@
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<div id="cardPickerSlots"></div>
@* Empty until a dealt card is pressed, and holding its line the whole time so
that filling it doesn't shove fifty-two cards down the screen under a thumb
that is already reaching for one. *@
<p id="cardPickerNote" class="pickerNote" role="status"></p>
<div id="cardPickerGrid"></div>
</div>
</div>
+55 -8
View File
@@ -298,7 +298,7 @@ button,
landed — and with the grey highlight suppressed above, these have to carry it.
Everything presses the same way it would on a real table: down and slightly
darker, no travel time. */
.holdemBtn:active:not(:disabled),
.holdemBtn:active:not(:disabled):not([aria-disabled="true"]),
.linkBtn:active:not(:disabled),
.seatMenuBtn:active:not(:disabled),
.seatSheetClose:active:not(:disabled),
@@ -313,7 +313,7 @@ button,
}
.cardSlot:active,
.pickerCard:active:not(:disabled),
.pickerCard:active:not(:disabled):not([aria-disabled="true"]),
.dealerChip:active {
transform: translateY(1px) scale(0.97);
transition-duration: 0.04s;
@@ -350,14 +350,14 @@ button,
so a new one cannot be added without this being updated. */
@media (prefers-reduced-motion: reduce) {
.holdemBtn:hover,
.holdemBtn:active:not(:disabled),
.holdemBtn:active:not(:disabled):not([aria-disabled="true"]),
.linkBtn:active:not(:disabled),
.seatMenuBtn:active:not(:disabled),
.seatSheetClose:active:not(:disabled),
.cardSlot:hover,
.cardSlot:active,
.pickerCard:hover:not(:disabled),
.pickerCard:active:not(:disabled),
.pickerCard:hover:not(:disabled):not([aria-disabled="true"]),
.pickerCard:active:not(:disabled):not([aria-disabled="true"]),
.dealerChip:active,
.raiseQuickBtn:active:not(:disabled),
.actionBtn:active,
@@ -572,7 +572,13 @@ button,
font-size: var(--fs-sm);
}
.holdemBtn:disabled {
/* Dimmed is dimmed, however it was said.
A control that has a reason to give is marked `aria-disabled` rather than `disabled`
so the press can be answered — see Unavailable in feedback.js — and the two have to
be indistinguishable on the screen, or the same state would have two looks depending
on an implementation detail nobody can see. */
.holdemBtn:disabled,
.holdemBtn[aria-disabled="true"] {
opacity: 0.4;
cursor: not-allowed;
transform: none;
@@ -2636,11 +2642,26 @@ input[type="checkbox"] {
}
@media (hover: hover) {
.pickerCard:hover:not(:disabled) {
.pickerCard:hover:not(:disabled):not([aria-disabled="true"]) {
transform: translateY(-1px);
}
}
/* What the paint job owes back, again.
`disabled` is a state the OS palette knows: a disabled control is drawn in GrayText
without being asked. `aria-disabled` is a word in the accessibility tree and nothing
more, so in forced-colors mode a dealt card came out looking exactly like a live one
— the whole distinction the grid is built on, gone, on the display setting used by
the people most likely to need it. Said in the system keywords, which are the only
colours that survive. */
@media (forced-colors: active) {
.pickerCard[aria-disabled="true"],
.holdemBtn[aria-disabled="true"] {
color: GrayText;
border-color: GrayText;
}
}
.pickerCard.cardRed {
color: #c0392b;
}
@@ -2652,13 +2673,39 @@ input[type="checkbox"] {
/* Already dealt somewhere else at the table — visibly out of play rather than
silently accepting a duplicate. */
.pickerCard:disabled {
.pickerCard:disabled,
.pickerCard[aria-disabled="true"] {
background: #1d1d1d;
border-color: #303030;
color: var(--line-strong);
cursor: not-allowed;
}
/* Which card, and who has it. Empty most of the time and holding its line anyway: a
message that appears between the slots and the grid would otherwise push fifty-two
cards down the screen under a thumb already on its way to one of them.
One line, and not by choosing short words. Measured on a Galaxy S8 at 20px of root
text, which is the narrowest the sweep goes: the note's box is 318px, and the first
wording — "A♠ is already dealt to Wolfgang Von Brandenburg." at 24 characters of name,
the most a seat will take — drew 332px and wrapped, shoving the grid 21px down mid-tap.
Trimming it to "dealt to" bought 39px of margin, and then twenty-four capital Ws drew
434px and took it all back. A name is whatever somebody typed, so no phrasing is safe
and the box has to be the thing that holds: nowrap, and the tail is clipped. What gets
clipped is the end of a name; what is always readable is the card and where it went,
which is the half that answers the press. The whole sentence stays on the card's own
`title` for a mouse and for a screen reader. */
.pickerNote {
min-height: 1.35em;
margin: 0 0 var(--sp-2);
color: var(--gold-bright);
font-size: var(--fs-sm);
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pickerSuit {
font-size: 1.05em;
line-height: 1;
@@ -157,17 +157,21 @@ const CardPicker = {
return cards;
},
// Where the arrows can actually go. A card already dealt somewhere is a disabled
// button, and a disabled button cannot take focus, so stepping has to keep going in
// the same direction until it finds one that can — and stay put rather than wrap if
// it runs out of grid. Wrapping would put the dealer on a different rank without
// them asking to move rank.
// Where the arrows can go: one card, in the direction asked for, and nowhere if
// that runs off the end of the grid. Wrapping would put the dealer on a different
// rank without them asking to move rank.
//
// This used to hunt past dealt cards, because a `disabled` button cannot take focus
// — and hunting did the very thing the no-wrap rule was written to prevent. Right
// from A♠ with the ace of hearts dealt landed on A♦, so the column silently became
// the one to its right, and the next press of Down walked K♦ instead of K♥. A dealt
// card is `aria-disabled` now, so it holds focus like every other cell and the grid
// tells the truth about its own shape; the APG asks for exactly that inside a
// composite widget, and pressing one is answered rather than ignored.
nextCursor(from, step) {
const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")];
for (let i = from + step; i >= 0 && i < buttons.length; i += step) {
if (!buttons[i].disabled) return i;
}
return from;
const count = document.querySelectorAll("#cardPickerGrid .pickerCard").length;
const to = from + step;
return to >= 0 && to < count ? to : from;
},
// One Tab stop for fifty-two cards.
@@ -185,14 +189,11 @@ const CardPicker = {
aimCursor(index, { focus = false } = {}) {
const buttons = [...document.querySelectorAll("#cardPickerGrid .pickerCard")];
if (!buttons.length) return;
const live = buttons[index] && !buttons[index].disabled ? index : this.nextCursor(index, 1);
this.cursor = buttons[live] && !buttons[live].disabled ? live : this.nextCursor(index, -1);
this.cursor = Math.max(0, Math.min(buttons.length - 1, index));
buttons.forEach((btn, i) => {
btn.tabIndex = i === this.cursor && !btn.disabled ? 0 : -1;
btn.tabIndex = i === this.cursor ? 0 : -1;
});
if (focus && buttons[this.cursor] && !buttons[this.cursor].disabled) {
buttons[this.cursor].focus({ preventScroll: false });
}
if (focus) buttons[this.cursor].focus({ preventScroll: false });
},
onGridKey(event) {
@@ -226,6 +227,13 @@ const CardPicker = {
const used = this.cardsInUse();
const values = this.slotValues();
// Whatever the line was answering has been answered. Cleared here rather than
// on a timer, because a paint is exactly the set of things that make the last
// answer stale — a card went in, or the sheet moved to a different slot — and
// nothing else in this sheet redraws.
const note = document.getElementById("cardPickerNote");
if (note) note.textContent = "";
document.getElementById("cardPickerTitle").textContent = this.onBoard()
? `Board — ${this.boardSlotLabel(this.cardIndex).toLowerCase()}`
: `${HoldemState.displayName(this.state.seats[this.seatIndex], this.seatIndex)} — card ${this.cardIndex + 1}`;
@@ -240,7 +248,13 @@ const CardPicker = {
btn.classList.toggle("active", slot === this.cardIndex);
const value = values[slot] || "";
btn.textContent = value || "—";
if (this.onBoard()) btn.title = this.boardSlotLabel(slot);
// What it is called out loud. On the eye it is a card or an em dash, and an
// em dash is what a screen reader was being given as the whole name of the
// control: "dash, button", twice, above fifty-two more. The slot knows which
// slot it is and what is in it, so it says both.
const which = this.onBoard() ? this.boardSlotLabel(slot) : `Card ${slot + 1}`;
btn.setAttribute("aria-label", value ? `${which}: ${value}` : `${which}: empty`);
if (this.onBoard()) btn.title = which;
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
// The name this control answers to after a redraw. A slot is always the
// same slot — flop 2 stays flop 2 — so its number is the whole of it.
@@ -261,13 +275,19 @@ const CardPicker = {
btn.type = "button";
btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red);
btn.disabled = !!takenBy;
btn.dataset.focusKey = card;
if (takenBy) {
btn.title = takenBy === "the board"
? "Already face up on the board"
: `Already dealt to ${takenBy}`;
}
// Named in the sentence rather than left as "this one", because the sentence
// is read above the grid and the card that was pressed can be eight rows
// down it. Short by measurement, not by taste — see .pickerNote.
const label = `${rank}${suit.symbol}`;
Unavailable.set(btn, !!takenBy, takenBy === "the board"
? `${label} — face up on the board`
: `${label} — dealt to ${takenBy}`);
// Still the tooltip, and now also the accessible description — `title` only
// reaches a screen reader on an element it is allowed to land on, which is
// the other half of what `disabled` was costing here.
if (takenBy) btn.title = Unavailable.reason(btn);
else btn.removeAttribute("title");
if ((values[this.cardIndex] || "").toUpperCase() === card) {
btn.classList.add("selected");
}
@@ -62,6 +62,10 @@ const Toast = {
clearTimeout(this.timer);
this.timer = null;
}
// The same guard publishHeight below already carries, for the same reason: the
// bar dismisses a toast on the way into every action it commits, and that path
// is otherwise plain bookkeeping that answers without a document.
if (typeof document === "undefined") return;
const host = document.getElementById("toastHost");
if (host) host.innerHTML = "";
this.publishHeight();
@@ -131,6 +135,80 @@ const Dialog = {
},
};
// A control that is on the screen and cannot act, and the reason it can't.
//
// `disabled` is the wrong attribute the moment there is something to say. It takes the
// control out of the tab order, so a screen reader never lands on it and never hears
// the `title` hung off it; and it eats the tap on a touch screen, where there is no
// hover to show a `title` to begin with. Measured on an iPhone with three hands dealt:
// seven of the card picker's fifty-two cards were dead, none of them could hold focus,
// and tapping one changed nothing whatsoever — no toast, no text, not a byte of DOM.
// The tool's answer to "who has the ace of spades?" existed, and was reachable only
// with a mouse.
//
// `aria-disabled` announces the same state and leaves the control reachable, so the
// press arrives and can be answered instead. Nothing enforces it for you — that is the
// whole point — so a press is intercepted once, centrally, in `answerPress` below.
const Unavailable = {
// Which control is being told off, if any. A press can land on the text inside a
// button rather than the button, so the answer is looked for upwards.
from(target) {
if (!target || typeof target.closest !== "function") return null;
return target.closest('[aria-disabled="true"]');
},
reason(el) {
return (el && el.dataset && el.dataset.whyNot) || "";
},
blocked(el) {
return !!el && el.getAttribute("aria-disabled") === "true";
},
// Both directions in one call, because every caller works out the condition anyway
// and the two halves have to stay in step — a control left marked after the reason
// stopped being true is worse than one that was never marked.
set(el, blocked, reason) {
if (!el) return;
if (!blocked) {
el.removeAttribute("aria-disabled");
delete el.dataset.whyNot;
return;
}
el.setAttribute("aria-disabled", "true");
el.dataset.whyNot = reason || "";
},
// Where the answer is written.
//
// The toast is fixed to the bottom of the window, and a sheet that stands 88% of
// the screen tall is sitting on top of it — so a control inside one answers inside
// it instead. A sheet opts in by naming its own line: `data-why-note="someId"`.
// Anything else falls through to the toast, which is where the table says things.
say(el, why) {
const host = el && typeof el.closest === "function" ? el.closest("[data-why-note]") : null;
const note = host ? document.getElementById(host.dataset.whyNote) : null;
if (note) {
note.textContent = why;
return;
}
Toast.show(why);
},
// One listener for the whole table, in the capture phase so it arrives before the
// control's own handler and can stop it. Registered once, by main.js.
listen(root) {
root.addEventListener("click", event => {
const el = this.from(event.target);
const why = this.reason(el);
if (!why) return;
event.preventDefault();
event.stopPropagation();
this.say(el, why);
}, true);
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = { Toast, Dialog };
module.exports = { Toast, Dialog, Unavailable };
}
@@ -5,6 +5,13 @@ document.addEventListener("DOMContentLoaded", () => {
// `hidden` attribute they all already toggle.
Modal.mount();
// A press on a control that can't act is answered rather than swallowed. One
// listener for the whole table, because the alternative is remembering to guard
// every click handler that might one day be attached to a dimmed control — and the
// one that gets forgotten is a dead tap with no explanation, which is the bug this
// is here to end. See Unavailable in feedback.js.
Unavailable.listen(document);
// ---- A scroll is not an edit ----
//
// A focused `<input type="number">` is a spinbutton, and a browser lets the mouse
@@ -260,12 +260,18 @@ const Render = {
// here is only the part about what a control can do — which is still worth
// saying, because a shortcut that cannot work yet should not look ready.
//
// Disabled rather than hidden: hiding them would move the header the moment
// the first player is named, and a control that vanishes teaches nothing about
// what it was for.
// Dimmed rather than hidden: hiding them would move the header the moment the
// first player is named, and a control that vanishes teaches nothing about what
// it was for.
//
// Dimmed rather than `disabled`, because there is a reason and it was only ever
// readable with a mouse. `disabled` took both of these out of the tab order, so
// the sentence hung off them never reached a screen reader, and it swallowed the
// tap on the device this tool is built for — leaving a dealer's first screen
// holding two grey buttons that answered nothing when pressed. See Unavailable.
const nothingToRunYet = !this.state.seats.some(seat => HoldemState.seatInPlay(seat));
[["resolvePotBtn", "Nothing to resolve — nobody is at the table yet"],
["nextHandBtn", "Nothing to move on from — nobody is at the table yet"]]
[["resolvePotBtn", "Nothing to resolve yet — seat a player first."],
["nextHandBtn", "Nothing to move on from — seat a player first."]]
.forEach(([id, why]) => {
const button = document.getElementById(id);
if (!button) return;
@@ -274,7 +280,7 @@ const Render = {
if (button.dataset.titleWhenUsable === undefined) {
button.dataset.titleWhenUsable = button.getAttribute("title") || "";
}
button.disabled = nothingToRunYet;
Unavailable.set(button, nothingToRunYet, why);
const title = nothingToRunYet ? why : button.dataset.titleWhenUsable;
if (title) button.setAttribute("title", title);
else button.removeAttribute("title");
@@ -3221,7 +3227,13 @@ const Render = {
const emptyHint = document.getElementById("handHistoryEmptyHint");
const history = this.state.handHistory || [];
emptyHint.hidden = history.length > 0;
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
// This one was greyed out with no explanation anywhere — not even a tooltip —
// so on every device it was a button that did nothing and said nothing about it.
Unavailable.set(
document.getElementById("exportHistoryBtn"),
history.length === 0,
"No hands to export yet — the file fills in as the night is played."
);
const query = this.historyFilter || "";
const shown = query ? history.filter(entry => this.handMatchesFilter(entry, query)) : history;
+9
View File
@@ -43,6 +43,14 @@ const CardPicker = load("cardPicker");
// navigator.wakeLock — see wakeLock.test.js.
const WakeLock = load("wakeLock");
// feedback.js exports two namespaces and a third; Unavailable is the one with anything
// to test, and render.js and cardPicker.js reach for it as a global the way the browser
// hands it to them. See theUnansweredPress.
const { Toast, Dialog, Unavailable } = require(path.join(SRC, "feedback.js"));
globalThis.Toast = Toast;
globalThis.Dialog = Dialog;
globalThis.Unavailable = Unavailable;
// Modal is DOM behaviour through and through, but two pieces of it answer here: the
// list of what counts as a Tab stop, which is a string, and preservingFocus, which asks
// so little of a document that a stand-in can answer it. See theKeyboardInASheet.
@@ -117,6 +125,7 @@ module.exports = {
SeatMenu,
Modal,
QuickSplit,
Unavailable,
makeTable,
chipsOnTable,
contributions,
+19 -8
View File
@@ -32,16 +32,27 @@ const ruleFor = selector => {
return CSS.slice(at, CSS.indexOf("}", at));
};
// The body of a whole at-rule block, braces balanced.
// Every block of a given at-rule, braces balanced, joined.
//
// This used to take the first one and stop, which held for exactly as long as there was
// one of them. A second forced-colors block was added nearer the top of the file for a
// control that dropped out of the OS palette, and the bill-paying test below started
// reading that block instead and reporting the pot wizard's answers missing — a false
// failure about a rule nobody had touched. There is no reason a stylesheet should
// answer a media query in one place, so this asks about all of them.
const atRule = query => {
const at = CSS.indexOf(`@media ${query} {`);
assert.ok(at >= 0, `there is no \`@media ${query}\` block`);
let depth = 0;
for (let i = CSS.indexOf("{", at); i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) return CSS.slice(at, i + 1);
const open = `@media ${query} {`;
const blocks = [];
for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
let depth = 0;
for (let i = CSS.indexOf("{", at); i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) { blocks.push(CSS.slice(at, i + 1)); break; }
}
assert.ok(blocks.length && depth === 0, `\`${open}\` never closes`);
}
assert.fail(`\`@media ${query}\` never closes`);
assert.ok(blocks.length, `there is no \`@media ${query}\` block`);
return blocks.join("\n");
};
// ---------------------------------------------------------------------------
+19 -1
View File
@@ -28,7 +28,25 @@ const VIEWPORTS = [
const OVERLAYS = [
["rulings", () => document.getElementById("openRulingsBtn").click()],
["seat menu", () => SeatMenu.open(HoldemState.load(), {}, 0)],
["card picker", () => { document.getElementById("cardPickerOverlay").hidden = false; }],
// Opened the way a dealer opens it, with cards already out. This used to un-hide the
// overlay and nothing else, so the grid inside it was whatever `paint()` had last
// drawn — which on a freshly loaded page is nothing at all. For the whole life of
// this sweep the card picker was audited as an empty sheet: no slots, no fifty-two
// targets, no dealt cards, none of the three things it is made of.
["card picker", () => {
const state = HoldemState.load();
const playing = state.seats.filter(seat => seat.inHand);
if (playing[0]) playing[0].cards = ["AS", "KH"];
if (playing[1]) playing[1].cards = ["QD", "QC"];
state.board = ["7C", "2H", "TS", "", ""];
const empty = playing[2] || playing[0];
CardPicker.open(state, { onPick: () => {}, onDone: () => {} }, state.seats.indexOf(empty), 0);
// And with its answer showing. The line that says where a dealt card went is the
// one piece of this sheet that is empty until something is pressed, so pressing
// one is the only way the sweep ever sees it holding text.
const out = document.querySelector('#cardPickerGrid .pickerCard[aria-disabled="true"]');
if (out) out.click();
}],
["add chips", () => AddChips.open(HoldemState.load(), 0, () => {})],
["pot wizard", () => document.getElementById("resolvePotBtn").click()],
];
+37 -14
View File
@@ -269,9 +269,12 @@ describe("fifty-two cards, one Tab stop", () => {
assert.equal(deck[51].card, "2C");
});
// A dealt card is `aria-disabled` rather than `disabled`, so it holds focus like any
// other cell — which is what lets the arrows tell the truth about the grid, and what
// lets pressing one be answered instead of ignored. See theUnansweredPress.
const grid = keys => keys.map(key => ({
dataset: { focusKey: key.replace("!", "") },
disabled: key.includes("!"),
getAttribute: name => (name === "aria-disabled" && key.includes("!") ? "true" : null),
tabIndex: 0,
focus() {},
}));
@@ -281,16 +284,24 @@ describe("fifty-two cards, one Tab stop", () => {
return withDocument({ querySelectorAll: () => nodes }, () => body(nodes));
};
test("a step lands on a card that can actually take focus", () => {
// 7C is dealt, so stepping down the clubs from the eight has to pass over it.
test("a step is one card, whether or not that card is already out", () => {
// 7C is dealt. Stepping down the clubs from the eight lands on it and says so,
// rather than quietly arriving at the six.
withGrid(["8C", "7C!", "6C"], () => {
assert.equal(CardPicker.nextCursor(0, 1), 2);
assert.equal(CardPicker.nextCursor(0, 1), 1);
});
});
test("and keeps going as far as it has to", () => {
withGrid(["AS", "KS!", "QS!", "JS!", "TS"], () => {
assert.equal(CardPicker.nextCursor(0, 1), 4);
// The whole reason the skipping had to go. Hunting for the next card that could take
// focus did the exact thing the no-wrap rule below was written to prevent: it moved
// the dealer sideways without being asked. Right from the ace of spades over a dealt
// ace of hearts landed on the ace of diamonds, so the column silently became the one
// to its right, and the next press of Down walked the king of diamonds.
test("and it never slides the dealer into a column they didn't ask for", () => {
withGrid(["AS", "AH!", "AD", "AC", "KS", "KH", "KD", "KC"], () => {
const afterRight = CardPicker.nextCursor(0, 1);
assert.equal(afterRight, 1, "a dealt ace of hearts is still the cell to the right of the ace of spades");
assert.equal(CardPicker.nextCursor(afterRight, 4), 5, "and down from it is the king of the same suit");
});
});
@@ -311,19 +322,31 @@ describe("fifty-two cards, one Tab stop", () => {
});
// Which happens after every pick: the cursor is sitting on a card that has just
// become a dealt card.
test("a cursor left on a dealt card moves to one that can hold it", () => {
// become a dealt card. It stays there, because that cell is still a cell and its
// neighbours are still where they were a moment ago.
test("a cursor left on a dealt card stays on it", () => {
withGrid(["AS!", "AH", "AD", "AC"], nodes => {
CardPicker.aimCursor(0);
assert.equal(CardPicker.cursor, 1);
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, 0, -1, -1]);
assert.equal(CardPicker.cursor, 0);
assert.deepEqual(nodes.map(n => n.tabIndex), [0, -1, -1, -1]);
});
});
test("and looks backwards when there is nothing left ahead of it", () => {
withGrid(["2S", "2H", "2D!", "2C!"], () => {
test("and a grid with nothing left in it still has somewhere to put the cursor", () => {
withGrid(["2S!", "2H!", "2D!", "2C!"], nodes => {
CardPicker.aimCursor(3);
assert.equal(CardPicker.cursor, 1);
assert.equal(CardPicker.cursor, 3);
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, -1, 0]);
});
});
// An index from outside the grid is clamped rather than wrapped, which is what
// Home and End lean on: they ask for one card past either end and take what they
// are given.
test("Home and End ask past the ends and land on them", () => {
withGrid(["AS", "AH", "AD", "AC"], () => {
assert.equal(CardPicker.nextCursor(-1, 1), 0);
assert.equal(CardPicker.nextCursor(4, -1), 3);
});
});
+279
View File
@@ -0,0 +1,279 @@
// A control that can't act, pressed on the device this tool is for.
//
// Measured on an iPhone, three hands dealt, the card picker open on an empty seat:
// seven of the fifty-two cards were greyed out, none of them could take focus, and
// tapping one changed nothing whatsoever — no toast, no text, not a byte of DOM. The
// tool did have an answer to "who has the ace of spades?"; it was a `title`, and a
// `title` needs a mouse. The same held in the header, where a new dealer's very first
// screen is two dimmed buttons, and on Export CSV, which was greyed out with no
// explanation written anywhere at all.
//
// `disabled` is the wrong attribute whenever there is something to say. It takes the
// control out of the tab order, so a screen reader never lands on it and never hears
// the description; and it eats the press on a touch screen, where the hover that would
// have shown the `title` does not exist. NN/g's rule for the case is explicit — on a
// touch interface, a press on a disabled control should raise the explanation — and
// WCAG 3.3.1 wants the instruction reachable at all. `aria-disabled` announces the same
// state and leaves the control reachable, so the press arrives and is answered.
//
// It also fixed something nobody had noticed. The picker's arrow keys hunted past dealt
// cards, because a `disabled` button cannot hold focus — and hunting did the exact thing
// the grid's no-wrap rule was written to prevent: Right from the ace of spades over a
// dealt ace of hearts landed on the ace of diamonds, so the column silently shifted and
// the next press of Down walked a different suit. A cell that can hold focus needs no
// hunting, so that code is gone and the grid tells the truth about its own shape.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const { Unavailable } = require("./load");
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
const MARKUP = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
const src = name => fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", `${name}.js`), "utf8");
const ruleFor = selector => {
const at = CSS.indexOf(`\n${selector} {`);
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
return CSS.slice(at, CSS.indexOf("}", at));
};
// Enough of an element for a module that only ever reads three attributes off one.
const element = (attrs = {}) => ({
dataset: {},
attrs: { ...attrs },
setAttribute(name, value) { this.attrs[name] = String(value); },
getAttribute(name) { return name in this.attrs ? this.attrs[name] : null; },
removeAttribute(name) { delete this.attrs[name]; },
});
// ---------------------------------------------------------------------------
describe("marking a control that cannot act", () => {
test("it says so in the tree, and carries the reason with it", () => {
const btn = element();
Unavailable.set(btn, true, "Nothing to resolve yet — seat a player first.");
assert.equal(btn.getAttribute("aria-disabled"), "true");
assert.equal(Unavailable.reason(btn), "Nothing to resolve yet — seat a player first.");
assert.ok(Unavailable.blocked(btn));
});
// The state and the reason are set and cleared in one call on purpose. A control
// left marked after the reason stopped being true is worse than one never marked:
// it is a live control that refuses to work and explains why in the past tense.
test("and both halves come off together", () => {
const btn = element();
Unavailable.set(btn, true, "Nothing to resolve yet.");
Unavailable.set(btn, false, "Nothing to resolve yet.");
assert.equal(btn.getAttribute("aria-disabled"), null);
assert.equal(Unavailable.reason(btn), "");
assert.ok(!Unavailable.blocked(btn));
});
test("a control nobody marked is not blocked and has nothing to say", () => {
assert.ok(!Unavailable.blocked(element()));
assert.equal(Unavailable.reason(element()), "");
assert.equal(Unavailable.reason(null), "");
assert.ok(!Unavailable.blocked(null));
});
// `aria-disabled="false"` is a real thing to write and it means enabled. Reading the
// attribute's presence rather than its value would have got that backwards.
test("and one marked false is enabled, which is what the attribute means", () => {
const btn = element({ "aria-disabled": "false" });
assert.ok(!Unavailable.blocked(btn));
});
});
describe("what happens when it is pressed", () => {
// The press can land on a span inside the button rather than the button, which is
// most of what the picker's cards are made of.
test("the answer is looked for upwards from whatever was hit", () => {
const btn = element();
Unavailable.set(btn, true, "A♠ — dealt to Maya");
const rank = { closest: selector => (selector === '[aria-disabled="true"]' ? btn : null) };
assert.equal(Unavailable.reason(Unavailable.from(rank)), "A♠ — dealt to Maya");
});
test("a press on nothing in particular finds nothing", () => {
assert.equal(Unavailable.from(null), null);
assert.equal(Unavailable.from({}), null);
});
// Nothing about `aria-disabled` stops a click reaching its own handler — that is the
// trade for staying reachable. One listener does the stopping, in the capture phase,
// so a control's own handler never runs and never has to remember to check.
test("the press is stopped before the control's own handler sees it", () => {
const listener = src("feedback").slice(src("feedback").indexOf("listen(root) {"));
assert.match(listener, /addEventListener\("click"[\s\S]*?\}, true\)/,
"the listener is not in the capture phase, so it cannot stop anything");
assert.match(listener, /event\.preventDefault\(\)/);
assert.match(listener, /event\.stopPropagation\(\)/);
});
test("and it is wired once, for the whole table", () => {
const main = src("main");
assert.match(main, /Unavailable\.listen\(document\)/);
assert.equal((main.match(/Unavailable\.listen\(/g) || []).length, 1);
});
// A control with nothing to say is not a control this is about — the seat sheet's
// own greyed-out fields, say. Swallowing its press would be a new dead end.
test("a marked control with no reason is left entirely alone", () => {
const btn = element();
Unavailable.set(btn, true, "");
assert.equal(Unavailable.reason(btn), "");
});
});
describe("where the answer is written", () => {
// The toast is fixed to the bottom of the window at z-index 80; the card picker's
// scrim is 95 and its sheet stands 88vh tall. A toast raised for a control inside
// that sheet would be behind it, or over the cards if it were not.
test("a sheet that covers the toast says it itself", () => {
assert.match(MARKUP, /id="cardPickerSheet"[^>]*data-why-note="cardPickerNote"/);
assert.match(MARKUP, /id="cardPickerNote"[^>]*role="status"/);
});
test("and everything else falls through to the toast", () => {
const say = src("feedback").slice(src("feedback").indexOf("say(el, why) {"));
assert.match(say, /closest\("\[data-why-note\]"\)/);
assert.match(say, /Toast\.show\(why\)/);
});
// Cleared on the next paint rather than on a timer: a paint is exactly the set of
// things that make the last answer stale, and nothing else in the sheet redraws.
test("the line is cleared by the next thing that changes the sheet", () => {
const picker = src("cardPicker");
const at = picker.indexOf("paint() {");
assert.ok(at >= 0);
const opening = picker.slice(at, picker.indexOf("cardPickerTitle", at));
assert.match(opening, /getElementById\("cardPickerNote"\)/);
assert.match(opening, /note\.textContent = ""/);
});
});
describe("the line holds its place", () => {
// Filling it must not move the grid. It sits between the slots and the cards, so a
// line that appeared from nothing would shove fifty-two targets down the screen
// under a thumb already travelling towards one of them.
test("it is one line tall whether or not it is saying anything", () => {
const rule = ruleFor(".pickerNote");
assert.match(rule, /min-height:\s*1\.35em/);
assert.match(rule, /line-height:\s*1\.35/);
});
// Measured: the note's box is 318px on a Galaxy S8 at 20px of root text, and a name
// is whatever somebody typed. Twenty-four capital Ws draw 434px in it. No wording
// is safe, so the box is what holds.
test("and it cannot become two, whatever the name is", () => {
const rule = ruleFor(".pickerNote");
assert.match(rule, /white-space:\s*nowrap/);
assert.match(rule, /overflow:\s*hidden/);
assert.match(rule, /text-overflow:\s*ellipsis/);
});
test("what it measured is written beside the rule", () => {
const at = CSS.indexOf("/* Which card, and who has it.");
assert.ok(at >= 0, "the rule carries no note saying why");
const note = CSS.slice(at, CSS.indexOf("*/", at));
assert.match(note, /318px/);
assert.match(note, /434px/);
});
// Which is the half that survives the clipping, and the half that does not.
test("the card and where it went come before the name", () => {
const picker = src("cardPicker");
assert.match(picker, /\$\{label\} — face up on the board/);
assert.match(picker, /\$\{label\} — dealt to \$\{takenBy\}/);
});
});
describe("dimmed looks dimmed, however it was said", () => {
test("a button marked either way is painted the same", () => {
const at = CSS.indexOf("\n.holdemBtn:disabled,");
assert.ok(at >= 0, "the two states are painted by separate rules, so they can drift apart");
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /\.holdemBtn\[aria-disabled="true"\]/);
});
test("and so is a card", () => {
const at = CSS.indexOf("\n.pickerCard:disabled,");
assert.ok(at >= 0);
assert.match(CSS.slice(at, CSS.indexOf("}", at)), /\.pickerCard\[aria-disabled="true"\]/);
});
// A press animation on a control that will not act is a lie told in 40ms.
test("neither one presses down under a finger that can't use it", () => {
[".holdemBtn:active", ".pickerCard:active", ".pickerCard:hover"].forEach(base => {
const found = CSS.includes(`${base}:not(:disabled):not([aria-disabled="true"])`);
assert.ok(found, `\`${base}\` still animates for a control marked aria-disabled`);
});
});
// `disabled` is a state the OS palette knows and draws in GrayText by itself.
// `aria-disabled` is a word in the accessibility tree and nothing else, so in
// forced-colors mode a dealt card came out looking exactly like a live one.
test("and the OS is told about it too, since it only knows the other one", () => {
// Whichever of the file's forced-colors blocks it landed in — there is more than
// one of them, and the stylesheet is free to answer a query wherever it likes.
const open = "@media (forced-colors: active) {";
const blocks = [];
for (let at = CSS.indexOf(open); at >= 0; at = CSS.indexOf(open, at + 1)) {
blocks.push(CSS.slice(at, CSS.indexOf("\n}", at)));
}
assert.ok(blocks.length, "nothing in the file answers forced-colors at all");
const answering = blocks.filter(b => /\[aria-disabled="true"\]/.test(b));
assert.equal(answering.length, 1, "the dimmed state has no forced-colors answer");
assert.match(answering[0], /\.pickerCard\[aria-disabled="true"\]/);
assert.match(answering[0], /\.holdemBtn\[aria-disabled="true"\]/);
assert.match(answering[0], /color:\s*GrayText/);
});
});
describe("the three controls that were dead ends", () => {
test("the header's two say why, and are reachable enough to say it", () => {
const render = src("render");
const at = render.indexOf("renderHeaderActions() {");
const body = render.slice(at, render.indexOf("\n },", at));
assert.match(body, /Unavailable\.set\(button, nothingToRunYet, why\)/);
assert.ok(!/button\.disabled = /.test(body), "one of them is still natively disabled");
assert.match(body, /Nothing to resolve yet/);
assert.match(body, /Nothing to move on from/);
});
test("Export CSV has a reason at all, which it never did", () => {
const render = src("render");
const at = render.indexOf('getElementById("exportHistoryBtn")');
assert.ok(at >= 0);
const around = render.slice(at - 200, at + 260);
assert.match(around, /Unavailable\.set\(/);
assert.match(around, /No hands to export yet/);
});
// Found the same afternoon, in the same sheet, by running the whole audit over an
// open overlay for the first time: the two slot buttons above the grid are drawn as
// a card or an em dash, and the em dash was the entire name a screen reader was
// given. "Dash, button", twice, on the sheet where a dealer writes a card down.
test("and the slots above it are called something", () => {
const picker = src("cardPicker");
const at = picker.indexOf("btn.className = \"pickerSlot\"");
const body = picker.slice(at, picker.indexOf("slots.appendChild(btn)", at));
assert.match(body, /this\.boardSlotLabel\(slot\) : `Card \$\{slot \+ 1\}`/,
"a seat's slots still have no name of their own");
assert.match(body, /setAttribute\("aria-label", value \? .* : .*empty.*\)/,
"an empty slot does not say that it is empty");
});
test("and a dealt card names itself rather than saying \"this one\"", () => {
const picker = src("cardPicker");
const at = picker.indexOf("const takenBy = used.get(card)");
const body = picker.slice(at, picker.indexOf("grid.appendChild(btn)", at));
assert.match(body, /Unavailable\.set\(btn, !!takenBy/);
assert.ok(!/btn\.disabled = /.test(body), "the cards are still natively disabled");
// The `title` is the same sentence, and now reaches a screen reader because the
// element it hangs off can be landed on.
assert.match(body, /btn\.title = Unavailable\.reason\(btn\)/);
});
});
+20
View File
@@ -273,6 +273,25 @@ const LayoutAudit = {
return { name: "tap-highlight", offenders, checked: this.visibleControls().length };
},
// A control that is on the screen and cannot act has to say why, on the device it is
// being used on. `disabled` cannot: it drops the control out of the tab order, so a
// screen reader never lands on it to hear the `title`, and it eats the tap on a touch
// screen, where there is no hover to show a `title` anyway. That left the card picker
// answering "who has the ace of spades?" only to a mouse, and Export CSV answering
// nobody at all.
//
// So a control that is dimmed is either marked `aria-disabled` with a reason on it —
// which the one listener in main.js turns into a sentence when it is pressed — or it
// is `disabled` and there is genuinely nothing to say, which is the seat sheet's
// fields and a stepper sitting on its own end. Having a `title` and being `disabled`
// is the combination that cannot work, and it is the one this looks for.
dimmedControlsExplain() {
const offenders = this.visibleControls()
.filter(el => el.disabled && (el.getAttribute("title") || "").trim())
.map(el => `${this.describe(el)} :: disabled, and its only explanation is a title`);
return { name: "a control that can't act says why where it can be read", offenders };
},
// Text selection must stay ON for anything a dealer types into.
textSelectable() {
const offenders = [...document.querySelectorAll("input[type='text'], input[type='number'], textarea")]
@@ -1583,6 +1602,7 @@ const LayoutAudit = {
this.furnitureBudget(),
this.touchAction(),
this.tapHighlight(),
this.dimmedControlsExplain(),
this.textSelectable(),
this.occlusion(options.occlusionSelector),
this.collapsedColumns(),