diff --git a/package-lock.json b/package-lock.json
index 93514ba71..f2b1aa353 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25126,6 +25126,15 @@
"node": ">=10"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
+ "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/lucide-react-native": {
"version": "0.546.0",
"resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz",
@@ -35703,6 +35712,7 @@
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3",
"framer-motion": "^12.35.2",
+ "lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-markdown": "^10.1.0",
diff --git a/packages/website/package.json b/packages/website/package.json
index f53f30022..6f78ac0c8 100644
--- a/packages/website/package.json
+++ b/packages/website/package.json
@@ -16,6 +16,7 @@
"@tanstack/react-router": "^1.120.3",
"@tanstack/react-start": "^1.120.3",
"framer-motion": "^12.35.2",
+ "lucide-react": "^1.7.0",
"react": "^19.1.4",
"react-dom": "^19.1.4",
"react-markdown": "^10.1.0",
diff --git a/packages/website/public/iphone-mockup-left.png b/packages/website/public/iphone-mockup-left.png
new file mode 100644
index 000000000..8b18865b0
Binary files /dev/null and b/packages/website/public/iphone-mockup-left.png differ
diff --git a/packages/website/public/phone-1.png b/packages/website/public/phone-1.png
new file mode 100644
index 000000000..0aaaf74f2
Binary files /dev/null and b/packages/website/public/phone-1.png differ
diff --git a/packages/website/public/phone-2.png b/packages/website/public/phone-2.png
new file mode 100644
index 000000000..5bfdde1de
Binary files /dev/null and b/packages/website/public/phone-2.png differ
diff --git a/packages/website/public/phone-3.png b/packages/website/public/phone-3.png
new file mode 100644
index 000000000..11ec117b6
Binary files /dev/null and b/packages/website/public/phone-3.png differ
diff --git a/packages/website/src/components/hero-mockup.tsx b/packages/website/src/components/hero-mockup.tsx
new file mode 100644
index 000000000..613f7f856
--- /dev/null
+++ b/packages/website/src/components/hero-mockup.tsx
@@ -0,0 +1,737 @@
+import * as React from "react";
+import { motion } from "framer-motion";
+import {
+ FolderGit2,
+ Paperclip,
+ ArrowUp,
+ X,
+ MessagesSquare,
+ Plus,
+ Settings,
+ Monitor,
+ GitCommitHorizontal,
+ ChevronDown,
+ ChevronRight,
+ ListChevronsUpDown,
+ Bot,
+ Check,
+ Loader,
+ SquareTerminal,
+ PanelLeft,
+ Ellipsis,
+ Mic,
+ GitPullRequest,
+ SquarePen,
+ Columns2,
+ Rows2,
+} from "lucide-react";
+import { ClaudeIcon, CodexIcon, SourceControlIcon } from "~/components/mockup";
+
+// ── Stagger timing ──────────────────────────────────────
+
+const D = {
+ shell: 0,
+ sidebar: 0.1,
+ titleBar: 0.15,
+ tabs: 0.25,
+ chat: 0.35,
+ diffPanel: 0.55,
+};
+
+function fade(delay: number) {
+ return {
+ initial: { opacity: 0, y: 8 } as const,
+ animate: { opacity: 1, y: 0 } as const,
+ transition: { duration: 0.5, delay, ease: "easeOut" as const },
+ };
+}
+
+// ── Data ────────────────────────────────────────────────
+
+type ChatItem =
+ | { type: "user"; text: string }
+ | { type: "text"; text: string; bold?: boolean }
+ | { type: "tool"; label: string; summary?: string; status: "running" | "done" };
+
+const CHAT: ChatItem[] = [
+ { type: "user", text: "Build me an internal dashboard for tracking customer returns" },
+ { type: "text", text: "I'll break this down into planning and implementation.", bold: true },
+ { type: "tool", label: "Run plan-technical", summary: "codex · plan-technical", status: "done" },
+ { type: "tool", label: "Run plan-design", summary: "claude · plan-design", status: "done" },
+ { type: "tool", label: "Wait for agents", summary: "plan-technical plan-design", status: "done" },
+ { type: "text", text: "Got the plans. Spinning up Codex for implementation.", bold: true },
+ { type: "tool", label: "Run implement", summary: "codex · 12 files changed", status: "done" },
+ { type: "text", text: "Implementation done. Requesting review from Claude." },
+ { type: "tool", label: "Run review", summary: "claude · no issues found", status: "running" },
+ { type: "text", text: "All tasks complete. Dashboard is ready.", bold: true },
+];
+
+// ── Sidebar data ────────────────────────────────────────
+
+type SidebarWorkspace = {
+ name: string;
+ kind: "checkout" | "worktree";
+ status: "running" | "done" | "idle" | "syncing";
+ selected?: boolean;
+ diffStat?: { additions: number; deletions: number };
+ pr?: { number: number; state: "open" | "merged" | "closed" };
+};
+
+type SidebarProject = {
+ initial: string;
+ name: string;
+ workspaces: SidebarWorkspace[];
+};
+
+const SIDEBAR_PROJECTS: SidebarProject[] = [
+ {
+ initial: "A",
+ name: "acme/returns-app",
+ workspaces: [
+ { name: "main", kind: "checkout", status: "syncing", selected: true, diffStat: { additions: 247, deletions: 15 } },
+ { name: "feat/dashboard", kind: "worktree", status: "done", pr: { number: 142, state: "open" } },
+ ],
+ },
+ {
+ initial: "P",
+ name: "acme/payments",
+ workspaces: [
+ { name: "main", kind: "checkout", status: "idle" },
+ { name: "fix/stripe-webhook", kind: "worktree", status: "done", diffStat: { additions: 38, deletions: 4 }, pr: { number: 89, state: "merged" } },
+ ],
+ },
+ {
+ initial: "I",
+ name: "acme/infra",
+ workspaces: [
+ { name: "main", kind: "checkout", status: "idle" },
+ { name: "feat/k8s-autoscale", kind: "worktree", status: "syncing", diffStat: { additions: 91, deletions: 3 } },
+ ],
+ },
+ {
+ initial: "D",
+ name: "acme/design-system",
+ workspaces: [
+ { name: "main", kind: "checkout", status: "idle" },
+ ],
+ },
+];
+
+// ── Tab data ─────────────────────────────────────────────
+
+type TabDef = {
+ name: string;
+ provider: "claude" | "codex" | "terminal";
+ done: boolean;
+ active?: boolean;
+};
+
+const TABS: TabDef[] = [
+ { name: "Orchestrator", provider: "claude", done: false, active: true },
+ { name: "Implement", provider: "codex", done: true },
+ { name: "Review", provider: "claude", done: true },
+];
+
+// ── Diff data ──────────────────────────────────────────
+
+type DiffLineType = "add" | "remove" | "context" | "header";
+type DiffLine = {
+ type: DiffLineType;
+ ln: string | null;
+ content?: string;
+ tokens?: Array<{ text: string; cls: string }>;
+};
+
+const DIFF_LINES: DiffLine[] = [
+ { type: "add", ln: "1", tokens: [{ text: "import", cls: "text-syn-keyword" }, { text: " { ", cls: "text-syn-punctuation" }, { text: "useState", cls: "text-syn-variable" }, { text: " } ", cls: "text-syn-punctuation" }, { text: "from", cls: "text-syn-keyword" }, { text: ' "react"', cls: "text-syn-string" }] },
+ { type: "add", ln: "2", tokens: [{ text: "import", cls: "text-syn-keyword" }, { text: " { ", cls: "text-syn-punctuation" }, { text: "ReturnTable", cls: "text-syn-variable" }, { text: " } ", cls: "text-syn-punctuation" }, { text: "from", cls: "text-syn-keyword" }, { text: ' "./components"', cls: "text-syn-string" }] },
+ { type: "add", ln: "3", tokens: [] },
+ { type: "add", ln: "4", tokens: [{ text: "export", cls: "text-syn-keyword" }, { text: " function", cls: "text-syn-keyword" }, { text: " Dashboard", cls: "text-syn-function" }, { text: "() {", cls: "text-syn-punctuation" }] },
+ { type: "add", ln: "5", tokens: [{ text: " const", cls: "text-syn-keyword" }, { text: " [returns, setReturns]", cls: "text-syn-variable" }, { text: " = ", cls: "text-syn-operator" }, { text: "useState", cls: "text-syn-function" }, { text: "([])", cls: "text-syn-punctuation" }] },
+ { type: "add", ln: "6", tokens: [{ text: " const", cls: "text-syn-keyword" }, { text: " [filter, setFilter]", cls: "text-syn-variable" }, { text: " = ", cls: "text-syn-operator" }, { text: "useState", cls: "text-syn-function" }, { text: '("all")', cls: "text-syn-string" }] },
+ { type: "add", ln: "7", tokens: [] },
+ { type: "add", ln: "8", tokens: [{ text: " ", cls: "text-syn-punctuation" }, { text: "return", cls: "text-syn-keyword" }, { text: " (", cls: "text-syn-punctuation" }] },
+ { type: "add", ln: "9", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "main", cls: "text-syn-tag" }, { text: " className", cls: "text-syn-property" }, { text: '="', cls: "text-syn-punctuation" }, { text: "min-h-screen p-8", cls: "text-syn-string" }, { text: '">', cls: "text-syn-punctuation" }] },
+ { type: "add", ln: "10", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "h1", cls: "text-syn-tag" }, { text: ">Customer Returns", cls: "text-syn-variable" }, { text: "h1", cls: "text-syn-tag" }, { text: ">", cls: "text-syn-punctuation" }] },
+ { type: "add", ln: "11", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "FilterBar", cls: "text-syn-tag" }, { text: " value", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "filter", cls: "text-syn-variable" }, { text: "}", cls: "text-syn-punctuation" }, { text: " onChange", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "setFilter", cls: "text-syn-variable" }, { text: "} />", cls: "text-syn-punctuation" }] },
+ { type: "add", ln: "12", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "ReturnTable", cls: "text-syn-tag" }, { text: " data", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "returns", cls: "text-syn-variable" }, { text: "} />", cls: "text-syn-punctuation" }] },
+ { type: "add", ln: "13", tokens: [{ text: " <", cls: "text-syn-punctuation" }, { text: "StatusChart", cls: "text-syn-tag" }, { text: " data", cls: "text-syn-property" }, { text: "={", cls: "text-syn-punctuation" }, { text: "returns", cls: "text-syn-variable" }, { text: "} />", cls: "text-syn-punctuation" }] },
+ { type: "add", ln: "14", tokens: [{ text: " ", cls: "text-syn-punctuation" }, { text: "main", cls: "text-syn-tag" }, { text: ">", cls: "text-syn-punctuation" }] },
+ { type: "add", ln: "15", tokens: [{ text: " )", cls: "text-syn-punctuation" }] },
+ { type: "add", ln: "16", tokens: [{ text: "}", cls: "text-syn-punctuation" }] },
+];
+
+// ── Implementation agent chat data ──────────────────────
+
+const IMPLEMENT_CHAT: ChatItem[] = [
+ { type: "text", text: "Starting implementation of the returns dashboard.", bold: true },
+ { type: "tool", label: "Edit file", summary: "src/pages/dashboard.tsx", status: "done" },
+ { type: "tool", label: "Edit file", summary: "src/components/return-table.tsx", status: "done" },
+ { type: "tool", label: "Edit file", summary: "src/components/filter-bar.tsx", status: "done" },
+ { type: "tool", label: "Edit file", summary: "src/components/status-chart.tsx", status: "done" },
+ { type: "tool", label: "Run command", summary: "npm run typecheck", status: "done" },
+ { type: "text", text: "Typecheck passes. Adding API route and data fetching." },
+ { type: "tool", label: "Edit file", summary: "src/api/returns.ts", status: "done" },
+ { type: "tool", label: "Edit file", summary: "src/hooks/use-returns.ts", status: "done" },
+ { type: "tool", label: "Run command", summary: "npm run typecheck", status: "running" },
+];
+
+// ── Terminal log lines ─────────────────────────────────
+
+const TERMINAL_LINES = [
+ { text: "$ npm run dev", cls: "text-mock-fg" },
+ { text: "", cls: "" },
+ { text: "> acme-returns@0.1.0 dev", cls: "text-mock-fg-muted" },
+ { text: "> next dev --turbopack", cls: "text-mock-fg-muted" },
+ { text: "", cls: "" },
+ { text: " ▲ Next.js 15.3.1 (Turbopack)", cls: "text-mock-fg" },
+ { text: " - Local: http://localhost:3000", cls: "text-mock-fg-muted" },
+ { text: "", cls: "" },
+ { text: " ✓ Starting...", cls: "text-mock-green" },
+ { text: " ✓ Ready in 1.2s", cls: "text-mock-green" },
+ { text: " ○ Compiling /dashboard ...", cls: "text-mock-fg-muted" },
+ { text: " ✓ Compiled /dashboard in 340ms", cls: "text-mock-green" },
+ { text: " ○ Compiling /api/returns ...", cls: "text-mock-fg-muted" },
+ { text: " ✓ Compiled /api/returns in 120ms", cls: "text-mock-green" },
+];
+
+// ── Sub-components ──────────────────────────────────────
+
+function TrafficLights() {
+ return (
+
+ );
+}
+
+function ProviderIcon({ provider, muted = false }: { provider: "claude" | "codex" | "terminal"; muted?: boolean }) {
+ const cls = muted ? "text-mock-fg-muted" : "text-mock-fg";
+ if (provider === "terminal") return ;
+ return provider === "claude"
+ ?
+ : ;
+}
+
+function TabBarAction({ icon: Icon }: { icon: React.ComponentType<{ size?: number; className?: string; strokeWidth?: number }> }) {
+ return (
+
+
+
+ );
+}
+
+function PaneTabBar({ tabs, focused = false }: { tabs: TabDef[]; focused?: boolean }) {
+ return (
+
+ {tabs.map((tab) => (
+
+ {tab.active && (
+
+ )}
+
+
+
+
+ {tab.name}
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+
+function Composer({ provider, model, focused = false }: { provider: "claude" | "codex"; model: string; focused?: boolean }) {
+ const Icon = provider === "claude" ? ClaudeIcon : CodexIcon;
+ return (
+
+
+
+ {focused && (
+
+ )}
+
+ Message the agent, tag @files, or use /commands and /skills
+
+
+
+
+
+ );
+}
+
+function ChatMessages({ items }: { items: ChatItem[] }) {
+ return (
+
+ {items.map((item, i) => {
+ if (item.type === "user") {
+ return (
+
+ );
+ }
+ if (item.type === "tool") {
+ const isDone = item.status === "done";
+ return (
+
+
+
+
+
+ {item.label}
+
+ {item.summary && (
+
+ {item.summary}
+
+ )}
+
+ {isDone ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ }
+ return (
+
+ {item.text}
+
+ );
+ })}
+
+ );
+}
+
+function ChatArea() {
+ return (
+
+
+
+
+ );
+}
+
+function ImplementPane() {
+ return (
+
+
+
+
+ );
+}
+
+function TerminalPane() {
+ return (
+
+
+ {TERMINAL_LINES.map((line, i) => (
+
+ {line.text}
+
+ ))}
+
+
+ );
+}
+
+function ExplorerSidebar() {
+ return (
+
+ {/* Explorer header — h-12, Changes/Files tabs */}
+
+
+
+ Changes
+
+
+ Files
+
+
+
+
+ {/* Secondary header — h-9, Uncommitted dropdown + filter icons */}
+
+
+ {/* File header — expanded */}
+
+
+
+ dashboard.tsx
+ src/pages
+ New
+
+
+ +16
+ -0
+
+
+
+ {/* Diff lines */}
+
+ {DIFF_LINES.map((line, i) => {
+ const isAdd = line.type === "add";
+ const isRemove = line.type === "remove";
+ const lineBg = isAdd ? "bg-mock-diff-add" : isRemove ? "bg-mock-diff-remove" : "bg-mock-surface1";
+ const lineNumCls = isAdd ? "text-mock-green-400" : isRemove ? "text-mock-red" : "text-mock-fg-muted";
+
+ return (
+
+
+ {line.ln ?? ""}
+
+
+ {line.tokens?.map((tok, j) => (
+ {tok.text}
+ ))}
+
+
+ );
+ })}
+
+
+ {/* Collapsed file rows */}
+ {[
+ { name: "return-table.tsx", dir: "src/components", added: 42, removed: 8 },
+ { name: "filter-bar.tsx", dir: "src/components", added: 28, removed: 3 },
+ { name: "status-chart.tsx", dir: "src/components", added: 19, removed: 0, isNew: true },
+ { name: "returns.ts", dir: "src/api", added: 12, removed: 5 },
+ { name: "index.tsx", dir: "src/pages", added: 6, removed: 2 },
+ ].map((file) => (
+
+
+
+ {file.name}
+ {file.dir}
+ {file.isNew && (
+ New
+ )}
+
+
+ +{file.added}
+ -{file.removed}
+
+
+ ))}
+
+
+
+ );
+}
+
+// Snake traversal order matching the real app: [0, 1, 3, 5, 4, 2] (2-col × 3-row grid)
+// DOT_SEQUENCE[sequenceIndex] = dotIndex, so sequenceIndex = indexOf(dotIndex)
+const DOT_SEQUENCE = [0, 1, 3, 5, 4, 2] as const;
+const SYNCED_LOADER_DURATION_MS = 950;
+const DOT_COUNT = 6;
+const STEP_MS = SYNCED_LOADER_DURATION_MS / DOT_COUNT; // 158.333…ms per step
+
+function SyncedLoader({ size = 11 }: { size?: number }) {
+ const gap = Math.max(1, Math.round(size * 0.12));
+ const dotSize = Math.max(2, Math.floor((size - gap * 2) / 3));
+ const gridW = dotSize * 2 + gap;
+ const gridH = dotSize * 3 + gap * 2;
+
+ return (
+
+
+ {Array.from({ length: DOT_COUNT }).map((_, dotIndex) => {
+ const col = dotIndex % 2;
+ const row = Math.floor(dotIndex / 2);
+ // The snake sequence index for this dot: when is it the "head"?
+ const sequenceIndex = DOT_SEQUENCE.indexOf(dotIndex as (typeof DOT_SEQUENCE)[number]);
+ // Negative delay syncs the animation so this dot is the head at t=0 when sequenceIndex=0.
+ // At t=0, headIndex should be 0, meaning sequenceIndex=0 dot is the head.
+ // Each dot is shifted by sequenceIndex steps back in time.
+ const delayMs = -(sequenceIndex * STEP_MS);
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+function WorkspaceStatusDot({ status }: { status: SidebarWorkspace["status"] }) {
+ if (status === "running") return
;
+ if (status === "done") return
;
+ return null;
+}
+
+function Sidebar() {
+ // Shared left edge: pl-3 (12px) for traffic lights, icons, footer dot
+ // Workspace indent: pl-7 (28px)
+ // PR badge aligns with workspace text: pl-[42px]
+ return (
+
+ {/* Traffic lights */}
+
+
+
+
+ {/* Sessions header */}
+
+
+ Sessions
+
+
+ {/* Project list */}
+
+ {SIDEBAR_PROJECTS.map((project) => (
+
+ {/* Project row — icon aligns with traffic lights / sessions */}
+
+
+ {project.initial}
+
+
+ {project.name}
+
+
+
+ {/* Workspace rows — indented one level */}
+ {project.workspaces.map((workspace) => (
+
+
+
+ {workspace.status === "syncing" ? (
+
+ ) : (
+ <>
+ {workspace.kind === "worktree"
+ ?
+ :
+ }
+ {workspace.status !== "idle" && (
+
+
+
+ )}
+ >
+ )}
+
+
+ {workspace.name}
+
+ {workspace.diffStat && (
+
+ +{workspace.diffStat.additions}
+ -{workspace.diffStat.deletions}
+
+ )}
+
+ {workspace.pr && (
+
+
+
+ #{workspace.pr.number} · {workspace.pr.state === "open" ? "Open" : workspace.pr.state === "merged" ? "Merged" : "Closed"}
+
+
+ )}
+
+ ))}
+
+ ))}
+
+
+ {/* Footer — dot aligns with traffic lights / icons */}
+
+
+ );
+}
+
+// ── Desktop Mockup ──────────────────────────────────────
+
+function DesktopMockup() {
+ const containerRef = React.useRef(null);
+ const [scale, setScale] = React.useState(1);
+
+ React.useEffect(() => {
+ function updateScale() {
+ if (!containerRef.current) return;
+ const parentWidth = containerRef.current.parentElement?.clientWidth ?? 1200;
+ const designWidth = 1200;
+ setScale(Math.min(1, parentWidth / designWidth));
+ }
+ updateScale();
+ window.addEventListener("resize", updateScale);
+ return () => window.removeEventListener("resize", updateScale);
+ }, []);
+
+ return (
+
+
+ {/* Top-level: left sidebar | center column | explorer sidebar — all full height */}
+
+ {/* Left sidebar — full height */}
+
+
+
+
+ {/* Center column: title bar + split panes */}
+
+ {/* Title bar — belongs to center column only */}
+
+
+
+
+
main
+
acme/returns-app
+
+
+
+
+
+
+
+
+
+ {/* Split panes */}
+
+ {/* Left pane: all agent tabs + chat */}
+
+
+ {/* Resize handle */}
+
+
+ {/* Right pane: terminal only */}
+
+
+
+
+
+
+
+ {/* Explorer sidebar — full height, pushes center column */}
+
+
+
+
+
+
+ );
+}
+
+// ── Export ───────────────────────────────────────────────
+
+export function HeroMockup() {
+ return ;
+}
diff --git a/packages/website/src/components/landing-page.tsx b/packages/website/src/components/landing-page.tsx
index f61da8164..9de68bc37 100644
--- a/packages/website/src/components/landing-page.tsx
+++ b/packages/website/src/components/landing-page.tsx
@@ -1,5 +1,5 @@
import * as React from "react";
-import { motion, AnimatePresence } from "framer-motion";
+import { motion, AnimatePresence, useInView, useScroll, useTransform, useMotionValueEvent } from "framer-motion";
import { CursorFieldProvider } from "~/components/butterfly";
import { CommandDialog } from "~/components/command-dialog";
import {
@@ -13,6 +13,9 @@ import {
TerminalIcon,
GlobeIcon,
} from "~/downloads";
+import { Mic } from "lucide-react";
+import { HeroMockup } from "~/components/hero-mockup";
+import { ClaudeIcon } from "~/components/mockup";
import "~/styles.css";
interface LandingPageProps {
@@ -25,10 +28,8 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
{/* Hero section with background image */}
-
-
-
+
@@ -41,22 +42,24 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
transition={{ duration: 0.8, delay: 0.5, ease: "easeOut" }}
className="relative px-6 md:px-8 pb-8 md:pb-16"
>
-
-
+
+
+
+ {/* Phone showcase */}
+
+
{/* Content section */}
-
+
-
-
+
+
+
+
@@ -256,7 +259,7 @@ function Hero({ title, subtitle }: { title: React.ReactNode; subtitle: string })
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
- className="text-3xl md:text-5xl font-semibold tracking-tight"
+ className="text-3xl md:text-5xl font-medium tracking-tight"
>
{title}
@@ -299,134 +302,466 @@ function AgentBadge({ name, icon }: { name: string; icon: React.ReactNode }) {
);
}
-function Features() {
+function FeatureSection({
+ title,
+ description,
+ children,
+}: {
+ title: string;
+ description: string;
+ children: React.ReactNode;
+}) {
return (
-
- {/* Primary differentiators - 2x2 */}
-
- {[
- {
- title: "Self-hosted",
- description:
- "Agents run on your machine with your full dev environment. Use your tools, your configs and your skills.",
- icon: (
-
-
-
-
-
-
- ),
- },
- {
- title: "Multi-provider",
- description:
- "Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.",
- icon: (
-
-
-
-
-
-
- ),
- },
- {
- title: "Voice control",
- description:
- "Dictate tasks or talk through problems in voice mode. Hands-free when you need it.",
- icon: (
-
-
-
-
-
- ),
- },
- {
- title: "Cross-device",
- description:
- "iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.",
- icon: (
-
-
-
-
- ),
- },
- ].map((feature, i) => (
-
+
+
{title}
+
{description}
+
+ {children}
+
+ );
+}
+
+function PrinciplesSection() {
+ return (
+
+
+ Here's what's under the hood.
+
+
+ );
+}
+
+function MultiProviderSection() {
+ const providers = [
+ { name: "Claude Code", icon: },
+ { name: "Codex", icon: },
+ { name: "OpenCode", icon: },
+ ];
+
+ return (
+
+
+ {providers.map((p) => (
+
-
-
{feature.icon}
-
{feature.title}
-
-
{feature.description}
-
+
{p.icon}
+
{p.name}
+
+ ))}
+
+
+ );
+}
+
+function SelfHostedDiagram() {
+ const clients = [
+ { name: "Desktop", icon: },
+ { name: "Web", icon: },
+ { name: "Mobile", icon: },
+ { name: "CLI", icon: },
+ ];
+ const hosts = ["MacBook Pro", "Hetzner VM", "Dev server"];
+ const containerRef = React.useRef(null);
+ const clientRefs = React.useRef<(HTMLDivElement | null)[]>([]);
+ const hostRefs = React.useRef<(HTMLDivElement | null)[]>([]);
+ const centerRef = React.useRef(null);
+ const [paths, setPaths] = React.useState<{ left: string[]; right: string[] }>({ left: [], right: [] });
+
+ React.useEffect(() => {
+ function computePaths() {
+ const container = containerRef.current;
+ const center = centerRef.current;
+ if (!container || !center) return;
+
+ const cRect = container.getBoundingClientRect();
+ const mRect = center.getBoundingClientRect();
+ const midL = mRect.left - cRect.left;
+ const midR = mRect.right - cRect.left;
+ const midY = mRect.top - cRect.top + mRect.height / 2;
+
+ const left = clientRefs.current.map((el) => {
+ if (!el) return "";
+ const r = el.getBoundingClientRect();
+ const x1 = r.right - cRect.left;
+ const y1 = r.top - cRect.top + r.height / 2;
+ const cpx = x1 + (midL - x1) * 0.6;
+ return `M${x1},${y1} C${cpx},${y1} ${midL - (midL - x1) * 0.3},${midY} ${midL},${midY}`;
+ });
+
+ const right = hostRefs.current.map((el) => {
+ if (!el) return "";
+ const r = el.getBoundingClientRect();
+ const x2 = r.left - cRect.left;
+ const y2 = r.top - cRect.top + r.height / 2;
+ const cpx = midR + (x2 - midR) * 0.4;
+ return `M${midR},${midY} C${cpx},${midY} ${x2 - (x2 - midR) * 0.3},${y2} ${x2},${y2}`;
+ });
+
+ setPaths({ left, right });
+ }
+
+ computePaths();
+ window.addEventListener("resize", computePaths);
+ return () => window.removeEventListener("resize", computePaths);
+ }, []);
+
+ return (
+ <>
+ {/* Mobile: vertical stack */}
+
+
+ {clients.map((c) => (
+
+ {c.icon}
+ {c.name}
+
+ ))}
+
+
+
+
E2E Encrypted Relay
+
or
+
Direct Connection
+
+
+
+ {hosts.map((h) => (
+
+
+
+
+
+
+
+ {h}
+
+ ))}
+
+
+
+ {/* Desktop: horizontal with bezier curves */}
+
+ {/* SVG curves */}
+
+ {[...paths.left, ...paths.right].map((d, i) => (
+ d &&
+ ))}
+
+
+ {/* Clients */}
+
+ {clients.map((c, i) => (
+
{ clientRefs.current[i] = el; }}
+ className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm backdrop-blur-sm"
+ >
+ {c.icon}
+ {c.name}
+
))}
- {/* Also */}
-
- Multi-host
- Built-in terminal
- Git worktrees
- E2E encrypted relay
- Open source
-
+ {/* Spacer */}
+
+
+ {/* Center label */}
+
+
E2E Encrypted Relay
+
or
+
Direct Connection
+
+
+ {/* Spacer */}
+
+
+ {/* Hosts */}
+
+ {hosts.map((h, i) => (
+
{ hostRefs.current[i] = el; }}
+ className="flex items-center gap-3 rounded-lg border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm backdrop-blur-sm"
+ >
+
+
+
+
+
+
+ {h}
+
+ ))}
+
+ >
+ );
+}
+
+function SelfHostedSection() {
+ return (
+
+
+
+ );
+}
+
+
+function ShortcutsSection() {
+ const shortcuts = [
+ { keys: ["⌘", "1-9"], action: "Switch panels" },
+ { keys: ["⌘", "D"], action: "Split vertical" },
+ { keys: ["⌘", "Shift", "D"], action: "Split horizontal" },
+ { keys: ["⌘", "W"], action: "Close panel" },
+ { keys: ["⌘", "N"], action: "New agent" },
+ { keys: ["⌘", "K"], action: "Command palette" },
+ ];
+
+ return (
+
+
+ {shortcuts.map((s) => (
+
+
{s.action}
+
+ {s.keys.map((k) => (
+
+ {k}
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
+
+function VoiceWaveform() {
+ const barCount = 48;
+ return (
+
+ {Array.from({ length: barCount }).map((_, i) => {
+ // Create a natural-looking waveform envelope — louder in center, quieter at edges
+ const center = barCount / 2;
+ const dist = Math.abs(i - center) / center;
+ const envelope = 1 - dist * dist; // quadratic falloff
+ const minH = 4;
+ const maxH = 56;
+ const baseH = minH + (maxH - minH) * envelope;
+ // Vary per-bar so it doesn't look uniform
+ const jitter = Math.sin(i * 2.3) * 0.3 + Math.cos(i * 1.7) * 0.2;
+ const h = Math.max(minH, baseH * (0.5 + 0.5 * Math.abs(jitter + Math.sin(i * 0.8))));
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+const USER_WORDS = "Refactor the auth middleware to use the new session store, then run the test suite".split(" ");
+const RESPONSE_WORDS = "I'll update the auth middleware to use SessionStore instead of the legacy cookie-based approach. Let me refactor the middleware and update the tests.".split(" ");
+const DICTATION_LAG = 2;
+const RESPONSE_LAG = 3;
+const WORD_APPEAR_MS = 150;
+const RESPONSE_WORD_MS = 60;
+const PHASE_GAP_MS = 800;
+const LOOP_PAUSE_MS = 3000;
+
+type VoicePhase = "dictation" | "dictation-flush" | "pause" | "response" | "response-flush" | "done";
+
+function useVoiceConversation() {
+ const [phase, setPhase] = React.useState("dictation");
+ const [wordIndex, setWordIndex] = React.useState(0);
+
+ React.useEffect(() => {
+ if (phase === "dictation") {
+ if (wordIndex < USER_WORDS.length) {
+ const t = setTimeout(() => setWordIndex((w) => w + 1), WORD_APPEAR_MS);
+ return () => clearTimeout(t);
+ }
+ setPhase("dictation-flush");
+ setWordIndex(0);
+ return;
+ }
+ if (phase === "dictation-flush") {
+ if (wordIndex < DICTATION_LAG) {
+ const t = setTimeout(() => setWordIndex((w) => w + 1), WORD_APPEAR_MS);
+ return () => clearTimeout(t);
+ }
+ const t = setTimeout(() => { setPhase("pause"); }, PHASE_GAP_MS);
+ return () => clearTimeout(t);
+ }
+ if (phase === "pause") {
+ const t = setTimeout(() => { setPhase("response"); setWordIndex(0); }, PHASE_GAP_MS);
+ return () => clearTimeout(t);
+ }
+ if (phase === "response") {
+ if (wordIndex < RESPONSE_WORDS.length) {
+ const t = setTimeout(() => setWordIndex((w) => w + 1), RESPONSE_WORD_MS);
+ return () => clearTimeout(t);
+ }
+ setPhase("response-flush");
+ setWordIndex(0);
+ return;
+ }
+ if (phase === "response-flush") {
+ if (wordIndex < RESPONSE_LAG) {
+ const t = setTimeout(() => setWordIndex((w) => w + 1), RESPONSE_WORD_MS);
+ return () => clearTimeout(t);
+ }
+ const t = setTimeout(() => { setPhase("done"); }, LOOP_PAUSE_MS);
+ return () => clearTimeout(t);
+ }
+ if (phase === "done") {
+ const t = setTimeout(() => { setPhase("dictation"); setWordIndex(0); }, 0);
+ return () => clearTimeout(t);
+ }
+ }, [phase, wordIndex]);
+
+ // Compute effective word indices for rendering
+ let dictationWordIndex: number;
+ if (phase === "dictation") {
+ dictationWordIndex = wordIndex;
+ } else if (phase === "dictation-flush") {
+ dictationWordIndex = USER_WORDS.length + wordIndex;
+ } else {
+ dictationWordIndex = USER_WORDS.length + DICTATION_LAG;
+ }
+
+ let responseWordIndex: number;
+ if (phase === "response") {
+ responseWordIndex = wordIndex;
+ } else if (phase === "response-flush") {
+ responseWordIndex = RESPONSE_WORDS.length + wordIndex;
+ } else if (phase === "done") {
+ responseWordIndex = RESPONSE_WORDS.length + RESPONSE_LAG;
+ } else {
+ responseWordIndex = 0;
+ }
+
+ const showResponse = phase === "response" || phase === "response-flush" || phase === "done";
+
+ return { dictationWordIndex, responseWordIndex, showResponse };
+}
+
+function StreamingWords({ words, wordIndex, confirmLag = 2 }: { words: string[]; wordIndex: number; confirmLag?: number }) {
+ return (
+
+ {/* Invisible full text to reserve height at any viewport width */}
+
+ {words.join(" ")}
+
+ {/* Visible streaming text overlaid */}
+
+ {words.map((word, i) => {
+ if (i >= wordIndex) return null;
+ const confirmed = i < wordIndex - confirmLag;
+ return (
+
+ {word}{" "}
+
+ );
+ })}
+
+
+ );
+}
+
+function LocalVoiceSection() {
+ const { dictationWordIndex, responseWordIndex, showResponse } = useVoiceConversation();
+
+ return (
+
+
+
+ {/* Waveform area */}
+
+
+
+
+ {/* User dictation */}
+
+
+ {/* Agent response — always rendered to reserve space */}
+
+
+
+
);
}
@@ -849,94 +1184,131 @@ interface CLIExample {
const cliExamples: CLIExample[] = [
{
- title: "Launch and monitor",
+ title: "Run agents",
description:
- "Give an agent a task and watch it work. The --worktree flag spins up an isolated git branch so you can run multiple agents on the same repo without conflicts.",
- code: `paseo run --provider claude/opus-4.6 "implement user authentication"
-paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
+ "Launch agents locally or on any remote host. The --worktree flag spins up an isolated git branch so you can run multiple agents on the same repo without conflicts.",
+ code: `paseo run "implement user authentication"
+paseo run --provider codex --worktree feature-x "implement feature X"
+paseo run --host devbox:6767 "run the full test suite"
paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task`,
},
{
- title: "Orchestration",
+ title: "Loops",
description:
- "Agents can use the CLI too. Tell one agent to spawn others, split up the work, and pull everything together when they're done.",
- code: `# Spawn two agents in parallel, wait, then synthesize
-paseo run --detach "implement the frontend" --name frontend
-paseo run --detach "implement the API layer" --name api
+ "Have one agent do the work, another verify the result, and loop until it passes. Built-in, no shell scripting needed.",
+ code: `# Worker-verifier loop: fix tests until they pass
+paseo loop run "make all tests pass" \\
+ --verify "verify tests pass and the code is production-ready" \\
+ --verify-check "npm test" \\
+ --max-iterations 5
-paseo wait frontend api
-
-paseo run "review both branches and write an integration plan"`,
+paseo loop ls # list running loops
+paseo loop logs abc123 # stream loop output`,
},
{
- title: "Structured output",
+ title: "Schedules",
description:
- "Pass a JSON schema and get typed data back from any agent run. No output parsing, just the structured result you asked for.",
- code: `result=$(paseo run \\
- --output-schema '{
- "type": "object",
- "properties": {
- "severity": { "type": "string", "enum": ["low", "medium", "high"] },
- "issues": { "type": "array", "items": { "type": "string" } }
- },
- "required": ["severity", "issues"]
- }' \\
- "audit this codebase for security issues")
+ "Run agents on a cron schedule. Automate recurring tasks like dependency updates, security audits, or report generation.",
+ code: `# Run a security audit every Monday at 9am
+paseo schedule create --cron "0 9 * * 1" \\
+ "audit the codebase for security issues and open PRs for fixes"
-echo $result | jq '.severity' # "high"
-echo $result | jq '.issues[0]' # "SQL injection on line 42"`,
- },
- {
- title: "Remote",
- description:
- "Point at any daemon on your network or over the internet. Run agents on a beefy server from your laptop.",
- code: `# Set once for the session
-export PASEO_HOST=workstation.local:6767
-paseo run "run the full test suite"
-paseo ls
-
-# Or per-command
-paseo --host gpu-server:6767 run "train the model"
-paseo --host gpu-server:6767 attach abc123`,
- },
- {
- title: "Worker-judge loops",
- description:
- "Have one agent do the work and another judge the result. Loop until it passes. Good for test fixes, code review, or any task with clear acceptance criteria.",
- code: `while true; do
- paseo run "make all tests pass"
-
- verdict=$(paseo run \\
- --output-schema '{"type":"object","properties":{"passed":{"type":"boolean"}},"required":["passed"]}' \\
- "verify tests pass and the code is production-ready")
-
- echo "$verdict" | jq -e '.passed' && break
-done`,
+paseo schedule ls # list all schedules
+paseo schedule pause abc123 # pause a schedule
+paseo schedule delete abc123 # remove a schedule`,
},
];
-function MobileSection() {
+function PhoneShowcase() {
+ const containerRef = React.useRef(null);
+ const textInView = useInView(containerRef, { once: true, margin: "-80px" });
+
+ // Scroll-linked animation: track how far through the container the user has scrolled
+ const { scrollYProgress } = useScroll({
+ target: containerRef,
+ offset: ["start end", "center center"],
+ });
+
+ // Responsive slide distance
+ const [slideDistance, setSlideDistance] = React.useState(260);
+ React.useEffect(() => {
+ function update() {
+ setSlideDistance(window.innerWidth < 768 ? 140 : 260);
+ }
+ update();
+ window.addEventListener("resize", update);
+ return () => window.removeEventListener("resize", update);
+ }, []);
+
+ // Side phones start at x=0 (behind center) and slide out to final position
+ const sideOpacity = useTransform(scrollYProgress, [0.2, 0.6], [0, 1]);
+ const leftX = useTransform(scrollYProgress, [0.2, 0.6], [0, -slideDistance]);
+ const rightX = useTransform(scrollYProgress, [0.2, 0.6], [0, slideDistance]);
+
return (
-
-
-
Mobile-first
-
- The mobile app has full feature parity with desktop. Launch agents, review diffs, talk through problems with voice, all from your phone.
+
+ {/* Arrow + text */}
+
+
+
+
+
+ When you want to step away from your desk, you can.
-
-
-
+
+ The native mobile app has full feature parity with desktop.
+
+
+
+ {/* Phone trio — side phones are absolute, start behind center, slide outward with perspective rotation */}
+
+ {/* Left phone — rotated to face inward */}
+
-
+
+
+ {/* Center phone */}
+
+
+
+
+ {/* Right phone — rotated to face inward */}
+
+
+
-
+
);
}
@@ -945,20 +1317,10 @@ function CLISection() {
const active = cliExamples[activeIndex];
return (
-
-
-
CLI
-
- Everything you can do in the app, you can do from the terminal.
-
-
-
{cliExamples.map((example, i) => (
-
{active.description}
{active.code}
@@ -999,7 +1360,7 @@ function CLISection() {
-
+
);
}
@@ -1012,7 +1373,7 @@ function FAQ() {
transition={{ duration: 0.5, ease: "easeOut" }}
className="space-y-6"
>
- FAQ
+ FAQ
Yes. Paseo is free and open source. You need Claude Code, Codex, or OpenCode installed
@@ -1092,21 +1453,9 @@ function SponsorCTA() {
transition={{ duration: 0.5, ease: "easeOut" }}
className="rounded-xl bg-white/5 border border-white/10 p-8 md:p-10 text-left space-y-4 max-w-xl mx-auto"
>
- Paseo is an independent project
- I believe that open source and freedom of choice always win for developer tools.
-
-
- Paseo has no VC funding and no big team behind it. I built it because the existing tools
- weren't good enough for me. No tracking, no telemetry, no forced accounts, no vendor lock-in.
-
-
- The monetization story is still taking shape. The obvious path is optional hosted
- infrastructure like cloud sandboxes for teams. But Paseo itself will always be FOSS.
-
-
- If you like Paseo, consider sponsoring development.
+ I built Paseo because I wanted better tools for coding agents on my own setup. It's an independent open source project, built around freedom of choice and real workflows. If you like what I'm building, consider becoming a supporter.
- Mo
@@ -1137,12 +1486,12 @@ function SponsorCTA() {
function FAQItem({ question, children }: { question: string; children: React.ReactNode }) {
return (
-
- +
- -
+
+ +
+ −
{question}
- {children}
+ {children}
);
}
diff --git a/packages/website/src/components/mockup/icons.tsx b/packages/website/src/components/mockup/icons.tsx
new file mode 100644
index 000000000..43d82090f
--- /dev/null
+++ b/packages/website/src/components/mockup/icons.tsx
@@ -0,0 +1,28 @@
+// Exact SVG paths from packages/app/src/components/icons/
+
+export function ClaudeIcon({ size = 13, className }: { size?: number; className?: string }) {
+ return (
+
+
+
+ );
+}
+
+export function CodexIcon({ size = 13, className }: { size?: number; className?: string }) {
+ return (
+
+
+
+ );
+}
+
+export function SourceControlIcon({ size = 14, className }: { size?: number; className?: string }) {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/packages/website/src/components/mockup/index.ts b/packages/website/src/components/mockup/index.ts
new file mode 100644
index 000000000..f4a6c1277
--- /dev/null
+++ b/packages/website/src/components/mockup/index.ts
@@ -0,0 +1 @@
+export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
diff --git a/packages/website/src/routes/docs.tsx b/packages/website/src/routes/docs.tsx
index e53c7f39d..886472b09 100644
--- a/packages/website/src/routes/docs.tsx
+++ b/packages/website/src/routes/docs.tsx
@@ -61,7 +61,7 @@ function DocsLayout() {
))}
-
+
diff --git a/packages/website/src/routes/index.tsx b/packages/website/src/routes/index.tsx
index afbb061dc..73572272f 100644
--- a/packages/website/src/routes/index.tsx
+++ b/packages/website/src/routes/index.tsx
@@ -15,12 +15,7 @@ export const Route = createFileRoute("/")({
function Home() {
return (
- All your coding agents,
- from anywhere
- >
- }
+ title={<>The development environment built for coding agents>}
subtitle="Run any coding agent from your phone, desktop, or terminal. Self-hosted, multi-provider, open source."
/>
);
diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css
index 2e6a1a831..b518feaa2 100644
--- a/packages/website/src/styles.css
+++ b/packages/website/src/styles.css
@@ -1,12 +1,17 @@
@import "tailwindcss";
+@keyframes voice-bar {
+ 0% { transform: scaleY(1); }
+ 100% { transform: scaleY(0.3); }
+}
+
/* Title font - centralized for easy changes */
.font-title {
font-family: inherit;
}
-/* Inline code styling - applies to code elements not inside pre blocks */
-:not(pre) > code {
+/* Inline code styling - scoped to prose content */
+.prose :not(pre) > code {
background-color: var(--color-muted);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
@@ -87,10 +92,57 @@
--color-secondary: #27272a;
--color-secondary-foreground: #fafafa;
+ /* Mockup palette (from app unistyles theme) */
+ --color-mock-surface0: #18181c;
+ --color-mock-surface1: #1f1f23;
+ --color-mock-surface2: #27272a;
+ --color-mock-surface3: #3f3f46;
+ --color-mock-sidebar: #121216;
+ --color-mock-fg: #fafafa;
+ --color-mock-fg-muted: #a1a1aa;
+ --color-mock-border: #27272a;
+ --color-mock-border-accent: #34343a;
+ --color-mock-accent: #20744A;
+ --color-mock-green: #22c55e;
+ --color-mock-green-400: #4ade80;
+ --color-mock-red: #ef4444;
+ --color-mock-amber: #f59e0b;
+ --color-mock-blue: #3b82f6;
+ --color-mock-zinc500: #71717a;
+ --color-mock-zinc600: #52525b;
+ --color-mock-diff-add: rgba(46, 160, 67, 0.15);
+ --color-mock-diff-remove: rgba(248, 81, 73, 0.1);
+
+ /* Syntax highlighting */
+ --color-syn-keyword: #ff7b72;
+ --color-syn-comment: #8b949e;
+ --color-syn-string: #a5d6ff;
+ --color-syn-number: #79c0ff;
+ --color-syn-function: #d2a8ff;
+ --color-syn-tag: #7ee787;
+ --color-syn-property: #79c0ff;
+ --color-syn-variable: #c9d1d9;
+ --color-syn-punctuation: #c9d1d9;
+ --color-syn-operator: #79c0ff;
+
--animate-flutter-left: flutter-left 0.6s ease-in-out infinite;
--animate-flutter-right: flutter-right 0.6s ease-in-out infinite;
--animate-float: float 8s ease-in-out infinite;
--animate-flutter-body: flutter-body 0.5s ease-in-out infinite;
+ --animate-synced-snake-dot: synced-snake-dot 950ms linear infinite;
+}
+
+/* Synced loader — 6-dot snake animation matching the real app.
+ Each dot runs this keyframe with a staggered delay so that at any moment
+ up to 4 dots are visible at decreasing opacity (head=1, trail=0.72/0.46/0.22).
+ steps(1,end) keeps each opacity level solid for its full 1/6-cycle slot. */
+@keyframes synced-snake-dot {
+ 0% { opacity: 1; animation-timing-function: steps(1, end); }
+ 16.667% { opacity: 0.72; animation-timing-function: steps(1, end); }
+ 33.333% { opacity: 0.46; animation-timing-function: steps(1, end); }
+ 50% { opacity: 0.22; animation-timing-function: steps(1, end); }
+ 66.667% { opacity: 0; animation-timing-function: steps(1, end); }
+ 83.333% { opacity: 0; animation-timing-function: steps(1, end); }
}
@keyframes flutter-left {