diff --git a/website/app/components/terminal-demo.tsx b/website/app/components/terminal-demo.tsx new file mode 100644 index 0000000..11b7be4 --- /dev/null +++ b/website/app/components/terminal-demo.tsx @@ -0,0 +1,559 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +type TabId = "terminal" | "screen" | "cursor"; + +type Tab = { + id: TabId; + label: string; + icon: string; + color: string; + colorClass: string; + phases: Phase[]; +}; + +type Phase = { + name: string; + duration: number; +}; + +// ── Demo data ──────────────────────────────────────────────────────────────── + +const MACHINES = [ + { name: "gpu-server", os: "linux" }, + { name: "staging-01", os: "linux" }, + { name: "dev-macbook", os: "macos" }, + { name: "prod-api", os: "linux" }, +]; + +const RECENT_DIRS = [ + "~/projects/api-server", + "~/projects/ml-pipeline", + "~/dotfiles", +]; + +const TABS: Tab[] = [ + { + id: "terminal", + label: "~ terminal", + icon: "~", + color: "#22d3ee", + colorClass: "text-cyan-400", + phases: [ + { name: "typing", duration: 800 }, + { name: "picker", duration: 400 }, + { name: "navigating", duration: 800 }, + { name: "selecting", duration: 300 }, + { name: "transition", duration: 1200 }, + { name: "result", duration: 2500 }, + ], + }, + { + id: "screen", + label: "▶ screen", + icon: "▶", + color: "#e879f9", + colorClass: "text-fuchsia-400", + phases: [ + { name: "typing", duration: 800 }, + { name: "picker", duration: 400 }, + { name: "navigating", duration: 1200 }, + { name: "selecting", duration: 300 }, + { name: "transition", duration: 1800 }, + ], + }, + { + id: "cursor", + label: "▣ cursor", + icon: "▣", + color: "#facc15", + colorClass: "text-yellow-400", + phases: [ + { name: "typing", duration: 800 }, + { name: "picker", duration: 400 }, + { name: "selecting", duration: 300 }, + { name: "directory", duration: 600 }, + { name: "dirSelect", duration: 1200 }, + { name: "transition", duration: 1800 }, + ], + }, +]; + +function totalDuration(tab: Tab): number { + return tab.phases.reduce((sum, p) => sum + p.duration, 0); +} + +// ── Subcomponents ──────────────────────────────────────────────────────────── + +function HeaderBox({ subtitle }: { subtitle: string }) { + return ( +
+
+ remote control + v0.1.0 +
+
{subtitle}
+
+ ); +} + +function MachineList({ + selected, + highlight, +}: { + selected: number; + highlight: boolean; +}) { + return ( +
+ {MACHINES.map((m, i) => { + const isSel = i === selected; + const flash = isSel && highlight; + return ( +
+ {isSel ? ">" : " "} + {m.name} + {m.os} +
+ ); + })} +
+ ); +} + +function ModeBar({ + tab, + modeLabel, +}: { + tab: Tab; + modeLabel: string; +}) { + return ( +
+
+ {modeLabel} + (shift+tab to cycle) +
+ + tab next machine · / to search + +
+ ); +} + +function DirectoryPicker({ selectedDir }: { selectedDir: number }) { + return ( +
+
+ d + irectory (enter for ~) +
+
+
recent paths
+ {RECENT_DIRS.map((dir, i) => { + const isSel = i === selectedDir; + return ( +
+ {isSel ? ">" : " "} + {dir} +
+ ); + })} +
+
+ ); +} + +// ── Typewriter hook ────────────────────────────────────────────────────────── + +function useTypewriter( + text: string, + active: boolean, + signal: AbortSignal, + onDone: () => void, +) { + const [typed, setTyped] = useState(""); + + useEffect(() => { + if (!active) { + setTyped(""); + return; + } + + let i = 0; + setTyped(""); + + function next() { + if (signal.aborted) return; + if (i < text.length) { + i++; + setTyped(text.slice(0, i)); + const delay = 40 + Math.random() * 80; + setTimeout(next, delay); + } else { + onDone(); + } + } + + const start = setTimeout(next, 200); + return () => clearTimeout(start); + }, [active, text, signal, onDone]); + + return typed; +} + +// ── Main component ─────────────────────────────────────────────────────────── + +export function TerminalDemo() { + const [activeTab, setActiveTab] = useState(0); + const [phaseIndex, setPhaseIndex] = useState(-1); // -1 = not started + const [progress, setProgress] = useState(0); + const [isVisible, setIsVisible] = useState(false); + const [reducedMotion, setReducedMotion] = useState(false); + + const containerRef = useRef(null); + const abortRef = useRef(null); + const startTimeRef = useRef(0); + const rafRef = useRef(0); + + const tab = TABS[activeTab]; + const phaseName = phaseIndex >= 0 ? tab.phases[phaseIndex]?.name ?? "done" : "idle"; + const tabTotal = totalDuration(tab); + + // ── Reduced motion check ─────────────────────────────────────────────── + + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setReducedMotion(mq.matches); + const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + // ── Intersection observer ────────────────────────────────────────────── + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const observer = new IntersectionObserver( + ([entry]) => setIsVisible(entry.isIntersecting), + { threshold: 0.3 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + // ── Phase sequencer ──────────────────────────────────────────────────── + + const advancePhase = useCallback(() => { + setPhaseIndex((prev) => { + const next = prev + 1; + if (next >= TABS[activeTab].phases.length) { + // Auto-advance to next tab + setTimeout(() => { + setActiveTab((t) => (t + 1) % TABS.length); + }, 0); + return prev; + } + return next; + }); + }, [activeTab]); + + const runAnimation = useCallback( + (tabIdx: number) => { + // Cancel previous + abortRef.current?.abort(); + const ac = new AbortController(); + abortRef.current = ac; + + setPhaseIndex(0); + setProgress(0); + startTimeRef.current = performance.now(); + + // Progress bar RAF + const tabDur = totalDuration(TABS[tabIdx]); + function tick() { + if (ac.signal.aborted) return; + const elapsed = performance.now() - startTimeRef.current; + const p = Math.min(elapsed / tabDur, 1); + setProgress(p); + if (p < 1) { + rafRef.current = requestAnimationFrame(tick); + } + } + rafRef.current = requestAnimationFrame(tick); + + // Phase timer + let phaseIdx = 0; + function scheduleNext() { + if (ac.signal.aborted) return; + const phases = TABS[tabIdx].phases; + if (phaseIdx >= phases.length) return; + + const dur = phases[phaseIdx].duration; + setTimeout(() => { + if (ac.signal.aborted) return; + phaseIdx++; + if (phaseIdx < phases.length) { + setPhaseIndex(phaseIdx); + scheduleNext(); + } else { + // Tab done, auto-advance + setTimeout(() => { + if (ac.signal.aborted) return; + const nextTab = (tabIdx + 1) % TABS.length; + setActiveTab(nextTab); + }, 300); + } + }, dur); + } + scheduleNext(); + + return () => { + ac.abort(); + cancelAnimationFrame(rafRef.current); + }; + }, + [], + ); + + // Trigger animation when tab changes and visible + useEffect(() => { + if (!isVisible || reducedMotion) return; + const cleanup = runAnimation(activeTab); + return cleanup; + }, [activeTab, isVisible, reducedMotion, runAnimation]); + + // ── Typewriter for prompt ────────────────────────────────────────────── + + const signal = abortRef.current?.signal ?? new AbortController().signal; + const isTyping = phaseName === "typing"; + const typedText = useTypewriter("rc", isTyping, signal, advancePhase); + + // ── Compute derived state ────────────────────────────────────────────── + + // Selected machine index based on phase + let selectedMachine = 0; + let highlightFlash = false; + + if (tab.id === "terminal") { + if (phaseName === "navigating" || phaseName === "selecting" || phaseName === "transition" || phaseName === "result") { + selectedMachine = 1; // staging-01 + } + if (phaseName === "selecting") highlightFlash = true; + } else if (tab.id === "screen") { + if (phaseName === "navigating") selectedMachine = 1; // animating + if (phaseName === "selecting" || phaseName === "transition") { + selectedMachine = 2; // dev-macbook + } + if (phaseName === "selecting") highlightFlash = true; + } else if (tab.id === "cursor") { + if (phaseName === "selecting") highlightFlash = true; + } + + // Navigating animation for screen tab - move through machines + const [navIndex, setNavIndex] = useState(0); + useEffect(() => { + if (tab.id === "screen" && phaseName === "navigating") { + setNavIndex(0); + const t1 = setTimeout(() => setNavIndex(1), 400); + const t2 = setTimeout(() => setNavIndex(2), 800); + return () => { clearTimeout(t1); clearTimeout(t2); }; + } + if (tab.id === "terminal" && phaseName === "navigating") { + setNavIndex(0); + const t1 = setTimeout(() => setNavIndex(1), 400); + return () => clearTimeout(t1); + } + }, [tab.id, phaseName]); + + // Directory selection animation + const [dirIndex, setDirIndex] = useState(0); + useEffect(() => { + if (phaseName === "dirSelect") { + setDirIndex(0); + const t1 = setTimeout(() => setDirIndex(1), 400); + const t2 = setTimeout(() => setDirIndex(2), 800); + return () => { clearTimeout(t1); clearTimeout(t2); }; + } + }, [phaseName]); + + // Effective selected for navigating phases + let effectiveSelected = selectedMachine; + if (tab.id === "screen" && phaseName === "navigating") effectiveSelected = navIndex; + if (tab.id === "terminal" && phaseName === "navigating") effectiveSelected = navIndex; + + // ── Render helpers ───────────────────────────────────────────────────── + + function renderContent() { + // Reduced motion: show static final state + if (reducedMotion) { + return renderPickerScreen(tab, 1, false); + } + + // Idle / typing: show prompt + if (phaseName === "idle" || phaseName === "typing") { + return ( +
+
+ $ + {typedText} + +
+
+ ); + } + + // Transition messages + if (phaseName === "transition") { + const messages: Record = { + terminal: "Copying SSH key to staging-01...", + screen: "Opening Screen Sharing to dev-macbook...", + cursor: "Opening Cursor to gpu-server...", + }; + return ( +
+
{messages[tab.id]}
+
+ ); + } + + // Result (terminal only) + if (phaseName === "result") { + return ( +
+
+ user@staging-01 + : + ~ + $ + +
+
+ ); + } + + // Directory phases (cursor tab) + if (phaseName === "directory" || phaseName === "dirSelect") { + return renderDirectoryScreen(); + } + + // Picker phases + return renderPickerScreen(tab, effectiveSelected, highlightFlash); + } + + function renderPickerScreen(t: Tab, sel: number, flash: boolean) { + const subtitles: Record = { + terminal: "quickly access remote machines", + screen: "quickly access remote machines", + cursor: "quickly access remote machines", + }; + + return ( +
+ + + +
+ ); + } + + function renderDirectoryScreen() { + return ( +
+ + +
+ + esc to go back · tab to cycle · enter to continue + +
+
+ ); + } + + // ── Tab click handler ────────────────────────────────────────────────── + + function handleTabClick(idx: number) { + setActiveTab(idx); + } + + // ── Render ───────────────────────────────────────────────────────────── + + return ( +
+
+
+ {/* Window chrome */} +
+ {/* macOS dots */} +
+
+
+
+
+ + {/* Tabs */} +
+ {TABS.map((t, i) => { + const isActive = i === activeTab; + return ( + + ); + })} +
+
+ + {/* Terminal content */} +
+ {renderContent()} +
+
+
+
+ ); +} diff --git a/website/app/globals.css b/website/app/globals.css index 74f9ac0..68cdcb2 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -49,6 +49,15 @@ html { } /* Emerald gradient text utility */ +@keyframes blink-cursor { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +.blink-cursor { + animation: blink-cursor 1s step-end infinite; +} + .text-emerald-gradient { background: linear-gradient(135deg, #10b981 0%, #34d399 50%, #6ee7b7 100%); -webkit-background-clip: text; diff --git a/website/app/page.tsx b/website/app/page.tsx index e02af1a..876b2ea 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Terminal, Monitor, Code, Sparkle, Copy, Check } from "lucide-react"; +import { TerminalDemo } from "./components/terminal-demo"; function CopyButton({ text }: { text: string }) { const [copied, setCopied] = useState(false); return ( @@ -102,6 +103,8 @@ export default function Home() { + {/* Terminal Demo */} + {/* Features */}