Stop the things that actually move for anyone who asked them not to

The stylesheet had honoured prefers-reduced-motion for a while, in the three
places it mattered least: 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, which for anyone with a vestibular disorder is
the part that matters. A button dipping under a thumb is not what makes people
ill.

Four things were doing it, and the three largest were in JavaScript where a
stylesheet cannot reach: the seat editor sheet, which slides the full height
of itself up from off-screen every time a dealer taps a chair; the toast; and
three scrollIntoView({ behavior: "smooth" }) calls, the most provocative of
the lot because what moves is the whole viewport rather than one control.

The scrolls go through Render.bringIntoView now, which reads the setting per
call rather than at page load -- a dealer who turns it on mid-session should
be taken seriously without reloading a table they cannot afford to reload. The
toast keeps its fade and loses its travel, because something that appears
already in place reads as a rendering glitch rather than as news. The sheet's
visibility delay flips at once, or it would leave a sheet that has already
jumped away still painted for a quarter of a second.

The press-down block held nine :active selectors and looked deliberate. It was
written to stop a stuck press state when a tap turns into a scroll, and had
reached for the reduced-motion query as the nearest proxy -- so it covered
.dealerChip and .raiseQuickBtn while missing .actionBtn, the class on Fold,
Call, Raise and Post blinds & deal. Read as its own rule the membership is not
a judgement call: every :hover and :active transform in the file belongs
there. There are sixteen. Nine were listed.

The scan that found the other seven took two goes. A line-by-line version
walked straight past a deliberately broken build, because the injected rule
was written on one line and neither "opens a rule" nor "is a transform"
matched. Parsing blocks caught it -- and then failed on the clean file, which
is how .toastAction, .toastClose and .potWizardChoice came to light: a
multi-line group had been truncated to its last line, so nothing had ever
looked at them.

910 tests pass, 16 of them new. Six script and stylesheet fault injections are
each caught, and the reduced/no-preference split is verified in a real browser
against both context settings.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-09 05:21:07 -06:00
co-authored by Claude Opus 5
parent 0d878a176d
commit fafbd8df32
6 changed files with 451 additions and 5 deletions
+75 -2
View File
@@ -249,17 +249,39 @@ button,
(`translate(-50%, -50%)` plus its own left/top). A press state setting `transform` (`translate(-50%, -50%)` plus its own left/top). A press state setting `transform`
would drop that translate and throw every pressed seat about 38px off its spot. */ would drop that translate and throw every pressed seat about 38px off its spot. */
/* Nobody should get a stuck press state from a tap that turned into a scroll. */ /* Nothing on this page moves under the pointer for anyone who asked it not to.
`:hover` and `:active` together, because they are the same 1px of travel and the
setting does not distinguish them.
This list used to hold nine `:active` selectors and its membership looked arbitrary,
because it was: it was written to stop a *stuck press state* when a tap turns into a
scroll, and it reached for the reduced-motion query as the nearest available proxy.
Solving one problem with another's tool is how it came to cover `.dealerChip` and
`.raiseQuickBtn` while missing `.actionBtn` — the class on Fold, Call, Raise and Post
blinds & deal, which is to say the most-tapped control in the tool — and every
`:hover` in the file.
Read as its own rule rather than as a side effect of that one, the membership is not
a judgement call at all: every `:hover` and `:active` transform in the stylesheet
belongs here. reducedMotion.test.js scans for them and fails on any that is missing,
so a new one cannot be added without this being updated. */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.holdemBtn:hover,
.holdemBtn:active:not(:disabled), .holdemBtn:active:not(:disabled),
.linkBtn:active:not(:disabled), .linkBtn:active:not(:disabled),
.seatMenuBtn:active:not(:disabled), .seatMenuBtn:active:not(:disabled),
.seatSheetClose:active:not(:disabled), .seatSheetClose:active:not(:disabled),
.cardSlot:hover,
.cardSlot:active, .cardSlot:active,
.pickerCard:hover:not(:disabled),
.pickerCard:active:not(:disabled), .pickerCard:active:not(:disabled),
.dealerChip:active, .dealerChip:active,
.raiseQuickBtn:active:not(:disabled), .raiseQuickBtn:active:not(:disabled),
.actionUndo:active:not(:disabled) { .actionBtn:active,
.actionUndo:active:not(:disabled),
.toastAction:active,
.toastClose:active,
.potWizardChoice:active {
transform: none; transform: none;
} }
} }
@@ -3579,6 +3601,33 @@ body.hasActionBar #toastHost {
transform: translateY(0); transform: translateY(0);
} }
/* Reduced motion keeps the fade and loses the travel.
This file already honoured the setting in three places, and all three were the wrong
size of thing: a 2px press-down on a button, a checkbox's tick, and the acting seat's
pulse. What it never covered was the motion that actually moves something across the
screen — and for anyone who gets motion sick, a 12px slide is the part that matters,
not a button dipping under a thumb.
Deleting the animation outright is the wrong answer too. A toast that simply appears,
already in place, reads as a rendering glitch rather than as news, and the same
guidance that says to cut the movement says to keep something: fade it instead, so
the interface still feels like it responded. */
@media (prefers-reduced-motion: reduce) {
#toastHost {
transition: none;
}
.toast {
transform: none;
transition: opacity 0.18s ease;
}
.toast.toastVisible {
transform: none;
}
}
.toastText { .toastText {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -4477,6 +4526,30 @@ body.hasActionBar #toastHost {
} }
} }
/* The seat sheet, for anyone who asked not to be moved.
This is the largest movement the tool makes: on a phone the seat editor is a bottom
sheet that travels the full height of itself, up from off-screen, every time a dealer
taps a chair — which on a busy table is most of the taps they make all night.
Placed after the 700px block rather than inside it because the rule it overrides is an
ID selector: at equal specificity the later one wins, and a copy sitting earlier in
the file would have looked right and done nothing.
The visibility delay has to go with the transform, and that is the part worth being
careful about. It exists so the slide-out stays on screen for its own 0.25s; with the
slide gone, the same delay leaves a sheet that has already jumped away still painted
for a quarter of a second. So it flips at once here, in both directions. */
@media (max-width: 700px) and (prefers-reduced-motion: reduce) {
#seatEditorPanel {
transition: visibility 0s linear 0s;
}
body.seatSheetOpen #seatEditorPanel {
transition: visibility 0s;
}
}
/* ---------- A phone on its side ---------- /* ---------- A phone on its side ----------
Deliberately last in the file. The felt is an aspect-ratio box whose height comes Deliberately last in the file. The felt is an aspect-ratio box whose height comes
from `padding-bottom`, and the `max-width: 900px` block above re-sets that to 100% from `padding-bottom`, and the `max-width: 900px` block above re-sets that to 100%
@@ -1018,7 +1018,7 @@ const ActionBar = {
if (!field) return; if (!field) return;
const panel = field.closest("details"); const panel = field.closest("details");
if (panel) panel.open = true; if (panel) panel.open = true;
field.scrollIntoView({ block: "center", behavior: "smooth" }); Render.bringIntoView(field, { block: "center" });
field.focus(); field.focus();
field.select(); field.select();
})); }));
@@ -373,7 +373,7 @@ document.addEventListener("DOMContentLoaded", () => {
const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat)); const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat));
Render.toggleSeatEditor(index < 0 ? 0 : index); Render.toggleSeatEditor(index < 0 ? 0 : index);
const panel = document.getElementById("seatEditorPanel"); const panel = document.getElementById("seatEditorPanel");
if (panel) panel.scrollIntoView({ block: "nearest", behavior: "smooth" }); Render.bringIntoView(panel, { block: "nearest" });
}, },
beginAction: label => { beginAction: label => {
@@ -852,7 +852,7 @@ document.addEventListener("DOMContentLoaded", () => {
// costs a screen reader the sentence. // costs a screen reader the sentence.
if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent); if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent);
jump.addEventListener("click", () => { jump.addEventListener("click", () => {
section.scrollIntoView({ block: "start", behavior: "smooth" }); Render.bringIntoView(section, { block: "start" });
// The heading is what the dealer is now reading, so that is where the // The heading is what the dealer is now reading, so that is where the
// keyboard and a screen reader should be too. // keyboard and a screen reader should be too.
heading.setAttribute("tabindex", "-1"); heading.setAttribute("tabindex", "-1");
@@ -47,6 +47,35 @@ const Render = {
if (typeof WakeLock !== "undefined") WakeLock.sync(this.state); if (typeof WakeLock !== "undefined") WakeLock.sync(this.state);
}, },
// ---- Bringing something into view, at the speed the reader asked for ----
//
// Three places scroll something into view — the raise pad's amount field, the seat
// editor sheet, and the sidebar section a shortcut jumps to — and all three asked
// for "smooth" unconditionally.
//
// A smooth scroll is the most provocative motion a page can make for somebody with a
// vestibular disorder, because what moves is the whole viewport rather than one
// control. The stylesheet has honoured `prefers-reduced-motion` for a while; the
// scripts were never asked to, and between them they held the three largest
// movements in the tool.
//
// Read per call rather than cached. The setting can change while the page is open,
// and a dealer who turns it on mid-session should not have to reload the table to be
// taken seriously.
prefersReducedMotion() {
return typeof matchMedia === "function"
&& matchMedia("(prefers-reduced-motion: reduce)").matches;
},
// Same arguments as scrollIntoView, minus the one the caller shouldn't be choosing.
bringIntoView(el, options = {}) {
if (!el || typeof el.scrollIntoView !== "function") return;
el.scrollIntoView({
...options,
behavior: this.prefersReducedMotion() ? "auto" : "smooth",
});
},
renderSetupFields() { renderSetupFields() {
document.getElementById("seatCountInput").value = this.state.seatCount; document.getElementById("seatCountInput").value = this.state.seatCount;
document.getElementById("directionSelect").value = this.state.direction; document.getElementById("directionSelect").value = this.state.direction;
+62
View File
@@ -621,6 +621,68 @@ still names whoever didn't turn over; closing it leaves **Showdown — resolve p
bar; and pressing it again resumes at the player the dealer stopped on, because the list bar; and pressing it again resumes at the player the dealer stopped on, because the list
is computed from the cards on the table rather than remembered from the last attempt. is computed from the cards on the table rather than remembered from the last attempt.
## The motion setting was honoured in the three places it mattered least
The stylesheet had answered `prefers-reduced-motion` for a while. All three places it did
were the wrong size of thing: a 2px press-down on a button, a checkbox's 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, and the three largest were in JavaScript, where a stylesheet
cannot reach:
- the seat editor, a bottom sheet that slides the full height of itself up from
off-screen every time a dealer taps a chair, which on a busy table is most of the taps
they make all night;
- the toast, sliding in from below;
- and three `scrollIntoView({ behavior: "smooth" })` calls — the most provocative of the
lot, because what moves is the whole viewport rather than one control.
The scroll calls now go through `Render.bringIntoView`, which reads the setting per call
rather than at page load: a dealer who turns it on mid-session should be taken seriously
without reloading, and the table is a thing they cannot afford to reload lightly.
The toast keeps its fade and loses its travel. Deleting the animation outright is the
wrong answer — something that appears already in place reads as a rendering glitch rather
than as news — and the guidance that says to cut movement says to keep *something*.
The sheet needed one more thing than the obvious. Its `visibility` transition carries a
0.25s delay so the slide-out stays on screen for its own animation; with the slide gone,
that same delay leaves a sheet that has already jumped away still painted for a quarter
of a second. It flips at once under reduced motion, in both directions.
### A list whose membership was arbitrary, because it was solving another problem
The press-down block held nine `:active` selectors and looked deliberate. It wasn't. It
had been written to stop a *stuck press state* when a tap turns into a scroll, and it
reached for the reduced-motion query as the nearest available proxy — so it covered
`.dealerChip` and `.raiseQuickBtn` while missing `.actionBtn`, the class on Fold, Call,
Raise and Post blinds & deal, which is to say the most-tapped control in the tool.
Read as its own rule, membership is not a judgement call: **every `:hover` and `:active`
transform in the file belongs there.** There are sixteen. Nine were listed. The other
seven are now.
### The scan that found the last three, and the injection that found the scan
The general test scans the stylesheet for pointer transforms and fails on any without an
escape, so a new one cannot be added without the block being updated. It took two goes,
and both faults are worth keeping written down.
The first version scanned line by line: remember the last line ending in `{`, then look
for lines starting with `transform:`. A deliberately broken version was run at it — a new
hover lift, uncovered — and the scan **passed**. The rule had been written on one line,
so the selector and the declaration were in the same string and neither pattern matched.
Rewritten to parse blocks, it caught that. It also immediately failed on the *clean*
file, which is how the last three came to light: a multi-line comma-separated group had
been truncated to its final line by the old scan, so `.toastAction`, `.toastClose` and
`.potWizardChoice` had never been looked at by anything. A grouped rule is nine separate
promises, and it is now split on commas and checked one at a time — comparing the joined
string against the block would only have passed if both lists happened to be written in
the same order.
## Two pixels, and every seat on the felt paid for them ## Two pixels, and every seat on the felt paid for them
The sweep had carried the same residual for several rounds: on the two smallest phones The sweep had carried the same residual for several rounds: on the two smallest phones
+282
View File
@@ -0,0 +1,282 @@
// 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");
});
});