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 for (const g of glows) {127 g.age += dt;128 g.phase = (g.phase + (g.speed * dt) / 1000) % 1;129 const lifetimeFraction = g.age / g.lifetime;130 const lifetimeEnvelope = Math.sin(lifetimeFraction * Math.PI);131 const pulseAlpha = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5;132 const alpha = Math.max(0, lifetimeEnvelope) * pulseAlpha * 0.85;133 glowMap.set(`${g.row},${g.col}`, alpha);134 if (g.age >= g.lifetime) Object.assign(g, spawn());135 }136 }137138 for (let row = 0; row < NUM_ROWS; row++) {139 const speed = SPEEDS[row % SPEEDS.length];140 offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W;141142 const y = row * LINE_H + FONT_SIZE + 8;143 const baseIdx = Math.floor(offsets[row] / CHAR_W);144 const xOffset = offsets[row] % CHAR_W;145146 for (let col = 0; col < CHARS_PER_ROW; col++) {147 const x = col * CHAR_W - xOffset;148 if (x < -CHAR_W || x > W + CHAR_W) continue;149 const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length;150151 const glowAlpha = glowMap.get(`${row},${col}`) ?? 0;152 if (glowAlpha > 0.01) {153 const [ar, ag, ab] = accentRgb;154 ctx.save();155 ctx.shadowColor = `rgba(${ar}, ${ag}, ${ab}, ${glowAlpha * 0.9})`;156 ctx.shadowBlur = 10;157 ctx.fillStyle = `rgba(${ar}, ${ag}, ${ab}, ${0.6 + glowAlpha * 0.4})`;158 ctx.fillText(runes[idx]!, x, y);159 ctx.restore();160 } else {161 ctx.fillStyle = `rgba(245, 240, 234, ${baseOpacity})`;162 ctx.fillText(runes[idx]!, x, y);163 }164 }165 }166167 raf = requestAnimationFrame(frame);168 };169170 const start = () => {171 if (running) return;172 running = true;173 last = performance.now();174 raf = requestAnimationFrame(frame);175 };176177 const stop = () => {178 if (!running) return;179 running = false;180 cancelAnimationFrame(raf);181 };182183 const onVisibility = () => {184 if (document.hidden) stop();185 else start();186 };187 document.addEventListener("visibilitychange", onVisibility);188189 const fonts = document.fonts;190 if (fonts) {191 fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);192 } else {193 start();194 }195196 onCleanup(() => {197 stop();198 document.removeEventListener("visibilitychange", onVisibility);199 accentObserver.disconnect();200 });201 });202203 return (204 <canvas205 ref={(el) => { canvas = el; }}206 width={320}207 height={520}208 class="peach-rune-background"209 aria-hidden="true"210 />211 );212}