Files
JoshHeaps.Net/tests/holdem/designTokens.test.js
T
jheapsandClaude Opus 5 0f7213ec2d Open a seat on what is on it, and put the sheet in front of the table
The seat sheet is the second-most-used surface here — every buy-in, every
rename, every correction goes through it — and it was the least finished one.
Seven things were stacked flat at one weight and all open at once. Measured on
a 390px phone it came to 594px and scrolled, and the three controls a dealer
touches on nearly every visit were 132px of that. The chip counter alone was
174px, the tallest single thing in the sheet, for a job most tables do once a
night or never; the two hole-card slots were 62px of dashed grey on every table
that never asked to be shown a card.

Cards, the contribution and the custom fields are behind a fold now, and which
of the three stand open is a question about the seat rather than a setting: a
seat holding cards opens its cards, a seat with chips counted opens its count,
a seat with a note on it opens its notes. A shut section says the answer on its
own row — the cards, the total, how many notes — so nothing already entered is
further away than it was. An empty chair is 419px and does not scroll; a seat
with everything filled in is 625 and opens all three.

The fields have the width the sheet always had. `.fieldRow input` caps at 140px,
which is right in a 320px sidebar of settings and wrong in a sheet holding a
whole phone: "Name" sat against the left edge with 203px of nothing between it
and a field a third of the row wide. Same 44px control, 300px instead of 140.

The chip counter's values are text. They were a `title` attribute, which is a
tooltip, which is nothing on the device this is used on — five identical number
boxes told apart by five coloured dots. It also left the wrapping <label> with
no text in it, so they had no accessible name either.

And the sheet has a scrim. It was the only one of the tool's seven overlays
without one, while covering the felt from 250px down and the action bar with
it — modal in everything but appearance. What stayed lit above it was the
header, with Misdeal, Next Hand and Resolve Pot live under a thumb reaching for
a name field. It dismisses on a tap and on Escape now, like the other six; a
card picker opened from inside still takes Escape first. In landscape the scrim
stops where the sheet stops, so Fold, Call and Raise keep the rail.

It is put away with visibility rather than opacity, which is the same lesson the
sheet above it already learned about transforms — and the layout sweep caught it
the same way. At `opacity: 0` a fixed, full-window box is invisible and still
entirely there: bottomFurniture() read it as furniture standing 640px tall at
the bottom of a 640px phone, in every state and on every screen, and asked
scroll-padding to reserve the whole window for it. Ninety-eight scenarios failed
on it; hidden is hidden, and the sweep is back to the five it started with.

The toast steps over the sheet the way it already steps over the action bar.
Both are fixed to the bottom of a phone, and a "Keeping the screen on" toast
was landing squarely on the seat form with its Undo and its × live above the
fields. The height is measured and published, because it changes the moment a
section is unfolded.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-09 07:29:03 -06:00

165 lines
8.4 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.
// 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.
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/);
});
});