RuneBackground.tsx

6.2 KB · raw

1import { onCleanup, onMount } from "solid-js";23interface RuneBackgroundProps {4  opacity?: number;5  /** When true, a handful of runes pulse with the accent color at random6   *  screen positions, mirroring the Android lock-screen's RuneGridBackground7   *  glow. Reads `--peach-accent` from the root element. */8  accentGlow?: boolean;9}1011const FONT_SIZE = 14;12// Wider row + column spacing so the visible rune count is ~40% of the13// original spec — quieter, more deliberate drift. Opacity stays the same.14const LINE_H = FONT_SIZE * 3.8;15const CHAR_W = FONT_SIZE * 2.2;16// Three slow drift speeds (px/sec) — much quieter than the export canvas.17// Atmosphere, not focus.18const SPEEDS = [8, 11, 6];19const RUNE_LEN = 120;20const FALLBACK_ACCENT = "#F49C70";2122// Deterministic rune string drawn from the PeachScript PUA range23// (U+E000..U+E0FF). Same characters every render so the background24// has a consistent texture across mounts.25function 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    // ── Accent glow (lock screen) ─────────────────────────────────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      // Advance glow timers124      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}