The chips were the last thing on the table nobody had measured, and the sweep had never seen one: it audited a table that had never had a bet placed on it, so a third of what is on the felt during a hand went unlooked at for the tool's whole life. It posts blinds, deals and calls three times now before it measures anything. What that found, and what this fixes: - Chips accumulated. Seats are kept and updated in place rather than rebuilt, so clearing the bets layer stopped clearing the ones anchored inside a seat and nothing else ever did. An iPhone 14 had one chip per seat after the deal and five by the third render; a dealer's every tap left another disc on the felt. - The ring-or-cling decision asked how wide the window was. A phone held sideways is 844px across and gives the felt 117px: it passed the width test, took the ring, and every chip on it was painted under a puck — the seats carry z-index 1 and the bets layer carries none, so a chip that lands under one is gone, and with it the only number saying what that player has in. Measured now, and only measured. - Getting clear of a box was `edgeReach(puck) + edgeReach(chip)`, which is right only straight up, down or sideways. Two axis-aligned boxes are clear when they are apart on one axis, so the travel is the reach of the box you get by adding their widths and their heights. At 768x1024 a $10 leaving a seat on the diagonal wanted 88px and got 77, and came to rest 11px inside its own player's puck — every seat off the four compass points, which is most of them. - Every chip was laid out at the first chip's size. A chip is as wide as the number on it: "$5" is 21px and "$10" is 27px on a phone, so each $10 was placed as if it were 6px narrower than it is. The widest is no better: at 768x1024 one 47px chip made the whole table give up a ring the other four had room for. - Anchored chips settled where they landed. At 640x360 the felt is 528x83 and a puck is 55 of those pixels tall: clearing it takes 41px, the players facing each other are 70px apart, and both chips set off 41px into a gap that is 70 wide. Three overlapping pairs on a twelve-handed table. Each chip now gives ground only to something it is actually standing on, a pixel at a time — a midline rule or a single scale factor both punish the seats at the ends of a rail, which were never going to hit anything. - Nothing knew the pot total was there. Seat 1's $10 was sitting on "Pot $65". Two things off the felt, from the same sweep: - The toast outlived its news. "Next hand — button on seat 2. [Undo]" stayed up while the hand was dealt and three players called: an offer to move the button, wired to a snapshot that predates the hand, so taking it would have thrown the hand away without saying so. It was also in the way of thirty small screens' seats and controls. The bar drops it as it takes the next action; the bar's own Undo names what it would actually take back. - The pot dialog cut a name off. Three players in the pot on a Galaxy S8, two rows visible, the third below a hairline that reads as the end of the list — a dealer ticking the two they could see would have paid two thirds of a three-way pot. The 85vh budget was still subtracting the action bar's height from a dialog that has sat above the bar since its z-index was fixed, and a player's ceiling moves beside their name on a short screen. All three fit now, and where a list does run on, it runs on under a fade rather than stopping dead on a row boundary. The pot badge goes on a phone held sideways: 86x66 of a 692x113 felt, for a number the action bar prints three inches below it, standing in the only band a bet chip has. The audit stopped calling one thing another. An element clipped away by a panel that scrolls is not covered by whatever is painted at that height — the wizard's third winner came back for four viewports as "fully covered by button Confirm Winner(s)". 190 audits: 25 failing configurations to 7, and every one of those seven is the seat ring hanging past the rail on a landscape phone, which is the known residual from the last round. 663 unit tests, 44 of them new. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
159 lines
7.9 KiB
JavaScript
159 lines
7.9 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.
|
|
const fromScript = new Set([
|
|
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
|
]);
|
|
// 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/);
|
|
});
|
|
});
|