Update command line interface with interactive typing and visual feedback

Refactors the command line interface to include an interactive SSH command input with real-time visual feedback, dynamic button icons, and animated responses to user commands, including navigation to documentation.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 6def8112-39d2-4641-b93b-f39108179f33
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 49353dc6-fccb-41d3-8b94-6617238c72ba
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/42ae33dd-8759-4196-85a5-434465c72ece/6def8112-39d2-4641-b93b-f39108179f33/tF7dS0e
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
keshavananddev
2026-04-24 03:48:17 +00:00
parent 9c878fb1d0
commit ac056ef7fb
3 changed files with 289 additions and 108 deletions

View File

@@ -13,18 +13,19 @@
<canvas id="field" aria-hidden="true"></canvas>
<main class="stage">
<div class="command-box" id="commandBox" role="button" tabindex="0" aria-label="Copy ssh command">
<span class="dollar">$</span><span class="typed" id="typed"></span><span class="caret" id="caret">&#9608;</span>
<button class="copy-btn" id="copyBtn" aria-label="Copy to clipboard">
<svg id="copyIcon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
<div class="command-box" id="commandBox">
<span class="dollar">$</span><span class="typed" id="typed"></span><span class="user-input" id="userInput"></span><span class="caret" id="caret">&#9608;</span>
<button class="action-btn" id="actionBtn" aria-label="Copy to clipboard">
<svg id="actionIcon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path id="iconPath" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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" />
</svg>
</button>
</div>
<a href="/docs.html" class="docs-hint" id="docsLink">how it works</a>
</main>
<a href="/docs.html" class="help-flag" id="helpFlag" aria-label="how it works">-h</a>
<span class="byline" id="byline">// vibe coded to present human code</span>
<span class="byline">// vibe coded to present human code</span>
<script type="module" src="/src/main.ts"></script>
</body>

View File

@@ -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<void>((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 === ' ') {
/* ---------- 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();
copy();
submit();
return;
}
});
copyBtn.addEventListener('click', (e) => {
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;

View File

@@ -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; }
}