Files
JoshHeaps.Net/tests/holdem/nativeControls.test.js
T
jheapsandClaude Opus 5 c1d0105d12 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
2026-09-13 22:41:06 -06:00

133 lines
6.5 KiB
JavaScript

// The half of the interface the stylesheet does not draw.
//
// The browser draws the checkboxes, the menu a <select> opens, the scrollbars, the
// caret, the spellcheck underline and the autofill wash, and it draws all of them for a
// light page unless the page says otherwise. This one never did: measured,
// `color-scheme: normal` on a #141414 ground, with three checkboxes in the Table Setup
// panel rendering as bright white squares — the loudest objects on the screen, brighter
// than the gold — and two more in the seat sheet doing the same over the felt.
//
// The comment on the pot wizard's hand-painted checkbox called it "the last raw browser
// control in the tool". Six others were still the platform's, and three of those were
// still 13x13 against a 24px floor this stylesheet had already written down once and
// applied to exactly one box.
//
// Painting is the expensive way to fix that, and the pot wizard's box shows the bill:
// a tick drawn as a `background` on a pseudo-element is not a colour forced-colors mode
// knows how to replace, so it simply disappears — no visible checked state at all, on
// the dialog that pays the table. Declaring the scheme fixes six controls natively and
// costs nothing; the seventh stays painted on purpose and pays its own way.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const fs = require("fs");
const path = require("path");
const CSS = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "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));
};
// 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 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.ok(blocks.length, `there is no \`@media ${query}\` block`);
return blocks.join("\n");
};
// ---------------------------------------------------------------------------
describe("the page says what colour it is", () => {
const root = ruleFor(":root");
test("dark, to the browser and not only to the eye", () => {
assert.match(root, /color-scheme:\s*dark/);
});
// The checked colour is the green this tool already uses for yes, so the one
// painted checkbox and the six native ones agree rather than nearly agree.
test("and the checked colour is the tool's own green, not the platform's blue", () => {
assert.match(root, /--accent-on:\s*#1e8449/);
assert.match(root, /accent-color:\s*var\(--accent-on\)/);
assert.match(ruleFor(".potWizardChoice input:checked"), /background:\s*#1e8449/);
});
});
describe("a checkbox is one size", () => {
test("every one of them, not the one that was noticed", () => {
assert.match(ruleFor('input[type="checkbox"]'), /width:\s*24px/);
assert.match(ruleFor('input[type="checkbox"]'), /height:\s*24px/);
});
test("and no box carries a size of its own any more", () => {
assert.ok(!/#dedicatedDealerToggle\s*\{/.test(CSS),
"one checkbox is still sized by id, which is how five got left behind");
assert.ok(!/width:\s*22px/.test(ruleFor(".potWizardChoice input")),
"the painted box restates its size instead of taking the one above");
});
// A stated width is only a suggestion to a flex item. The coarse-pointer block had
// already been bitten by this and said so; the mouse case had not.
test("a swatch in a crowded row keeps the width it was given", () => {
assert.match(ruleFor(".chipColorInput"), /flex:\s*none/);
});
});
describe("what painting a control costs, and who pays it", () => {
// If a future round paints another one, this is the bill it has to settle too.
test("nothing gives up the platform's drawing without answering forced colours", () => {
const forced = atRule("(forced-colors: active)");
const painted = [...CSS.matchAll(/\n([^\n{}]+)\s*\{[^}]*appearance:\s*none/g)]
.map(m => m[1].trim());
assert.ok(painted.length > 0, "nothing is painted, so this test is measuring nothing");
painted.forEach(selector => {
assert.ok(forced.includes(selector),
`\`${selector}\` drops the platform's control but has no forced-colors answer`);
});
});
// A tick drawn as a background is gone in forced-colors mode; a system keyword is
// the only colour guaranteed to survive, because it *is* the palette.
test("the tick that is drawn rather than typed survives the OS repainting it", () => {
const forced = atRule("(forced-colors: active)");
assert.match(forced, /\.potWizardChoice input::before\s*\{[^}]*background:\s*CanvasText/);
});
test("and so does the row's own chosen state, which is a gradient", () => {
const forced = atRule("(forced-colors: active)");
assert.match(forced, /\.potWizardChoice\.chosen\s*\{[^}]*outline:\s*2px solid Highlight/);
});
});
describe("the other page of the same tool", () => {
const QUICKSPLIT = fs.readFileSync(
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "quicksplit.css"), "utf8");
// It is the darker of the two pages and is opened from the felt mid-hand. It has no
// checkboxes to go white, but scrollbars, caret and selection were still drawn for
// a light page.
test("Quick Split says what colour it is too", () => {
assert.match(QUICKSPLIT, /:root\s*\{[^}]*color-scheme:\s*dark/);
});
});