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
↓ peach-browser-8a31f36f.tar.gzatlas at this save
files changed1 file
src/popup/components/LockRuneCanvas.tsx+119 −91src/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