Two things on the felt and the bar that were spending weight on the wrong half of what they had to say. The undo pill names the last action, and on a phone it had stopped doing it. It is a fixed width — its text changes on every tap, and a control that resizes under a thumb reads as the interface twitching — and that width leaves the label 73px. Measured across one hand, four of the five labels a hand produces wanted more: ↶ dealing the hand 104 ↶ Christopher's fold 109 ↶ Mike from work's fold 128 ↶ Alexandra's fold 100 ↶ Dee's fold 69 (the only one that fitted) so the safety net spent most of a night reading "↶ dealing …". Cut as one string it keeps the wrong half: "↶ Mike from wor…" names everybody except the action. So an action's name is handed to commit() in two halves now — who it was, and what they did — and the actor comes off whole when the act cannot fit beside it. The whole sentence stays on the button's title and aria-label, which is what the guidance asks of a control that has had to shorten its own name. Two things fell out of it. The label cannot overflow — the act inside it is the item with `overflow: hidden`, so it swallows the excess and the row measures as though everything fitted — which is why the fit asks the act and not the label; asked the label, "↶ dealing the hand" kept its actor and quietly cut "the hand" off inside it. And the pill was still printing a keycap on phones: every other control on the bar has dropped its cap under `hover: none` since that rule was written, but this one is a different class and had been left out of it, so the narrowest label on the bar was the only one spending 22px on a shortcut nothing could press. That 22px is exactly what "raise to $1,200" needed to keep its digits. Nothing is cut now at any viewport in the sweep: a phone reads "↶ the hand", "↶ Dee's fold", "↶ raise to $20", "↶ call"; a desktop and the landscape rail keep the actor wherever it fits. And the empty chairs. An open seat was drawn as a whole puck of near-black on the green — the same box a seated player gets, at the same weight, saying nothing. Five players nine-handed is four of them, and on the felt they read as holes punched in the table: the eye had to sort the loud empty boxes from the loud full ones before it could find who was in the hand. The wash was there for one reason, which is that the chair number's contrast cannot depend on whether its chair sits over the rim, the dark green or the light — about Lc 5 across one ring. So the wash moved off the chair and under the number, which is the only thing an open chair has to say. The dashes still say "open", the box a thumb lands on has not moved a pixel, and the felt gets four boxes' worth of quiet back. 1039 unit tests; 418 layout audits across 13 viewports, clean. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
245 lines
14 KiB
JavaScript
245 lines
14 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("what an open chair is allowed to weigh", () => {
|
|
// An empty seat used to be drawn as a whole puck of near-black on the green: the same
|
|
// box a seated player gets, at the same weight, saying nothing. Five players
|
|
// nine-handed is four of them, and they read as holes punched in the felt rather than
|
|
// as chairs nobody is in — the eye had to sort the loud empty boxes from the loud full
|
|
// ones before it could find who was in the hand.
|
|
//
|
|
// The wash the box carried was there for one reason: the chair number's contrast
|
|
// cannot depend on whether its chair happens to sit over the brown rim, the dark green
|
|
// or the light, which is about Lc 5 across one ring. So the wash moved off the chair
|
|
// and under the number, which is the only thing an open chair has to say.
|
|
|
|
test("the chair is quieter than the player who could sit in it", () => {
|
|
const empty = css.match(/\.seat\.seatEmpty \{([^}]*)\}/);
|
|
const seat = css.match(/\n\.seat \{([^}]*)\}/);
|
|
assert.ok(empty && seat);
|
|
assert.match(seat[1], /background:\s*linear-gradient/,
|
|
"a taken seat is a puck with a ground of its own");
|
|
const alpha = empty[1].match(/background:\s*rgba\([^)]*?([0-9.]+)\s*\)/);
|
|
assert.ok(alpha, "an open chair should still be washed, only faintly");
|
|
assert.ok(Number(alpha[1]) <= 0.35,
|
|
`an open chair at ${alpha[1]} reads as a hole in the felt, not a chair`);
|
|
});
|
|
|
|
test("and it still says it is open", () => {
|
|
const empty = css.match(/\.seat\.seatEmpty \{([^}]*)\}/)[1];
|
|
assert.match(empty, /border-style:\s*dashed/,
|
|
"the dashes are what carry the meaning; they always were");
|
|
});
|
|
|
|
test("the number keeps a ground of its own, because it is the part that is read", () => {
|
|
const rule = css.match(/\.seatEmpty \.seatChairNumber \{([^}]*)\}/);
|
|
assert.ok(rule, "no ground under the one thing an open chair says");
|
|
assert.match(rule[1], /background:\s*rgba/);
|
|
assert.match(rule[1], /border-radius:\s*var\(--r-pill\)/);
|
|
});
|
|
});
|
|
|
|
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 gives, never the keycap", () => {
|
|
const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/);
|
|
assert.ok(rule, ".actionUndoLabel rule not found");
|
|
assert.match(rule[0], /overflow:\s*hidden/);
|
|
assert.match(css.match(/\.actionUndoKey\s*\{[^}]*\}/)[0], /flex:\s*none/);
|
|
});
|
|
|
|
test("and within the label, the act outlives the actor", () => {
|
|
// Three items, not one string: the arrow, who it was, and what they did. Cut as
|
|
// one string, "↶ Mike from work's fold" reads "↶ Mike from wor…" on a phone —
|
|
// everything except the thing you would be taking back. The actor is dropped
|
|
// whole instead, so the act is what survives, and only the act may ellipsize.
|
|
const lead = css.match(/\.actionUndoGlyph,\s*\n\.actionUndoLead\s*\{([^}]*)\}/);
|
|
assert.ok(lead, "the arrow and the actor should be fixed items");
|
|
assert.match(lead[1], /flex:\s*none/);
|
|
assert.match(css, /\.actionUndoLead\[hidden\]\s*\{[^}]*display:\s*none/);
|
|
const act = css.match(/\.actionUndoAct\s*\{([^}]*)\}/);
|
|
assert.ok(act, ".actionUndoAct rule not found");
|
|
assert.match(act[1], /text-overflow:\s*ellipsis/);
|
|
assert.match(act[1], /min-width:\s*0/);
|
|
});
|
|
});
|