diff --git a/.replit b/.replit new file mode 100644 index 0000000..a9d2a79 --- /dev/null +++ b/.replit @@ -0,0 +1,32 @@ +modules = ["nodejs-20"] + +[nix] +channel = "stable-25_05" + +[workflows] +runButton = "Project" + +[[workflows.workflow]] +name = "Project" +mode = "parallel" +author = "agent" + +[[workflows.workflow.tasks]] +task = "workflow.run" +args = "Start application" + +[[workflows.workflow]] +name = "Start application" +author = "agent" + +[[workflows.workflow.tasks]] +task = "shell.exec" +args = "npm run dev" +waitForPort = 5000 + +[workflows.workflow.metadata] +outputType = "webview" + +[[ports]] +localPort = 5000 +externalPort = 80 diff --git a/index.html b/index.html index dca2da6..97235b9 100644 --- a/index.html +++ b/index.html @@ -4,29 +4,34 @@ - Terminal Portfolio + keshavanand — ssh in - -
-
-
- $ - ssh portfolio@keshavanand.net +
+

hi, i'm keshav.

+ +
+
+ $ +
- -
Copied!
+ click to copy
- how it works -
+ + how it works → + + +
+ // hand-written, not vibe-coded +
- \ No newline at end of file + diff --git a/replit.md b/replit.md new file mode 100644 index 0000000..cf6188c --- /dev/null +++ b/replit.md @@ -0,0 +1,30 @@ +# Terminal Portfolio + +A minimalist landing page that points visitors to an SSH-accessible portfolio (`ssh portfolio@keshavanand.net`). The webshell behind the SSH endpoint is a separate concern; this repo is just the marketing/landing surface. + +## Stack + +- Vite + TypeScript (vanilla, no framework) +- Plain CSS, JetBrains Mono via Google Fonts +- Two pages: `index.html` (homepage) and `docs.html` (how it works) + +## Structure + +- `index.html` — homepage markup +- `src/main.ts` — homepage typewriter + copy-to-clipboard logic +- `src/style.css` — homepage styles (dark, dot-grid background, soft glow) +- `docs.html` / `src/docs.ts` / `src/docs.css` — docs page +- `vite.config.ts` — multi-page build config; dev server on `0.0.0.0:5000` with `allowedHosts: true` for Replit + +## Design notes + +The homepage intentionally avoids busy background animation. It uses: +- A static CSS dot grid + a single soft radial glow (no JS animation loop) +- A short typewriter intro for the SSH command with a humanized cadence +- A blinking block caret that persists after typing +- Subtle opacity-only fade-ins (no transform-based slide-ins) +- Honors `prefers-reduced-motion` + +## Run + +- Workflow `Start application` runs `npm run dev` on port 5000. diff --git a/src/main.ts b/src/main.ts index 85f3220..e2b0d72 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,188 +1,59 @@ import './style.css'; -// Beautiful Matrix Rain Background -const canvas = document.getElementById('matrix') as HTMLCanvasElement; -const ctx = canvas.getContext('2d')!; - -let width = canvas.width = window.innerWidth; -let height = canvas.height = window.innerHeight; - -// Catppuccin Mocha colors -const colors = { - green: '#a6e3a1', - blue: '#89b4fa', - mauve: '#cba6f7', - text: '#cdd6f4' -}; - -const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@#$%'; -const fontSize = 12; -const columns = Math.floor(width / fontSize); -const drops: number[] = Array(columns).fill(1); - -// Mouse interaction -let mouseX = width / 2; -let mouseY = height / 2; - -// Trail effect -const trail: { x: number; y: number; color: string; life: number }[] = []; - -function createExplosion(x: number, y: number) { - const color = Math.random() > 0.5 ? colors.green : colors.blue; - for (let i = 0; i < 12; i++) { - const angle = (Math.PI * 2 * i) / 12; - const speed = Math.random() * 2 + 1; - trail.push({ - x: x + (Math.random() - 0.5) * 20, - y: y + (Math.random() - 0.5) * 20, - color: color, - life: 1 - }); - } -} - -function draw() { - // Subtle fade for trails - ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; - ctx.fillRect(0, 0, width, height); - - // Draw trail with glow - if (trail.length > 0) { - ctx.font = `${fontSize}px monospace`; - ctx.globalAlpha = 0.3; - for (let i = trail.length - 1; i >= 0; i--) { - const t = trail[i]; - ctx.fillStyle = t.color; - ctx.shadowBlur = 8; - ctx.shadowColor = t.color; - ctx.fillText(chars[Math.floor(Math.random() * chars.length)], t.x, t.y); - - t.life -= 0.03; - if (t.life <= 0) trail.splice(i, 1); - } - ctx.shadowBlur = 0; - } - - // Matrix rain with distance-based brightness - ctx.font = `${fontSize}px monospace`; - for (let i = 0; i < drops.length; i++) { - const x = i * fontSize; - const y = drops[i] * fontSize; - - // Calculate distance from cursor - const dist = Math.sqrt((x - mouseX) ** 2 + (y - mouseY) ** 2); - - // Brighter near cursor, dimmer elsewhere - const brightness = Math.max(0.2, 1 - dist / 250); - - // Pick color based on distance - let color: string; - if (dist < 80) color = colors.green; - else if (dist < 150) color = colors.blue; - else color = colors.mauve; - - ctx.fillStyle = color; - ctx.fillText(chars[Math.floor(Math.random() * chars.length)], x, y); - - if (drops[i] * fontSize > height + fontSize && Math.random() > 0.95) drops[i] = 0; - drops[i]++; - } - - ctx.globalAlpha = 1; -} - -function update() { - // Add trail around cursor - if (Math.random() > 0.4) { - const dist = Math.sqrt((mouseX - width/2) ** 2 + (mouseY - height/2) ** 2); - trail.push({ - x: mouseX + (Math.random() - 0.5) * 20, - y: mouseY + (Math.random() - 0.5) * 20, - color: dist < 80 ? colors.green : colors.blue, - life: 1 - }); - } - if (trail.length > 10) trail.shift(); - - // Update drops - for (let i = 0; i < drops.length; i++) { - if (drops[i] * fontSize > height + fontSize && Math.random() > 0.93) drops[i] = 0; - drops[i]++; - } -} - -function loop() { - update(); - draw(); - requestAnimationFrame(loop); -} - -// Mouse movement -window.addEventListener('mousemove', (e) => { - mouseX = e.clientX; - mouseY = e.clientY; -}); - -// Click explosion -window.addEventListener('click', (e) => { - createExplosion(e.clientX, e.clientY); -}); - -// Touch support -window.addEventListener('touchmove', (e) => { - if ('ontouchstart' in window) { - mouseX = e.touches[0].clientX; - mouseY = e.touches[0].clientY; - } -}, { passive: true }); - -window.addEventListener('touchstart', (e) => { - if ('ontouchstart' in window) { - createExplosion(e.touches[0].clientX, e.touches[0].clientY); - } -}, { passive: true }); - -// Resize handler -function resize() { - width = canvas.width = window.innerWidth; - height = canvas.height = window.innerHeight; -} - -window.addEventListener('resize', resize); -resize(); - -loop(); - -// Copy functionality -const commandBox = document.getElementById('commandBox') as HTMLDivElement; -const copyBtn = document.getElementById('copyBtn') as HTMLButtonElement; -const tooltip = document.getElementById('tooltip') as HTMLDivElement; const COMMAND = 'ssh portfolio@keshavanand.net'; -async function copyToClipboard() { +const typedEl = document.getElementById('typed') as HTMLSpanElement; +const caretEl = document.getElementById('caret') as HTMLSpanElement; +const commandBox = document.getElementById('commandBox') as HTMLDivElement; +const copyBtn = document.getElementById('copyBtn') as HTMLButtonElement; +const hintEl = document.getElementById('hint') as HTMLSpanElement; + +// Typewriter intro — small, intentional, then the caret keeps blinking. +const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +function type(i = 0) { + if (i > COMMAND.length) return; + typedEl.textContent = COMMAND.slice(0, i); + // slight humanized rhythm + const next = 28 + Math.random() * 38; + setTimeout(() => type(i + 1), next); +} + +if (reduceMotion) { + typedEl.textContent = COMMAND; +} else { + // small lead-in delay so the page settles first + setTimeout(() => type(), 380); +} + +// Copy on click / keypress +async function copy() { try { await navigator.clipboard.writeText(COMMAND); + commandBox.classList.add('copied'); copyBtn.classList.add('copied'); - tooltip.classList.add('show'); + hintEl.classList.add('copied'); + const original = hintEl.textContent; + hintEl.textContent = 'copied'; setTimeout(() => { + commandBox.classList.remove('copied'); copyBtn.classList.remove('copied'); - tooltip.classList.remove('show'); - }, 2000); - } catch (err) { - console.error('Failed to copy:', err); + hintEl.classList.remove('copied'); + hintEl.textContent = original; + }, 1400); + } catch { + // silent — clipboard can be blocked in some contexts } } +commandBox.addEventListener('click', copy); +commandBox.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + copy(); + } +}); copyBtn.addEventListener('click', (e) => { e.stopPropagation(); - copyToClipboard(); + copy(); }); - -commandBox.addEventListener('click', (e) => { - if (!(e.target as HTMLElement).closest('.copy-btn')) copyToClipboard(); -}); - -// Fade in docs link after 3 seconds -setTimeout(() => { - document.getElementById('docsLink')?.classList.add('show'); -}, 3000); diff --git a/src/style.css b/src/style.css index 3265211..ec61da1 100644 --- a/src/style.css +++ b/src/style.css @@ -1,15 +1,11 @@ :root { - /* Catppuccin Mocha - Darker Hacker Theme */ - --base: #000000; - --mantle: #0a0a0a; - --crust: #050505; - --text: #cdd6f4; - --subtext0: #a6adc8; - --surface0: #1a1a1a; - --surface1: #2a2a2a; - --green: #a6e3a1; - --blue: #89b4fa; - --mauve: #cba6f7; + --bg: #0b0b0d; + --fg: #e6e6e6; + --dim: #6a6a72; + --line: #1c1c20; + --accent: #a6e3a1; + --accent-soft: rgba(166, 227, 161, 0.18); + --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; } * { @@ -18,205 +14,207 @@ box-sizing: border-box; } +html, body { + height: 100%; +} + body { - font-family: "JetBrains Mono", monospace; - background-color: var(--base); - color: var(--text); + font-family: var(--mono); + background: var(--bg); + color: var(--fg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + overflow: hidden; + position: relative; + /* very subtle dot grid — static, no JS */ + background-image: + radial-gradient(circle at 1px 1px, rgba(255, 255, 255, 0.045) 1px, transparent 0); + background-size: 22px 22px; +} + +/* a single, static, very soft glow behind the content — gives depth without movement */ +body::before { + content: ""; + position: fixed; + inset: 0; + background: radial-gradient( + 600px 400px at 50% 45%, + rgba(166, 227, 161, 0.06), + transparent 70% + ); + pointer-events: none; + z-index: 0; +} + +.stage { + position: relative; + z-index: 1; min-height: 100vh; display: flex; flex-direction: column; + align-items: flex-start; justify-content: center; - align-items: center; - padding: 2rem; - line-height: 1.6; - overflow: hidden; - position: relative; -} + gap: 1.25rem; + max-width: 560px; + margin: 0 auto; + padding: 2rem 1.75rem; -#matrix { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - pointer-events: none; -} - -.container { - width: 100%; - max-width: 500px; - position: relative; - z-index: 1; - display: flex; - flex-direction: column; - align-items: center; - gap: 2rem; - padding: 1rem; opacity: 0; - animation: containerFloatUp 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards; + animation: fade 600ms ease-out 80ms forwards; } -@keyframes containerFloatUp { - from { - opacity: 0; - transform: translateY(30px); - } - to { - opacity: 1; - transform: translateY(0); - } +@keyframes fade { + to { opacity: 1; } } -.terminal-header { - margin-bottom: 3rem; - text-align: left; - width: 100%; -} - -.terminal-line { - color: var(--subtext0); - font-size: 0.95rem; - line-height: 1.8; - margin-bottom: 0.5rem; -} - -.prompt { - color: var(--green); -} - -.user { - color: var(--blue); -} - -.path { - color: var(--mauve); -} - -.command-container { - position: relative; +.hello { + color: var(--dim); + font-size: 0.9rem; + letter-spacing: 0.01em; +} + +.command-row { width: 100%; + display: flex; + align-items: center; + gap: 0.85rem; + flex-wrap: wrap; } .command-box { - background: rgba(0, 0, 0, 0.8); - border: 1px solid rgba(166, 227, 161, 0.3); - border-radius: 6px; - padding: 1rem 1.25rem; - font-size: 1.1rem; + flex: 1 1 auto; + min-width: 0; + background: rgba(10, 10, 12, 0.6); + border: 1px solid var(--line); + border-radius: 4px; + padding: 0.95rem 1.1rem; + font-size: 1rem; font-weight: 500; - color: var(--text); + color: var(--fg); display: flex; align-items: center; - gap: 0.75rem; + gap: 0.6rem; cursor: pointer; user-select: none; - transition: all 0.3s ease; + transition: border-color 180ms ease, background 180ms ease; + position: relative; + white-space: nowrap; + overflow: hidden; } -.command-box:hover { - border-color: rgba(166, 227, 161, 0.6); - box-shadow: 0 0 20px rgba(166, 227, 161, 0.1); - transform: translateY(-2px); +.command-box:hover, +.command-box:focus-visible { + border-color: var(--accent-soft); + background: rgba(12, 14, 12, 0.8); + outline: none; } -.command-box:active { - transform: translateY(0); +.command-box.copied { + border-color: var(--accent); } .dollar { - color: var(--green); + color: var(--accent); font-weight: 600; + flex-shrink: 0; } -.command { - color: var(--text); - flex: 1; +.typed { + color: var(--fg); + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +} + +.caret { + display: inline-block; + color: var(--accent); + font-size: 0.85em; + transform: translateY(-1px); + margin-left: 1px; + 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 { - position: absolute; - right: 0.75rem; - top: 50%; - transform: translateY(-50%); + margin-left: auto; background: none; border: none; - color: var(--subtext0); + color: var(--dim); cursor: pointer; - padding: 0.25rem; - display: flex; + padding: 0.2rem; + display: inline-flex; align-items: center; justify-content: center; - transition: color 0.2s ease; - z-index: 10; + transition: color 160ms ease; + flex-shrink: 0; } -.copy-btn:hover { - color: var(--green); -} - -.copy-btn.copied { - color: var(--green); -} +.copy-btn:hover { color: var(--fg); } +.copy-btn.copied { color: var(--accent); } .copy-btn svg { - width: 18px; - height: 18px; + width: 16px; + height: 16px; } -.tooltip { - position: absolute; - top: -2rem; - right: 0; - background: rgba(26, 26, 26, 0.9); - color: var(--green); - padding: 0.3rem 0.6rem; - font-size: 0.65rem; - opacity: 0; - pointer-events: none; - transition: opacity 0.2s ease; - white-space: nowrap; - z-index: 20; +.hint { + color: var(--dim); + font-size: 0.78rem; + transition: color 160ms ease, opacity 200ms ease; } -.tooltip.show { - opacity: 1; -} - -@media (max-width: 640px) { - body { - padding: 1.5rem; - } - - .container { - gap: 1.5rem; - padding: 1rem; - } - - .command-box { - font-size: 0.95rem; - padding: 0.85rem 1rem; - } - - .docs-link { - font-size: 0.8rem; - } +.hint.copied { + color: var(--accent); } .docs-link { - color: var(--subtext0); + color: var(--dim); text-decoration: none; font-size: 0.85rem; - opacity: 0; - transition: opacity 0.4s ease; - position: relative; - padding: 0.5rem 1rem; + padding: 0.2rem 0; + border-bottom: 1px solid transparent; + transition: color 160ms ease, border-color 160ms ease; } .docs-link:hover { - color: var(--green); + color: var(--fg); + border-bottom-color: var(--line); } -.docs-link.show { - opacity: 1; +.byline { + position: fixed; + left: 0; + right: 0; + bottom: 1.25rem; + text-align: center; + color: var(--dim); + font-size: 0.72rem; + letter-spacing: 0.02em; + z-index: 1; + opacity: 0; + animation: fade 600ms ease-out 1100ms forwards; +} + +@media (max-width: 640px) { + .stage { + padding: 1.5rem 1.25rem; + gap: 1rem; + } + .command-box { + font-size: 0.9rem; + padding: 0.85rem 0.95rem; + } + .hint { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .stage, .byline { animation: none; opacity: 1; } + .caret { animation: none; } } diff --git a/vite.config.ts b/vite.config.ts index af21215..9d3ac0a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,16 @@ import { defineConfig } from 'vite'; import { resolve } from 'path'; export default defineConfig({ + server: { + host: '0.0.0.0', + port: 5000, + allowedHosts: true, + }, + preview: { + host: '0.0.0.0', + port: 5000, + allowedHosts: true, + }, build: { rollupOptions: { input: { @@ -10,4 +20,4 @@ export default defineConfig({ }, }, }, -}); \ No newline at end of file +});