Refactor HTML structure for command input to use a single inline container with `white-space: pre` to precisely control spacing, and adjust JavaScript to add a trailing space to the typed text and increase the pause before the enter key press in the auto-type animation. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 6def8112-39d2-4641-b93b-f39108179f33 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 3f816ee5-1289-41ac-aa73-2bd1494876d3 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/42ae33dd-8759-4196-85a5-434465c72ece/6def8112-39d2-4641-b93b-f39108179f33/roUqm0y Replit-Helium-Checkpoint-Created: true
469 lines
13 KiB
TypeScript
469 lines
13 KiB
TypeScript
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<void>((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<T>(a: ArrayLike<T>): 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;
|