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
+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");
};
// ---------------------------------------------------------------------------