import './style.css'; const COMMAND = 'ssh portfolio@keshavanand.net'; const typedEl = document.getElementById('typed') as HTMLSpanElement; const userEl = document.getElementById('userInput') as HTMLSpanElement; const caretEl = document.getElementById('caret') as HTMLSpanElement; const commandBox = document.getElementById('commandBox') as HTMLDivElement; const actionBtn = document.getElementById('actionBtn') as HTMLButtonElement; const iconPath = document.getElementById('iconPath') as unknown as SVGPathElement; const docsLink = document.getElementById('docsLink') as HTMLAnchorElement; const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; /* ---------- icons ---------- */ const COPY_PATH = 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z'; // "↵" return arrow — a hooked left arrow (very recognizable as Enter) const ENTER_PATH = 'M9 10l-5 5m0 0l5 5m-5-5h12a4 4 0 004-4V5'; const CHECK_PATH = 'M5 13l4 4L19 7'; function setIcon(d: string) { iconPath.setAttribute('d', d); } /* ---------- state ---------- */ let userInput = ''; let inputEnabled = false; // true once typewriter completes let isAutoTyping = false; // true while clicking-how-it-works animation runs function refreshUI() { userEl.textContent = userInput; if (userInput.length > 0) { setIcon(ENTER_PATH); actionBtn.setAttribute('aria-label', 'Run command'); commandBox.classList.add('armed'); } else { setIcon(COPY_PATH); actionBtn.setAttribute('aria-label', 'Copy command'); commandBox.classList.remove('armed'); } } /* ---------- typewriter intro (with scramble) ---------- */ const SCRAMBLE = '!<>-_\\/[]{}—=+*^?#abcdef0123456789'; function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); } function typeIntro(target: string, onDone?: () => void) { let resolved = ''; let i = 0; function nextChar() { if (i >= target.length) { // append a trailing space so the caret rests one char past the prompt typedEl.textContent = resolved + ' '; onDone?.(); return; } let cycles = target[i] === ' ' ? 0 : 1 + Math.floor(Math.random() * 2); function flicker() { if (cycles <= 0) { resolved += target[i]; typedEl.textContent = resolved; i++; setTimeout(nextChar, 8 + Math.random() * 18); return; } const ch = SCRAMBLE[Math.floor(Math.random() * SCRAMBLE.length)]; typedEl.textContent = resolved + ch; cycles--; setTimeout(flicker, 14); } flicker(); } nextChar(); } if (reduceMotion) { typedEl.textContent = COMMAND + ' '; inputEnabled = true; } else { setTimeout(() => typeIntro(COMMAND, () => { inputEnabled = true; }), 180); } /* ---------- copy ---------- */ let iconResetTimer: number | undefined; async function copyCommand() { try { await navigator.clipboard.writeText(COMMAND); setIcon(CHECK_PATH); window.clearTimeout(iconResetTimer); iconResetTimer = window.setTimeout(() => { // only reset if user hasn't started typing in the meantime if (userInput.length === 0) setIcon(COPY_PATH); }, 1100); } catch { /* clipboard can be blocked */ } } /* ---------- submit ---------- */ function submit() { const cmd = userInput.trim().toLowerCase(); if (cmd === '') return; if (cmd === '-h' || cmd === '--help' || cmd === 'h' || cmd === 'help') { // brief flash, then navigate commandBox.classList.add('submit'); setTimeout(() => { window.location.href = '/docs.html'; }, 260); return; } if (cmd === 'clear' || cmd === 'cls') { userInput = ''; refreshUI(); return; } // unknown command — shake, then clear after a beat commandBox.classList.remove('shake'); // force reflow so the animation restarts on rapid presses void commandBox.offsetWidth; commandBox.classList.add('shake'); setTimeout(() => { commandBox.classList.remove('shake'); userInput = ''; refreshUI(); }, 420); } /* ---------- keyboard input (after typewriter completes) ---------- */ function onKeydown(e: KeyboardEvent) { if (!inputEnabled || isAutoTyping) return; // let browser shortcuts pass if (e.metaKey || e.ctrlKey || e.altKey) return; // ignore navigation keys we don't care about if ( e.key === 'Tab' || e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Shift' || e.key === 'CapsLock' || e.key === 'Meta' || e.key === 'Control' || e.key === 'Alt' ) return; if (e.key === 'Enter') { e.preventDefault(); submit(); return; } if (e.key === 'Backspace') { e.preventDefault(); if (userInput.length > 0) { userInput = userInput.slice(0, -1); refreshUI(); } return; } if (e.key === 'Escape') { e.preventDefault(); userInput = ''; refreshUI(); return; } if (e.key.length === 1) { e.preventDefault(); // cap input length so it doesn't overflow if (userInput.length < 32) { userInput += e.key; refreshUI(); } } } document.addEventListener('keydown', onKeydown); /* ---------- click handlers on the box / action button ---------- */ function actionClick() { if (userInput.length > 0) submit(); else copyCommand(); } actionBtn.addEventListener('click', (e) => { e.stopPropagation(); actionClick(); }); commandBox.addEventListener('click', (e) => { // don't double-fire when clicking the button if ((e.target as HTMLElement).closest('.action-btn')) return; actionClick(); }); /* ---------- auto-type from "how it works" link ---------- */ function rand(a: ArrayLike): T { return a[Math.floor(Math.random() * a.length)]; } async function autoTypeHelp() { if (!inputEnabled || isAutoTyping) return; isAutoTyping = true; // clear current input, arm the box userInput = ''; refreshUI(); commandBox.classList.add('armed'); // ripple from where the user clicked the link const linkRect = docsLink.getBoundingClientRect(); spawnRipple(linkRect.left + linkRect.width / 2, linkRect.top + linkRect.height / 2); await sleep(90); // PHASE 1 — heavy glitch flicker across both character slots, // with a tiny box jitter for chromatic-aberration energy commandBox.classList.add('glitch'); const glitchSteps = 14; for (let s = 0; s < glitchSteps; s++) { const a = rand(SCRAMBLE); const b = Math.random() > 0.4 ? rand(SCRAMBLE) : ''; userEl.textContent = a + b; // micro-jitter the box const jx = (Math.random() - 0.5) * 3; const jy = (Math.random() - 0.5) * 1.5; commandBox.style.transform = `translate(${jx.toFixed(2)}px, ${jy.toFixed(2)}px)`; await sleep(28 + Math.random() * 18); } commandBox.style.transform = ''; commandBox.classList.remove('glitch'); // PHASE 2 — settle to "-h" with a brief micro-flicker on each char userInput = ''; for (const ch of '-h') { // 1-2 quick scramble flickers, then resolve for (let c = 0; c < 2; c++) { userEl.textContent = userInput + rand(SCRAMBLE); await sleep(34); } userInput += ch; refreshUI(); await sleep(60); } // PHASE 3 — let the user read "-h" before it fires, then ripple from the box await sleep(850); const boxRect = commandBox.getBoundingClientRect(); spawnRipple(boxRect.right - 24, boxRect.top + boxRect.height / 2); // PHASE 4 — Enter "press": punch the action icon down + a confirming ripple actionBtn.style.transition = 'transform 90ms ease-out'; actionBtn.style.transform = 'scale(0.78)'; await sleep(110); actionBtn.style.transform = 'scale(1)'; await sleep(140); actionBtn.style.transition = ''; actionBtn.style.transform = ''; isAutoTyping = false; submit(); } docsLink.addEventListener('click', (e) => { e.preventDefault(); autoTypeHelp(); }); /* ---------- cursor-reactive dot field + click ripples ---------- */ const canvas = document.getElementById('field') as HTMLCanvasElement; const ctx = canvas.getContext('2d')!; type Dot = { bx: number; by: number; ox: number; oy: number }; let dots: Dot[] = []; let dpr = Math.max(1, window.devicePixelRatio || 1); let w = 0; let h = 0; const SPACING = 26; const RADIUS = 170; const PUSH = 18; let targetX = -9999; let targetY = -9999; let curX = -9999; let curY = -9999; let active = false; type Ripple = { x: number; y: number; born: number }; const ripples: Ripple[] = []; const RIPPLE_LIFE = 900; const RIPPLE_MAX = 520; const RIPPLE_BAND = 70; const RIPPLE_PUSH = 36; function build() { dpr = Math.max(1, window.devicePixelRatio || 1); w = window.innerWidth; 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); dots = []; const pad = SPACING; for (let y = -pad; y < h + pad; y += SPACING) { for (let x = -pad; x < w + pad; x += SPACING) { dots.push({ bx: x, by: y, ox: 0, oy: 0 }); } } } function onMove(x: number, y: number) { targetX = x; targetY = y; active = true; } function spawnRipple(x: number, y: number) { ripples.push({ x, y, born: performance.now() }); if (ripples.length > 8) ripples.shift(); } window.addEventListener('mousemove', (e) => onMove(e.clientX, e.clientY), { passive: true }); window.addEventListener('mouseleave', () => { active = false; }); window.addEventListener('click', (e) => spawnRipple(e.clientX, e.clientY)); window.addEventListener('touchmove', (e) => { if (e.touches[0]) onMove(e.touches[0].clientX, e.touches[0].clientY); }, { passive: true }); window.addEventListener('touchstart', (e) => { if (e.touches[0]) spawnRipple(e.touches[0].clientX, e.touches[0].clientY); }, { passive: true }); window.addEventListener('touchend', () => { active = false; }); window.addEventListener('resize', build); build(); const BASE_RGB = '230, 230, 230'; const ACCENT_RGB = '166, 227, 161'; const BASE_ALPHA = 0.11; const PEAK_ALPHA = 0.9; function frame() { const now = performance.now(); if (!active) { targetX = -9999; targetY = -9999; } curX += (targetX - curX) * 0.18; curY += (targetY - curY) * 0.18; for (let i = ripples.length - 1; i >= 0; i--) { if (now - ripples[i].born > RIPPLE_LIFE) ripples.splice(i, 1); } const rippleState = ripples.map((rp) => { const p = (now - rp.born) / RIPPLE_LIFE; return { x: rp.x, y: rp.y, radius: p * RIPPLE_MAX, fade: 1 - p }; }); ctx.clearRect(0, 0, w, h); const r2 = RADIUS * RADIUS; for (let i = 0; i < dots.length; i++) { const d = dots[i]; const dx = d.bx - curX; const dy = d.by - curY; const d2 = dx * dx + dy * dy; let tx = 0; let ty = 0; let strength = 0; if (d2 < r2) { const dist = Math.sqrt(d2) || 0.0001; const t = 1 - dist / RADIUS; strength = t * t; tx = (dx / dist) * strength * PUSH; ty = (dy / dist) * strength * PUSH; } let rippleStrength = 0; for (let k = 0; k < rippleState.length; k++) { const rp = rippleState[k]; const rdx = d.bx - rp.x; const rdy = d.by - rp.y; const rdist = Math.sqrt(rdx * rdx + rdy * rdy) || 0.0001; const delta = Math.abs(rdist - rp.radius); if (delta < RIPPLE_BAND) { const ringT = 1 - delta / RIPPLE_BAND; const s = ringT * ringT * rp.fade; tx += (rdx / rdist) * s * RIPPLE_PUSH; ty += (rdy / rdist) * s * RIPPLE_PUSH; if (s > rippleStrength) rippleStrength = s; } } d.ox += (tx - d.ox) * 0.22; d.oy += (ty - d.oy) * 0.22; const px = d.bx + d.ox; const py = d.by + d.oy; const visStrength = Math.min(1, strength + rippleStrength); const alpha = BASE_ALPHA + (PEAK_ALPHA - BASE_ALPHA) * visStrength; const size = 1 + visStrength * 1.6; if (visStrength > 0.04) { const blend = visStrength; const r = Math.round(230 * (1 - blend) + 166 * blend); const g = Math.round(230 * (1 - blend) + 227 * blend); const b = Math.round(230 * (1 - blend) + 161 * blend); ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`; } else { ctx.fillStyle = `rgba(${BASE_RGB}, ${alpha})`; } ctx.fillRect(px - size / 2, py - size / 2, size, size); } if (active) { const grad = ctx.createRadialGradient(curX, curY, 0, curX, curY, RADIUS); grad.addColorStop(0, `rgba(${ACCENT_RGB}, 0.10)`); grad.addColorStop(1, `rgba(${ACCENT_RGB}, 0)`); ctx.fillStyle = grad; ctx.fillRect(curX - RADIUS, curY - RADIUS, RADIUS * 2, RADIUS * 2); } for (let k = 0; k < rippleState.length; k++) { const rp = rippleState[k]; if (rp.radius < 4) continue; ctx.strokeStyle = `rgba(${ACCENT_RGB}, ${0.16 * rp.fade})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(rp.x, rp.y, rp.radius, 0, Math.PI * 2); ctx.stroke(); } requestAnimationFrame(frame); } if (!reduceMotion) { requestAnimationFrame(frame); } else { ctx.fillStyle = `rgba(${BASE_RGB}, ${BASE_ALPHA})`; for (const d of dots) ctx.fillRect(d.bx, d.by, 1, 1); } void caretEl;