RecoveryRuneBackground.tsx

4.9 KB · raw

1import { onCleanup, onMount } from "solid-js";23/**4 * RecoveryRuneBackground — a sparse, high-contrast rune canvas for recovery5 * screens only.6 *7 * Per the spec: ~50% fewer runes than the lock screen, opacity 0.12–0.20,8 * accent color tint, erratic movement, 20–45 second dwell, max 6 visible.9 * The erratic movement is achieved by varying drift speed per frame using10 * Perlin-like noise approximations from frame to frame.11 */1213interface RecoveryRuneBackgroundProps {14  /** Accent color to tint the runes with. Defaults to the brand accent. */15  accentColor?: string;16}1718// Peach brand accent color used for default tinting19const DEFAULT_ACCENT = "rgba(229, 128, 92, 0.16)"; // #E5805C at ~16% opacity2021// Fewer rows = fewer total visible runes22const FONT_SIZE = 14;23const LINE_H = FONT_SIZE * 5.2; // wider row spacing → fewer visible rows24const CHAR_W = FONT_SIZE * 2.2;25const SPEEDS = [6, 9, 12, 7, 14]; // varied base speeds per row26const RUNE_LEN = 60; // 50% fewer runes than RuneBackground's 1202728function buildRuneString(): string {29  const out: string[] = [];30  for (let i = 0; i < RUNE_LEN; i++) {31    out.push(String.fromCodePoint(0xe000 + ((i * 37 + 13) & 0xff)));32  }33  return out.join("");34}3536/** Simple smooth noise using sine — deterministic per row/frame offset. */37function smoothNoise(x: number, t: number): number {38  return (Math.sin(x * 0.07 + t * 0.0004) * 0.5 +39    Math.sin(x * 0.13 - t * 0.0006) * 0.3 +40    Math.sin(x * 0.19 + t * 0.0003) * 0.2);41}4243export function RecoveryRuneBackground(props: RecoveryRuneBackgroundProps) {44  let canvas: HTMLCanvasElement | undefined;45  let raf = 0;46  let running = false;47  // Track per-row accumulated time so we get erratic (non-linear) drift48  const rowTime: number[] = [];4950  onMount(() => {51    if (!canvas) return;52    const ctx = canvas.getContext("2d");53    if (!ctx) return;5455    const W = canvas.width;56    const H = canvas.height;57    // 0.12–0.20 opacity range; use a mid-range default58    const baseOpacity = 0.15;59    const accentColor = props.accentColor ?? DEFAULT_ACCENT;6061    const runes = buildRuneString();62    const NUM_ROWS = Math.ceil(H / LINE_H) + 1;63    const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 4;64    const TOTAL_W = runes.length * CHAR_W;6566    const offsets = Array.from({ length: NUM_ROWS }, () =>67      Math.random() * TOTAL_W,68    );69    // Each row has a random base speed multiplier for variety (0.6–1.4×)70    const speedMults = Array.from({ length: NUM_ROWS }, () =>71      0.6 + Math.random() * 0.8,72    );7374    // Initialize per-row time for erratic motion75    for (let i = 0; i < NUM_ROWS; i++) {76      rowTime[i] = Math.random() * 30000;77    }7879    let last = performance.now();8081    const frame = (now: number) => {82      const dt = Math.min(now - last, 64);83      last = now;8485      ctx.clearRect(0, 0, W, H);86      ctx.font = `${FONT_SIZE}px PeachScript, monospace`;87      ctx.textBaseline = "alphabetic";8889      for (let row = 0; row < NUM_ROWS; row++) {90        rowTime[row] += dt;9192        // Smooth noise modulation: makes drift speed feel erratic, not linear93        const noise = smoothNoise(row, rowTime[row]);94        const speed = SPEEDS[row % SPEEDS.length] * speedMults[row] * (1 + noise * 0.4);9596        offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W;9798        const y = row * LINE_H + FONT_SIZE + 8;99        const baseIdx = Math.floor(offsets[row] / CHAR_W);100        const xOffset = offsets[row] % CHAR_W;101102        for (let col = 0; col < CHARS_PER_ROW; col++) {103          const x = col * CHAR_W - xOffset;104          if (x < -CHAR_W || x > W + CHAR_W) continue;105          const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length;106107          // Vary opacity per character using smooth noise for depth108          const charNoise = Math.abs(smoothNoise(row * 3 + col, rowTime[row]));109          const charOpacity = baseOpacity * (0.6 + charNoise * 0.8);110          ctx.fillStyle = accentColor.replace(/[\d.]+\)$/, `${charOpacity.toFixed(2)})`);111          ctx.fillText(runes[idx]!, x, y);112        }113      }114115      raf = requestAnimationFrame(frame);116    };117118    const start = () => {119      if (running) return;120      running = true;121      last = performance.now();122      raf = requestAnimationFrame(frame);123    };124125    const stop = () => {126      if (!running) return;127      running = false;128      cancelAnimationFrame(raf);129    };130131    const onVisibility = () => {132      if (document.hidden) stop();133      else start();134    };135    document.addEventListener("visibilitychange", onVisibility);136    const fontReady = document.fonts?.load(`${FONT_SIZE}px PeachScript`);137    if (fontReady) fontReady.finally(start);138    else start();139140    onCleanup(() => {141      stop();142      document.removeEventListener("visibilitychange", onVisibility);143    });144  });145146  return (147    <canvas148      ref={(el) => {149        canvas = el;150      }}151      width={320}152      height={520}153      class="peach-rune-background peach-rune-background--recovery"154      aria-hidden="true"155    />156  );157}