From 0657618a1a1fed852935ae6472b9403584bec0d5 Mon Sep 17 00:00:00 2001 From: Bryce Bjork Date: Fri, 20 Mar 2026 20:57:47 +0000 Subject: [PATCH] Add animated terminal demo to homepage Show the rc workflow directly on the landing page so visitors can understand the terminal, screen sharing, and Cursor flows before reading the rest of the feature copy. Co-authored-by: Bryce Bjork --- website/app/components/terminal-demo.tsx | 559 +++++++++++++++++++++++ website/app/globals.css | 9 + website/app/page.tsx | 3 + 3 files changed, 571 insertions(+) create mode 100644 website/app/components/terminal-demo.tsx 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 */}