// 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(`(? 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 => /(? assert.match(body, /(? /(? { 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/); }); });