From 0c7b676e23e9912ffe769fcd6bd828b2e34b0378 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Mon, 8 Jun 2026 19:40:13 -0700 Subject: [PATCH] Replace cursor fire with hacker bit-trail particles on Command Deck. Swaps orange flame blobs for rising 0/1 and hex glyphs in green/cyan with glow, capped rAF particles, and prefers-reduced-motion support. --- .../web/src/components/Visual/CursorFire.css | 13 ++ .../web/src/components/Visual/CursorFire.tsx | 162 ++++++++++-------- server/web/src/components/components.test.tsx | 28 ++- server/web/src/styles/mobile.css | 2 +- 4 files changed, 129 insertions(+), 76 deletions(-) create mode 100644 server/web/src/components/Visual/CursorFire.css diff --git a/server/web/src/components/Visual/CursorFire.css b/server/web/src/components/Visual/CursorFire.css new file mode 100644 index 0000000..e4b7555 --- /dev/null +++ b/server/web/src/components/Visual/CursorFire.css @@ -0,0 +1,13 @@ +.cursor-hacker-fx { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9998; + mix-blend-mode: screen; +} + +@media (prefers-reduced-motion: reduce) { + .cursor-hacker-fx { + display: none !important; + } +} diff --git a/server/web/src/components/Visual/CursorFire.tsx b/server/web/src/components/Visual/CursorFire.tsx index 0ac6300..18cf116 100644 --- a/server/web/src/components/Visual/CursorFire.tsx +++ b/server/web/src/components/Visual/CursorFire.tsx @@ -1,114 +1,143 @@ import { useEffect, useRef } from 'react'; +import './CursorFire.css'; + +const BIT_CHARS = '01'; +const HEX_CHARS = '0123456789ABCDEF'; +const MAX_PARTICLES = 280; +const EMIT_PER_FRAME = 5; +const EMIT_WINDOW_MS = 90; +const FONT_STACK = '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace'; interface Particle { x: number; y: number; vx: number; vy: number; - life: number; // 1 → 0 - size: number; + life: number; decay: number; + char: string; + fontSize: number; + tint: 'cyan' | 'green'; +} + +function pickChar(): string { + if (Math.random() < 0.72) return BIT_CHARS[Math.floor(Math.random() * 2)]; + return HEX_CHARS[Math.floor(Math.random() * HEX_CHARS.length)]; +} + +function colorForLife(life: number, tint: Particle['tint']): string { + const a = Math.min(1, life * 0.92); + if (tint === 'cyan') { + if (life > 0.55) return `rgba(0, 232, 245, ${a})`; + return `rgba(0, 210, 170, ${a * 0.8})`; + } + if (life > 0.55) return `rgba(0, 255, 136, ${a})`; + return `rgba(0, 190, 96, ${a * 0.75})`; +} + +function glowForTint(tint: Particle['tint']): string { + return tint === 'cyan' ? '#00e8f5' : '#00ff88'; } export default function CursorFire() { const canvasRef = useRef(null); const particles = useRef([]); - const mouse = useRef({ x: -9999, y: -9999, moved: false }); - const rafRef = useRef(0); + const mouse = useRef({ x: -9999, y: -9999 }); + const lastMoveRef = useRef(0); + const rafRef = useRef(0); useEffect(() => { + const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + if (motionQuery.matches) return; + const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; + let running = true; + const resize = () => { - canvas.width = window.innerWidth; - canvas.height = window.innerHeight; + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = window.innerWidth; + const h = window.innerHeight; + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; resize(); window.addEventListener('resize', resize); const onMove = (e: MouseEvent) => { - mouse.current = { x: e.clientX, y: e.clientY, moved: true }; + mouse.current = { x: e.clientX, y: e.clientY }; + lastMoveRef.current = performance.now(); }; - window.addEventListener('mousemove', onMove); + window.addEventListener('mousemove', onMove, { passive: true }); + + const stopOnReducedMotion = () => { + if (!motionQuery.matches) return; + running = false; + cancelAnimationFrame(rafRef.current); + particles.current = []; + ctx.clearRect(0, 0, canvas.width, canvas.height); + }; + motionQuery.addEventListener('change', stopOnReducedMotion); const emit = () => { + if (performance.now() - lastMoveRef.current > EMIT_WINDOW_MS) return; const { x, y } = mouse.current; - // Emit 6 particles per frame at cursor - for (let i = 0; i < 6; i++) { - const spread = 8; + + for (let i = 0; i < EMIT_PER_FRAME; i++) { + const spread = 10; particles.current.push({ x: x + (Math.random() - 0.5) * spread, - y: y + (Math.random() - 0.5) * (spread * 0.5), - vx: (Math.random() - 0.5) * 1.2, - vy: -(Math.random() * 2.8 + 1.8), + y: y + (Math.random() - 0.5) * (spread * 0.4), + vx: (Math.random() - 0.5) * 1.4, + vy: -(Math.random() * 2.6 + 1.6), life: 1, - size: Math.random() * 14 + 7, - decay: Math.random() * 0.022 + 0.016, + decay: Math.random() * 0.024 + 0.018, + char: pickChar(), + fontSize: Math.random() * 10 + 9, + tint: Math.random() < 0.55 ? 'cyan' : 'green', }); } - // Cap particle count for perf - if (particles.current.length > 400) { - particles.current = particles.current.slice(-400); + + if (particles.current.length > MAX_PARTICLES) { + particles.current = particles.current.slice(-MAX_PARTICLES); } }; const draw = () => { + if (!running) return; ctx.clearRect(0, 0, canvas.width, canvas.height); - - // Additive blending makes overlapping particles look white-hot - ctx.globalCompositeOperation = 'screen'; - emit(); const alive: Particle[] = []; for (const p of particles.current) { - // Turbulent horizontal drift - p.vx += (Math.random() - 0.5) * 0.35; - // Slight drag on vx - p.vx *= 0.97; - // Upward acceleration (heat rises) - p.vy -= 0.04; - + p.vx += (Math.random() - 0.5) * 0.28; + p.vx *= 0.96; + p.vy -= 0.035; p.x += p.vx; p.y += p.vy; p.life -= p.decay; - // Particles shrink as they cool - p.size *= 0.982; + p.fontSize *= 0.985; - if (p.life <= 0 || p.size < 1) continue; + if (p.life <= 0 || p.fontSize < 6) continue; alive.push(p); - const l = p.life; - // Color temperature: white-yellow core → orange → red → dark red - let r: number, g: number, b: number; - if (l > 0.75) { - // White-hot - r = 255; g = 255; b = Math.round((l - 0.75) / 0.25 * 220); - } else if (l > 0.5) { - // Yellow-orange - r = 255; g = Math.round(100 + (l - 0.5) / 0.25 * 155); b = 0; - } else if (l > 0.25) { - // Orange-red - r = 255; g = Math.round((l - 0.25) / 0.25 * 100); b = 0; - } else { - // Deep red, fading - r = Math.round(160 + l / 0.25 * 95); g = 0; b = 0; - } - - const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size); - grad.addColorStop(0, `rgba(${r},${g},${b},${l})`); - grad.addColorStop(0.4, `rgba(${r},${Math.round(g * 0.6)},0,${l * 0.6})`); - grad.addColorStop(1, `rgba(0,0,0,0)`); - - ctx.beginPath(); - ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); - ctx.fillStyle = grad; - ctx.fill(); + const glow = 6 + p.life * 10; + ctx.shadowBlur = glow; + ctx.shadowColor = glowForTint(p.tint); + ctx.font = `600 ${p.fontSize}px ${FONT_STACK}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = colorForLife(p.life, p.tint); + ctx.fillText(p.char, p.x, p.y); } + ctx.shadowBlur = 0; particles.current = alive; rafRef.current = requestAnimationFrame(draw); }; @@ -116,22 +145,13 @@ export default function CursorFire() { rafRef.current = requestAnimationFrame(draw); return () => { + running = false; cancelAnimationFrame(rafRef.current); window.removeEventListener('resize', resize); window.removeEventListener('mousemove', onMove); + motionQuery.removeEventListener('change', stopOnReducedMotion); }; }, []); - return ( - - ); + return