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;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    const ctx = canvas.getContext("2d");52    if (!ctx) return;5354    const W = canvas.width;55    const H = canvas.height;56    // 0.12–0.20 opacity range; use a mid-range default57    const baseOpacity = 0.15;58    const accentColor = props.accentColor ?? DEFAULT_ACCENT;5960    const runes = buildRuneString();61    const NUM_ROWS = Math.ceil(H / LINE_H) + 1;62    const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 4;63    const TOTAL_W = runes.length * CHAR_W;6465    const offsets = Array.from({ length: NUM_ROWS }, () =>66      Math.random() * TOTAL_W,67    );68    // Each row has a random base speed multiplier for variety (0.6–1.4×)69    const speedMults = Array.from({ length: NUM_ROWS }, () =>70      0.6 + Math.random() * 0.8,71    );7273    // Initialize per-row time for erratic motion74    for (let i = 0; i < NUM_ROWS; i++) {75      rowTime[i] = Math.random() * 30000;76    }7778    let last = performance.now();7980    const frame = (now: number) => {81      const dt = Math.min(now - last, 64);82      last = now;8384      ctx.clearRect(0, 0, W, H);85      ctx.font = `${FONT_SIZE}px PeachScript, monospace`;86      ctx.textBaseline = "alphabetic";8788      for (let row = 0; row < NUM_ROWS; row++) {89        rowTime[row] += dt;9091        // Smooth noise modulation: makes drift speed feel erratic, not linear92        const noise = smoothNoise(row, rowTime[row]);93        const speed = SPEEDS[row % SPEEDS.length] * speedMults[row] * (1 + noise * 0.4);9495        offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_W;9697        const y = row * LINE_H + FONT_SIZE + 8;98        const baseIdx = Math.floor(offsets[row] / CHAR_W);99        const xOffset = offsets[row] % CHAR_W;100101        for (let col = 0; col < CHARS_PER_ROW; col++) {102          const x = col * CHAR_W - xOffset;103          if (x < -CHAR_W || x > W + CHAR_W) continue;104          const idx = (((baseIdx + col) % runes.length) + runes.length) % runes.length;105106          // Vary opacity per character using smooth noise for depth107          const charNoise = Math.abs(smoothNoise(row * 3 + col, rowTime[row]));108          const charOpacity = baseOpacity * (0.6 + charNoise * 0.8);109          ctx.fillStyle = accentColor.replace(/[\d.]+\)$/, `${charOpacity.toFixed(2)})`);110          ctx.fillText(runes[idx]!, x, y);111        }112      }113114      raf = requestAnimationFrame(frame);115    };116117    const start = () => {118      if (running) return;119      running = true;120      last = performance.now();121      raf = requestAnimationFrame(frame);122    };123124    const stop = () => {125      if (!running) return;126      running = false;127      cancelAnimationFrame(raf);128    };129130    const onVisibility = () => {131      if (document.hidden) stop();132      else start();133    };134    document.addEventListener("visibilitychange", onVisibility);135    const fontReady = document.fonts?.load(`${FONT_SIZE}px PeachScript`);136    if (fontReady) fontReady.finally(start);137    else start();138139    onCleanup(() => {140      stop();141      document.removeEventListener("visibilitychange", onVisibility);142    });143  });144145  return (146    <canvas147      ref={canvas}148      width={320}149      height={520}150      class="peach-rune-background peach-rune-background--recovery"151      aria-hidden="true"152    />153  );154}