Every length in this stylesheet that isn't a structural clearance is in `rem`. That is the right way round — it means the page follows the browser's font size, which is what a phone's text-size slider changes — and it means there was a whole axis of behaviour that 320 audits had never once touched, because every one of them ran at the default 16px. The sweep gets a third pass: the same surfaces on the eight phone-shaped viewports at 20px of root text, which is 125% on an Android slider. Everything it found on its first run was caught by a check that had been sitting there for months with nothing to say. The page slid sideways. Three header buttons that will not break and will not shrink — `white-space: nowrap` and a `min-width: auto` floor apiece — were told `flex-wrap: nowrap`, so when they stopped fitting they had nowhere to go: "Resolve Pot" ended 23px past the right edge of a 360px window and put a horizontal scrollbar under the whole document; at 24px, 60px past the edge of a 390px one. They take a second row now on the day they need one, and still fit on the first and still grow into it at any ordinary size. Figures painted over the seat next door. Which of the two type steps an amount got was decided by counting characters — eight for one, ten for the other — which is a guess at how wide a figure is that happens to hold at one text size and one puck width. At 20px on a 44px puck, "$1,200" is six characters, took no step at all, and wanted 38px of the 34 it had. Render.fitAmounts measures now, in the batched passes fitSeatNames already uses, and takes the separators out before it takes a step of type: "$1200" at full size beats "$1,200" at three-quarters of it. And the pucks grew, which the ring cannot answer. The row heights are px tokens so an amount stepping its type down shortens its digits and not its row — but a token is a floor, not a ceiling. At 125% the puck went from 55px tall to 65 and twelve of them came down on each other, because at the 44px floor a thumb needs there is nowhere narrower to search. The two sizes a puck is written at are capped at exactly the sizes they have always been drawn at, so nothing changes for anybody at an ordinary text size — and the felt is a diagram, so the rule that already governed a puck's width now governs its height. Roomy pucks are exempt; they have the room. The same figure is in Standings in full, at whatever size its owner asked for. Two smaller things on the way. The landscape rail opened at its top, so its overflow came off the bottom — where Cancel and Raise to are; it opens at the end now, which is the same reason it is anchored low at all. And the check that called those buttons unreachable was wrong: the rail scrolls on purpose, and a sweep that cries wolf is a sweep nobody reads. 418 audits, clean. One shape it is not held to, with the arithmetic in runUiAudit.js: twelve players all-in on a 360x640 phone leaves the felt 290x163 once the bar has taken 283px of the 640, and twelve 44x62 pucks do not fit around an ellipse 163px deep. Reported as a note every run rather than a failure every run. 1026 unit tests. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
191 lines
10 KiB
JavaScript
191 lines
10 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/25px — borders, hairline offsets, and the 25px of room a seat
|
|
// puck's name gives the dealer button beside it: the chip's
|
|
// own 22px plus the 3px it is inset by, which theRail's
|
|
// "as wide as the chip it stands in for" holds it to.
|
|
// 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", "25px", "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 — a relative bump inside a component.
|
|
// 9px — the floor under the seat puck's shrink-to-fit applied to a name. It is a
|
|
// limit rather than a size: past it the word stops being legible from
|
|
// across a table and an honest ellipsis is the better answer.
|
|
// 10px, 12px — the ceilings on a seat puck's two sizes, which are the one place in
|
|
// this file where type does not follow the browser's font size. Each is
|
|
// exactly the size that row has always been drawn at, so a cap only ever
|
|
// engages when a phone's text is turned up; see the #seatsLayer token block
|
|
// for why a ring whose pucks are already at the 44px floor cannot answer a
|
|
// taller row any other way. Limits rather than sizes.
|
|
const ALLOWED = new Set(["16px", "1.05em", "9px", "10px", "12px"]);
|
|
|
|
// The seat puck's two sizes are custom properties that feed `font-size` through
|
|
// `var()`, so the sweep above walks straight past them — which is exactly where a
|
|
// raw number would end up hiding. They are held to the same scale.
|
|
const TYPE_TOKENS = ["--seatNameSize", "--seatAmountSize"];
|
|
|
|
test("every font-size comes off the scale", () => {
|
|
const offenders = valuesIn(["font-size", ...TYPE_TOKENS])
|
|
.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.
|
|
// The seat sheet's height is the same idea as the action bar's, and for the same
|
|
// reason: two things fixed to the bottom of a phone, and the toast has to clear
|
|
// whichever is taller. It changes when a section of the sheet is unfolded, so it
|
|
// is measured rather than written down — see Render.watchSeatSheetHeight.
|
|
const fromScript = new Set([
|
|
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
|
"--toastHeight", "--seatSheetHeight",
|
|
]);
|
|
// 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.
|
|
//
|
|
// A length at every breakpoint, never a percentage: `min(10rem, 34%)` was tried
|
|
// and the percentage resolves against the row it sits in, which is sized to its
|
|
// own contents — the two chase each other, and the sweep caught the pill drawn
|
|
// 38px wide at ten viewports. A phone gets a narrower one through a media query
|
|
// instead, which is still one width at a time.
|
|
const rules = [...css.matchAll(/\.actionUndo\s*\{([^}]*)\}/g)].map(m => m[1]);
|
|
assert.ok(rules.length, ".actionUndo rule not found");
|
|
rules.filter(body => /(?<![\w-])width:/.test(body)).forEach(body =>
|
|
assert.match(body, /(?<![\w-])width:\s*([0-9.]+rem|100%)/,
|
|
"a content width would let the pill resize under the dealer's thumb"));
|
|
assert.ok(rules.some(body => /(?<![\w-])width:\s*[0-9.]+rem/.test(body)),
|
|
"the pill should still state a fixed width somewhere");
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|