WCAG 2.2 SC 2.4.11 asks that a focused control not be completely hidden
by something the page has put on top of it. The bar is fixed over the
bottom and is not in the flow, so the browser does not know it is there:
Tab to a control near the bottom and it is scrolled flush against the
edge of the window, under the bar, and the browser considers it done.
Tabbing the whole page measured 16 stops resting completely covered on a
390x844 phone and 27 more partly, plus four under a toast on the desktop.
It is now 0 and 0 on desktop, phone and a phone on its side — clean
against 2.4.12 (AAA) as well, which allows no overlap at all.
Three things were wrong and they needed different answers.
scroll-padding-bottom on the scroll container, sized from the bar's own
published height and the toast's, handles the case where the browser has
to scroll to reach the control. That is half of it, and the half that
looks like all of it: a control the browser already considers visible is
one it will not scroll to, and a fixed overlay on top does not enter into
that decision. Measured — tabbing to Common rulings with a toast up left
it 100% covered and scrollIntoView({block:"nearest"}) moved nothing. So a
focusin handler measures the overlap and scrolls by it, and when the page
has no room left (26px against 94px wanted) the toast gives way instead.
Sizing the page padding for the toast would have fixed that by making the
page grow and shrink 64px whenever anything was announced.
The page's room to scroll past the bar was 9rem on the sidebar alone,
which left the table's column — most of a desktop — with none. It is now
on the whole app and taken from the bar's real height, so it is also 0 in
the layout where the bar is a rail down the side.
And the seat editor's bottom sheet was put away with a transform, which
moves a thing without putting it away: its Done button sat at y=850 on an
844px screen, focusable, announced, and impossible to scroll to, because
a fixed element does not move when the page does. visibility:hidden puts
it away, transitioned so the slide-out still runs.
uiAudit.js gained focusHasRoom() and focusablesReachable() for the two
static halves. 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
161 lines
8.1 KiB
JavaScript
161 lines
8.1 KiB
JavaScript
// The stylesheet has to keep picking from the scales.
|
|
//
|
|
// Before the token block went in, holdem.css used 42 different spacing values, 31 font
|
|
// sizes, 14 border-radii and 139 colours, and defined three custom properties. None of
|
|
// that was anybody's decision — each number was chosen at the moment it was needed, and
|
|
// the result read as crowded even where nothing overlapped, because a 0.42rem gap next
|
|
// to a 0.45rem gap next to a 0.5rem gap gives the eye no rhythm to lock onto.
|
|
//
|
|
// A one-off cleanup would last until the next `padding: 0.35rem`. So the scales are
|
|
// checked here instead: a new value has to be a step on one of them, or an exception
|
|
// with a reason written next to it.
|
|
//
|
|
// This reads the stylesheet as text rather than through a CSS parser, on purpose — the
|
|
// rest of this suite has no dependencies and runs in a second, and a regex over
|
|
// declarations is enough to catch the thing that actually happens, which is somebody
|
|
// (me) typing a number instead of reaching for a token.
|
|
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_PATH = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css");
|
|
const css = fs.readFileSync(CSS_PATH, "utf8");
|
|
|
|
// Comments carry measurements and rationale — "measured at 342px tall on a phone" —
|
|
// and none of that is a declaration. Strip them before looking for values.
|
|
const declarations = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
|
|
// Everything after the token block. The :root block is where the raw values are
|
|
// supposed to be.
|
|
const body = declarations.slice(declarations.indexOf("#potBadgeAmount,"));
|
|
|
|
const valuesIn = (properties, source = body) => {
|
|
const found = [];
|
|
// The lookbehind rejects a hyphen as well as a word character, so `scroll-padding`
|
|
// isn't read as `padding`. It caught the pot wizard's scroll-padding the moment
|
|
// that went in — correctly by its own rules, wrongly in substance: those two values
|
|
// are the measured heights of the dialog's sticky header and footer, a clearance
|
|
// like the action bar's, not a step in the spacing rhythm.
|
|
const re = new RegExp(`(?<![\\w-])(${properties.join("|")})\\s*:([^;}]*)`, "g");
|
|
let m;
|
|
while ((m = re.exec(source)) !== null) {
|
|
const [, prop, value] = m;
|
|
// calc() and env() do their own arithmetic on tokens; the tokens inside them
|
|
// are still checked, because the regex below looks at bare numbers only.
|
|
(value.match(/(?<![\w-])[0-9.]+(rem|px|em)\b/g) || []).forEach(v => found.push({ prop, value: v }));
|
|
}
|
|
return found;
|
|
};
|
|
|
|
describe("the spacing scale", () => {
|
|
// Hairlines and structural clearances, each one deliberate:
|
|
// 0px/1px/3px/4px/22px — borders, hairline offsets, and the 22px of room a seat
|
|
// puck's name gives the dealer button beside it.
|
|
// 5rem/9rem — how far the page has to scroll past the fixed action
|
|
// bar. Not rhythm; clearance for a thing of a known size.
|
|
const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "22px", "5rem", "9rem"]);
|
|
|
|
test("every gap, margin and padding comes off the scale", () => {
|
|
const offenders = valuesIn(
|
|
["padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
|
|
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
|
|
"gap", "row-gap", "column-gap"])
|
|
.filter(v => !ALLOWED.has(v.value));
|
|
|
|
assert.deepEqual(offenders, [],
|
|
"use var(--sp-0 … --sp-6), or add the value to ALLOWED with a reason");
|
|
});
|
|
|
|
test("and the scale is the one the tokens define", () => {
|
|
["--sp-0", "--sp-1", "--sp-2", "--sp-3", "--sp-4", "--sp-5", "--sp-6"]
|
|
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
|
|
});
|
|
});
|
|
|
|
describe("the type scale", () => {
|
|
// 16px — the size below which iOS zooms the page when a field is focused, so
|
|
// every text input states it. Not a typographic choice.
|
|
// 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for
|
|
// a six-figure stack on a 58px seat puck.
|
|
const ALLOWED = new Set(["16px", "1.05em", "0.44rem"]);
|
|
|
|
test("every font-size comes off the scale", () => {
|
|
const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value));
|
|
assert.deepEqual(offenders, [],
|
|
"use var(--fs-2xs … --fs-2xl), or add the value to ALLOWED with a reason");
|
|
});
|
|
|
|
test("and the scale is the one the tokens define", () => {
|
|
["--fs-2xs", "--fs-xs", "--fs-sm", "--fs-base", "--fs-md", "--fs-lg", "--fs-xl", "--fs-2xl"]
|
|
.forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`));
|
|
});
|
|
});
|
|
|
|
describe("the corner scale", () => {
|
|
test("every border-radius comes off it", () => {
|
|
const offenders = valuesIn(["border-radius"]).filter(v => v.value !== "2px");
|
|
assert.deepEqual(offenders, [], "use var(--r-sm | --r-md | --r-lg | --r-pill)");
|
|
});
|
|
});
|
|
|
|
describe("the tokens themselves", () => {
|
|
test("every var() the stylesheet reaches for exists", () => {
|
|
const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4)));
|
|
// Published from JS at runtime rather than declared here — see actionBar.js for
|
|
// the first two, render.js for the seat width and the chip's denomination colour,
|
|
// which is picked per chip from the amount on it, and feedback.js for the
|
|
// toast's height, which is one line or two depending on what is being said.
|
|
const fromScript = new Set([
|
|
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
|
"--toastHeight",
|
|
]);
|
|
// Defined by the site's own stylesheet, not this one.
|
|
const fromSite = new Set(["--font-mono"]);
|
|
const missing = [...used].filter(t =>
|
|
!fromScript.has(t) && !fromSite.has(t) && !new RegExp(`\\${t}\\s*:`).test(css));
|
|
assert.deepEqual(missing, []);
|
|
});
|
|
|
|
test("and the dark surfaces stay off pure black", () => {
|
|
// Shadows are invisible on #000, so depth has to come from a lighter surface —
|
|
// and white text on true black is the pairing that smears on an OLED phone,
|
|
// which is what this tool is used on.
|
|
const surfaces = [...css.matchAll(/--surface-\d+:\s*(#[0-9a-fA-F]{3,8})/g)].map(m => m[1].toLowerCase());
|
|
assert.ok(surfaces.length >= 4, "the surface ramp should have four steps");
|
|
surfaces.forEach(hex => {
|
|
assert.notEqual(hex, "#000");
|
|
assert.notEqual(hex, "#000000");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("numbers that change while the dealer watches", () => {
|
|
test("are set in tabular figures", () => {
|
|
// A "1" is narrower than an "8" in a proportional face, so a pot ticking from
|
|
// $18 to $21 shuffles its digits sideways and the eye reads movement where
|
|
// there is only arithmetic.
|
|
assert.match(css, /font-variant-numeric:\s*tabular-nums/);
|
|
["#potBadgeAmount", ".seatPuckStack", ".actionPot", ".standingsRow"]
|
|
.forEach(sel => assert.ok(css.includes(sel),
|
|
`${sel} should be in the tabular-figures list`));
|
|
});
|
|
});
|
|
|
|
describe("controls that must not resize under a thumb", () => {
|
|
test("the undo pill has a fixed width, not a content width", () => {
|
|
// It names the last action, so its label changes on every tap. Measured across
|
|
// one hand before this: 148, 110, 109, 108, 109, 110, 120.
|
|
const rule = css.match(/\.actionUndo\s*\{[^}]*\}/);
|
|
assert.ok(rule, ".actionUndo rule not found");
|
|
assert.match(rule[0], /width:\s*[0-9.]+rem/);
|
|
});
|
|
|
|
test("and its label is the part that ellipsizes", () => {
|
|
const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/);
|
|
assert.ok(rule, ".actionUndoLabel rule not found");
|
|
assert.match(rule[0], /text-overflow:\s*ellipsis/);
|
|
});
|
|
});
|