// Motion, for the people who asked for less of it. // // The stylesheet had honoured `prefers-reduced-motion` for a while, and all three places // it did were the wrong size of thing: a 2px press-down on a button, a checkbox tick, // and the acting seat's pulse. What none of them covered was motion that moves something // across the screen — and for anyone with a vestibular disorder that is the part that // matters. A button dipping under a thumb is not what makes people ill. // // Four things were doing it. A seat editor that slides the full height of itself up from // off-screen, every time a dealer taps a chair. A toast that slides in from below. And // three `scrollIntoView({ behavior: "smooth" })` calls, which are the most provocative of // the lot, because what moves is the whole viewport rather than one control. // // The scripts are checked here against a stand-in for matchMedia; the stylesheet is // checked against its own text, the way the seat footprint rules already are. const test = require("node:test"); const { describe, afterEach } = require("node:test"); const assert = require("node:assert"); const fs = require("fs"); const path = require("path"); const { Render } = require("./load"); const CSS = fs.readFileSync( path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8"); // ---- The scripts ------------------------------------------------------------ // Node has no matchMedia at all, so this installs one rather than replacing one. It is // defined as a plain value here for the same reason `navigator` had to be defined with // defineProperty in wakeLock.test.js: assigning to a global the runtime owns can be a // silent no-op, and a test that thinks it installed a fake and didn't passes for the // wrong reason. const setReducedMotion = wanted => { Object.defineProperty(globalThis, "matchMedia", { value: query => ({ matches: wanted && /prefers-reduced-motion:\s*reduce/.test(query), media: query, }), configurable: true, writable: true, }); }; const unsetMatchMedia = () => { delete globalThis.matchMedia; }; // An element that records what it was asked to do, and nothing else. const fakeElement = () => { const calls = []; return { calls, scrollIntoView(options) { calls.push(options); } }; }; afterEach(unsetMatchMedia); describe("what the tool reads off the setting", () => { test("a browser asking for less motion is heard", () => { setReducedMotion(true); assert.equal(Render.prefersReducedMotion(), true); }); test("and one that isn't, isn't", () => { setReducedMotion(false); assert.equal(Render.prefersReducedMotion(), false); }); test("a browser too old to have the question is not assumed to be answering it", () => { // No matchMedia at all. The safe reading is "no preference stated", which leaves // the tool behaving exactly as it did — not "reduce", which would quietly take // the animation away from everybody on an old browser. unsetMatchMedia(); assert.equal(Render.prefersReducedMotion(), false); }); }); describe("bringing something into view", () => { test("scrolls smoothly when nobody has asked otherwise", () => { setReducedMotion(false); const el = fakeElement(); Render.bringIntoView(el, { block: "center" }); assert.deepEqual(el.calls, [{ block: "center", behavior: "smooth" }]); }); test("and jumps when they have", () => { setReducedMotion(true); const el = fakeElement(); Render.bringIntoView(el, { block: "center" }); assert.deepEqual(el.calls, [{ block: "center", behavior: "auto" }]); }); test("the caller still chooses where it lands, only not how it gets there", () => { // Each of the three callers wants a different alignment — the raise field // centred, the seat sheet nearest, the sidebar section at the top — and the // helper must not flatten that into one answer. setReducedMotion(true); for (const block of ["center", "nearest", "start"]) { const el = fakeElement(); Render.bringIntoView(el, { block }); assert.equal(el.calls[0].block, block); } }); test("a caller that never chose gets a plain scroll rather than an exception", () => { setReducedMotion(false); const el = fakeElement(); Render.bringIntoView(el); assert.equal(el.calls[0].behavior, "smooth"); }); test("nothing to scroll is not an error", () => { // main.js reaches for #seatEditorPanel by id and passes whatever it finds. setReducedMotion(true); assert.doesNotThrow(() => Render.bringIntoView(null)); assert.doesNotThrow(() => Render.bringIntoView(undefined)); assert.doesNotThrow(() => Render.bringIntoView({})); }); test("the setting is re-read every time, not remembered from page load", () => { // A dealer who turns reduced motion on mid-session should be taken seriously // without reloading the table — and the table is a thing they cannot afford to // reload lightly. setReducedMotion(false); const el = fakeElement(); Render.bringIntoView(el, { block: "start" }); setReducedMotion(true); Render.bringIntoView(el, { block: "start" }); assert.deepEqual(el.calls.map(c => c.behavior), ["smooth", "auto"]); }); }); // ---- The stylesheet --------------------------------------------------------- // Everything inside the @media block whose condition matches, concatenated — there is // more than one reduced-motion block in the file and they govern different things. const reducedMotionBlocks = (extraCondition = null) => { const found = []; const needle = "@media "; for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) { const open = CSS.indexOf("{", at); const condition = CSS.slice(at + needle.length, open).trim(); if (!/prefers-reduced-motion:\s*reduce/.test(condition)) continue; if (extraCondition && !condition.includes(extraCondition)) continue; let depth = 0; let i = open; for (; i < CSS.length; i++) { if (CSS[i] === "{") depth++; else if (CSS[i] === "}" && --depth === 0) break; } found.push(CSS.slice(open + 1, i)); } return found; }; describe("the stylesheet stops the things that actually travel", () => { test("there is more than one reduced-motion block, and one of them is for the sheet", () => { assert.ok(reducedMotionBlocks().length >= 2, "the press-down block alone was never the whole job"); assert.equal(reducedMotionBlocks("max-width").length, 1, "the seat sheet's override has to carry the same width condition as the rule it beats"); }); test("the toast keeps its fade and loses its travel", () => { const all = reducedMotionBlocks().join("\n"); const toast = all.slice(all.indexOf(".toast")); assert.match(toast, /transform:\s*none/, "a toast sliding up from the bottom edge is exactly the motion being asked about"); // The guidance is not "delete the animation": something that appears already in // place reads as a rendering glitch rather than as news. Opacity survives. assert.match(toast, /transition:\s*opacity/, "the fade has to stay, or the toast stops reading as something that just happened"); }); test("the seat sheet stops sliding, and stops being painted after it has gone", () => { const [sheet] = reducedMotionBlocks("max-width"); assert.ok(sheet, "the sheet override should exist"); assert.doesNotMatch(sheet, /transition:[^;]*transform/, "the transform transition is the slide, and it is the largest movement here"); // The delay is what kept the slide-out on screen for its own quarter second. // With the slide gone it would leave a sheet that has already jumped away still // painted, which is a flicker rather than a courtesy. const flips = sheet.match(/transition:\s*visibility\s+0s[^;]*/g) || []; assert.equal(flips.length, 2, "both directions need saying: the rule that puts the sheet away and the one that opens it"); assert.ok(flips.every(rule => /^transition:\s*visibility\s+0s(\s+linear\s+0s)?\s*$/.test(rule.trim())), `visibility has to flip at once in both directions once there is no slide to cover: ${flips}`); assert.doesNotMatch(sheet, /linear\s+0\.\d+s/, "no leftover delay measured against an animation that no longer runs"); }); }); // ---- And the same rule, applied to whatever the file grows next -------------- // // The three checks above name the things that were wrong. This one is the general // version, and it is the one that will still be doing work in a year: it scans the // stylesheet for every `:hover` and `:active` transform there is and insists each one // has an off switch. // // Written because the existing block's membership had gone wrong in a way no // enumerated test would have caught. It held nine `:active` selectors and looked // deliberate, but it had been written to stop a *stuck press state* when a tap turns // into a scroll — reaching for the reduced-motion query as the nearest proxy — and // solving one problem with another's tool is how it came to cover `.dealerChip` and // `.raiseQuickBtn` while missing `.actionBtn`, the class on every button in the action // bar, and every `:hover` in the file. describe("every hover and press in the file has an off switch", () => { // Selectors that move something under the pointer. // // Parsed as blocks rather than scanned line by line, and that distinction is not // pedantry — the line-by-line version was written first and a fault injection walked // straight past it. A rule written on one line puts its selector and its declaration // in the same string, so neither "this line opens a rule" nor "this line is a // transform" matched, and the scan reported a clean file while the lift it was meant // to find sat in plain sight. // // The pattern matches innermost blocks only: a selector cannot contain braces, so an // @media wrapper's body never matches and its contents are reached individually. const pointerTransforms = () => { const withoutComments = CSS.replace(/\/\*[\s\S]*?\*\//g, ""); const found = []; for (const [, selector, body] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { const name = selector.trim(); if (name.startsWith("@")) continue; if (!name.includes(":hover") && !name.includes(":active")) continue; const moves = body.match(/(^|[;{\s])transform\s*:\s*([^;]+)/); if (!moves || /^none\b/.test(moves[2].trim())) continue; // Split the group. A comma-separated rule is nine separate promises, and // checking the joined string against the reduce block is a test that can only // pass if both lists happen to be written in the same order — which is to say // a test that passes by luck. It also hid three selectors outright: the // line-scanning version kept only the last line of a multi-line group, so // .toastAction, .toastClose and .potWizardChoice were never looked at. for (const one of name.split(",")) { const selector = one.trim().replace(/\s+/g, " "); if (!selector.includes(":hover") && !selector.includes(":active")) continue; found.push({ selector, declaration: moves[2].trim() }); } } return found; }; const reduceText = () => reducedMotionBlocks().join("\n"); test("there are some to check, or this test is measuring nothing", () => { // The guard the accent check taught us to write: a scan that finds nothing // passes just as green as a scan that finds everything covered. assert.ok(pointerTransforms().length >= 4, "the file should still have hover/press transforms in it"); }); test("and every one of them is turned off under reduced motion", () => { const text = reduceText(); // A selector counts as covered when it appears in a reduce block, comma-separated // list and all. Compared with whitespace flattened, because the reduce block puts // each selector on its own line and the rule it answers may not. const flat = text.replace(/\s+/g, " "); const missing = pointerTransforms() .filter(rule => !flat.includes(rule.selector)) .map(rule => `${rule.selector} { transform: ${rule.declaration} }`); assert.deepEqual(missing, [], `these move under the pointer with no reduced-motion escape:\n ${missing.join("\n ")}`); }); test("including the one a dealer touches most", () => { // Named on its own as well as caught by the scan, because this is the specific // omission that prompted the scan: Fold, Call, Raise and Post blinds & deal all // carry .actionBtn, and it was the one control missing from a list of nine. assert.match(reduceText(), /\.actionBtn:active/, "the action bar's own buttons are the most-pressed thing in the tool"); }); test("a looping animation only runs where motion is welcome", () => { // The acting seat breathes. An infinite animation is the one kind that cannot be // waited out, so it has to be inside a no-preference block rather than merely // overridden in a reduce one. const noPreference = CSS.indexOf("@media (prefers-reduced-motion: no-preference)"); assert.ok(noPreference > 0, "the pulse should sit behind a no-preference query"); const infinite = [...CSS.matchAll(/animation:[^;]*infinite/g)].map(m => m.index); assert.ok(infinite.length > 0, "there should still be a looping animation to check"); assert.ok(infinite.every(at => at > noPreference), "every looping animation has to sit inside the no-preference block"); }); });