c5d246f728d6

replace static peachscript text with animated RuneCanvas — scrolling rows with accent embers

time
2026-04-27 17:50 UTC · 6 months ago
machine
koh2
parent
f2a5092253bd
3 files src/popup/components/RuneCanvas.tsx+199 −0src/popup/styles/popup.css+4 −30src/popup/views/Export.tsx+2 −1
src/popup/components/RuneCanvas.tsx+199 −0
@@ -0,0 +1,199 @@1+import { onCleanup, onMount } from "solid-js";2+3+interface RuneCanvasProps {4+  runeString: string;5+  width: number;6+  height: number;7+}8+9+const FONT_SIZE = 17;10+const LINE_HEIGHT = FONT_SIZE * 2.3;11+const LETTER_WIDTH = FONT_SIZE * 1.35;12+const LETTER_SPACING = FONT_SIZE * 0.18;13+const SPEEDS = [18, 24, 14];14+const FALLBACK_ACCENT = "#F49C70";15+16+function readAccentHex(): string {17+  const raw = getComputedStyle(document.documentElement)18+    .getPropertyValue("--peach-accent")19+    .trim();20+  if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;21+  return FALLBACK_ACCENT;22+}23+24+export function RuneCanvas(props: RuneCanvasProps) {25+  let canvas!: HTMLCanvasElement;26+  let raf = 0;27+  let running = false;28+  let accentHex = FALLBACK_ACCENT;29+  let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70];30+31+  function setAccent(hex: string) {32+    accentHex = hex;33+    accentRgb = [34+      parseInt(hex.slice(1, 3), 16),35+      parseInt(hex.slice(3, 5), 16),36+      parseInt(hex.slice(5, 7), 16),37+    ];38+  }39+40+  onMount(() => {41+    const ctx = canvas.getContext("2d");42+    if (!ctx) return;43+44+    setAccent(readAccentHex());45+46+    const accentObserver = new MutationObserver(() => {47+      const next = readAccentHex();48+      if (next !== accentHex) setAccent(next);49+    });50+    accentObserver.observe(document.documentElement, {51+      attributes: true,52+      attributeFilter: ["style"],53+    });54+55+    const W = props.width;56+    const H = props.height;57+58+    const SYMBOLS_PER_ROW =59+      Math.ceil(W / (LETTER_WIDTH + LETTER_SPACING)) + 4;60+    const NUM_ROWS = Math.floor(H / LINE_HEIGHT) + 1;61+    const TOTAL_VISIBLE = NUM_ROWS * SYMBOLS_PER_ROW;62+    const GLOW_COUNT = Math.min(8, Math.max(3, Math.floor(TOTAL_VISIBLE * 0.03)));63+64+    interface Glow {65+      row: number;66+      col: number;67+      phase: number;68+      speed: number;69+      lifetime: number;70+      age: number;71+    }72+73+    const spawn = (): Glow => ({74+      row: Math.floor(Math.random() * NUM_ROWS),75+      col: Math.floor(Math.random() * SYMBOLS_PER_ROW),76+      phase: Math.random(),77+      speed: 0.4 + Math.random() * 0.4,78+      lifetime: 2000 + Math.random() * 3000,79+      age: 0,80+    });81+82+    const glows: Glow[] = Array.from({ length: GLOW_COUNT }, spawn);83+84+    const offsets: number[] = Array.from({ length: NUM_ROWS }, () =>85+      Math.random() * props.runeString.length * (LETTER_WIDTH + LETTER_SPACING),86+    );87+88+    const TOTAL_STRING_WIDTH =89+      props.runeString.length * (LETTER_WIDTH + LETTER_SPACING) || 1;90+91+    let lastTime = performance.now();92+93+    const frame = (now: number) => {94+      const dt = Math.min(now - lastTime, 64); // clamp huge dt after pause95+      lastTime = now;96+97+      ctx.clearRect(0, 0, W, H);98+      ctx.fillStyle = "#1a1410";99+      ctx.fillRect(0, 0, W, H);100+      ctx.font = `${FONT_SIZE}px PeachScript, monospace`;101+      ctx.textBaseline = "alphabetic";102+103+      const glowMap = new Map<string, number>();104+      for (const g of glows) {105+        g.age += dt;106+        g.phase = (g.phase + (g.speed * dt) / 1000) % 1;107+        const lifetimeFraction = g.age / g.lifetime;108+        const lifetimeEnvelope = Math.sin(lifetimeFraction * Math.PI);109+        const pulseAlpha = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5;110+        const alpha = Math.max(0, lifetimeEnvelope) * pulseAlpha * 0.85;111+        glowMap.set(`${g.row},${g.col}`, alpha);112+        if (g.age >= g.lifetime) Object.assign(g, spawn());113+      }114+115+      const [ar, ag, ab] = accentRgb;116+      const runes = props.runeString;117+      const runeLen = runes.length;118+      if (runeLen === 0) {119+        raf = requestAnimationFrame(frame);120+        return;121+      }122+123+      for (let row = 0; row < NUM_ROWS; row++) {124+        const speed = SPEEDS[row % SPEEDS.length];125+        offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_STRING_WIDTH;126+        const y = row * LINE_HEIGHT + FONT_SIZE + 12;127+128+        for (let col = 0; col < SYMBOLS_PER_ROW; col++) {129+          const x =130+            col * (LETTER_WIDTH + LETTER_SPACING) -131+            (offsets[row] % (LETTER_WIDTH + LETTER_SPACING));132+          if (x < -LETTER_WIDTH || x > W + LETTER_WIDTH) continue;133+134+          const baseIdx = Math.floor(offsets[row] / (LETTER_WIDTH + LETTER_SPACING));135+          const strIdx = (((baseIdx + col) % runeLen) + runeLen) % runeLen;136+          const char = runes[strIdx];137+138+          const glowAlpha = glowMap.get(`${row},${col}`) ?? 0;139+140+          if (glowAlpha > 0.01) {141+            ctx.save();142+            ctx.shadowColor = `rgba(${ar}, ${ag}, ${ab}, ${glowAlpha * 0.9})`;143+            ctx.shadowBlur = 10;144+            ctx.fillStyle = `rgba(${ar}, ${ag}, ${ab}, ${0.6 + glowAlpha * 0.4})`;145+            ctx.fillText(char, x, y);146+            ctx.restore();147+          } else {148+            ctx.fillStyle = "rgba(245, 240, 234, 0.82)";149+            ctx.fillText(char, x, y);150+          }151+        }152+      }153+154+      raf = requestAnimationFrame(frame);155+    };156+157+    const start = () => {158+      if (running) return;159+      running = true;160+      lastTime = performance.now();161+      raf = requestAnimationFrame(frame);162+    };163+164+    const stop = () => {165+      if (!running) return;166+      running = false;167+      cancelAnimationFrame(raf);168+    };169+170+    const onVisibility = () => {171+      if (document.hidden) stop();172+      else start();173+    };174+    document.addEventListener("visibilitychange", onVisibility);175+176+    // Font-load guard: render boxes if font isn't ready, so wait for it.177+    document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);178+179+    onCleanup(() => {180+      stop();181+      accentObserver.disconnect();182+      document.removeEventListener("visibilitychange", onVisibility);183+    });184+  });185+186+  return (187+    <canvas188+      ref={canvas}189+      width={props.width}190+      height={props.height}191+      class="peach-rune-canvas"192+      style={{193+        display: "block",194+        width: `${props.width}px`,195+        height: `${props.height}px`,196+      }}197+    />198+  );199+}

view file

src/popup/styles/popup.css+4 −30
@@ -1575,38 +1575,12 @@15751575   display: grid;15761576   gap: 10px;15771577 }1578-.peach-script-string {1579-  font-family: "PeachScript", monospace;1580-  font-size: 22px;1581-  line-height: 1.55;1582-  letter-spacing: 0.04em;1583-  color: var(--peach-t0);1584-  word-break: break-all;1585-  overflow-wrap: anywhere;1586-  white-space: normal;1587-  user-select: all;1588-  background: color-mix(in srgb, var(--peach-accent) 5%, var(--peach-bg));1589-  border: 1px solid color-mix(in srgb, var(--peach-accent) 22%, var(--peach-t3));1578+.peach-rune-canvas {15901579   border-radius: 8px;1591-  padding: 14px 16px;1592-  margin: 0;1580+  border: 1px solid color-mix(in srgb, var(--peach-accent) 22%, var(--peach-t3));1581+  background: #1a1410;15931582   flex: 1;1594-  min-height: 140px;1595-  overflow-x: hidden;1596-  overflow-y: auto;1597-  scrollbar-width: thin;1598-  scrollbar-color: var(--peach-accent-dim) transparent;1599-  text-shadow: 0 0 24px color-mix(in srgb, var(--peach-accent) 32%, transparent);1600-}1601-.peach-script-string::-webkit-scrollbar {1602-  width: 6px;1603-}1604-.peach-script-string::-webkit-scrollbar-thumb {1605-  background: var(--peach-accent-dim);1606-  border-radius: 3px;1607-}1608-.peach-script-string::-webkit-scrollbar-track {1609-  background: transparent;1583+  align-self: center;16101584 }16111585 .peach-script-input {16121586   font-family: "PeachScript", monospace;

view file

src/popup/views/Export.tsx+2 −1
@@ -7,6 +7,7 @@77 import { sendMessage } from "../App";88 import { buildPayload } from "../../crypto/peachPayload";99 import { encodePeachScript } from "../../crypto/peachScript";10+import { RuneCanvas } from "../components/RuneCanvas";1011 import {1112   QR_MAX_BYTES,1213   renderPeachQR,@@ -204,7 +205,7 @@204205         >205206           <div class="peach-settings-group peach-export-card peach-export-card--primary peach-export-result-group">206207             <p class="peach-settings-label">peachscript</p>207-            <div class="peach-script-string">{script()}</div>208+            <RuneCanvas runeString={script() ?? ""} width={288} height={240} />208209             <p class="peach-export-card-copy" style="margin:0 0 8px">209210               {entryCount()} entr{entryCount() === 1 ? "y" : "ies"}210211               {stats().cards > 0 ? ` · ${stats().cards} card${stats().cards === 1 ? "" : "s"}` : ""}

view file