e056e5981747

add RuneBackground component for atmospheric rune drift behind onboarding

time
2026-04-28 04:40 UTC · 6 months ago
machine
koh2
parent
e7cf01cbd96c
1 file src/popup/components/RuneBackground.tsx+114 −0
src/popup/components/RuneBackground.tsx+114 −0
@@ -0,0 +1,114 @@1+import { onCleanup, onMount } from "solid-js";2+3+interface RuneBackgroundProps {4+  opacity?: number;5+}6+7+const FONT_SIZE = 14;8+const LINE_H = FONT_SIZE * 2.4;9+const CHAR_W = FONT_SIZE * 1.4;10+// Three slow drift speeds (px/sec) — much quieter than the export canvas.11+// Atmosphere, not focus.12+const SPEEDS = [8, 11, 6];13+const RUNE_LEN = 120;14+15+// Deterministic rune string drawn from the PeachScript PUA range16+// (U+E000..U+E0FF). Same characters every render so the background17+// has a consistent texture across mounts.18+function buildRuneString(): string {19+  const out: string[] = [];20+  for (let i = 0; i < RUNE_LEN; i++) {21+    out.push(String.fromCodePoint(0xe000 + ((i * 37 + 13) & 0xff)));22+  }23+  return out.join("");24+}25+26+export function RuneBackground(props: RuneBackgroundProps) {27+  let canvas!: HTMLCanvasElement;28+  let raf = 0;29+  let running = false;30+31+  onMount(() => {32+    const ctx = canvas.getContext("2d");33+    if (!ctx) return;34+35+    const W = canvas.width;36+    const H = canvas.height;37+    const baseOpacity = props.opacity ?? 0.07;38+39+    const runes = buildRuneString();40+    const NUM_ROWS = Math.ceil(H / LINE_H) + 1;41+    const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 4;42+    const TOTAL_W = runes.length * CHAR_W;43+44+    const offsets = Array.from({ length: NUM_ROWS }, () =>45+      Math.random() * TOTAL_W,46+    );47+48+    let last = performance.now();49+50+    const frame = (now: number) => {51+      const dt = Math.min(now - last, 64);52+      last = now;53+54+      ctx.clearRect(0, 0, W, H);55+      ctx.font = `${FONT_SIZE}px PeachScript, monospace`;56+      ctx.fillStyle = `rgba(245, 240, 234, ${baseOpacity})`;57+      ctx.textBaseline = "alphabetic";58+59+      for (let row = 0; row < NUM_ROWS; row++) {60+        const speed = SPEEDS[row % SPEEDS.length];61+        offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W;62+63+        const y = row * LINE_H + FONT_SIZE + 8;64+        const baseIdx = Math.floor(offsets[row] / CHAR_W);65+        const xOffset = offsets[row] % CHAR_W;66+67+        for (let col = 0; col < CHARS_PER_ROW; col++) {68+          const x = col * CHAR_W - xOffset;69+          if (x < -CHAR_W || x > W + CHAR_W) continue;70+          const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length;71+          ctx.fillText(runes[idx]!, x, y);72+        }73+      }74+75+      raf = requestAnimationFrame(frame);76+    };77+78+    const start = () => {79+      if (running) return;80+      running = true;81+      last = performance.now();82+      raf = requestAnimationFrame(frame);83+    };84+85+    const stop = () => {86+      if (!running) return;87+      running = false;88+      cancelAnimationFrame(raf);89+    };90+91+    const onVisibility = () => {92+      if (document.hidden) stop();93+      else start();94+    };95+    document.addEventListener("visibilitychange", onVisibility);96+97+    document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);98+99+    onCleanup(() => {100+      stop();101+      document.removeEventListener("visibilitychange", onVisibility);102+    });103+  });104+105+  return (106+    <canvas107+      ref={canvas}108+      width={320}109+      height={520}110+      class="peach-rune-background"111+      aria-hidden="true"112+    />113+  );114+}

view file