From fa20db06320fb8d0be70cf91ad231c7a29c3e938 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 14 Aug 2026 17:47:40 -0400 Subject: [PATCH] test(site-a11y): hold the unit grid to reflowing at 320px Counts the sprite, the button's padding and border, the grid gap and the page and card padding, then checks that three columns either fit at 320px or that a media query has taken one away before they stop fitting. The arithmetic beats a remembered breakpoint: the number moves the moment anyone touches the sprite size or a padding, and the failure says what the grid needs against what the viewport has left. It counts chrome and not the unit names, so it is a floor and a browser is still what says the page does not scroll. --- web/scripts/camo-reflow.test.mjs | 129 +++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 web/scripts/camo-reflow.test.mjs diff --git a/web/scripts/camo-reflow.test.mjs b/web/scripts/camo-reflow.test.mjs new file mode 100644 index 0000000..fea0876 --- /dev/null +++ b/web/scripts/camo-reflow.test.mjs @@ -0,0 +1,129 @@ +/** + * The unit grid must not push the page sideways on a phone. + * + * `.camo-fleet` is three columns of actual-size sprites, and actual size is the + * point: it is where a player finds out whether their camo survives being drawn + * small. That makes its width a hard floor rather than something the layout can + * squeeze, and a grid with a floor inside a flex row does not wrap โ€” it overran + * the document. At 320px the whole page scrolled sideways by 14px, and every + * 360px phone scrolled too. + * + * WCAG 1.4.10 puts the line at 320px, so the rule is: at 320px, three columns + * either fit or there are not three columns. + * + * The arithmetic is here rather than a remembered breakpoint, because the + * number moves the moment anyone touches the sprite size or a padding. If this + * fails, it prints what the grid now needs and what the viewport has left. + * + * It counts sprites and chrome, not the unit names under them, so its numbers + * come out a little under what a browser measures โ€” "Medium Mek ยท quad" is + * wider than the sprite it labels and widens that column. That makes this a + * floor: passing here is necessary and a browser is still the thing that says + * the page does not scroll. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const src = fileURLToPath(new URL("../src/", import.meta.url)); +const css = await readFile(`${src}styles.css`, "utf8"); +const mech = await readFile(`${src}camo/mech.ts`, "utf8"); + +/** WCAG 1.4.10 reflow: no horizontal scrolling at this width. */ +const REFLOW = 320; +const ROOT_FONT = 16; + +function number(source, pattern, what) { + const found = pattern.exec(source); + assert.ok(found, `${what} moved or was renamed`); + return Number(found[1]); +} + +/** A CSS length in px. Only px and rem appear in these rules. */ +function length(raw, what) { + const found = /^([\d.]+)(px|rem)$/.exec(raw.trim()); + assert.ok(found, `${what} is ${raw}, which this test cannot measure`); + return Number(found[1]) * (found[2] === "rem" ? ROOT_FONT : 1); +} + +/** The body of a rule, by its selector. */ +function rule(selector) { + const start = css.indexOf(`\n${selector} {`); + assert.notEqual(start, -1, `${selector} moved or was renamed`); + const end = css.indexOf("\n}", start); + return css.slice(start, end); +} + +function shorthandInline(body, property, what) { + const found = new RegExp(`${property}:\\s*([^;]+);`).exec(body); + assert.ok(found, `${what} moved or was renamed`); + const parts = found[1].trim().split(/\s+/); + // One value applies to both sides; two are block then inline. + return length(parts.length > 1 ? parts[1] : parts[0], what); +} + +test("three columns of units fit at 320px, or there are not three", () => { + const sprite = number(mech, /SPRITE_W = (\d+)/, "SPRITE_W"); + + const button = rule(".camo-unit-btn"); + const buttonPad = shorthandInline( + button, + "padding", + ".camo-unit-btn padding", + ); + const buttonBorder = length( + /border:\s*([\d.]+(?:px|rem))/.exec(button)[1], + ".camo-unit-btn border", + ); + const gap = length( + /gap:\s*([^;]+);/.exec(rule(".camo-fleet"))[1], + ".camo-fleet gap", + ); + + // Everything the page and the card spend either side of the grid. + const around = + 2 * shorthandInline(rule("#app"), "padding", "#app padding") + + 2 * shorthandInline(rule(".card"), "padding", ".card padding"); + + const column = sprite + 2 * buttonPad + 2 * buttonBorder; + const threeWide = 3 * column + 2 * gap; + const available = REFLOW - around; + + if (threeWide <= available) return; // three still fit; nothing to reflow + + // They do not fit, so a narrow-width rule has to take a column away, and it + // has to fire before the grid stops fitting rather than at some width + // somebody liked the look of. + const narrow = + /@media \(max-width: (\d+)px\) \{\s*\.camo-fleet \{\s*grid-template-columns: repeat\((\d)/.exec( + css, + ); + assert.ok( + narrow, + `three columns need ${threeWide}px and a ${REFLOW}px viewport leaves ${available}px, ` + + `but no @media rule reduces .camo-fleet's columns`, + ); + + const [, threshold, columns] = narrow; + assert.ok( + Number(columns) < 3, + `the narrow rule still asks for ${columns} columns`, + ); + // The width at which three columns stop fitting. The breakpoint has to be at + // least this wide or there is a band of phones still scrolling. + const breaksAt = threeWide + around; + assert.ok( + Number(threshold) >= breaksAt, + `.camo-fleet drops to ${columns} columns at ${threshold}px, but three stop ` + + `fitting at ${breaksAt}px โ€” viewports between those two scroll sideways`, + ); + + const fewer = Number(columns) * column + (Number(columns) - 1) * gap; + assert.ok( + fewer <= available, + `${columns} columns need ${fewer}px and ${REFLOW}px leaves ${available}px`, + ); +}); -- 2.51.2