Opened the pot wizard — the one dialog in this tool that divides money — and its Confirm button was below a scroll. Not on a tall table, not on one screen: at every size I measured, with six players and a side pot. iPhone 14 390x844 244px of the dialog below the fold Galaxy S8 360x640 417px 844x390 landscape 583px desktop 1280x900 172px Nothing on screen said there was anything under the list of names, so a dealer with the table waiting had to scroll a list of players to find the only control that finishes the hand. Two goes at this, and the first was wrong. A sticky footer is the obvious answer and it cannot work here: the actions were the last child of the scrolling content, so their containing block ended exactly where they did and `bottom` had no room to lift them. Sticky on the *header* did work, for the mirror-image reason — its container extends below it — which is what made the failure confusing to read. Capping the player list wasn't enough either; the pot breakdown and the log still overflowed on a short screen. So the dialog is a proper column now: a title that doesn't move, a body that scrolls, and a footer outside the scroll. potWizard.js appends its buttons to the footer instead of into the body, and clears both together so a step can't inherit the previous step's buttons. Measured after: Confirm on screen and hit-testable at all six configurations, twelve-handed on a Galaxy S8 included, and the dialog itself no longer scrolls at all. The checkboxes are painted now, too. `accent-color` only ever coloured the tick — the box around it stayed the platform's own bright white square, which on a near-black dialog was the loudest thing on screen and the last raw control in a tool where everything else had been drawn. Same surface ramp, same corner radius, and a tick drawn with clip-path so it can't pick up a substituted font. uiAudit gains "an open dialog's buttons are on screen", which hit-tests rather than trusting geometry — a button can be inside the viewport and still be under something. Fault-injected: putting the footer back inside the scrolling body reports Confirm 105px below the fold. Two things checked and left alone, because they turned out to be right: the standings table hides three columns on narrow screens through a container query keyed to the sidebar rather than the viewport, which is why a desktop shows fewer columns than an iPad — the sidebar is the narrower box there. And In/Won/ Net read $0 for hands settled outside the wizard, which the comment above them already explains. Caught by my own guard on the way past: the token test read `scroll-padding` as `padding` and failed the spacing scale. Correct by its rules, wrong in substance — those values are a sticky bar's measured height, not a step in the rhythm — so the lookbehind now rejects a hyphen as well as a word character. Re-fault- injected to confirm it still catches a real ad-hoc gap. 619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes and 35 across the five overlays. The whole wizard walked end to end: side pot resolved, hand logged, $1,560 on the table before and after. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
155 lines
7.7 KiB
JavaScript
155 lines
7.7 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.
|
|
const fromScript = new Set(["--actionBarHeight", "--feltRoomPx"]);
|
|
// 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/);
|
|
});
|
|
});
|