From 9de9cca1aa4cb7cffb4534bb56a6169905b44702 Mon Sep 17 00:00:00 2001 From: keshavananddev <53607429-keshavananddev@users.noreply.replit.com> Date: Fri, 24 Apr 2026 03:59:27 +0000 Subject: [PATCH] Unify website and documentation pages with a consistent terminal aesthetic Extract shared canvas animation module, refactor docs page to a terminal man-page style, and align overall visual language. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 6def8112-39d2-4641-b93b-f39108179f33 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 87e11a5a-0718-4680-a1d4-0d2c471eca80 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/42ae33dd-8759-4196-85a5-434465c72ece/6def8112-39d2-4641-b93b-f39108179f33/Mumw6Ni Replit-Helium-Checkpoint-Created: true --- docs.html | 172 ++++++++++---------- src/docs.css | 438 ++++++++++++++++++++++++++++++--------------------- src/docs.ts | 51 ++++-- src/field.ts | 205 ++++++++++++++++++++++++ src/main.ts | 186 +--------------------- 5 files changed, 597 insertions(+), 455 deletions(-) create mode 100644 src/field.ts diff --git a/docs.html b/docs.html index aa67369..4c7b4c0 100644 --- a/docs.html +++ b/docs.html @@ -3,101 +3,111 @@ - Documentation - Terminal Portfolio + man ssh — keshavanand.net - -
-
- $ cd .. -

Documentation

-

The who, what, when, where, why

+ + + +
+ $ cd .. + +
+

man ssh

+

portfolio(1)·keshavanand.net·v1

+
+ + + +
+ $ ssh portfolio@keshavanand.net +
-
-
- ssh portfolio@keshavanand.net - -
-
+
+

# name

+

+ A keystroke-driven portfolio. The command above opens a real SSH session into a restricted, passwordless + account on a homeserver and drops you into a custom C++/FTXUI interface. No web app, no JS, no installer — just the shell you already have. +

+
-
-
-

What is it?

-

- When executed in your terminal, this command renders a fully interactive shell portfolio experience. - Much like a digital resume or personal site, it allows you to explore my work and background through - a safe, secure, and purely text-based interface. - It is completely harmless to your system. -

-
+
+

# synopsis

+
$ ssh portfolio@keshavanand.net
+
-
-

Command Breakdown

-
-
-

ssh →

-

Secure Shell: The standard terminal protocol for logging into and controlling remote computers safely.

-
-
-

portfolio →

-

User: A passwordless, restricted user on my homeserver meant for public access.

-
-
-

keshavanand.net →

-

Domain: Points to a web record that directs your request to my server's public IP (gateway).

-
+
+

# arguments

+
+
+
ssh
+
secure shell — the standard remote-login protocol. ubiquitous on macOS, linux, and modern windows.
-
+
+
portfolio
+
a passwordless, restricted user account. the only thing it can do is launch the portfolio binary.
+
+
+
keshavanand.net
+
resolves to my homeserver's public IP. the gateway forwards port 22 to the box.
+
+ +
-
-

How to Run

-
    -
  1. Copy the command above using the clipboard tool.
  2. -
  3. Open your native terminal (Terminal on Mac/Linux, PowerShell on Windows).
  4. -
  5. Paste the command and accept the host identity by typing 'yes' if prompted.
  6. -
  7. Press Enter to launch the interactive environment.
  8. -
  9. Terminate the process at any time by pressing Ctrl + C.
  10. -
-
+
+

# usage

+
    +
  1. 01 copy the command above (click the box, or the icon).
  2. +
  3. 02 open your terminal — Terminal, iTerm, Alacritty, Windows Terminal, anything.
  4. +
  5. 03 paste, hit return. accept the host fingerprint with yes on first connect.
  6. +
  7. 04 navigate with arrow keys + return. quit any time with Ctrl+C.
  8. +
+
-
-

Code Logic

-

- This command gives authenticated access to the 'portfolio' user account on my private home server. -

-

- Instead of a standard bash or zsh shell, the user session triggers a custom-coded C++ executable. - This binary utilizes FTXUI—a sophisticated functional terminal user interface library—to handle - real-time rendering and input. -

-
+
+

# implementation

+

+ The portfolio user's login shell isn't bash — it's a custom C++ binary that uses + FTXUI + for layout, focus management, and rendering. Input is captured raw, output is repainted on a frame loop. + It is sandboxed, read-only, and harmless to your machine. +

+
-
-

Resources

- -
-
+ + + +
// vibe coded to present human code
- diff --git a/src/docs.css b/src/docs.css index 1bd0764..fac1c8b 100644 --- a/src/docs.css +++ b/src/docs.css @@ -1,11 +1,13 @@ :root { - --base: #000000; - --text: #cdd6f4; - --subtext0: #a6adc8; - --surface0: #1a1a1a; - --surface1: #2a2a2a; - --green: #a6e3a1; - --blue: #89b4fa; + --bg: #0b0b0d; + --fg: #e6e6e6; + --dim: #6a6a72; + --dimmer: #4a4a52; + --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; } * { @@ -14,238 +16,320 @@ box-sizing: border-box; } -body { - font-family: "JetBrains Mono", monospace; - background-color: var(--base); - color: var(--text); - min-height: 100vh; - padding: 2rem 1.5rem; +html { height: 100%; } + +body.docs { + font-family: var(--mono); + background: var(--bg); + color: var(--fg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + min-height: 100%; + overflow-x: hidden; + overflow-y: auto; + position: relative; line-height: 1.7; } -.container { - max-width: 650px; +#field { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + z-index: 0; + pointer-events: none; + display: block; +} + +.page { + position: relative; + z-index: 1; + max-width: 720px; margin: 0 auto; + padding: 3rem 1.75rem 6rem; + opacity: 0; + animation: fade 500ms ease-out 60ms forwards; } -.header { - margin-bottom: 3rem; -} +@keyframes fade { to { opacity: 1; } } -.back-link { - color: var(--subtext0); - text-decoration: none; - font-size: 0.85rem; - margin-bottom: 2rem; +/* back link — same accent treatment as the home dollar */ +.back { display: inline-block; - transition: color 0.2s ease; + color: var(--dim); + text-decoration: none; + font-size: 0.78rem; + letter-spacing: 0.02em; + padding: 0.15rem 0; + transition: color 180ms ease, transform 180ms ease; +} +.back:hover { + color: var(--accent); + transform: translateX(-2px); } -.back-link:hover { - color: var(--green); -} - -.back-link:hover { - color: var(--green); +.head { + margin-top: 2.5rem; } h1 { - color: var(--text); - font-size: 2.5rem; - margin: 1rem 0 0.5rem 0; - font-weight: 600; + font-size: 1.65rem; + font-weight: 500; + letter-spacing: 0.01em; + color: var(--fg); } -.subtitle { - color: var(--subtext0); - font-size: 1rem; +h1 .accent { color: var(--accent); } + +.sub { + margin-top: 0.45rem; + color: var(--dim); + font-size: 0.78rem; + letter-spacing: 0.04em; } -.command-reference { - margin: 2rem 0 3rem 0; +.sub .dot { + margin: 0 0.55rem; + color: var(--dimmer); } +.rule { + height: 1px; + background: var(--line); + margin: 1.6rem 0 1.5rem; +} + +/* command box — visually identical to the home version, sans caret/input */ .command-box { - background: rgba(26, 26, 26, 0.8); - border: 1px solid var(--green); - padding: 1.25rem; + width: 100%; + background: rgba(10, 10, 12, 0.55); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + border: 1px solid var(--line); + border-radius: 4px; + padding: 1.05rem 1.15rem; + font-size: 1.05rem; + font-weight: 500; + color: var(--fg); display: flex; align-items: center; - justify-content: space-between; - gap: 1rem; - transition: border-color 0.2s ease; + gap: 0; + transition: border-color 200ms ease, box-shadow 200ms ease; + position: relative; + overflow: hidden; } .command-box:hover { - border-color: var(--blue); + border-color: var(--accent-soft); } -.command-box code { - color: var(--green); - font-size: 1.1rem; - flex: 1; +.command-box.copied { + border-color: var(--accent-strong); + box-shadow: 0 0 0 1px rgba(166, 227, 161, 0.10), 0 0 24px rgba(166, 227, 161, 0.10); } -.copy-btn { - background: none; - border: none; - color: var(--subtext0); - cursor: pointer; - padding: 0.5rem; - border-radius: 4px; - transition: all 0.2s ease; - display: flex; - align-items: center; - position: relative; -} - -.copy-btn:hover { - color: var(--green); - background-color: var(--surface0); -} - -.copy-btn .check-icon { - display: none; - color: var(--green); -} - -.copy-btn.copied .copy-icon { - display: none; -} - -.copy-btn.copied .check-icon { +.line { + flex: 1 1 auto; + min-width: 0; + white-space: pre; + overflow: hidden; + text-overflow: clip; display: block; } -.sections { - display: flex; - flex-direction: column; - gap: 3rem; +.dollar { + color: var(--accent); + font-weight: 600; } -.section { +.typed { color: var(--fg); } + +.action-btn { + margin-left: 0.6rem; + background: none; + border: none; + color: var(--dim); + cursor: pointer; + padding: 0.2rem; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + transition: color 180ms ease, transform 180ms ease; +} +.action-btn:hover { color: var(--fg); } +.action-btn svg { width: 16px; height: 16px; } + +/* sections — terminal-style "# heading" then prose */ +.block { + margin-top: 2.6rem; } h2 { - color: var(--green); - font-size: 1.5rem; - margin-bottom: 1.25rem; + font-size: 0.85rem; + font-weight: 500; + color: var(--fg); + letter-spacing: 0.04em; + text-transform: lowercase; + margin-bottom: 0.85rem; +} + +h2 .hash { + color: var(--accent); + margin-right: 0.4rem; font-weight: 600; } -p { - color: var(--subtext0); - margin-bottom: 1rem; - font-size: 0.95rem; +.block p { + color: var(--dim); + font-size: 0.88rem; + line-height: 1.75; } -strong { - color: var(--text); - font-weight: 600; -} +.block p .fg { color: var(--fg); } -.breakdown-grid { +.block p a { + color: var(--accent); + text-decoration: none; + border-bottom: 1px dashed transparent; + transition: border-color 180ms ease; +} +.block p a:hover { border-bottom-color: var(--accent-soft); } + +/* code block — quieter, leaner than the command box */ +.code { + font-family: var(--mono); + font-size: 0.92rem; + background: rgba(10, 10, 12, 0.4); + border-left: 1px solid var(--accent-soft); + padding: 0.8rem 1rem; + color: var(--fg); + overflow-x: auto; +} +.code .dim { color: var(--dim); margin-right: 0.5rem; } +.code .accent { color: var(--accent); } + +/* arguments — definition list, mono leader */ +.defs { + display: flex; + flex-direction: column; + gap: 0.9rem; +} +.def { display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 1.5rem; - margin-top: 1.5rem; -} - -.breakdown-item { - padding: 1rem; - border-bottom: 1px solid rgba(166, 227, 161, 0.1); -} - -.breakdown-item:last-child { - border-bottom: none; -} - -.breakdown-item h3 { - color: var(--blue); - font-size: 1rem; - margin-bottom: 0.5rem; - font-weight: 600; -} - -.breakdown-item p { - font-size: 0.9rem; - line-height: 1.6; + grid-template-columns: minmax(160px, 200px) 1fr; + gap: 1rem; + padding: 0.5rem 0; + border-bottom: 1px dashed var(--line); + align-items: baseline; +} +.def:last-child { border-bottom: none; } +.def dt { + font-size: 0.85rem; + font-weight: 500; +} +.def dd { + color: var(--dim); + font-size: 0.85rem; + line-height: 1.7; } +/* steps — numbered, monospace numerals */ .steps { list-style: none; - counter-reset: step; - margin-top: 1rem; -} - -.steps li { - counter-increment: step; - margin-bottom: 1.25rem; display: flex; - gap: 1rem; - color: var(--subtext0); - font-size: 0.95rem; + flex-direction: column; + gap: 0.55rem; } - -.steps li::before { - content: counter(step, decimal-leading-zero); - color: var(--green); - font-weight: 600; - min-width: 2rem; +.steps li { + color: var(--dim); + font-size: 0.88rem; + line-height: 1.7; +} +.steps .step { + display: inline-block; + color: var(--accent); + margin-right: 0.85rem; + font-weight: 500; } kbd { - background-color: var(--surface0); - border: 1px solid var(--surface1); - padding: 0.2rem 0.5rem; - border-radius: 4px; - color: var(--text); - font-size: 0.9em; - font-family: inherit; + font-family: var(--mono); + font-size: 0.78em; + color: var(--fg); + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.03); + padding: 0.05rem 0.35rem; + border-radius: 3px; + margin: 0 0.1rem; } -.resources-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 1rem; - margin-top: 1.5rem; +/* see also — link list */ +.links { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.4rem; } - -.resource-card { - display: block; - padding: 0.75rem 1rem; - background: rgba(26, 26, 26, 0.5); - border: 1px solid rgba(166, 227, 161, 0.15); +.links a { + display: inline-flex; + align-items: baseline; + gap: 0.55rem; + color: var(--fg); text-decoration: none; - color: var(--text); - margin: 0.5rem 0; - transition: border-color 0.2s ease, background-color 0.2s ease; + font-size: 0.88rem; + padding: 0.25rem 0; + transition: color 180ms ease, transform 180ms ease; +} +.links a:hover { + color: var(--accent); + transform: translateX(3px); +} +.links .arrow { color: var(--accent); } + +/* footer — quiet, like a man-page footer */ +.foot { + margin-top: 4rem; + padding-top: 1.2rem; + border-top: 1px solid var(--line); + color: var(--dimmer); + font-size: 0.7rem; + letter-spacing: 0.04em; +} +.foot .dot { + margin: 0 0.5rem; } -.resource-card:hover { - border-color: var(--green); - background: rgba(26, 26, 26, 0.7); +/* whisper-quiet byline, identical to home */ +.byline { + position: fixed; + left: 50%; + bottom: 1rem; + transform: translateX(-50%); + z-index: 1; + font-family: var(--mono); + font-size: 0.62rem; + letter-spacing: 0.02em; + color: rgba(106, 106, 114, 0.28); + pointer-events: none; + opacity: 0; + animation: fadeWhisper 900ms ease-out 1500ms forwards; + white-space: nowrap; } -.resource-card span { - font-size: 0.9rem; -} +@keyframes fadeWhisper { to { opacity: 1; } } -@media (max-width: 768px) { - body { - padding: 1.5rem 1rem; - } - - h1 { - font-size: 1.8rem; - } - - h2 { - font-size: 1.1rem; - } - - .breakdown-grid, - .resources-grid { +@media (max-width: 640px) { + .page { padding: 2rem 1.25rem 5rem; } + h1 { font-size: 1.35rem; } + .command-box { font-size: 0.92rem; padding: 0.9rem 1rem; } + .def { grid-template-columns: 1fr; + gap: 0.25rem; } + .byline { font-size: 0.56rem; } +} + +@media (prefers-reduced-motion: reduce) { + .page, .byline { animation: none; opacity: 1; } } diff --git a/src/docs.ts b/src/docs.ts index e6b8298..f84de0e 100644 --- a/src/docs.ts +++ b/src/docs.ts @@ -1,16 +1,41 @@ import './docs.css'; +import { initField } from './field'; -const copyBtn = document.getElementById('copyBtn') as HTMLButtonElement; -const command = 'ssh portfolio@keshavanand.net'; +initField('field'); -if (copyBtn) { - copyBtn.addEventListener('click', async () => { - try { - await navigator.clipboard.writeText(command); - copyBtn.classList.add('copied'); - setTimeout(() => copyBtn.classList.remove('copied'), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }); -} \ No newline at end of file +const COMMAND = 'ssh portfolio@keshavanand.net'; + +const copyBtn = document.getElementById('copyBtn') as HTMLButtonElement | null; +const commandBox = document.getElementById('commandBox') as HTMLDivElement | null; +const copyPath = document.getElementById('copyPath') as unknown as SVGPathElement | null; + +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 resetTimer: number | undefined; + +async function copy() { + if (!commandBox || !copyPath) return; + try { + await navigator.clipboard.writeText(COMMAND); + copyPath.setAttribute('d', CHECK_PATH); + commandBox.classList.add('copied'); + window.clearTimeout(resetTimer); + resetTimer = window.setTimeout(() => { + copyPath.setAttribute('d', COPY_PATH); + commandBox.classList.remove('copied'); + }, 1400); + } catch { + /* clipboard blocked */ + } +} + +copyBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + copy(); +}); +commandBox?.addEventListener('click', (e) => { + if ((e.target as HTMLElement).closest('.action-btn')) return; + copy(); +}); diff --git a/src/field.ts b/src/field.ts new file mode 100644 index 0000000..a02beb1 --- /dev/null +++ b/src/field.ts @@ -0,0 +1,205 @@ +type Dot = { bx: number; by: number; ox: number; oy: number }; +type Ripple = { x: number; y: number; born: number }; + +const SPACING = 26; +const RADIUS = 170; +const PUSH = 18; + +const RIPPLE_LIFE = 900; +const RIPPLE_MAX = 520; +const RIPPLE_BAND = 70; +const RIPPLE_PUSH = 36; + +const BASE_RGB = '230, 230, 230'; +const ACCENT_RGB = '166, 227, 161'; +const BASE_ALPHA = 0.11; +const PEAK_ALPHA = 0.9; + +let canvas: HTMLCanvasElement | null = null; +let ctx: CanvasRenderingContext2D | null = null; + +let dots: Dot[] = []; +const ripples: Ripple[] = []; + +let dpr = 1; +let w = 0; +let h = 0; + +let targetX = -9999; +let targetY = -9999; +let curX = -9999; +let curY = -9999; +let active = false; + +let started = false; + +function build() { + if (!canvas || !ctx) return; + 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; +} + +export function spawnRipple(x: number, y: number) { + ripples.push({ x, y, born: performance.now() }); + if (ripples.length > 8) ripples.shift(); +} + +function frame() { + if (!ctx) return; + 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); +} + +export function initField(canvasId = 'field') { + if (started) return; + const el = document.getElementById(canvasId) as HTMLCanvasElement | null; + if (!el) return; + canvas = el; + ctx = canvas.getContext('2d'); + if (!ctx) return; + started = true; + + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + build(); + + 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); + window.addEventListener('scroll', () => { + // keep ripples / dots independent of page scroll — recompute baselines + }, { passive: true }); + + if (reduceMotion) { + if (!ctx) return; + ctx.fillStyle = `rgba(${BASE_RGB}, ${BASE_ALPHA})`; + for (const d of dots) ctx.fillRect(d.bx, d.by, 1, 1); + } else { + requestAnimationFrame(frame); + } +} diff --git a/src/main.ts b/src/main.ts index 3891b45..8cf81cc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import './style.css'; +import { initField, spawnRipple } from './field'; const COMMAND = 'ssh portfolio@keshavanand.net'; @@ -280,189 +281,6 @@ docsLink.addEventListener('click', (e) => { /* ---------- 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); -} +initField('field'); void caretEl;