// — the falling-glyph "digital rain" backdrop, drawn on a canvas // that fills its positioned parent. Purely decorative, so it is hidden from // 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 stopped (and cleared) when // motion is reduced: the settings menu's `data-motion` override wins over // `prefers-reduced-motion`, and is followed live. import { useEffect, useRef } from "preact/hooks"; import { CELL, columnsFor, fadeColorOf, glyphFor, rainAllowed, startDrops, stepDrops, } from "./rain.js"; import "./MatrixRain.css"; const STEP_MS = 55; // `getComputedStyle` on a custom property returns its declared value // unresolved (`var(--x)`), so resolve a concrete rgb() by computing a real // `color` on a probe element instead. function resolveColor(varName: string, fallback: string): string { const probe = document.createElement("span"); probe.style.color = `var(${varName})`; probe.style.display = "none"; document.body.appendChild(probe); const c = getComputedStyle(probe).color; probe.remove(); return /^rgb/.test(c) ? c : fallback; } export function MatrixRain() { const ref = useRef(null); useEffect(() => { const canvas = ref.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) return; // 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() { glyphColor = resolveColor("--green", "rgb(166, 227, 161)"); fadeColor = fadeColorOf(resolveColor("--bg", "rgb(30, 30, 46)")); } resolveColors(); const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); const schemeQuery = window.matchMedia("(prefers-color-scheme: dark)"); let cols = 0; let drops: number[] = []; function resize() { if (!canvas || !ctx) return; canvas.width = canvas.offsetWidth; canvas.height = canvas.offsetHeight; cols = columnsFor(canvas.width); drops = startDrops(cols, Math.random); ctx.font = `${CELL}px monospace`; // a canvas resize resets the font } function tick() { if (!canvas || !ctx) return; ctx.fillStyle = fadeColor; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = glyphColor; for (let i = 0; i < cols; i++) { ctx.fillText(glyphFor(Math.random), i * CELL, (drops[i] ?? 0) * CELL); } 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); }; const pause = () => { if (timer !== null) { window.clearInterval(timer); timer = null; } }; // 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", sync); sync(); return () => { pause(); watch.disconnect(); schemeQuery.removeEventListener("change", onSettingsChange); motionQuery.removeEventListener("change", onSettingsChange); window.removeEventListener("resize", resize); document.removeEventListener("visibilitychange", sync); }; }, []); return