1import { onCleanup, onMount } from "solid-js";23interface RuneBackgroundProps {4 opacity?: number;5 678 accentGlow?: boolean;9}1011const FONT_SIZE = 14;121314const LINE_H = FONT_SIZE * 3.8;15const CHAR_W = FONT_SIZE * 2.2;161718const SPEEDS = [8, 11, 6];19const RUNE_LEN = 120;20const FALLBACK_ACCENT = "#F49C70";2122232425function buildRuneString(): string {26 const out: string[] = [];27 for (let i = 0; i < RUNE_LEN; i++) {28 out.push(String.fromCodePoint(0xe000 + ((i * 37 + 13) & 0xff)));29 }30 return out.join("");31}3233function readAccentHex(): string {34 const raw = getComputedStyle(document.documentElement)35 .getPropertyValue("--peach-accent")36 .trim();37 if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;38 return FALLBACK_ACCENT;39}4041function hexToRgb(hex: string): [number, number, number] {42 hex = hex.replace("#", "");43 return [44 parseInt(hex.slice(0, 2), 16),45 parseInt(hex.slice(2, 4), 16),46 parseInt(hex.slice(4, 6), 16),47 ];48}4950export function RuneBackground(props: RuneBackgroundProps) {51 let canvas!: HTMLCanvasElement;52 let raf = 0;53 let running = false;5455 onMount(() => {56 const ctx = canvas.getContext("2d");57 if (!ctx) return;5859 const W = canvas.width;60 const H = canvas.height;61 const baseOpacity = props.opacity ?? 0.07;6263 const runes = buildRuneString();64 const NUM_ROWS = Math.ceil(H / LINE_H) + 1;65 const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 4;66 const TOTAL_W = runes.length * CHAR_W;6768 const offsets = Array.from({ length: NUM_ROWS }, () =>69 Math.random() * TOTAL_W,70 );7172 73 let accentHex = readAccentHex();74 let accentRgb = hexToRgb(accentHex);7576 const accentObserver = new MutationObserver(() => {77 const next = readAccentHex();78 if (next !== accentHex) {79 accentHex = next;80 accentRgb = hexToRgb(next);81 }82 });83 accentObserver.observe(document.documentElement, {84 attributes: true,85 attributeFilter: ["style"],86 });8788 interface Glow {89 row: number;90 col: number;91 phase: number;92 speed: number;93 lifetime: number;94 age: number;95 }9697 const spawn = (): Glow => ({98 row: Math.floor(Math.random() * NUM_ROWS),99 col: Math.floor(Math.random() * CHARS_PER_ROW),100 phase: Math.random(),101 speed: 0.4 + Math.random() * 0.4,102 lifetime: 2000 + Math.random() * 3000,103 age: 0,104 });105106 const GLOW_COUNT = 8;107 const glows: Glow[] = props.accentGlow108 ? Array.from({ length: GLOW_COUNT }, spawn)109 : [];110111 const glowMap = new Map<string, number>();112113 let last = performance.now();114115 const frame = (now: number) => {116 const dt = Math.min(now - last, 64);117 last = now;118119 ctx.clearRect(0, 0, W, H);120 ctx.font = `${FONT_SIZE}px PeachScript, monospace`;121 ctx.textBaseline = "alphabetic";122123 124 glowMap.clear();125 if (props.accentGlow) {126 const [ar, ag, ab] = accentRgb;127 for (const g of glows) {128 g.age += dt;129 g.phase = (g.phase + (g.speed * dt) / 1000) % 1;130 const lifetimeFraction = g.age / g.lifetime;131 const lifetimeEnvelope = Math.sin(lifetimeFraction * Math.PI);132 const pulseAlpha = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5;133 const alpha = Math.max(0, lifetimeEnvelope) * pulseAlpha * 0.85;134 glowMap.set(`${g.row},${g.col}`, alpha);135 if (g.age >= g.lifetime) Object.assign(g, spawn());136 }137 }138139 for (let row = 0; row < NUM_ROWS; row++) {140 const speed = SPEEDS[row % SPEEDS.length];141 offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W;142143 const y = row * LINE_H + FONT_SIZE + 8;144 const baseIdx = Math.floor(offsets[row] / CHAR_W);145 const xOffset = offsets[row] % CHAR_W;146147 for (let col = 0; col < CHARS_PER_ROW; col++) {148 const x = col * CHAR_W - xOffset;149 if (x < -CHAR_W || x > W + CHAR_W) continue;150 const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length;151152 const glowAlpha = glowMap.get(`${row},${col}`) ?? 0;153 if (glowAlpha > 0.01) {154 const [ar, ag, ab] = accentRgb;155 ctx.save();156 ctx.shadowColor = `rgba(${ar}, ${ag}, ${ab}, ${glowAlpha * 0.9})`;157 ctx.shadowBlur = 10;158 ctx.fillStyle = `rgba(${ar}, ${ag}, ${ab}, ${0.6 + glowAlpha * 0.4})`;159 ctx.fillText(runes[idx]!, x, y);160 ctx.restore();161 } else {162 ctx.fillStyle = `rgba(245, 240, 234, ${baseOpacity})`;163 ctx.fillText(runes[idx]!, x, y);164 }165 }166 }167168 raf = requestAnimationFrame(frame);169 };170171 const start = () => {172 if (running) return;173 running = true;174 last = performance.now();175 raf = requestAnimationFrame(frame);176 };177178 const stop = () => {179 if (!running) return;180 running = false;181 cancelAnimationFrame(raf);182 };183184 const onVisibility = () => {185 if (document.hidden) stop();186 else start();187 };188 document.addEventListener("visibilitychange", onVisibility);189190 const fonts = document.fonts;191 if (fonts) {192 fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);193 } else {194 start();195 }196197 onCleanup(() => {198 stop();199 document.removeEventListener("visibilitychange", onVisibility);200 accentObserver.disconnect();201 });202 });203204 return (205 <canvas206 ref={(el) => { canvas = el; }}207 width={320}208 height={520}209 class="peach-rune-background"210 aria-hidden="true"211 />212 );213}