diff --git a/frontend/packages/swarm-ui/src/shell/MatrixRain.tsx b/frontend/packages/swarm-ui/src/shell/MatrixRain.tsx index 95c37298..da960376 100644 --- a/frontend/packages/swarm-ui/src/shell/MatrixRain.tsx +++ b/frontend/packages/swarm-ui/src/shell/MatrixRain.tsx @@ -3,8 +3,9 @@ // assistive tech and takes no pointer events. The glyphs take the live // `--green` and the fade trail the live `--bg`, so a theme swap recolours it. // Stepped on an interval (the steppy cadence is the look, and it costs -// little), paused while the tab is hidden, and not drawn at all under -// `prefers-reduced-motion`. The same effect as the hive dashboard's home +// little), paused while the tab is hidden, and stopped (and cleared) when +// motion is reduced: the settings menu's `data-motion` override wins over +// `prefers-reduced-motion`, and is followed live. The same effect as the hive dashboard's home // page, which is plain JS over a fixed canvas. import { useEffect, useRef } from "preact/hooks"; import { @@ -12,6 +13,7 @@ import { columnsFor, fadeColorOf, glyphFor, + rainAllowed, startDrops, stepDrops, } from "./rain.js"; @@ -39,11 +41,11 @@ export function MatrixRain() { const canvas = ref.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) return; - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; - // Resolved again whenever the theme can have changed: the shell applies - // its theme override from an effect that runs after this one, and a - // later switch from the settings menu changes it too. + // Resolved again whenever the theme or motion setting can have changed: + // the shell applies its overrides (`data-theme`, `data-motion`) from an + // effect that runs after this one, and a later switch from the settings + // menu changes them too. let glyphColor = ""; let fadeColor = ""; function resolveColors() { @@ -51,10 +53,8 @@ export function MatrixRain() { fadeColor = fadeColorOf(resolveColor("--bg", "rgb(30, 30, 46)")); } resolveColors(); - const themeWatch = new MutationObserver(resolveColors); - themeWatch.observe(document.documentElement, { attributes: true }); + const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); const schemeQuery = window.matchMedia("(prefers-color-scheme: dark)"); - schemeQuery.addEventListener("change", resolveColors); let cols = 0; let drops: number[] = []; @@ -79,6 +79,9 @@ export function MatrixRain() { drops = stepDrops(drops, canvas.height, Math.random); } + const allowed = () => + rainAllowed(document.documentElement.dataset.motion, motionQuery.matches); + let timer: number | null = null; const play = () => { if (timer === null) timer = window.setInterval(tick, STEP_MS); @@ -89,18 +92,40 @@ export function MatrixRain() { timer = null; } }; - const onVisibility = () => (document.hidden ? pause() : play()); + + // Runs the rain when motion is allowed and the tab is visible; otherwise + // stops it and clears what was drawn. Safe to call on any change. + function sync() { + if (allowed() && !document.hidden) { + play(); + } else { + pause(); + if (!allowed() && canvas && ctx) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + } + } + } + const onSettingsChange = () => { + resolveColors(); + sync(); + }; + + const watch = new MutationObserver(onSettingsChange); + watch.observe(document.documentElement, { attributes: true }); + schemeQuery.addEventListener("change", onSettingsChange); + motionQuery.addEventListener("change", onSettingsChange); resize(); window.addEventListener("resize", resize); - document.addEventListener("visibilitychange", onVisibility); - play(); + document.addEventListener("visibilitychange", sync); + sync(); return () => { pause(); - themeWatch.disconnect(); - schemeQuery.removeEventListener("change", resolveColors); + watch.disconnect(); + schemeQuery.removeEventListener("change", onSettingsChange); + motionQuery.removeEventListener("change", onSettingsChange); window.removeEventListener("resize", resize); - document.removeEventListener("visibilitychange", onVisibility); + document.removeEventListener("visibilitychange", sync); }; }, []); diff --git a/frontend/packages/swarm-ui/src/shell/rain.ts b/frontend/packages/swarm-ui/src/shell/rain.ts index 12525b8a..a1d4a927 100644 --- a/frontend/packages/swarm-ui/src/shell/rain.ts +++ b/frontend/packages/swarm-ui/src/shell/rain.ts @@ -19,6 +19,19 @@ const RESTART_ABOVE = 0.975; /** The fade trail's alpha. */ const FADE_ALPHA = 0.09; +/** Whether the rain may move. The app's own motion setting (`data-motion` on + * the document, from the settings menu) wins over the OS preference in either + * direction: `reduce` stops it, `allow` lets it run on a reduce-motion OS, and + * anything else follows the OS. */ +export function rainAllowed( + dataMotion: string | undefined, + osReduces: boolean, +): boolean { + if (dataMotion === "reduce") return false; + if (dataMotion === "allow") return true; + return !osReduces; +} + /** How many columns fill a canvas this wide. */ export function columnsFor(width: number): number { return Math.max(1, Math.ceil(width / CELL)); diff --git a/frontend/packages/swarm-ui/test/rain.test.mjs b/frontend/packages/swarm-ui/test/rain.test.mjs index 2e774f66..4dbef80a 100644 --- a/frontend/packages/swarm-ui/test/rain.test.mjs +++ b/frontend/packages/swarm-ui/test/rain.test.mjs @@ -9,12 +9,29 @@ import { columnsFor, fadeColorOf, glyphFor, + rainAllowed, startDrops, stepDrops, } from "../src/shell/rain.ts"; const always = (n) => () => n; +test("the app's reduce setting stops the rain whatever the OS says", () => { + assert.equal(rainAllowed("reduce", false), false); + assert.equal(rainAllowed("reduce", true), false); +}); + +test("the app's allow setting runs the rain even on a reduce-motion OS", () => { + assert.equal(rainAllowed("allow", true), true); + assert.equal(rainAllowed("allow", false), true); +}); + +test("with no app setting the rain follows the OS", () => { + assert.equal(rainAllowed(undefined, false), true); + assert.equal(rainAllowed(undefined, true), false); + assert.equal(rainAllowed("", true), false); +}); + test("a canvas gets one column per cell, rounded up, and at least one", () => { assert.equal(columnsFor(CELL * 10), 10); assert.equal(columnsFor(CELL * 10 + 1), 11);