From 83b5f0bd8927e1a942c06b1b33d3ab67d635fa3f Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 14 Oct 2026 17:28:36 +0200 Subject: [PATCH] swarm-ui: the start page's rain follows the app's motion setting The rain read only the OS reduced-motion query, once, at mount. The settings menu's motion override (data-motion on the document) wins over the OS in either direction everywhere else, and the shell applies it from an effect that runs after this one. Whether the rain may move is now rainAllowed(), and the component re-evaluates it on mount, on every document attribute change and on the media query's change, starting or stopping the interval and clearing the canvas. --- .../swarm-ui/src/shell/MatrixRain.tsx | 55 ++++++++++++++----- frontend/packages/swarm-ui/src/shell/rain.ts | 13 +++++ frontend/packages/swarm-ui/test/rain.test.mjs | 17 ++++++ 3 files changed, 70 insertions(+), 15 deletions(-) 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);