import { onCleanup, onMount } from "solid-js"; const FONT_SIZE = 14; const PARTICLE_COUNT = 5; const GRAVITY = 55; // px/s² pull toward target, scales with 1/dist const MAX_SPEED = 90; // px/s cap so nothing shoots through the input const ABSORB_DIST = 18; // px from target center — particle disappears const FADE_OUT_DIST = 60; // px — fade starts here const FADE_IN_MS = 600; // fade-in duration on spawn const FALLBACK_ACCENT = "#F49C70"; const FALLBACK_RUNE_IDLE_RGB = [245, 240, 234] as const; const FALLBACK_RUNE_IDLE_LIGHT_RGB = [34, 24, 9] as const; const RUNE_CHARS = Array.from( { length: 80 }, (_, i) => String.fromCodePoint(0xe000 + i), ); function randomRune(): string { return RUNE_CHARS[Math.floor(Math.random() * RUNE_CHARS.length)]; } function readAccentHex(): string { const raw = getComputedStyle(document.documentElement) .getPropertyValue("--peach-accent") .trim(); if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw; return FALLBACK_ACCENT; } function isLightTheme(): boolean { return document.documentElement.classList.contains("light"); } interface Particle { x: number; y: number; vx: number; vy: number; char: string; age: number; glow: boolean; } function spawnParticle( tx: number, ty: number, W: number, H: number, ): Particle { // Spawn anywhere in the canvas that isn't already near the target. // Bias toward the upper region since the input is near the bottom. let x: number, y: number, attempts = 0; do { x = 20 + Math.random() * (W - 40); y = 10 + Math.random() * (H * 0.80); attempts++; } while (Math.hypot(x - tx, y - ty) < 80 && attempts < 12); // Initial velocity: slow drift with a slight clockwise or counterclockwise // curl so particles trace curves rather than straight lines. const dx = tx - x, dy = ty - y; const len = Math.hypot(dx, dy) || 1; const speed = 4 + Math.random() * 8; const curl = (Math.random() < 0.5 ? 1 : -1) * (0.2 + Math.random() * 0.4); const vx = (dx / len + (-dy / len) * curl) * speed; const vy = (dy / len + (dx / len) * curl) * speed; return { x, y, vx, vy, char: randomRune(), age: 0, glow: Math.random() < 0.18 }; } export function LockRuneCanvas() { let canvas!: HTMLCanvasElement; onMount(() => { const ctx = canvas.getContext("2d"); if (!ctx) return; let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70]; let light = isLightTheme(); function setAccent(hex: string) { accentRgb = [ parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16), ]; } setAccent(readAccentHex()); const observer = new MutationObserver(() => { setAccent(readAccentHex()); light = isLightTheme(); }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["style", "class"], }); const W = canvas.offsetWidth || 320; const H = canvas.offsetHeight || 520; canvas.width = W; canvas.height = H; // Target: center of the password input, measured relative to the canvas. // Default to center-bottom if input not found. let tx = W / 2; let ty = H * 0.80; const inputEl = canvas.closest(".lock-screen") ?.querySelector(".lock-password-input") as HTMLElement | null; if (inputEl) { const cr = canvas.getBoundingClientRect(); const ir = inputEl.getBoundingClientRect(); tx = ir.left + ir.width / 2 - cr.left; ty = ir.top + ir.height / 2 - cr.top; } const particles: Particle[] = Array.from({ length: PARTICLE_COUNT }, () => spawnParticle(tx, ty, W, H), ); // Stagger ages so they don't all fade in simultaneously for (const p of particles) p.age = Math.random() * FADE_IN_MS; let lastTime = performance.now(); let raf = 0; let running = false; const frame = (now: number) => { const dt = Math.min(now - lastTime, 64); lastTime = now; const dt_s = dt / 1000; ctx.clearRect(0, 0, W, H); ctx.font = `${FONT_SIZE}px PeachScript, monospace`; ctx.textBaseline = "middle"; const idleRgb = light ? FALLBACK_RUNE_IDLE_LIGHT_RGB : FALLBACK_RUNE_IDLE_RGB; const [ar, ag, ab] = accentRgb; const [ir, ig, ib] = idleRgb; for (const p of particles) { p.age += dt; const dx = tx - p.x; const dy = ty - p.y; const dist = Math.hypot(dx, dy) || 1; // Absorbed — respawn if (dist < ABSORB_DIST) { Object.assign(p, spawnParticle(tx, ty, W, H)); continue; } // Gravitational pull — stronger as particle gets closer const gForce = GRAVITY * (200 / dist); p.vx += (dx / dist) * gForce * dt_s; p.vy += (dy / dist) * gForce * dt_s; // Speed cap const spd = Math.hypot(p.vx, p.vy); if (spd > MAX_SPEED) { p.vx = (p.vx / spd) * MAX_SPEED; p.vy = (p.vy / spd) * MAX_SPEED; } p.x += p.vx * dt_s; p.y += p.vy * dt_s; // Opacity envelope: fade in over FADE_IN_MS, fade out near target const fadeIn = Math.min(1, p.age / FADE_IN_MS); const fadeOut = Math.min(1, (dist - ABSORB_DIST) / FADE_OUT_DIST); const alpha = 0.14 * fadeIn * fadeOut; if (alpha < 0.005) continue; if (p.glow) { ctx.save(); ctx.shadowColor = `rgba(${ar},${ag},${ab},${alpha * 3.5})`; ctx.shadowBlur = 10; ctx.fillStyle = `rgba(${ar},${ag},${ab},${alpha * 2.5})`; ctx.fillText(p.char, p.x, p.y); ctx.restore(); } else { ctx.fillStyle = `rgba(${ir},${ig},${ib},${alpha})`; ctx.fillText(p.char, p.x, p.y); } } raf = requestAnimationFrame(frame); }; const start = () => { if (running) return; running = true; lastTime = performance.now(); raf = requestAnimationFrame(frame); }; const stop = () => { if (!running) return; running = false; cancelAnimationFrame(raf); }; const onVisibility = () => { if (document.hidden) stop(); else start(); }; document.addEventListener("visibilitychange", onVisibility); document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start); onCleanup(() => { stop(); observer.disconnect(); document.removeEventListener("visibilitychange", onVisibility); }); }); return ; }