diff --git a/index.html b/index.html index 43b008d..1b8cb8d 100644 --- a/index.html +++ b/index.html @@ -13,18 +13,19 @@
-
- $ -
+ + how it works
- -h - // vibe coded to present human code + // vibe coded to present human code diff --git a/src/main.ts b/src/main.ts index 8f0f130..1042c9c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,26 +1,64 @@ import './style.css'; const COMMAND = 'ssh portfolio@keshavanand.net'; +const PROMPT_TAIL = ' '; // single space between prefix and user input 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 copyBtn = document.getElementById('copyBtn') as HTMLButtonElement; -const copyIcon = document.getElementById('copyIcon') as unknown as SVGSVGElement; +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; -/* ---------- Typewriter with a small "decoder" scramble ---------- */ +/* ---------- 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 typeWithScramble(target: string, onDone?: () => void) { +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) { - typedEl.textContent = resolved; + typedEl.textContent = resolved + PROMPT_TAIL; onDone?.(); return; } @@ -44,57 +82,180 @@ function typeWithScramble(target: string, onDone?: () => void) { } if (reduceMotion) { - typedEl.textContent = COMMAND; + typedEl.textContent = COMMAND + PROMPT_TAIL; + inputEnabled = true; } else { - setTimeout(() => typeWithScramble(COMMAND), 180); + setTimeout(() => typeIntro(COMMAND, () => { + inputEnabled = true; + }), 180); } -/* ---------- Silent copy (no flashy feedback, just a brief icon swap) ---------- */ +/* ---------- copy ---------- */ -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'; -const CHECK_PATH = 'M5 13l4 4L19 7'; +let iconResetTimer: number | undefined; -function setIcon(d: string) { - const path = copyIcon.querySelector('path'); - if (path) path.setAttribute('d', d); -} - -let copyResetTimer: number | undefined; - -async function copy() { +async function copyCommand() { try { await navigator.clipboard.writeText(COMMAND); setIcon(CHECK_PATH); - window.clearTimeout(copyResetTimer); - copyResetTimer = window.setTimeout(() => setIcon(COPY_PATH), 1200); + 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; do nothing */ + /* clipboard can be blocked */ } } -commandBox.addEventListener('click', copy); -commandBox.addEventListener('keydown', (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - copy(); +/* ---------- 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; } -}); -copyBtn.addEventListener('click', (e) => { + + 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(); - copy(); + actionClick(); }); -/* ---------- Cursor-reactive dot field ---------- */ +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 ---------- */ + +async function autoTypeHelp() { + if (!inputEnabled || isAutoTyping) return; + isAutoTyping = true; + + // clear whatever the user had typed + userInput = ''; + refreshUI(); + commandBox.classList.add('armed'); + + await sleep(120); + + const text = '-h'; + for (const ch of text) { + // small scramble for flair, then settle + let cycles = 2; + while (cycles > 0) { + const sc = SCRAMBLE[Math.floor(Math.random() * SCRAMBLE.length)]; + userEl.textContent = userInput + sc; + await sleep(38); + cycles--; + } + userInput += ch; + refreshUI(); + await sleep(70); + } + + // brief pause so the user reads it + await sleep(260); + + // visual "Enter pressed" — pulse the action button + actionBtn.style.transform = 'scale(0.85)'; + setTimeout(() => { actionBtn.style.transform = ''; }, 140); + + await sleep(180); + + 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; // base x (CSS px) - by: number; // base y (CSS px) - ox: number; // offset x (current) - oy: number; // offset y (current) -}; +type Dot = { bx: number; by: number; ox: number; oy: number }; let dots: Dot[] = []; let dpr = Math.max(1, window.devicePixelRatio || 1); @@ -102,23 +263,21 @@ let w = 0; let h = 0; const SPACING = 26; -const RADIUS = 170; // influence radius around cursor -const PUSH = 18; // max displacement in px +const RADIUS = 170; +const PUSH = 18; -// Cursor position (CSS px). Lerped for smoothness. let targetX = -9999; let targetY = -9999; let curX = -9999; let curY = -9999; let active = false; -// Click ripples — expanding rings that push dots outward as they pass. type Ripple = { x: number; y: number; born: number }; const ripples: Ripple[] = []; -const RIPPLE_LIFE = 900; // ms -const RIPPLE_MAX = 520; // max radius in px -const RIPPLE_BAND = 70; // ring thickness -const RIPPLE_PUSH = 36; // peak displacement +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); @@ -131,7 +290,6 @@ function build() { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); dots = []; - // pad outside viewport so the grid feels infinite during displacement const pad = SPACING; for (let y = -pad; y < h + pad; y += SPACING) { for (let x = -pad; x < w + pad; x += SPACING) { @@ -148,7 +306,6 @@ function onMove(x: number, y: number) { function spawnRipple(x: number, y: number) { ripples.push({ x, y, born: performance.now() }); - // cap to keep things light if (ripples.length > 8) ripples.shift(); } @@ -174,7 +331,6 @@ const PEAK_ALPHA = 0.9; function frame() { const now = performance.now(); - // smooth cursor lerp if (!active) { targetX = -9999; targetY = -9999; @@ -182,17 +338,13 @@ function frame() { curX += (targetX - curX) * 0.18; curY += (targetY - curY) * 0.18; - // expire dead ripples for (let i = ripples.length - 1; i >= 0; i--) { if (now - ripples[i].born > RIPPLE_LIFE) ripples.splice(i, 1); } - // precompute ripple state - const rippleState = ripples.map(rp => { - const p = (now - rp.born) / RIPPLE_LIFE; // 0..1 - const radius = p * RIPPLE_MAX; - const fade = 1 - p; // weakens over time - return { x: rp.x, y: rp.y, radius, fade }; + 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); @@ -202,12 +354,12 @@ function frame() { for (let i = 0; i < dots.length; i++) { const d = dots[i]; - // --- cursor displacement --- const dx = d.bx - curX; const dy = d.by - curY; const d2 = dx * dx + dy * dy; - let tx = 0, ty = 0; + let tx = 0; + let ty = 0; let strength = 0; if (d2 < r2) { @@ -218,7 +370,6 @@ function frame() { ty = (dy / dist) * strength * PUSH; } - // --- ripple displacement (additive) --- let rippleStrength = 0; for (let k = 0; k < rippleState.length; k++) { const rp = rippleState[k]; @@ -227,7 +378,7 @@ function frame() { 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; // 0..1 across 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; @@ -235,14 +386,12 @@ function frame() { } } - // smooth toward target offset 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; - // combined visual strength (cursor + ripple, capped) const visStrength = Math.min(1, strength + rippleStrength); const alpha = BASE_ALPHA + (PEAK_ALPHA - BASE_ALPHA) * visStrength; const size = 1 + visStrength * 1.6; @@ -260,7 +409,6 @@ function frame() { ctx.fillRect(px - size / 2, py - size / 2, size, size); } - // soft cursor glow if (active) { const grad = ctx.createRadialGradient(curX, curY, 0, curX, curY, RADIUS); grad.addColorStop(0, `rgba(${ACCENT_RGB}, 0.10)`); @@ -269,7 +417,6 @@ function frame() { ctx.fillRect(curX - RADIUS, curY - RADIUS, RADIUS * 2, RADIUS * 2); } - // ripple ring outlines — very faint, just a hint for (let k = 0; k < rippleState.length; k++) { const rp = rippleState[k]; if (rp.radius < 4) continue; @@ -286,10 +433,8 @@ function frame() { if (!reduceMotion) { requestAnimationFrame(frame); } else { - // static render: just dim dots, no displacement ctx.fillStyle = `rgba(${BASE_RGB}, ${BASE_ALPHA})`; for (const d of dots) ctx.fillRect(d.bx, d.by, 1, 1); } -// keep tsc happy about caretEl import (used via DOM) void caretEl; diff --git a/src/style.css b/src/style.css index 72b2b67..c96ec0e 100644 --- a/src/style.css +++ b/src/style.css @@ -5,6 +5,7 @@ --line: #1c1c20; --accent: #a6e3a1; --accent-soft: rgba(166, 227, 161, 0.22); + --accent-strong: rgba(166, 227, 161, 0.55); --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; } @@ -44,7 +45,7 @@ body { flex-direction: column; align-items: center; justify-content: center; - gap: 1.25rem; + gap: 0.85rem; max-width: 580px; margin: 0 auto; padding: 2rem 1.75rem; @@ -71,18 +72,43 @@ body { display: flex; align-items: center; gap: 0.6rem; - cursor: pointer; + cursor: text; user-select: none; - transition: border-color 180ms ease; + transition: border-color 200ms ease, box-shadow 200ms ease; position: relative; white-space: nowrap; overflow: hidden; } -.command-box:hover, -.command-box:focus-visible { +.command-box:hover { border-color: var(--accent-soft); - outline: none; +} + +/* "armed" once user starts typing — invites Enter */ +.command-box.armed { + border-color: var(--accent-strong); + box-shadow: 0 0 0 1px rgba(166, 227, 161, 0.08), 0 0 24px rgba(166, 227, 161, 0.06); +} + +/* shake on invalid command */ +.command-box.shake { + animation: shake 380ms cubic-bezier(.36,.07,.19,.97); + border-color: rgba(243, 139, 168, 0.55); +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 15% { transform: translateX(-4px); } + 30% { transform: translateX(4px); } + 45% { transform: translateX(-3px); } + 60% { transform: translateX(3px); } + 80% { transform: translateX(-1px); } +} + +/* brief flash before navigating */ +.command-box.submit { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-soft), 0 0 28px rgba(166, 227, 161, 0.18); } .dollar { @@ -94,7 +120,11 @@ body { .typed { color: var(--fg); white-space: pre; - overflow: hidden; +} + +.user-input { + color: var(--fg); + white-space: pre; } .caret { @@ -106,14 +136,12 @@ body { animation: blink 1.05s steps(1, end) infinite; } -.caret.gone { display: none; } - @keyframes blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } } -.copy-btn { +.action-btn { margin-left: auto; background: none; border: none; @@ -124,39 +152,49 @@ body { align-items: center; justify-content: center; flex-shrink: 0; + transition: color 180ms ease, transform 180ms ease; } -.copy-btn:hover { color: var(--fg); } +.action-btn:hover { color: var(--fg); } -.copy-btn svg { +/* when armed, the action button is the Enter key — pulses gently to invite */ +.command-box.armed .action-btn { + color: var(--accent); + animation: pulse 1.6s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.08); opacity: 0.85; } +} + +.action-btn svg { width: 16px; height: 16px; + transition: transform 180ms ease; } -/* tiny "-h" flag — shows up late, sits in the bottom-left */ -.help-flag { - position: fixed; - left: 1rem; - bottom: 1rem; - z-index: 1; - font-family: var(--mono); +/* very minimal "how it works" subtitle — even fainter than before */ +.docs-hint { + color: rgba(106, 106, 114, 0.45); + text-decoration: none; font-size: 0.7rem; letter-spacing: 0.02em; - color: rgba(106, 106, 114, 0.55); - text-decoration: none; - padding: 0.15rem 0.3rem; + padding: 0.15rem 0.35rem; opacity: 0; - transition: color 200ms ease, opacity 200ms ease; - animation: fadeFaint 700ms ease-out 2200ms forwards; + animation: fadeFaint 700ms ease-out 1100ms forwards; + transition: color 200ms ease; + cursor: pointer; } -.help-flag:hover { color: var(--fg); } +.docs-hint:hover { color: var(--fg); } -/* even fainter — bottom-right */ +/* whisper-quiet byline, centered along the bottom */ .byline { position: fixed; - right: 1rem; + left: 50%; bottom: 1rem; + transform: translateX(-50%); z-index: 1; font-family: var(--mono); font-size: 0.62rem; @@ -164,15 +202,12 @@ body { color: rgba(106, 106, 114, 0.28); pointer-events: none; opacity: 0; - animation: fadeWhisper 900ms ease-out 2700ms forwards; + animation: fadeWhisper 900ms ease-out 1500ms forwards; + white-space: nowrap; } -@keyframes fadeFaint { - to { opacity: 1; } -} -@keyframes fadeWhisper { - to { opacity: 1; } -} +@keyframes fadeFaint { to { opacity: 1; } } +@keyframes fadeWhisper { to { opacity: 1; } } @media (max-width: 640px) { .stage { padding: 1.5rem 1.25rem; } @@ -180,12 +215,12 @@ body { font-size: 0.92rem; padding: 0.9rem 1rem; } - .help-flag { font-size: 0.65rem; } + .docs-hint { font-size: 0.66rem; } .byline { font-size: 0.56rem; } } @media (prefers-reduced-motion: reduce) { - .stage { animation: none; opacity: 1; } + .stage, .docs-hint, .byline { animation: none; opacity: 1; } .caret { animation: none; } - .help-flag, .byline { animation: none; opacity: 1; } + .command-box.armed .action-btn { animation: none; } }