import { onCleanup, onMount } from "solid-js"; interface RuneBackgroundProps { opacity?: number; /** When true, a handful of runes pulse with the accent color at random * screen positions, mirroring the Android lock-screen's RuneGridBackground * glow. Reads `--peach-accent` from the root element. */ accentGlow?: boolean; } const FONT_SIZE = 14; // Wider row + column spacing so the visible rune count is ~40% of the // original spec — quieter, more deliberate drift. Opacity stays the same. const LINE_H = FONT_SIZE * 3.8; const CHAR_W = FONT_SIZE * 2.2; // Three slow drift speeds (px/sec) — much quieter than the export canvas. // Atmosphere, not focus. const SPEEDS = [8, 11, 6]; const RUNE_LEN = 120; const FALLBACK_ACCENT = "#F49C70"; // Deterministic rune string drawn from the PeachScript PUA range // (U+E000..U+E0FF). Same characters every render so the background // has a consistent texture across mounts. 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(""); } function readAccentHex(): string { const raw = getComputedStyle(document.documentElement) .getPropertyValue("--peach-accent") .trim(); if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw; return FALLBACK_ACCENT; } function hexToRgb(hex: string): [number, number, number] { hex = hex.replace("#", ""); return [ parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16), ]; } export function RuneBackground(props: RuneBackgroundProps) { let canvas!: HTMLCanvasElement; let raf = 0; let running = false; onMount(() => { const ctx = canvas.getContext("2d"); if (!ctx) return; const W = canvas.width; const H = canvas.height; const baseOpacity = props.opacity ?? 0.07; 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, ); // ── Accent glow (lock screen) ───────────────────────────────── let accentHex = readAccentHex(); let accentRgb = hexToRgb(accentHex); const accentObserver = new MutationObserver(() => { const next = readAccentHex(); if (next !== accentHex) { accentHex = next; accentRgb = hexToRgb(next); } }); accentObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["style"], }); interface Glow { row: number; col: number; phase: number; speed: number; lifetime: number; age: number; } const spawn = (): Glow => ({ row: Math.floor(Math.random() * NUM_ROWS), col: Math.floor(Math.random() * CHARS_PER_ROW), phase: Math.random(), speed: 0.4 + Math.random() * 0.4, lifetime: 2000 + Math.random() * 3000, age: 0, }); const GLOW_COUNT = 8; const glows: Glow[] = props.accentGlow ? Array.from({ length: GLOW_COUNT }, spawn) : []; const glowMap = new Map(); 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"; // Advance glow timers glowMap.clear(); if (props.accentGlow) { const [ar, ag, ab] = accentRgb; for (const g of glows) { g.age += dt; g.phase = (g.phase + (g.speed * dt) / 1000) % 1; const lifetimeFraction = g.age / g.lifetime; const lifetimeEnvelope = Math.sin(lifetimeFraction * Math.PI); const pulseAlpha = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5; const alpha = Math.max(0, lifetimeEnvelope) * pulseAlpha * 0.85; glowMap.set(`${g.row},${g.col}`, alpha); if (g.age >= g.lifetime) Object.assign(g, spawn()); } } for (let row = 0; row < NUM_ROWS; row++) { const speed = SPEEDS[row % SPEEDS.length]; 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; const glowAlpha = glowMap.get(`${row},${col}`) ?? 0; if (glowAlpha > 0.01) { const [ar, ag, ab] = accentRgb; ctx.save(); ctx.shadowColor = `rgba(${ar}, ${ag}, ${ab}, ${glowAlpha * 0.9})`; ctx.shadowBlur = 10; ctx.fillStyle = `rgba(${ar}, ${ag}, ${ab}, ${0.6 + glowAlpha * 0.4})`; ctx.fillText(runes[idx]!, x, y); ctx.restore(); } else { ctx.fillStyle = `rgba(245, 240, 234, ${baseOpacity})`; 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 fonts = document.fonts; if (fonts) { fonts.load(`${FONT_SIZE}px PeachScript`).finally(start); } else { start(); } onCleanup(() => { stop(); document.removeEventListener("visibilitychange", onVisibility); accentObserver.disconnect(); }); }); return ( { canvas = el; }} width={320} height={520} class="peach-rune-background" aria-hidden="true" /> ); }