2d677f90e079

lock screen rune background — sparse slow canvas behind unlock form with glass panel

time
2026-04-28 05:28 UTC · 6 months ago
machine
koh2
parent
ce3d87b6878c
3 files src/popup/components/LockRuneCanvas.tsx+187 −0src/popup/styles/popup.css+111 −2src/popup/views/Lock.tsx+3 −1
src/popup/components/LockRuneCanvas.tsx+187 −0
@@ -0,0 +1,187 @@1+import { onCleanup, onMount } from "solid-js";2+3+const FONT_SIZE = 15;4+const LINE_H = FONT_SIZE * 2.8;5+const CHAR_W = FONT_SIZE * 1.7;6+const DENSITY = 0.30;7+const SPEEDS = [5, 7, 4];8+const GLOW_COUNT = 2;9+const FALLBACK_ACCENT = "#F49C70";10+11+function readAccentHex(): string {12+  const raw = getComputedStyle(document.documentElement)13+    .getPropertyValue("--peach-accent")14+    .trim();15+  if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw;16+  return FALLBACK_ACCENT;17+}18+19+export function LockRuneCanvas() {20+  let canvas!: HTMLCanvasElement;21+22+  onMount(() => {23+    const ctx = canvas.getContext("2d");24+    if (!ctx) return;25+26+    let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70];27+28+    function setAccent(hex: string) {29+      accentRgb = [30+        parseInt(hex.slice(1, 3), 16),31+        parseInt(hex.slice(3, 5), 16),32+        parseInt(hex.slice(5, 7), 16),33+      ];34+    }35+36+    setAccent(readAccentHex());37+38+    const accentObserver = new MutationObserver(() => {39+      setAccent(readAccentHex());40+    });41+    accentObserver.observe(document.documentElement, {42+      attributes: true,43+      attributeFilter: ["style"],44+    });45+46+    const W = canvas.offsetWidth || 360;47+    const H = canvas.offsetHeight || 600;48+    canvas.width = W;49+    canvas.height = H;50+51+    const NUM_ROWS = Math.ceil(H / LINE_H) + 1;52+    const CHARS_PER_ROW = Math.ceil(W / CHAR_W) + 2;53+54+    // Deterministic sparse grid — 30% of positions occupied55+    const runeString: (string | null)[] = Array.from(56+      { length: NUM_ROWS * CHARS_PER_ROW },57+      (_, i) => {58+        const hash = (i * 2654435761) >>> 0;59+        if ((hash % 100) >= DENSITY * 100) return null;60+        return String.fromCodePoint(0xe000 + (hash % 256));61+      },62+    );63+64+    const offsets = Array.from({ length: NUM_ROWS }, () =>65+      Math.random() * CHARS_PER_ROW * CHAR_W,66+    );67+68+    interface Glow {69+      row: number;70+      col: number;71+      phase: number;72+      speed: number;73+      lifetime: number;74+      age: number;75+    }76+77+    function spawnGlow(): Glow {78+      let row = 0, col = 0, attempts = 0;79+      do {80+        row = Math.floor(Math.random() * NUM_ROWS);81+        col = Math.floor(Math.random() * CHARS_PER_ROW);82+        attempts++;83+      } while (!runeString[row * CHARS_PER_ROW + col] && attempts < 20);84+      return {85+        row, col,86+        phase: Math.random(),87+        speed: 0.3 + Math.random() * 0.3,88+        lifetime: 3000 + Math.random() * 4000,89+        age: 0,90+      };91+    }92+93+    const glows: Glow[] = Array.from({ length: GLOW_COUNT }, spawnGlow);94+95+    let lastTime = performance.now();96+    let raf = 0;97+    let running = false;98+99+    const frame = (now: number) => {100+      const dt = Math.min(now - lastTime, 64);101+      lastTime = now;102+103+      ctx.clearRect(0, 0, W, H);104+      ctx.fillStyle = "#1a1410";105+      ctx.fillRect(0, 0, W, H);106+      ctx.font = `${FONT_SIZE}px PeachScript, monospace`;107+      ctx.textBaseline = "alphabetic";108+109+      const glowMap = new Map<string, number>();110+      for (const g of glows) {111+        g.age += dt;112+        g.phase = (g.phase + (g.speed * dt) / 1000) % 1;113+        const env = Math.sin((g.age / g.lifetime) * Math.PI);114+        const pulse = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5;115+        glowMap.set(`${g.row},${g.col}`, Math.max(0, env) * pulse * 0.60);116+        if (g.age >= g.lifetime) Object.assign(g, spawnGlow());117+      }118+119+      const [ar, ag, ab] = accentRgb;120+121+      for (let row = 0; row < NUM_ROWS; row++) {122+        const speed = SPEEDS[row % SPEEDS.length];123+        const totalW = CHARS_PER_ROW * CHAR_W;124+        offsets[row] = (offsets[row] + (speed * dt) / 1000) % totalW;125+        const y = row * LINE_H + FONT_SIZE + 12;126+127+        for (let col = 0; col < CHARS_PER_ROW; col++) {128+          const char = runeString[row * CHARS_PER_ROW + col];129+          if (!char) continue;130+131+          const x = col * CHAR_W - offsets[row] + CHAR_W;132+          if (x < -CHAR_W || x > W + CHAR_W) continue;133+134+          const glowA = glowMap.get(`${row},${col}`) ?? 0;135+136+          if (glowA > 0.05) {137+            ctx.save();138+            ctx.shadowColor = `rgba(${ar},${ag},${ab},${glowA * 0.9})`;139+            ctx.shadowBlur = 12;140+            ctx.fillStyle = `rgba(${ar},${ag},${ab},${0.4 + glowA * 0.5})`;141+            ctx.fillText(char, x, y);142+            ctx.restore();143+          } else {144+            ctx.fillStyle = "rgba(245,240,234,0.18)";145+            ctx.fillText(char, x, y);146+          }147+        }148+      }149+150+      raf = requestAnimationFrame(frame);151+    };152+153+    const start = () => {154+      if (running) return;155+      running = true;156+      lastTime = performance.now();157+      raf = requestAnimationFrame(frame);158+    };159+160+    const stop = () => {161+      if (!running) return;162+      running = false;163+      cancelAnimationFrame(raf);164+    };165+166+    const onVisibility = () => {167+      if (document.hidden) stop();168+      else start();169+    };170+    document.addEventListener("visibilitychange", onVisibility);171+172+    document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);173+174+    onCleanup(() => {175+      stop();176+      accentObserver.disconnect();177+      document.removeEventListener("visibilitychange", onVisibility);178+    });179+  });180+181+  return (182+    <canvas183+      ref={canvas}184+      class="peach-lock-rune-canvas"185+    />186+  );187+}

view file

src/popup/styles/popup.css+111 −2
@@ -1525,9 +1525,42 @@15251525   padding: 2px 0 10px;15261526 }15271527 .peach-settings-group {1528-  padding: 9px 16px 11px;1529-  border-bottom: 1px solid var(--peach-t3);1528+  margin: 4px 10px;1529+  padding: 9px 14px 11px;1530+  border-radius: 12px;1531+  border: 1px solid rgba(255, 255, 255, 0.07);1532+  background: rgba(26, 20, 16, 0.60);1533+  backdrop-filter: blur(10px) saturate(1.3);1534+  -webkit-backdrop-filter: blur(10px) saturate(1.3);15301535 }1536+1537+.peach-settings-group + .peach-settings-group {1538+  margin-top: 6px;1539+}1540+1541+@supports not (backdrop-filter: blur(1px)) {1542+  .peach-settings-group {1543+    background: rgba(26, 20, 16, 0.90);1544+    border-color: rgba(255, 255, 255, 0.09);1545+    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 4px 20px rgba(0, 0, 0, 0.35);1546+  }1547+}1548+1549+.light .peach-settings-group,1550+[data-theme="light"] .peach-settings-group {1551+  background: rgba(253, 248, 244, 0.68);1552+  border-color: rgba(0, 0, 0, 0.07);1553+  backdrop-filter: blur(10px) saturate(1.2);1554+  -webkit-backdrop-filter: blur(10px) saturate(1.2);1555+}1556+1557+@supports not (backdrop-filter: blur(1px)) {1558+  .light .peach-settings-group,1559+  [data-theme="light"] .peach-settings-group {1560+    background: rgba(253, 248, 244, 0.95);1561+    border-color: rgba(0, 0, 0, 0.09);1562+  }1563+}15311564 .peach-export-intro {15321565   padding-bottom: 8px;15331566 }@@ -3141,6 +3174,16 @@31413174   z-index: 0;31423175 }31433176 3177+.peach-lock-rune-canvas {3178+  position: absolute;3179+  inset: 0;3180+  width: 100%;3181+  height: 100%;3182+  display: block;3183+  pointer-events: none;3184+  z-index: 0;3185+}3186+31443187 .lock-screen-header {31453188   display: flex;31463189   align-items: center;@@ -3288,6 +3331,57 @@32883331     0 12px 28px rgba(0, 0, 0, 0.12);32893332 }32903333 3334+/* Glass surface — lock panel and settings groups */3335+.peach-glass {3336+  background: rgba(26, 20, 16, 0.65);3337+  border: 1px solid rgba(255, 255, 255, 0.08);3338+  border-radius: 12px;3339+  backdrop-filter: blur(12px) saturate(1.4);3340+  -webkit-backdrop-filter: blur(12px) saturate(1.4);3341+}3342+3343+@supports not (backdrop-filter: blur(1px)) {3344+  .peach-glass {3345+    background: rgba(26, 20, 16, 0.92);3346+    border: 1px solid rgba(255, 255, 255, 0.10);3347+    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 4px 24px rgba(0, 0, 0, 0.4);3348+  }3349+}3350+3351+.light .peach-glass,3352+[data-theme="light"] .peach-glass {3353+  background: rgba(253, 248, 244, 0.70);3354+  border: 1px solid rgba(0, 0, 0, 0.07);3355+  backdrop-filter: blur(12px) saturate(1.2);3356+  -webkit-backdrop-filter: blur(12px) saturate(1.2);3357+}3358+3359+@supports not (backdrop-filter: blur(1px)) {3360+  .light .peach-glass,3361+  [data-theme="light"] .peach-glass {3362+    background: rgba(253, 248, 244, 0.95);3363+    border: 1px solid rgba(0, 0, 0, 0.09);3364+  }3365+}3366+3367+/* Lock panel inherits its own border-radius and box-shadow on top of glass */3368+.peach-lock-panel.peach-glass {3369+  background: rgba(26, 20, 16, 0.65);3370+  border: 1px solid rgba(255, 255, 255, 0.08);3371+  box-shadow:3372+    inset 0 1px 0 rgba(255, 255, 255, 0.05),3373+    0 12px 28px rgba(0, 0, 0, 0.3);3374+}3375+3376+@supports not (backdrop-filter: blur(1px)) {3377+  .peach-lock-panel.peach-glass {3378+    background: rgba(26, 20, 16, 0.92);3379+    box-shadow:3380+      inset 0 1px 0 rgba(255, 255, 255, 0.05),3381+      0 12px 28px rgba(0, 0, 0, 0.4);3382+  }3383+}3384+32913385 .peach-lock-panel--secondary {32923386   gap: 12px;32933387 }@@ -5764,4 +5858,19 @@57645858   flex-direction: column;57655859   gap: 4px;57665860   padding: 4px 4px 0;5861+}5862+5863+/* Settings body — warm accent gradient gives the glass something to blur against */5864+.peach-settings-body {5865+  background:5866+    radial-gradient(5867+      ellipse at 30% 20%,5868+      color-mix(in srgb, var(--peach-accent) 8%, transparent) 0%,5869+      transparent 60%5870+    ),5871+    radial-gradient(5872+      ellipse at 70% 80%,5873+      rgba(30, 20, 10, 0.6) 0%,5874+      transparent 50%5875+    );57675876 }

view file

src/popup/views/Lock.tsx+3 −1
@@ -1,6 +1,7 @@11 import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";22 33 import { sendMessage } from "../App";4+import { LockRuneCanvas } from "../components/LockRuneCanvas";45 import { PeachMark, PeachWordmark } from "../components/PeachMark";56 import { ImportView } from "./Import";67 @@ -179,6 +180,7 @@179180       {/* ── Unlock (hasVault = true) ───────────────────────────────── */}180181       <Match when={props.hasVault}>181182         <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">183+          <LockRuneCanvas />182184           <header class="peach-screen-header lock-screen-header">183185             <div class="peach-wordmark-stack">184186               <PeachWordmark class="peach-wordmark-wrap" />@@ -200,7 +202,7 @@200202             </h1>201203           </div>202204 203-          <section class="peach-lock-panel peach-lock-panel--unlock">205+          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">204206             <label class="peach-label" for="unlock-password">205207               master password206208             </label>

view file