Update command line interface with interactive typing and visual feedback
Refactors the command line interface to include an interactive SSH command input with real-time visual feedback, dynamic button icons, and animated responses to user commands, including navigation to documentation. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 6def8112-39d2-4641-b93b-f39108179f33 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 49353dc6-fccb-41d3-8b94-6617238c72ba Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/42ae33dd-8759-4196-85a5-434465c72ece/6def8112-39d2-4641-b93b-f39108179f33/tF7dS0e Replit-Helium-Checkpoint-Created: true
This commit is contained in:
273
src/main.ts
273
src/main.ts
@@ -1,26 +1,64 @@
|
||||
import './style.css';
|
||||
|
||||
const COMMAND = 'ssh portfolio@keshavanand.net';
|
||||
const PROMPT_TAIL = ' '; // single space between prefix and user input
|
||||
|
||||
const typedEl = document.getElementById('typed') as HTMLSpanElement;
|
||||
const userEl = document.getElementById('userInput') as HTMLSpanElement;
|
||||
const caretEl = document.getElementById('caret') as HTMLSpanElement;
|
||||
const commandBox = document.getElementById('commandBox') as HTMLDivElement;
|
||||
const copyBtn = document.getElementById('copyBtn') as HTMLButtonElement;
|
||||
const copyIcon = document.getElementById('copyIcon') as unknown as SVGSVGElement;
|
||||
const actionBtn = document.getElementById('actionBtn') as HTMLButtonElement;
|
||||
const iconPath = document.getElementById('iconPath') as unknown as SVGPathElement;
|
||||
const docsLink = document.getElementById('docsLink') as HTMLAnchorElement;
|
||||
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/* ---------- Typewriter with a small "decoder" scramble ---------- */
|
||||
/* ---------- icons ---------- */
|
||||
|
||||
const COPY_PATH =
|
||||
'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z';
|
||||
// "↵" return arrow — a hooked left arrow (very recognizable as Enter)
|
||||
const ENTER_PATH = 'M9 10l-5 5m0 0l5 5m-5-5h12a4 4 0 004-4V5';
|
||||
const CHECK_PATH = 'M5 13l4 4L19 7';
|
||||
|
||||
function setIcon(d: string) {
|
||||
iconPath.setAttribute('d', d);
|
||||
}
|
||||
|
||||
/* ---------- state ---------- */
|
||||
|
||||
let userInput = '';
|
||||
let inputEnabled = false; // true once typewriter completes
|
||||
let isAutoTyping = false; // true while clicking-how-it-works animation runs
|
||||
|
||||
function refreshUI() {
|
||||
userEl.textContent = userInput;
|
||||
if (userInput.length > 0) {
|
||||
setIcon(ENTER_PATH);
|
||||
actionBtn.setAttribute('aria-label', 'Run command');
|
||||
commandBox.classList.add('armed');
|
||||
} else {
|
||||
setIcon(COPY_PATH);
|
||||
actionBtn.setAttribute('aria-label', 'Copy command');
|
||||
commandBox.classList.remove('armed');
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- typewriter intro (with scramble) ---------- */
|
||||
|
||||
const SCRAMBLE = '!<>-_\\/[]{}—=+*^?#abcdef0123456789';
|
||||
|
||||
function typeWithScramble(target: string, onDone?: () => void) {
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function typeIntro(target: string, onDone?: () => void) {
|
||||
let resolved = '';
|
||||
let i = 0;
|
||||
|
||||
function nextChar() {
|
||||
if (i >= target.length) {
|
||||
typedEl.textContent = resolved;
|
||||
typedEl.textContent = resolved + PROMPT_TAIL;
|
||||
onDone?.();
|
||||
return;
|
||||
}
|
||||
@@ -44,57 +82,180 @@ function typeWithScramble(target: string, onDone?: () => void) {
|
||||
}
|
||||
|
||||
if (reduceMotion) {
|
||||
typedEl.textContent = COMMAND;
|
||||
typedEl.textContent = COMMAND + PROMPT_TAIL;
|
||||
inputEnabled = true;
|
||||
} else {
|
||||
setTimeout(() => typeWithScramble(COMMAND), 180);
|
||||
setTimeout(() => typeIntro(COMMAND, () => {
|
||||
inputEnabled = true;
|
||||
}), 180);
|
||||
}
|
||||
|
||||
/* ---------- Silent copy (no flashy feedback, just a brief icon swap) ---------- */
|
||||
/* ---------- copy ---------- */
|
||||
|
||||
const COPY_PATH = 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z';
|
||||
const CHECK_PATH = 'M5 13l4 4L19 7';
|
||||
let iconResetTimer: number | undefined;
|
||||
|
||||
function setIcon(d: string) {
|
||||
const path = copyIcon.querySelector('path');
|
||||
if (path) path.setAttribute('d', d);
|
||||
}
|
||||
|
||||
let copyResetTimer: number | undefined;
|
||||
|
||||
async function copy() {
|
||||
async function copyCommand() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(COMMAND);
|
||||
setIcon(CHECK_PATH);
|
||||
window.clearTimeout(copyResetTimer);
|
||||
copyResetTimer = window.setTimeout(() => setIcon(COPY_PATH), 1200);
|
||||
window.clearTimeout(iconResetTimer);
|
||||
iconResetTimer = window.setTimeout(() => {
|
||||
// only reset if user hasn't started typing in the meantime
|
||||
if (userInput.length === 0) setIcon(COPY_PATH);
|
||||
}, 1100);
|
||||
} catch {
|
||||
/* clipboard can be blocked; do nothing */
|
||||
/* clipboard can be blocked */
|
||||
}
|
||||
}
|
||||
|
||||
commandBox.addEventListener('click', copy);
|
||||
commandBox.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
copy();
|
||||
/* ---------- 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;
|
||||
}
|
||||
});
|
||||
copyBtn.addEventListener('click', (e) => {
|
||||
|
||||
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();
|
||||
copy();
|
||||
actionClick();
|
||||
});
|
||||
|
||||
/* ---------- Cursor-reactive dot field ---------- */
|
||||
commandBox.addEventListener('click', (e) => {
|
||||
// don't double-fire when clicking the button
|
||||
if ((e.target as HTMLElement).closest('.action-btn')) return;
|
||||
actionClick();
|
||||
});
|
||||
|
||||
/* ---------- auto-type from "how it works" link ---------- */
|
||||
|
||||
async function autoTypeHelp() {
|
||||
if (!inputEnabled || isAutoTyping) return;
|
||||
isAutoTyping = true;
|
||||
|
||||
// clear whatever the user had typed
|
||||
userInput = '';
|
||||
refreshUI();
|
||||
commandBox.classList.add('armed');
|
||||
|
||||
await sleep(120);
|
||||
|
||||
const text = '-h';
|
||||
for (const ch of text) {
|
||||
// small scramble for flair, then settle
|
||||
let cycles = 2;
|
||||
while (cycles > 0) {
|
||||
const sc = SCRAMBLE[Math.floor(Math.random() * SCRAMBLE.length)];
|
||||
userEl.textContent = userInput + sc;
|
||||
await sleep(38);
|
||||
cycles--;
|
||||
}
|
||||
userInput += ch;
|
||||
refreshUI();
|
||||
await sleep(70);
|
||||
}
|
||||
|
||||
// brief pause so the user reads it
|
||||
await sleep(260);
|
||||
|
||||
// visual "Enter pressed" — pulse the action button
|
||||
actionBtn.style.transform = 'scale(0.85)';
|
||||
setTimeout(() => { actionBtn.style.transform = ''; }, 140);
|
||||
|
||||
await sleep(180);
|
||||
|
||||
isAutoTyping = false;
|
||||
submit();
|
||||
}
|
||||
|
||||
docsLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
autoTypeHelp();
|
||||
});
|
||||
|
||||
/* ---------- cursor-reactive dot field + click ripples ---------- */
|
||||
|
||||
const canvas = document.getElementById('field') as HTMLCanvasElement;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
type Dot = {
|
||||
bx: number; // base x (CSS px)
|
||||
by: number; // base y (CSS px)
|
||||
ox: number; // offset x (current)
|
||||
oy: number; // offset y (current)
|
||||
};
|
||||
type Dot = { bx: number; by: number; ox: number; oy: number };
|
||||
|
||||
let dots: Dot[] = [];
|
||||
let dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
@@ -102,23 +263,21 @@ let w = 0;
|
||||
let h = 0;
|
||||
|
||||
const SPACING = 26;
|
||||
const RADIUS = 170; // influence radius around cursor
|
||||
const PUSH = 18; // max displacement in px
|
||||
const RADIUS = 170;
|
||||
const PUSH = 18;
|
||||
|
||||
// Cursor position (CSS px). Lerped for smoothness.
|
||||
let targetX = -9999;
|
||||
let targetY = -9999;
|
||||
let curX = -9999;
|
||||
let curY = -9999;
|
||||
let active = false;
|
||||
|
||||
// Click ripples — expanding rings that push dots outward as they pass.
|
||||
type Ripple = { x: number; y: number; born: number };
|
||||
const ripples: Ripple[] = [];
|
||||
const RIPPLE_LIFE = 900; // ms
|
||||
const RIPPLE_MAX = 520; // max radius in px
|
||||
const RIPPLE_BAND = 70; // ring thickness
|
||||
const RIPPLE_PUSH = 36; // peak displacement
|
||||
const RIPPLE_LIFE = 900;
|
||||
const RIPPLE_MAX = 520;
|
||||
const RIPPLE_BAND = 70;
|
||||
const RIPPLE_PUSH = 36;
|
||||
|
||||
function build() {
|
||||
dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
@@ -131,7 +290,6 @@ function build() {
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
dots = [];
|
||||
// pad outside viewport so the grid feels infinite during displacement
|
||||
const pad = SPACING;
|
||||
for (let y = -pad; y < h + pad; y += SPACING) {
|
||||
for (let x = -pad; x < w + pad; x += SPACING) {
|
||||
@@ -148,7 +306,6 @@ function onMove(x: number, y: number) {
|
||||
|
||||
function spawnRipple(x: number, y: number) {
|
||||
ripples.push({ x, y, born: performance.now() });
|
||||
// cap to keep things light
|
||||
if (ripples.length > 8) ripples.shift();
|
||||
}
|
||||
|
||||
@@ -174,7 +331,6 @@ const PEAK_ALPHA = 0.9;
|
||||
function frame() {
|
||||
const now = performance.now();
|
||||
|
||||
// smooth cursor lerp
|
||||
if (!active) {
|
||||
targetX = -9999;
|
||||
targetY = -9999;
|
||||
@@ -182,17 +338,13 @@ function frame() {
|
||||
curX += (targetX - curX) * 0.18;
|
||||
curY += (targetY - curY) * 0.18;
|
||||
|
||||
// expire dead ripples
|
||||
for (let i = ripples.length - 1; i >= 0; i--) {
|
||||
if (now - ripples[i].born > RIPPLE_LIFE) ripples.splice(i, 1);
|
||||
}
|
||||
|
||||
// precompute ripple state
|
||||
const rippleState = ripples.map(rp => {
|
||||
const p = (now - rp.born) / RIPPLE_LIFE; // 0..1
|
||||
const radius = p * RIPPLE_MAX;
|
||||
const fade = 1 - p; // weakens over time
|
||||
return { x: rp.x, y: rp.y, radius, fade };
|
||||
const rippleState = ripples.map((rp) => {
|
||||
const p = (now - rp.born) / RIPPLE_LIFE;
|
||||
return { x: rp.x, y: rp.y, radius: p * RIPPLE_MAX, fade: 1 - p };
|
||||
});
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
@@ -202,12 +354,12 @@ function frame() {
|
||||
for (let i = 0; i < dots.length; i++) {
|
||||
const d = dots[i];
|
||||
|
||||
// --- cursor displacement ---
|
||||
const dx = d.bx - curX;
|
||||
const dy = d.by - curY;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
|
||||
let tx = 0, ty = 0;
|
||||
let tx = 0;
|
||||
let ty = 0;
|
||||
let strength = 0;
|
||||
|
||||
if (d2 < r2) {
|
||||
@@ -218,7 +370,6 @@ function frame() {
|
||||
ty = (dy / dist) * strength * PUSH;
|
||||
}
|
||||
|
||||
// --- ripple displacement (additive) ---
|
||||
let rippleStrength = 0;
|
||||
for (let k = 0; k < rippleState.length; k++) {
|
||||
const rp = rippleState[k];
|
||||
@@ -227,7 +378,7 @@ function frame() {
|
||||
const rdist = Math.sqrt(rdx * rdx + rdy * rdy) || 0.0001;
|
||||
const delta = Math.abs(rdist - rp.radius);
|
||||
if (delta < RIPPLE_BAND) {
|
||||
const ringT = 1 - delta / RIPPLE_BAND; // 0..1 across band
|
||||
const ringT = 1 - delta / RIPPLE_BAND;
|
||||
const s = ringT * ringT * rp.fade;
|
||||
tx += (rdx / rdist) * s * RIPPLE_PUSH;
|
||||
ty += (rdy / rdist) * s * RIPPLE_PUSH;
|
||||
@@ -235,14 +386,12 @@ function frame() {
|
||||
}
|
||||
}
|
||||
|
||||
// smooth toward target offset
|
||||
d.ox += (tx - d.ox) * 0.22;
|
||||
d.oy += (ty - d.oy) * 0.22;
|
||||
|
||||
const px = d.bx + d.ox;
|
||||
const py = d.by + d.oy;
|
||||
|
||||
// combined visual strength (cursor + ripple, capped)
|
||||
const visStrength = Math.min(1, strength + rippleStrength);
|
||||
const alpha = BASE_ALPHA + (PEAK_ALPHA - BASE_ALPHA) * visStrength;
|
||||
const size = 1 + visStrength * 1.6;
|
||||
@@ -260,7 +409,6 @@ function frame() {
|
||||
ctx.fillRect(px - size / 2, py - size / 2, size, size);
|
||||
}
|
||||
|
||||
// soft cursor glow
|
||||
if (active) {
|
||||
const grad = ctx.createRadialGradient(curX, curY, 0, curX, curY, RADIUS);
|
||||
grad.addColorStop(0, `rgba(${ACCENT_RGB}, 0.10)`);
|
||||
@@ -269,7 +417,6 @@ function frame() {
|
||||
ctx.fillRect(curX - RADIUS, curY - RADIUS, RADIUS * 2, RADIUS * 2);
|
||||
}
|
||||
|
||||
// ripple ring outlines — very faint, just a hint
|
||||
for (let k = 0; k < rippleState.length; k++) {
|
||||
const rp = rippleState[k];
|
||||
if (rp.radius < 4) continue;
|
||||
@@ -286,10 +433,8 @@ function frame() {
|
||||
if (!reduceMotion) {
|
||||
requestAnimationFrame(frame);
|
||||
} else {
|
||||
// static render: just dim dots, no displacement
|
||||
ctx.fillStyle = `rgba(${BASE_RGB}, ${BASE_ALPHA})`;
|
||||
for (const d of dots) ctx.fillRect(d.bx, d.by, 1, 1);
|
||||
}
|
||||
|
||||
// keep tsc happy about caretEl import (used via DOM)
|
||||
void caretEl;
|
||||
|
||||
109
src/style.css
109
src/style.css
@@ -5,6 +5,7 @@
|
||||
--line: #1c1c20;
|
||||
--accent: #a6e3a1;
|
||||
--accent-soft: rgba(166, 227, 161, 0.22);
|
||||
--accent-strong: rgba(166, 227, 161, 0.55);
|
||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ body {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.25rem;
|
||||
gap: 0.85rem;
|
||||
max-width: 580px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.75rem;
|
||||
@@ -71,18 +72,43 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
cursor: pointer;
|
||||
cursor: text;
|
||||
user-select: none;
|
||||
transition: border-color 180ms ease;
|
||||
transition: border-color 200ms ease, box-shadow 200ms ease;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.command-box:hover,
|
||||
.command-box:focus-visible {
|
||||
.command-box:hover {
|
||||
border-color: var(--accent-soft);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* "armed" once user starts typing — invites Enter */
|
||||
.command-box.armed {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: 0 0 0 1px rgba(166, 227, 161, 0.08), 0 0 24px rgba(166, 227, 161, 0.06);
|
||||
}
|
||||
|
||||
/* shake on invalid command */
|
||||
.command-box.shake {
|
||||
animation: shake 380ms cubic-bezier(.36,.07,.19,.97);
|
||||
border-color: rgba(243, 139, 168, 0.55);
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
15% { transform: translateX(-4px); }
|
||||
30% { transform: translateX(4px); }
|
||||
45% { transform: translateX(-3px); }
|
||||
60% { transform: translateX(3px); }
|
||||
80% { transform: translateX(-1px); }
|
||||
}
|
||||
|
||||
/* brief flash before navigating */
|
||||
.command-box.submit {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent-soft), 0 0 28px rgba(166, 227, 161, 0.18);
|
||||
}
|
||||
|
||||
.dollar {
|
||||
@@ -94,7 +120,11 @@ body {
|
||||
.typed {
|
||||
color: var(--fg);
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-input {
|
||||
color: var(--fg);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.caret {
|
||||
@@ -106,14 +136,12 @@ body {
|
||||
animation: blink 1.05s steps(1, end) infinite;
|
||||
}
|
||||
|
||||
.caret.gone { display: none; }
|
||||
|
||||
@keyframes blink {
|
||||
0%, 50% { opacity: 1; }
|
||||
50.01%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
.action-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -124,39 +152,49 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: color 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.copy-btn:hover { color: var(--fg); }
|
||||
.action-btn:hover { color: var(--fg); }
|
||||
|
||||
.copy-btn svg {
|
||||
/* when armed, the action button is the Enter key — pulses gently to invite */
|
||||
.command-box.armed .action-btn {
|
||||
color: var(--accent);
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.08); opacity: 0.85; }
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
/* tiny "-h" flag — shows up late, sits in the bottom-left */
|
||||
.help-flag {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 1;
|
||||
font-family: var(--mono);
|
||||
/* very minimal "how it works" subtitle — even fainter than before */
|
||||
.docs-hint {
|
||||
color: rgba(106, 106, 114, 0.45);
|
||||
text-decoration: none;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(106, 106, 114, 0.55);
|
||||
text-decoration: none;
|
||||
padding: 0.15rem 0.3rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
opacity: 0;
|
||||
transition: color 200ms ease, opacity 200ms ease;
|
||||
animation: fadeFaint 700ms ease-out 2200ms forwards;
|
||||
animation: fadeFaint 700ms ease-out 1100ms forwards;
|
||||
transition: color 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.help-flag:hover { color: var(--fg); }
|
||||
.docs-hint:hover { color: var(--fg); }
|
||||
|
||||
/* even fainter — bottom-right */
|
||||
/* whisper-quiet byline, centered along the bottom */
|
||||
.byline {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
left: 50%;
|
||||
bottom: 1rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem;
|
||||
@@ -164,15 +202,12 @@ body {
|
||||
color: rgba(106, 106, 114, 0.28);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
animation: fadeWhisper 900ms ease-out 2700ms forwards;
|
||||
animation: fadeWhisper 900ms ease-out 1500ms forwards;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes fadeFaint {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes fadeWhisper {
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes fadeFaint { to { opacity: 1; } }
|
||||
@keyframes fadeWhisper { to { opacity: 1; } }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stage { padding: 1.5rem 1.25rem; }
|
||||
@@ -180,12 +215,12 @@ body {
|
||||
font-size: 0.92rem;
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
.help-flag { font-size: 0.65rem; }
|
||||
.docs-hint { font-size: 0.66rem; }
|
||||
.byline { font-size: 0.56rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stage { animation: none; opacity: 1; }
|
||||
.stage, .docs-hint, .byline { animation: none; opacity: 1; }
|
||||
.caret { animation: none; }
|
||||
.help-flag, .byline { animation: none; opacity: 1; }
|
||||
.command-box.armed .action-btn { animation: none; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user