8a31f36f18f1

lock rune canvas — particle funnel effect, runes drawn toward password input

time
2026-04-28 05:43 UTC · 6 months ago
machine
koh2
parent
3cd2c71bbf8d
1 file src/popup/components/LockRuneCanvas.tsx+119 −91
src/popup/components/LockRuneCanvas.tsx+119 −91
@@ -1,27 +1,25 @@11 import { onCleanup, onMount } from "solid-js";22 3-const FONT_SIZE = 15;4-const LINE_HEIGHT = FONT_SIZE * 2.3;5-const LETTER_WIDTH = FONT_SIZE * 1.35;6-const LETTER_SPACING = FONT_SIZE * 0.18;7-const SPEEDS = [5, 7, 4];8-const GLOW_COUNT = 2;3+const FONT_SIZE = 14;4+const PARTICLE_COUNT = 5;5+const GRAVITY = 55;       // px/s² pull toward target, scales with 1/dist6+const MAX_SPEED = 90;     // px/s cap so nothing shoots through the input7+const ABSORB_DIST = 18;   // px from target center — particle disappears8+const FADE_OUT_DIST = 60; // px — fade starts here9+const FADE_IN_MS = 600;   // fade-in duration on spawn910 const FALLBACK_ACCENT = "#F49C70";10-const FALLBACK_RUNE_IDLE = "rgba(245,240,234,0.16)";11+const FALLBACK_RUNE_IDLE_RGB = [245, 240, 234] as const;12+const FALLBACK_RUNE_IDLE_LIGHT_RGB = [34, 24, 9] as const;1113 12-// 8% of string positions are runes; the rest are null (skipped).13-// Cyclic-scroll mechanic identical to RuneCanvas — smooth, not jumpy.14-const RUNE_STRING_LEN = 80;15-const DENSITY = 0.08;16-const RUNE_STRING: (string | null)[] = Array.from(17-  { length: RUNE_STRING_LEN },18-  (_, i) => {19-    const hash = (i * 2654435761) >>> 0;20-    if ((hash % 100) >= DENSITY * 100) return null;21-    return String.fromCodePoint(0xe000 + (hash % 256));22-  },14+const RUNE_CHARS = Array.from(15+  { length: 80 },16+  (_, i) => String.fromCodePoint(0xe000 + i),2317 );2418 19+function randomRune(): string {20+  return RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)];21+}22+2523 function readAccentHex(): string {2624   const raw = getComputedStyle(document.documentElement)2725     .getPropertyValue("--peach-accent")@@ -30,14 +28,47 @@3028   return FALLBACK_ACCENT;3129 }3230 33-function readRuneIdleColor(): string {34-  return (35-    getComputedStyle(document.documentElement)36-      .getPropertyValue("--peach-rune-idle")37-      .trim() || FALLBACK_RUNE_IDLE38-  );31+function isLightTheme(): boolean {32+  return document.documentElement.classList.contains("light");3933 }4034 35+interface Particle {36+  x: number;37+  y: number;38+  vx: number;39+  vy: number;40+  char: string;41+  age: number;42+  glow: boolean;43+}44+45+function spawnParticle(46+  tx: number,47+  ty: number,48+  W: number,49+  H: number,50+): Particle {51+  // Spawn anywhere in the canvas that isn't already near the target.52+  // Bias toward the upper region since the input is near the bottom.53+  let x: number, y: number, attempts = 0;54+  do {55+    x = 20 + Math.random() * (W - 40);56+    y = 10 + Math.random() * (H * 0.80);57+    attempts++;58+  } while (Math.hypot(x - tx, y - ty) < 80 && attempts < 12);59+60+  // Initial velocity: slow drift with a slight clockwise or counterclockwise61+  // curl so particles trace curves rather than straight lines.62+  const dx = tx - x, dy = ty - y;63+  const len = Math.hypot(dx, dy) || 1;64+  const speed = 4 + Math.random() * 8;65+  const curl = (Math.random() < 0.5 ? 1 : -1) * (0.2 + Math.random() * 0.4);66+  const vx = (dx / len + (-dy / len) * curl) * speed;67+  const vy = (dy / len + (dx / len) * curl) * speed;68+69+  return { x, y, vx, vy, char: randomRune(), age: 0, glow: Math.random() < 0.18 };70+}71+4172 export function LockRuneCanvas() {4273   let canvas!: HTMLCanvasElement;4374 @@ -46,7 +77,7 @@4677     if (!ctx) return;4778 4879     let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70];49-    let runeIdleColor = readRuneIdleColor();80+    let light = isLightTheme();5081 5182     function setAccent(hex: string) {5283       accentRgb = [@@ -58,50 +89,40 @@5889 5990     setAccent(readAccentHex());6091 61-    // Watch both style (accent color) and class (light/dark theme) changes6292     const observer = new MutationObserver(() => {6393       setAccent(readAccentHex());64-      runeIdleColor = readRuneIdleColor();94+      light = isLightTheme();6595     });6696     observer.observe(document.documentElement, {6797       attributes: true,6898       attributeFilter: ["style", "class"],6999     });70100 71-    const W = canvas.offsetWidth || 360;72-    const H = canvas.offsetHeight || 600;101+    const W = canvas.offsetWidth || 320;102+    const H = canvas.offsetHeight || 520;73103     canvas.width = W;74104     canvas.height = H;75105 76-    const CELL_W = LETTER_WIDTH + LETTER_SPACING;77-    const SYMBOLS_PER_ROW = Math.ceil(W / CELL_W) + 4;78-    const NUM_ROWS = Math.floor(H / LINE_HEIGHT) + 1;79-    const TOTAL_STRING_W = RUNE_STRING_LEN * CELL_W || 1;80-81-    interface Glow {82-      row: number;83-      col: number;84-      phase: number;85-      speed: number;86-      lifetime: number;87-      age: number;106+    // Target: center of the password input, measured relative to the canvas.107+    // Default to center-bottom if input not found.108+    let tx = W / 2;109+    let ty = H * 0.80;110+    const inputEl = canvas.closest(".lock-screen")111+      ?.querySelector(".lock-password-input") as HTMLElement | null;112+    if (inputEl) {113+      const cr = canvas.getBoundingClientRect();114+      const ir = inputEl.getBoundingClientRect();115+      tx = ir.left + ir.width / 2 - cr.left;116+      ty = ir.top + ir.height / 2 - cr.top;88117     }89118 90-    const spawn = (): Glow => ({91-      row: Math.floor(Math.random() * NUM_ROWS),92-      col: Math.floor(Math.random() * SYMBOLS_PER_ROW),93-      phase: Math.random(),94-      speed: 0.3 + Math.random() * 0.3,95-      lifetime: 3000 + Math.random() * 4000,96-      age: 0,97-    });98-99-    const glows: Glow[] = Array.from({ length: GLOW_COUNT }, spawn);100-101-    const offsets: number[] = Array.from({ length: NUM_ROWS }, () =>102-      Math.random() * TOTAL_STRING_W,119+    const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () =>120+      spawnParticle(tx, ty, W, H),103121     );104122 123+    // Stagger ages so they don't all fade in simultaneously124+    for (const p of particles) p.age = Math.random() * FADE_IN_MS;125+105126     let lastTime = performance.now();106127     let raf = 0;107128     let running = false;@@ -109,53 +130,60 @@109130     const frame = (now: number) => {110131       const dt = Math.min(now - lastTime, 64);111132       lastTime = now;133+      const dt_s = dt / 1000;112134 113-      // Transparent canvas — CSS handles the lock screen background per theme114135       ctx.clearRect(0, 0, W, H);115136       ctx.font = `${FONT_SIZE}px PeachScript, monospace`;116-      ctx.textBaseline = "alphabetic";137+      ctx.textBaseline = "middle";117138 118-      const glowMap = new Map<string, number>();119-      for (const g of glows) {120-        g.age += dt;121-        g.phase = (g.phase + (g.speed * dt) / 1000) % 1;122-        const env = Math.sin((g.age / g.lifetime) * Math.PI);123-        const pulse = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5;124-        glowMap.set(`${g.row},${g.col}`, Math.max(0, env) * pulse * 0.60);125-        if (g.age >= g.lifetime) Object.assign(g, spawn());126-      }127-139+      const idleRgb = light ? FALLBACK_RUNE_IDLE_LIGHT_RGB : FALLBACK_RUNE_IDLE_RGB;128140       const [ar, ag, ab] = accentRgb;141+      const [ir, ig, ib] = idleRgb;129142 130-      for (let row = 0; row < NUM_ROWS; row++) {131-        const speed = SPEEDS[row % SPEEDS.length];132-        offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_STRING_W;133-        const y = row * LINE_HEIGHT + FONT_SIZE + 12;143+      for (const p of particles) {144+        p.age += dt;134145 135-        for (let col = 0; col < SYMBOLS_PER_ROW; col++) {136-          const x = col * CELL_W - (offsets[row] % CELL_W);137-          if (x < -CELL_W || x > W + CELL_W) continue;146+        const dx = tx - p.x;147+        const dy = ty - p.y;148+        const dist = Math.hypot(dx, dy) || 1;138149 139-          const baseIdx = Math.floor(offsets[row] / CELL_W);140-          const strIdx =141-            (((baseIdx + col) % RUNE_STRING_LEN) + RUNE_STRING_LEN) %142-            RUNE_STRING_LEN;143-          const char = RUNE_STRING[strIdx];144-          if (!char) continue;150+        // Absorbed — respawn151+        if (dist < ABSORB_DIST) {152+          Object.assign(p, spawnParticle(tx, ty, W, H));153+          continue;154+        }145155 146-          const glowA = glowMap.get(`${row},${col}`) ?? 0;156+        // Gravitational pull — stronger as particle gets closer157+        const gForce = GRAVITY * (200 / dist);158+        p.vx += (dx / dist) * gForce * dt_s;159+        p.vy += (dy / dist) * gForce * dt_s;147160 148-          if (glowA > 0.05) {149-            ctx.save();150-            ctx.shadowColor = `rgba(${ar},${ag},${ab},${glowA * 0.9})`;151-            ctx.shadowBlur = 12;152-            ctx.fillStyle = `rgba(${ar},${ag},${ab},${0.4 + glowA * 0.5})`;153-            ctx.fillText(char, x, y);154-            ctx.restore();155-          } else {156-            ctx.fillStyle = runeIdleColor;157-            ctx.fillText(char, x, y);158-          }161+        // Speed cap162+        const spd = Math.hypot(p.vx, p.vy);163+        if (spd > MAX_SPEED) {164+          p.vx = (p.vx / spd) * MAX_SPEED;165+          p.vy = (p.vy / spd) * MAX_SPEED;166+        }167+168+        p.x += p.vx * dt_s;169+        p.y += p.vy * dt_s;170+171+        // Opacity envelope: fade in over FADE_IN_MS, fade out near target172+        const fadeIn = Math.min(1, p.age / FADE_IN_MS);173+        const fadeOut = Math.min(1, (dist - ABSORB_DIST) / FADE_OUT_DIST);174+        const alpha = 0.14 * fadeIn * fadeOut;175+        if (alpha < 0.005) continue;176+177+        if (p.glow) {178+          ctx.save();179+          ctx.shadowColor = `rgba(${ar},${ag},${ab},${alpha * 3.5})`;180+          ctx.shadowBlur = 10;181+          ctx.fillStyle = `rgba(${ar},${ag},${ab},${alpha * 2.5})`;182+          ctx.fillText(p.char, p.x, p.y);183+          ctx.restore();184+        } else {185+          ctx.fillStyle = `rgba(${ir},${ig},${ib},${alpha})`;186+          ctx.fillText(p.char, p.x, p.y);159187         }160188       }161189 

view file