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        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}