import { onCleanup, onMount } from "solid-js"; /** * RecoveryRuneBackground — a sparse, high-contrast rune canvas for recovery * screens only. * * Per the spec: ~50% fewer runes than the lock screen, opacity 0.12–0.20, * accent color tint, erratic movement, 20–45 second dwell, max 6 visible. * The erratic movement is achieved by varying drift speed per frame using * Perlin-like noise approximations from frame to frame. */ interface RecoveryRuneBackgroundProps { /** Accent color to tint the runes with. Defaults to the brand accent. */ accentColor?: string; } // Peach brand accent color used for default tinting const DEFAULT_ACCENT = "rgba(229, 128, 92, 0.16)"; // #E5805C at ~16% opacity // Fewer rows = fewer total visible runes const FONT_SIZE = 14; const LINE_H = FONT_SIZE * 5.2; // wider row spacing → fewer visible rows const CHAR_W = FONT_SIZE * 2.2; const SPEEDS = [6, 9, 12, 7, 14]; // varied base speeds per row const RUNE_LEN = 60; // 50% fewer runes than RuneBackground's 120 function buildRuneString(): string { const out: string[] = []; for (let i = 0; i < RUNE_LEN; i++) { out.push(String.fromCodePoint(0xe000 + ((i * 37 + 13) & 0xff))); } return out.join(""); } /** Simple smooth noise using sine — deterministic per row/frame offset. */ function smoothNoise(x: number, t: number): number { return (Math.sin(x * 0.07 + t * 0.0004) * 0.5 + Math.sin(x * 0.13 - t * 0.0006) * 0.3 + Math.sin(x * 0.19 + t * 0.0003) * 0.2); } export function RecoveryRuneBackground(props: RecoveryRuneBackgroundProps) { let canvas: HTMLCanvasElement | undefined; let raf = 0; let running = false; // Track per-row accumulated time so we get erratic (non-linear) drift const rowTime: number[] = []; onMount(() => { if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const W = canvas.width; const H = canvas.height; // 0.12–0.20 opacity range; use a mid-range default const baseOpacity = 0.15; const accentColor = props.accentColor ?? DEFAULT_ACCENT; const runes = buildRuneString(); const NUM_ROWS = Math.ceil(H / LINE_H) + 1; const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 4; const TOTAL_W = runes.length * CHAR_W; const offsets = Array.from({ length: NUM_ROWS }, () => Math.random() * TOTAL_W, ); // Each row has a random base speed multiplier for variety (0.6–1.4×) const speedMults = Array.from({ length: NUM_ROWS }, () => 0.6 + Math.random() * 0.8, ); // Initialize per-row time for erratic motion for (let i = 0; i < NUM_ROWS; i++) { rowTime[i] = Math.random() * 30000; } let last = performance.now(); const frame = (now: number) => { const dt = Math.min(now - last, 64); last = now; ctx.clearRect(0, 0, W, H); ctx.font = `${FONT_SIZE}px PeachScript, monospace`; ctx.textBaseline = "alphabetic"; for (let row = 0; row < NUM_ROWS; row++) { rowTime[row] += dt; // Smooth noise modulation: makes drift speed feel erratic, not linear const noise = smoothNoise(row, rowTime[row]); const speed = SPEEDS[row % SPEEDS.length] * speedMults[row] * (1 + noise * 0.4); offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W; const y = row * LINE_H + FONT_SIZE + 8; const baseIdx = Math.floor(offsets[row] / CHAR_W); const xOffset = offsets[row] % CHAR_W; for (let col = 0; col < CHARS_PER_ROW; col++) { const x = col * CHAR_W - xOffset; if (x < -CHAR_W || x > W + CHAR_W) continue; const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length; // Vary opacity per character using smooth noise for depth const charNoise = Math.abs(smoothNoise(row * 3 + col, rowTime[row])); const charOpacity = baseOpacity * (0.6 + charNoise * 0.8); ctx.fillStyle = accentColor.replace(/[\d.]+\)$/, `${charOpacity.toFixed(2)})`); ctx.fillText(runes[idx]!, x, y); } } raf = requestAnimationFrame(frame); }; const start = () => { if (running) return; running = true; last = performance.now(); raf = requestAnimationFrame(frame); }; const stop = () => { if (!running) return; running = false; cancelAnimationFrame(raf); }; const onVisibility = () => { if (document.hidden) stop(); else start(); }; document.addEventListener("visibilitychange", onVisibility); const fontReady = document.fonts?.load(`${FONT_SIZE}px PeachScript`); if (fontReady) fontReady.finally(start); else start(); onCleanup(() => { stop(); document.removeEventListener("visibilitychange", onVisibility); }); }); return ( { canvas = el; }} width={320} height={520} class="peach-rune-background peach-rune-background--recovery" aria-hidden="true" /> ); }