Compare commits
8 Commits
4b0e449e68
...
aae0a9d8d6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aae0a9d8d6 | ||
|
|
9de9cca1aa | ||
|
|
8b307b42b3 | ||
|
|
4d0c742b02 | ||
|
|
ac056ef7fb | ||
|
|
9c878fb1d0 | ||
|
|
d5fde28b15 | ||
|
|
b79bf1d4ca |
35
.replit
Normal file
35
.replit
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
expertMode = true
|
||||||
BIN
attached_assets/image_1777002696453.png
Normal file
BIN
attached_assets/image_1777002696453.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
162
docs.html
162
docs.html
@@ -8,96 +8,98 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="docs">
|
||||||
<div class="container">
|
<canvas id="field" aria-hidden="true"></canvas>
|
||||||
<div class="header">
|
|
||||||
<a href="/" class="back-link">$ cd ..</a>
|
<main class="page">
|
||||||
|
<a href="/" class="back">$ cd ..</a>
|
||||||
|
|
||||||
|
<header class="head">
|
||||||
<h1>Documentation</h1>
|
<h1>Documentation</h1>
|
||||||
<p class="subtitle">The who, what, when, where, why</p>
|
<p class="sub">The who, what, when, where, why</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="rule" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div class="command-box" id="commandBox">
|
||||||
|
<span class="line"><span class="dollar">$ </span><span class="typed">ssh portfolio@keshavanand.net</span></span>
|
||||||
|
<button class="action-btn" id="copyBtn" aria-label="Copy command">
|
||||||
|
<svg id="copyIcon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path id="copyPath" 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>
|
</div>
|
||||||
|
|
||||||
<div class="command-reference">
|
<section class="block">
|
||||||
<div class="command-box">
|
<h2><span class="hash">#</span> What is it?</h2>
|
||||||
<code id="sshCommand">ssh portfolio@keshavanand.net</code>
|
<p>
|
||||||
<button class="copy-btn" id="copyBtn" aria-label="Copy command">
|
When executed in your terminal, this command renders a fully interactive shell portfolio experience.
|
||||||
<svg class="copy-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
Much like a digital resume or personal site, it allows you to explore my work and background through
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
a safe, <span class="fg">secure</span>, and purely text-based interface.
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
<span class="fg">It is completely harmless to your system.</span>
|
||||||
</svg>
|
</p>
|
||||||
<svg class="check-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
</section>
|
||||||
<polyline points="20 6 9 17 4 12"></polyline>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sections">
|
<section class="block">
|
||||||
<section class="section">
|
<h2><span class="hash">#</span> Command Breakdown</h2>
|
||||||
<h2>What is it?</h2>
|
<dl class="defs">
|
||||||
<p>
|
<div class="def">
|
||||||
When executed in your terminal, this command renders a fully interactive shell portfolio experience.
|
<dt><span class="accent">ssh</span> <span class="arrow">→</span></dt>
|
||||||
Much like a digital resume or personal site, it allows you to explore my work and background through
|
<dd><span class="fg">Secure Shell:</span> The standard terminal protocol for logging into and controlling remote computers safely.</dd>
|
||||||
a safe, <strong>secure</strong>, and purely text-based interface.
|
|
||||||
<strong>It is completely harmless to your system.</strong>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<h2>Command Breakdown</h2>
|
|
||||||
<div class="breakdown-grid">
|
|
||||||
<div class="breakdown-item">
|
|
||||||
<h3>ssh →</h3>
|
|
||||||
<p><strong>Secure Shell:</strong> The standard terminal protocol for logging into and controlling remote computers safely.</p>
|
|
||||||
</div>
|
|
||||||
<div class="breakdown-item">
|
|
||||||
<h3>portfolio →</h3>
|
|
||||||
<p><strong>User:</strong> A passwordless, restricted user on my homeserver meant for public access.</p>
|
|
||||||
</div>
|
|
||||||
<div class="breakdown-item">
|
|
||||||
<h3>keshavanand.net →</h3>
|
|
||||||
<p><strong>Domain:</strong> Points to a web record that directs your request to my server's public IP (gateway).</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div class="def">
|
||||||
|
<dt><span class="accent">portfolio</span> <span class="arrow">→</span></dt>
|
||||||
|
<dd><span class="fg">User:</span> A passwordless, restricted user on my homeserver meant for public access.</dd>
|
||||||
|
</div>
|
||||||
|
<div class="def">
|
||||||
|
<dt><span class="accent">keshavanand.net</span> <span class="arrow">→</span></dt>
|
||||||
|
<dd><span class="fg">Domain:</span> Points to a web record that directs your request to my server's public IP (gateway).</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="block">
|
||||||
<h2>How to Run</h2>
|
<h2><span class="hash">#</span> How to Run</h2>
|
||||||
<ol class="steps">
|
<ol class="steps">
|
||||||
<li>Copy the command above using the clipboard tool.</li>
|
<li><span class="step">01</span> Copy the command above using the clipboard tool.</li>
|
||||||
<li>Open your native terminal (Terminal on Mac/Linux, PowerShell on Windows).</li>
|
<li><span class="step">02</span> Open your native terminal (Terminal on Mac/Linux, PowerShell on Windows).</li>
|
||||||
<li>Paste the command and accept the host identity by typing 'yes' if prompted.</li>
|
<li><span class="step">03</span> Paste the command and accept the host identity by typing 'yes' if prompted.</li>
|
||||||
<li>Press Enter to launch the interactive environment.</li>
|
<li><span class="step">04</span> Press Enter to launch the interactive environment.</li>
|
||||||
<li>Terminate the process at any time by pressing <kbd>Ctrl + C</kbd>.</li>
|
<li><span class="step">05</span> Terminate the process at any time by pressing <kbd>Ctrl</kbd>+<kbd>C</kbd>.</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="block">
|
||||||
<h2>Code Logic</h2>
|
<h2><span class="hash">#</span> Code Logic</h2>
|
||||||
<p>
|
<p>
|
||||||
This command gives authenticated access to the <strong>'portfolio'</strong> user account on my private home server.
|
This command gives authenticated access to the <span class="fg">'portfolio'</span> user account on my private home server.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Instead of a standard bash or zsh shell, the user session triggers a custom-coded <strong>C++ executable</strong>.
|
Instead of a standard bash or zsh shell, the user session triggers a custom-coded <span class="fg">C++ executable</span>.
|
||||||
This binary utilizes <strong>FTXUI</strong>—a sophisticated functional terminal user interface library—to handle
|
This binary utilizes <span class="fg">FTXUI</span>—a sophisticated functional terminal user interface library—to handle
|
||||||
real-time rendering and input.
|
real-time rendering and input.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="block">
|
||||||
<h2>Resources</h2>
|
<h2><span class="hash">#</span> Resources</h2>
|
||||||
<div class="resources-grid">
|
<ul class="links">
|
||||||
<a href="https://git.keshavanand.net/KeshavAnandCode/terminal-portfolio" target="_blank" rel="noopener noreferrer" class="resource-card">
|
<li>
|
||||||
<span>View Source Code</span>
|
<a href="https://git.keshavanand.net/KeshavAnandCode/terminal-portfolio" target="_blank" rel="noopener noreferrer">
|
||||||
</a>
|
<span class="arrow">→</span> View Source Code
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/webshell">
|
||||||
|
<span class="arrow">→</span> Run Web Shell
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<a href="/webshell" class="resource-card">
|
<div class="byline">// vibe coded to present human code</div>
|
||||||
<span>Run Web Shell</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script type="module" src="/src/docs.ts"></script>
|
<script type="module" src="/src/docs.ts"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
28
index.html
28
index.html
@@ -4,28 +4,28 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Terminal Portfolio</title>
|
<title>keshavanand — ssh in</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="matrix"></canvas>
|
<canvas id="field" aria-hidden="true"></canvas>
|
||||||
<div class="container">
|
|
||||||
<div class="command-container">
|
<main class="stage">
|
||||||
<div class="command-box" id="commandBox">
|
<div class="command-box" id="commandBox">
|
||||||
<span class="dollar">$</span>
|
<span class="line"><span class="dollar">$ </span><span class="typed" id="typed"></span><span class="user-input" id="userInput"></span><span class="caret" id="caret" aria-hidden="true"></span></span>
|
||||||
<span class="command">ssh portfolio@keshavanand.net</span>
|
<button class="action-btn" id="actionBtn" aria-label="Copy to clipboard">
|
||||||
</div>
|
<svg id="actionIcon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<button class="copy-btn" id="copyBtn" aria-label="Copy to clipboard">
|
<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 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" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="tooltip" id="tooltip">Copied!</div>
|
|
||||||
</div>
|
</div>
|
||||||
<a href="/docs.html" class="docs-link" id="docsLink">how it works</a>
|
|
||||||
</div>
|
<a href="/docs.html" class="docs-hint" id="docsLink">how it works</a>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<span class="byline">// vibe coded to present human code</span>
|
||||||
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
30
replit.md
Normal file
30
replit.md
Normal file
@@ -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.
|
||||||
418
src/docs.css
418
src/docs.css
@@ -1,11 +1,13 @@
|
|||||||
:root {
|
:root {
|
||||||
--base: #000000;
|
--bg: #0b0b0d;
|
||||||
--text: #cdd6f4;
|
--fg: #e6e6e6;
|
||||||
--subtext0: #a6adc8;
|
--dim: #6a6a72;
|
||||||
--surface0: #1a1a1a;
|
--dimmer: #4a4a52;
|
||||||
--surface1: #2a2a2a;
|
--line: #1c1c20;
|
||||||
--green: #a6e3a1;
|
--accent: #a6e3a1;
|
||||||
--blue: #89b4fa;
|
--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,304 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html { height: 100%; }
|
||||||
font-family: "JetBrains Mono", monospace;
|
|
||||||
background-color: var(--base);
|
body.docs {
|
||||||
color: var(--text);
|
font-family: var(--mono);
|
||||||
min-height: 100vh;
|
background: var(--bg);
|
||||||
padding: 2rem 1.5rem;
|
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;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
#field {
|
||||||
max-width: 650px;
|
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;
|
margin: 0 auto;
|
||||||
|
padding: 3rem 1.75rem 6rem;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fade 500ms ease-out 60ms forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
@keyframes fade { to { opacity: 1; } }
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-link {
|
/* back link — same accent treatment as the home dollar */
|
||||||
color: var(--subtext0);
|
.back {
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
display: inline-block;
|
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 {
|
.head {
|
||||||
color: var(--green);
|
margin-top: 2.5rem;
|
||||||
}
|
|
||||||
|
|
||||||
.back-link:hover {
|
|
||||||
color: var(--green);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
color: var(--text);
|
font-size: 2.2rem;
|
||||||
font-size: 2.5rem;
|
|
||||||
margin: 1rem 0 0.5rem 0;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.005em;
|
||||||
|
color: var(--fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.sub {
|
||||||
color: var(--subtext0);
|
margin-top: 0.5rem;
|
||||||
font-size: 1rem;
|
color: var(--dim);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-reference {
|
.rule {
|
||||||
margin: 2rem 0 3rem 0;
|
height: 1px;
|
||||||
|
background: var(--line);
|
||||||
|
margin: 1.6rem 0 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* command box — visually identical to the home version, sans caret/input */
|
||||||
.command-box {
|
.command-box {
|
||||||
background: rgba(26, 26, 26, 0.8);
|
width: 100%;
|
||||||
border: 1px solid var(--green);
|
background: rgba(10, 10, 12, 0.55);
|
||||||
padding: 1.25rem;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 0;
|
||||||
gap: 1rem;
|
transition: border-color 200ms ease, box-shadow 200ms ease;
|
||||||
transition: border-color 0.2s ease;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-box:hover {
|
.command-box:hover {
|
||||||
border-color: var(--blue);
|
border-color: var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-box code {
|
.command-box.copied {
|
||||||
color: var(--green);
|
border-color: var(--accent-strong);
|
||||||
font-size: 1.1rem;
|
box-shadow: 0 0 0 1px rgba(166, 227, 161, 0.10), 0 0 24px rgba(166, 227, 161, 0.10);
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-btn {
|
.line {
|
||||||
background: none;
|
flex: 1 1 auto;
|
||||||
border: none;
|
min-width: 0;
|
||||||
color: var(--subtext0);
|
white-space: pre;
|
||||||
cursor: pointer;
|
overflow: hidden;
|
||||||
padding: 0.5rem;
|
text-overflow: clip;
|
||||||
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 {
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sections {
|
.dollar {
|
||||||
display: flex;
|
color: var(--accent);
|
||||||
flex-direction: column;
|
font-weight: 600;
|
||||||
gap: 3rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
h2 {
|
||||||
color: var(--green);
|
font-size: 1.05rem;
|
||||||
font-size: 1.5rem;
|
font-weight: 500;
|
||||||
margin-bottom: 1.25rem;
|
color: var(--fg);
|
||||||
font-weight: 600;
|
letter-spacing: 0.005em;
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: var(--subtext0);
|
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
strong {
|
h2 .hash {
|
||||||
color: var(--text);
|
color: var(--accent);
|
||||||
|
margin-right: 0.4rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breakdown-grid {
|
.block p {
|
||||||
|
color: var(--dim);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block p .fg { color: var(--fg); }
|
||||||
|
|
||||||
|
.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;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
grid-template-columns: minmax(160px, 200px) 1fr;
|
||||||
gap: 1.5rem;
|
gap: 1rem;
|
||||||
margin-top: 1.5rem;
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px dashed var(--line);
|
||||||
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
.def:last-child { border-bottom: none; }
|
||||||
.breakdown-item {
|
.def dt {
|
||||||
padding: 1rem;
|
font-size: 0.92rem;
|
||||||
border-bottom: 1px solid rgba(166, 227, 161, 0.1);
|
font-weight: 500;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.def dt .arrow { color: var(--dim); }
|
||||||
.breakdown-item:last-child {
|
.def dd {
|
||||||
border-bottom: none;
|
color: var(--dim);
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-item h3 {
|
|
||||||
color: var(--blue);
|
|
||||||
font-size: 1rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-item p {
|
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
.def dd .fg { color: var(--fg); font-weight: 500; }
|
||||||
|
|
||||||
|
/* steps — numbered, monospace numerals */
|
||||||
.steps {
|
.steps {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
counter-reset: step;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.steps li {
|
|
||||||
counter-increment: step;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
flex-direction: column;
|
||||||
color: var(--subtext0);
|
gap: 0.55rem;
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
}
|
||||||
|
.steps li {
|
||||||
.steps li::before {
|
color: var(--dim);
|
||||||
content: counter(step, decimal-leading-zero);
|
font-size: 0.88rem;
|
||||||
color: var(--green);
|
line-height: 1.7;
|
||||||
font-weight: 600;
|
}
|
||||||
min-width: 2rem;
|
.steps .step {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-right: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
kbd {
|
kbd {
|
||||||
background-color: var(--surface0);
|
font-family: var(--mono);
|
||||||
border: 1px solid var(--surface1);
|
font-size: 0.78em;
|
||||||
padding: 0.2rem 0.5rem;
|
color: var(--fg);
|
||||||
border-radius: 4px;
|
border: 1px solid var(--line);
|
||||||
color: var(--text);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
font-size: 0.9em;
|
padding: 0.05rem 0.35rem;
|
||||||
font-family: inherit;
|
border-radius: 3px;
|
||||||
|
margin: 0 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resources-grid {
|
/* see also — link list */
|
||||||
display: grid;
|
.links {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
list-style: none;
|
||||||
gap: 1rem;
|
display: flex;
|
||||||
margin-top: 1.5rem;
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
.links a {
|
||||||
.resource-card {
|
display: inline-flex;
|
||||||
display: block;
|
align-items: baseline;
|
||||||
padding: 0.75rem 1rem;
|
gap: 0.55rem;
|
||||||
background: rgba(26, 26, 26, 0.5);
|
color: var(--fg);
|
||||||
border: 1px solid rgba(166, 227, 161, 0.15);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text);
|
font-size: 0.88rem;
|
||||||
margin: 0.5rem 0;
|
padding: 0.25rem 0;
|
||||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
transition: color 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
.links a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
.links .arrow { color: var(--accent); }
|
||||||
|
|
||||||
|
/* 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:hover {
|
@keyframes fadeWhisper { to { opacity: 1; } }
|
||||||
border-color: var(--green);
|
|
||||||
background: rgba(26, 26, 26, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.resource-card span {
|
@media (max-width: 640px) {
|
||||||
font-size: 0.9rem;
|
.page { padding: 2rem 1.25rem 5rem; }
|
||||||
}
|
h1 { font-size: 1.35rem; }
|
||||||
|
.command-box { font-size: 0.92rem; padding: 0.9rem 1rem; }
|
||||||
@media (max-width: 768px) {
|
.def {
|
||||||
body {
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breakdown-grid,
|
|
||||||
.resources-grid {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
.byline { font-size: 0.56rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.page, .byline { animation: none; opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/docs.ts
49
src/docs.ts
@@ -1,16 +1,41 @@
|
|||||||
import './docs.css';
|
import './docs.css';
|
||||||
|
import { initField } from './field';
|
||||||
|
|
||||||
const copyBtn = document.getElementById('copyBtn') as HTMLButtonElement;
|
initField('field');
|
||||||
const command = 'ssh portfolio@keshavanand.net';
|
|
||||||
|
|
||||||
if (copyBtn) {
|
const COMMAND = 'ssh portfolio@keshavanand.net';
|
||||||
copyBtn.addEventListener('click', async () => {
|
|
||||||
try {
|
const copyBtn = document.getElementById('copyBtn') as HTMLButtonElement | null;
|
||||||
await navigator.clipboard.writeText(command);
|
const commandBox = document.getElementById('commandBox') as HTMLDivElement | null;
|
||||||
copyBtn.classList.add('copied');
|
const copyPath = document.getElementById('copyPath') as unknown as SVGPathElement | null;
|
||||||
setTimeout(() => copyBtn.classList.remove('copied'), 2000);
|
|
||||||
} catch (err) {
|
const COPY_PATH =
|
||||||
console.error('Failed to copy:', err);
|
'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();
|
||||||
|
});
|
||||||
|
|||||||
205
src/field.ts
Normal file
205
src/field.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
448
src/main.ts
448
src/main.ts
@@ -1,188 +1,286 @@
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
|
import { initField, spawnRipple } from './field';
|
||||||
|
|
||||||
// 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';
|
const COMMAND = 'ssh portfolio@keshavanand.net';
|
||||||
|
|
||||||
async function copyToClipboard() {
|
const typedEl = document.getElementById('typed') as HTMLSpanElement;
|
||||||
try {
|
const userEl = document.getElementById('userInput') as HTMLSpanElement;
|
||||||
await navigator.clipboard.writeText(COMMAND);
|
const caretEl = document.getElementById('caret') as HTMLSpanElement;
|
||||||
copyBtn.classList.add('copied');
|
const commandBox = document.getElementById('commandBox') as HTMLDivElement;
|
||||||
tooltip.classList.add('show');
|
const actionBtn = document.getElementById('actionBtn') as HTMLButtonElement;
|
||||||
setTimeout(() => {
|
const iconPath = document.getElementById('iconPath') as unknown as SVGPathElement;
|
||||||
copyBtn.classList.remove('copied');
|
const docsLink = document.getElementById('docsLink') as HTMLAnchorElement;
|
||||||
tooltip.classList.remove('show');
|
|
||||||
}, 2000);
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy:', err);
|
/* ---------- 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
copyBtn.addEventListener('click', (e) => {
|
/* ---------- 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();
|
e.stopPropagation();
|
||||||
copyToClipboard();
|
actionClick();
|
||||||
});
|
});
|
||||||
|
|
||||||
commandBox.addEventListener('click', (e) => {
|
commandBox.addEventListener('click', (e) => {
|
||||||
if (!(e.target as HTMLElement).closest('.copy-btn')) copyToClipboard();
|
// don't double-fire when clicking the button
|
||||||
|
if ((e.target as HTMLElement).closest('.action-btn')) return;
|
||||||
|
actionClick();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fade in docs link after 3 seconds
|
/* ---------- auto-type from "how it works" link ---------- */
|
||||||
setTimeout(() => {
|
|
||||||
document.getElementById('docsLink')?.classList.add('show');
|
function rand<T>(a: ArrayLike<T>): T {
|
||||||
}, 3000);
|
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 ---------- */
|
||||||
|
|
||||||
|
initField('field');
|
||||||
|
|
||||||
|
void caretEl;
|
||||||
|
|||||||
322
src/style.css
322
src/style.css
@@ -1,15 +1,12 @@
|
|||||||
:root {
|
:root {
|
||||||
/* Catppuccin Mocha - Darker Hacker Theme */
|
--bg: #0b0b0d;
|
||||||
--base: #000000;
|
--fg: #e6e6e6;
|
||||||
--mantle: #0a0a0a;
|
--dim: #6a6a72;
|
||||||
--crust: #050505;
|
--line: #1c1c20;
|
||||||
--text: #cdd6f4;
|
--accent: #a6e3a1;
|
||||||
--subtext0: #a6adc8;
|
--accent-soft: rgba(166, 227, 161, 0.22);
|
||||||
--surface0: #1a1a1a;
|
--accent-strong: rgba(166, 227, 161, 0.55);
|
||||||
--surface1: #2a2a2a;
|
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
--green: #a6e3a1;
|
|
||||||
--blue: #89b4fa;
|
|
||||||
--mauve: #cba6f7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -18,205 +15,232 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "JetBrains Mono", monospace;
|
font-family: var(--mono);
|
||||||
background-color: var(--base);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--fg);
|
||||||
min-height: 100vh;
|
-webkit-font-smoothing: antialiased;
|
||||||
display: flex;
|
text-rendering: optimizeLegibility;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
#matrix {
|
#field {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
inset: 0;
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.stage {
|
||||||
width: 100%;
|
|
||||||
max-width: 500px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2rem;
|
justify-content: center;
|
||||||
padding: 1rem;
|
gap: 0.85rem;
|
||||||
|
max-width: 580px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1.75rem;
|
||||||
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: containerFloatUp 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
animation: fade 500ms ease-out 60ms forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes containerFloatUp {
|
@keyframes fade {
|
||||||
from {
|
to { opacity: 1; }
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(30px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-box {
|
.command-box {
|
||||||
background: rgba(0, 0, 0, 0.8);
|
width: 100%;
|
||||||
border: 1px solid rgba(166, 227, 161, 0.3);
|
background: rgba(10, 10, 12, 0.55);
|
||||||
border-radius: 6px;
|
backdrop-filter: blur(2px);
|
||||||
padding: 1rem 1.25rem;
|
-webkit-backdrop-filter: blur(2px);
|
||||||
font-size: 1.1rem;
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1.05rem 1.15rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--fg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0;
|
||||||
cursor: pointer;
|
cursor: text;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: all 0.3s ease;
|
transition: border-color 200ms ease, box-shadow 200ms ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* one inline container for all the prompt text — keeps spacing exact */
|
||||||
|
.line {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: clip;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-box:hover {
|
.command-box:hover {
|
||||||
border-color: rgba(166, 227, 161, 0.6);
|
border-color: var(--accent-soft);
|
||||||
box-shadow: 0 0 20px rgba(166, 227, 161, 0.1);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.command-box:active {
|
/* "armed" once user starts typing — invites Enter */
|
||||||
transform: translateY(0);
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* glitch state — subtle chromatic-aberration shadow on the typed text */
|
||||||
|
.command-box.glitch {
|
||||||
|
border-color: var(--accent-strong);
|
||||||
|
box-shadow: 0 0 0 1px rgba(166, 227, 161, 0.10), 0 0 32px rgba(166, 227, 161, 0.10);
|
||||||
|
}
|
||||||
|
.command-box.glitch .typed,
|
||||||
|
.command-box.glitch .user-input {
|
||||||
|
text-shadow:
|
||||||
|
-1px 0 rgba(243, 139, 168, 0.7),
|
||||||
|
1px 0 rgba(137, 180, 250, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dollar {
|
.dollar {
|
||||||
color: var(--green);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.command {
|
.typed {
|
||||||
color: var(--text);
|
color: var(--fg);
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-btn {
|
.user-input {
|
||||||
position: absolute;
|
color: var(--fg);
|
||||||
right: 0.75rem;
|
}
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
/* a real terminal block caret — exactly one monospace char wide, no offset */
|
||||||
|
.caret {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.6em;
|
||||||
|
height: 1.05em;
|
||||||
|
background: var(--accent);
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
margin-bottom: 0.05em;
|
||||||
|
animation: blink 1.05s steps(1, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
50.01%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
margin-left: 0.6rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--subtext0);
|
color: var(--dim);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.25rem;
|
padding: 0.2rem;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: color 0.2s ease;
|
flex: 0 0 auto;
|
||||||
z-index: 10;
|
transition: color 180ms ease, transform 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-btn:hover {
|
.action-btn:hover { color: var(--fg); }
|
||||||
color: var(--green);
|
|
||||||
|
/* 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-btn.copied {
|
@keyframes pulse {
|
||||||
color: var(--green);
|
0%, 100% { transform: scale(1); opacity: 1; }
|
||||||
|
50% { transform: scale(1.08); opacity: 0.85; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy-btn svg {
|
.action-btn svg {
|
||||||
width: 18px;
|
width: 16px;
|
||||||
height: 18px;
|
height: 16px;
|
||||||
|
transition: transform 180ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip {
|
/* very minimal "how it works" subtitle — even fainter than before */
|
||||||
position: absolute;
|
.docs-hint {
|
||||||
top: -2rem;
|
color: rgba(106, 106, 114, 0.45);
|
||||||
right: 0;
|
text-decoration: none;
|
||||||
background: rgba(26, 26, 26, 0.9);
|
font-size: 0.7rem;
|
||||||
color: var(--green);
|
letter-spacing: 0.02em;
|
||||||
padding: 0.3rem 0.6rem;
|
padding: 0.15rem 0.35rem;
|
||||||
font-size: 0.65rem;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
animation: fadeFaint 700ms ease-out 1100ms forwards;
|
||||||
transition: opacity 0.2s ease;
|
transition: color 200ms ease;
|
||||||
white-space: nowrap;
|
cursor: pointer;
|
||||||
z-index: 20;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip.show {
|
.docs-hint:hover { color: var(--fg); }
|
||||||
opacity: 1;
|
|
||||||
|
/* whisper-quiet byline, centered along the bottom */
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes fadeFaint { to { opacity: 1; } }
|
||||||
|
@keyframes fadeWhisper { to { opacity: 1; } }
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
body {
|
.stage { padding: 1.5rem 1.25rem; }
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
gap: 1.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.command-box {
|
.command-box {
|
||||||
font-size: 0.95rem;
|
font-size: 0.92rem;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.9rem 1rem;
|
||||||
}
|
|
||||||
|
|
||||||
.docs-link {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
.docs-hint { font-size: 0.66rem; }
|
||||||
|
.byline { font-size: 0.56rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.docs-link {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
color: var(--subtext0);
|
.stage, .docs-hint, .byline { animation: none; opacity: 1; }
|
||||||
text-decoration: none;
|
.caret { animation: none; }
|
||||||
font-size: 0.85rem;
|
.command-box.armed .action-btn { animation: none; }
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.4s ease;
|
|
||||||
position: relative;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.docs-link:hover {
|
|
||||||
color: var(--green);
|
|
||||||
}
|
|
||||||
|
|
||||||
.docs-link.show {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import { defineConfig } from 'vite';
|
|||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5000,
|
||||||
|
allowedHosts: true,
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5000,
|
||||||
|
allowedHosts: true,
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
Reference in New Issue
Block a user