Redesign landing page with interactive mockups and new sections

This commit is contained in:
Mohamed Boudra
2026-03-28 21:39:37 +07:00
parent a3aed9b8b6
commit 2e9f935dbc
13 changed files with 1418 additions and 245 deletions

10
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

View File

@@ -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 (
<div className="flex items-center gap-[6px]">
<div className="w-[11px] h-[11px] rounded-full bg-[#ff5f57]" />
<div className="w-[11px] h-[11px] rounded-full bg-[#febc2e]" />
<div className="w-[11px] h-[11px] rounded-full bg-[#28c840]" />
</div>
);
}
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 <SquareTerminal size={13} className={cls} />;
return provider === "claude"
? <ClaudeIcon size={13} className={cls} />
: <CodexIcon size={13} className={cls} />;
}
function TabBarAction({ icon: Icon }: { icon: React.ComponentType<{ size?: number; className?: string; strokeWidth?: number }> }) {
return (
<div className="w-4 h-5 flex items-center justify-center flex-shrink-0">
<Icon size={12} strokeWidth={1.5} className="text-mock-fg-muted" />
</div>
);
}
function PaneTabBar({ tabs, focused = false }: { tabs: TabDef[]; focused?: boolean }) {
return (
<div className="flex items-stretch h-7 bg-mock-surface0 border-b border-mock-border flex-shrink-0">
{tabs.map((tab) => (
<div key={tab.name} className="flex items-center gap-1.5 px-2 border-r border-mock-border relative min-w-0">
{tab.active && (
<div className={`absolute top-0 left-0 right-0 h-0.5 ${focused ? "bg-mock-accent" : "bg-mock-border-accent"}`} />
)}
<div className="relative flex-shrink-0">
<ProviderIcon provider={tab.provider} muted={!tab.active} />
</div>
<span className={`text-[11px] truncate ${tab.active ? "text-mock-fg" : "text-mock-fg-muted"}`}>
{tab.name}
</span>
<X size={11} className="text-mock-zinc500 flex-shrink-0 ml-auto" />
</div>
))}
<div className="ml-auto flex items-center px-1.5">
<TabBarAction icon={SquarePen} />
<TabBarAction icon={SquareTerminal} />
<TabBarAction icon={Columns2} />
<TabBarAction icon={Rows2} />
</div>
</div>
);
}
function Composer({ provider, model, focused = false }: { provider: "claude" | "codex"; model: string; focused?: boolean }) {
const Icon = provider === "claude" ? ClaudeIcon : CodexIcon;
return (
<div className="px-3 pb-3 flex-shrink-0">
<div className="bg-mock-surface1 border border-mock-border-accent rounded-2xl px-3 py-2 flex flex-col gap-[10px]">
<div className="flex items-center">
{focused && (
<span className="inline-block w-[1px] h-[13px] bg-mock-fg animate-pulse mr-[1px] flex-shrink-0" />
)}
<span className="text-[11px] text-mock-zinc500 leading-[1.4] select-none whitespace-nowrap overflow-hidden text-ellipsis">
Message the agent, tag @files, or use /commands and /skills
</span>
</div>
<div className="flex items-end justify-between">
<div className="flex items-center gap-0.5">
<div className="w-[22px] h-[22px] rounded-full flex items-center justify-center flex-shrink-0">
<Paperclip size={12} className="text-mock-fg" />
</div>
<div className="flex items-center gap-[4px] h-[22px] px-[6px] rounded-full">
<Icon size={12} className="text-mock-fg-muted" />
<span className="text-[10px] text-mock-fg-muted leading-none">{model}</span>
<ChevronDown size={10} className="text-mock-fg-muted" />
</div>
</div>
<div className="flex items-center gap-[6px]">
<div className="w-[22px] h-[22px] rounded-full flex items-center justify-center flex-shrink-0">
<Mic size={12} className="text-mock-fg" />
</div>
<div className="w-[22px] h-[22px] rounded-full bg-mock-accent flex items-center justify-center flex-shrink-0">
<ArrowUp size={12} className="text-white" />
</div>
</div>
</div>
</div>
</div>
);
}
function ChatMessages({ items }: { items: ChatItem[] }) {
return (
<div className="flex-1 overflow-hidden px-2 py-2">
{items.map((item, i) => {
if (item.type === "user") {
return (
<div key={i} className="flex justify-end py-1">
<div
className="bg-mock-surface2 px-2.5 py-1.5 max-w-[85%] leading-none"
style={{ borderRadius: "16px 2px 16px 16px" }}
>
<span className="text-[11px] text-mock-fg leading-none">{item.text}</span>
</div>
</div>
);
}
if (item.type === "tool") {
const isDone = item.status === "done";
return (
<div
key={i}
className="flex items-center rounded-lg px-2 py-[1px] -mx-[6px] border border-transparent"
>
<div className="w-[22px] h-[22px] rounded-full flex items-center justify-center flex-shrink-0 mr-1">
<Bot size={12} className="text-mock-fg-muted" />
</div>
<span className="text-[11px] text-mock-fg-muted truncate flex-shrink-0">
{item.label}
</span>
{item.summary && (
<span className="text-[11px] text-mock-zinc500 truncate flex-1 ml-2">
{item.summary}
</span>
)}
<div className="ml-auto flex-shrink-0 pl-2">
{isDone ? (
<Check size={11} className="text-mock-zinc500" />
) : (
<Loader size={11} className="text-mock-fg-muted animate-spin" />
)}
</div>
</div>
);
}
return (
<p
key={i}
className={`text-[11px] px-2 py-[1px] text-mock-fg leading-[1.5] ${item.bold ? "font-medium" : ""}`}
>
{item.text}
</p>
);
})}
</div>
);
}
function ChatArea() {
return (
<div className="flex flex-col flex-1 min-w-0 bg-mock-surface0">
<ChatMessages items={CHAT} />
<Composer provider="claude" model="Opus 4.6" focused />
</div>
);
}
function ImplementPane() {
return (
<div className="flex flex-col flex-1 min-w-0 min-h-0 bg-mock-surface0">
<ChatMessages items={IMPLEMENT_CHAT} />
<Composer provider="codex" model="gpt-5.4" />
</div>
);
}
function TerminalPane() {
return (
<div className="flex flex-col flex-1 min-w-0 min-h-0 bg-mock-surface0">
<div className="flex-1 overflow-hidden px-3 py-2">
{TERMINAL_LINES.map((line, i) => (
<div key={i} className="leading-[1.3]">
<code className={`text-[10px] font-mono ${line.cls} whitespace-pre`}>{line.text}</code>
</div>
))}
</div>
</div>
);
}
function ExplorerSidebar() {
return (
<div className="flex flex-col bg-mock-sidebar border-l border-mock-border w-[28%] flex-shrink-0 overflow-hidden">
{/* Explorer header — h-12, Changes/Files tabs */}
<div className="flex items-center h-10 px-2 border-b border-mock-border flex-shrink-0">
<div className="flex items-center gap-1">
<div className="flex items-center px-3 py-1.5 rounded-md bg-mock-surface1">
<span className="text-[11px] text-mock-fg font-medium">Changes</span>
</div>
<div className="flex items-center px-3 py-1.5 rounded-md">
<span className="text-[11px] text-mock-fg-muted">Files</span>
</div>
</div>
</div>
{/* Secondary header — h-9, Uncommitted dropdown + filter icons */}
<div className="flex items-center justify-between h-9 border-b border-mock-border flex-shrink-0">
<div className="flex items-center gap-1 ml-2 px-1 h-6 rounded">
<span className="text-[11px] text-mock-fg-muted">Uncommitted</span>
<ChevronDown size={11} className="text-mock-fg-muted" />
</div>
<div className="flex items-center pr-2">
<div className="flex items-center justify-center w-6 h-6 rounded">
<ListChevronsUpDown size={14} className="text-mock-fg-muted" />
</div>
</div>
</div>
{/* File header — expanded */}
<div className="flex items-center justify-between pl-2 pr-2 py-1.5 border-b border-mock-border flex-shrink-0">
<div className="flex items-center gap-1 flex-1 min-w-0 overflow-hidden">
<ChevronDown size={10} className="text-mock-fg-muted flex-shrink-0" />
<span className="text-[11px] text-mock-fg flex-shrink-0">dashboard.tsx</span>
<span className="text-[11px] text-mock-fg-muted truncate min-w-0"> src/pages</span>
<span
className="text-[10px] text-mock-green-400 px-2 py-[2px] rounded-md flex-shrink-0"
style={{ backgroundColor: "rgba(46, 160, 67, 0.2)" }}
>New</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-1">
<span className="text-[11px] text-mock-green-400">+16</span>
<span className="text-[11px] text-mock-red">-0</span>
</div>
</div>
{/* Diff lines */}
<div className="overflow-hidden bg-mock-surface1">
{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 (
<div key={i} className={`flex items-stretch ${lineBg}`}>
<div className="w-8 border-r border-mock-border flex-shrink-0 flex items-center justify-end">
<code className={`text-[10px] font-mono ${lineNumCls} select-none pr-2 py-[1px]`}>{line.ln ?? ""}</code>
</div>
<code className="text-[10px] font-mono text-mock-fg pl-3 pr-3 py-[1px] whitespace-pre flex-1 min-w-0">
{line.tokens?.map((tok, j) => (
<span key={j} className={tok.cls}>{tok.text}</span>
))}
</code>
</div>
);
})}
</div>
{/* 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) => (
<div key={file.name} className="flex items-center justify-between pl-2 pr-2 py-1.5 border-b border-mock-border">
<div className="flex items-center gap-1 flex-1 min-w-0 overflow-hidden">
<ChevronRight size={10} className="text-mock-fg-muted flex-shrink-0" />
<span className="text-[11px] text-mock-fg flex-shrink-0">{file.name}</span>
<span className="text-[11px] text-mock-fg-muted truncate min-w-0"> {file.dir}</span>
{file.isNew && (
<span
className="text-[10px] text-mock-green-400 px-2 py-[2px] rounded-md flex-shrink-0"
style={{ backgroundColor: "rgba(46, 160, 67, 0.2)" }}
>New</span>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-1">
<span className="text-[11px] text-mock-green-400">+{file.added}</span>
<span className="text-[11px] text-mock-red">-{file.removed}</span>
</div>
</div>
))}
<div className="flex-1" />
</div>
);
}
// 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 (
<div style={{ width: size, height: size, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<div style={{ position: "relative", width: gridW, height: gridH }}>
{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 (
<div
key={dotIndex}
style={{
position: "absolute",
left: col * (dotSize + gap),
top: row * (dotSize + gap),
width: dotSize,
height: dotSize,
borderRadius: "50%",
backgroundColor: "#f59e0b",
animationName: "synced-snake-dot",
animationDuration: `${SYNCED_LOADER_DURATION_MS}ms`,
animationTimingFunction: "linear",
animationIterationCount: "infinite",
animationDelay: `${delayMs}ms`,
}}
/>
);
})}
</div>
</div>
);
}
function WorkspaceStatusDot({ status }: { status: SidebarWorkspace["status"] }) {
if (status === "running") return <div className="w-[5px] h-[5px] rounded-full bg-mock-amber" />;
if (status === "done") return <div className="w-[5px] h-[5px] rounded-full bg-mock-green" />;
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 (
<div className="flex flex-col bg-mock-sidebar border-r border-mock-border w-[200px] flex-shrink-0">
{/* Traffic lights */}
<div className="flex items-center pl-3 pr-2 pt-3 pb-2">
<TrafficLights />
</div>
{/* Sessions header */}
<div className="flex items-center gap-2 pl-3 pr-2 py-2 border-b border-mock-border">
<MessagesSquare size={16} className="text-mock-fg-muted flex-shrink-0" />
<span className="text-sm text-mock-fg-muted">Sessions</span>
</div>
{/* Project list */}
<div className="flex-1 overflow-hidden pt-1 pb-4 min-h-0">
{SIDEBAR_PROJECTS.map((project) => (
<div key={project.name} className="mb-2">
{/* Project row — icon aligns with traffic lights / sessions */}
<div className="flex items-center gap-2 min-h-[32px] py-1.5 pl-3 pr-2">
<div className="w-4 h-4 rounded-sm border border-mock-border flex items-center justify-center flex-shrink-0">
<span className="text-[9px] text-mock-fg-muted leading-none">{project.initial}</span>
</div>
<span className="text-[13px] text-mock-fg font-normal truncate flex-1 min-w-0 leading-5">
{project.name}
</span>
</div>
{/* Workspace rows — indented one level */}
{project.workspaces.map((workspace) => (
<div key={workspace.name} className={`mb-1 mx-1.5 rounded-lg ${workspace.selected ? "bg-mock-surface1" : ""}`}>
<div className="flex items-center gap-2 min-h-[28px] py-1 pl-[22px] pr-1">
<div className="relative w-[14px] h-4 flex-shrink-0 flex items-center justify-center">
{workspace.status === "syncing" ? (
<SyncedLoader size={11} />
) : (
<>
{workspace.kind === "worktree"
? <FolderGit2 size={14} className="text-mock-fg-muted" />
: <Monitor size={14} className="text-mock-fg-muted" />
}
{workspace.status !== "idle" && (
<div className="absolute bottom-0 right-0">
<WorkspaceStatusDot status={workspace.status} />
</div>
)}
</>
)}
</div>
<span className="text-[12px] text-mock-fg/[0.76] font-normal truncate flex-1 min-w-0 leading-[1.4]">
{workspace.name}
</span>
{workspace.diffStat && (
<div className="flex items-center gap-1 flex-shrink-0">
<span className="text-[10px] text-mock-green-400 font-normal leading-none">+{workspace.diffStat.additions}</span>
<span className="text-[10px] text-mock-red font-normal leading-none">-{workspace.diffStat.deletions}</span>
</div>
)}
</div>
{workspace.pr && (
<div className="flex items-center gap-1 pl-[42px] pr-2 pb-0.5">
<GitPullRequest size={11} className="text-mock-fg-muted" />
<span className="text-[10px] text-mock-fg-muted leading-none truncate">
#{workspace.pr.number} · {workspace.pr.state === "open" ? "Open" : workspace.pr.state === "merged" ? "Merged" : "Closed"}
</span>
</div>
)}
</div>
))}
</div>
))}
</div>
{/* Footer — dot aligns with traffic lights / icons */}
<div className="flex items-center justify-between pl-3 pr-2 py-3 border-t border-mock-border">
<div className="flex items-center gap-2 min-w-0 flex-shrink">
<div className="w-2 h-2 rounded-full bg-mock-green flex-shrink-0" />
<span className="text-[13px] text-mock-fg-muted truncate">MacBook Pro</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className="w-6 h-6 flex items-center justify-center">
<Plus size={18} className="text-mock-fg-muted" />
</div>
<div className="w-6 h-6 flex items-center justify-center">
<Settings size={18} className="text-mock-fg-muted" />
</div>
</div>
</div>
</div>
);
}
// ── Desktop Mockup ──────────────────────────────────────
function DesktopMockup() {
const containerRef = React.useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="w-full overflow-hidden" style={{ height: `${1200 * (9 / 16) * scale}px` }}>
<div
className="mx-auto rounded-xl overflow-hidden border border-mock-border bg-mock-surface0 shadow-[6px_6px_0_rgba(0,0,0,0.4)] origin-top-left"
style={{ width: 1200, transform: `scale(${scale})` }}
>
{/* Top-level: left sidebar | center column | explorer sidebar — all full height */}
<div className="flex aspect-video">
{/* Left sidebar — full height */}
<motion.div {...fade(D.sidebar)} className="contents">
<Sidebar />
</motion.div>
{/* Center column: title bar + split panes */}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
{/* Title bar — belongs to center column only */}
<motion.div {...fade(D.titleBar)} className="flex items-center h-10 px-2 bg-mock-surface0 border-b border-mock-border flex-shrink-0">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="px-2 py-1 rounded-lg flex items-center justify-center flex-shrink-0">
<PanelLeft size={14} className="text-mock-fg-muted" />
</div>
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-[13px] font-light text-mock-fg truncate flex-shrink-0">main</span>
<span className="text-[13px] text-mock-fg-muted truncate flex-shrink min-w-0">acme/returns-app</span>
<div className="px-2 py-1 rounded-lg flex items-center justify-center flex-shrink-0">
<Ellipsis size={14} className="text-mock-fg-muted" />
</div>
</div>
</div>
<div className="flex items-center gap-2 ml-auto flex-shrink-0">
<div className="flex items-stretch rounded-md border border-mock-border-accent overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1">
<GitCommitHorizontal size={12} className="text-mock-fg-muted flex-shrink-0" />
<span className="text-[11px] text-mock-fg font-normal">Commit</span>
</div>
<div className="flex items-center justify-center w-7 border-l border-mock-border-accent">
<ChevronDown size={12} className="text-mock-fg-muted" />
</div>
</div>
<div className="flex items-center gap-2 px-3 py-1 rounded-md">
<SourceControlIcon size={14} className="text-mock-fg-muted" />
<span className="text-[11px] font-normal text-mock-green-400">+247</span>
<span className="text-[11px] font-normal text-mock-red">-15</span>
</div>
</div>
</motion.div>
{/* Split panes */}
<div className="flex flex-1 min-h-0">
{/* Left pane: all agent tabs + chat */}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<motion.div {...fade(D.tabs)}>
<PaneTabBar tabs={TABS} focused />
</motion.div>
<motion.div {...fade(D.chat)} className="flex-1 flex min-h-0">
<ChatArea />
</motion.div>
</div>
{/* Resize handle */}
<div className="w-px bg-mock-border flex-shrink-0" />
{/* Right pane: terminal only */}
<motion.div {...fade(D.chat)} className="flex flex-col flex-1 min-w-0 min-h-0">
<PaneTabBar tabs={[{ name: "npm run dev", provider: "terminal", done: false, active: true }]} />
<TerminalPane />
</motion.div>
</div>
</div>
{/* Explorer sidebar — full height, pushes center column */}
<motion.div {...fade(D.diffPanel)} className="contents">
<ExplorerSidebar />
</motion.div>
</div>
</div>
</div>
);
}
// ── Export ───────────────────────────────────────────────
export function HeroMockup() {
return <DesktopMockup />;
}

View File

@@ -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) {
<CursorFieldProvider>
{/* Hero section with background image */}
<div className="relative bg-cover bg-center bg-no-repeat">
<div className="absolute inset-0 bg-background/90" />
<div className="absolute inset-x-0 bottom-0 h-64 bg-linear-to-t from-black to-transparent" />
<div className="relative p-6 pb-10 md:px-20 md:pt-20 md:pb-12 max-w-3xl mx-auto">
<div className="relative p-6 pb-10 md:px-32 md:pt-20 md:pb-12 max-w-7xl mx-auto">
<Nav />
<Hero title={title} subtitle={subtitle} />
<GetStarted />
@@ -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"
>
<div className="max-w-6xl lg:max-w-7xl xl:max-w-[90rem] mx-auto">
<img
src="/hero-mockup.png"
alt="Paseo app showing agent management interface"
className="w-full rounded-lg shadow-2xl"
/>
<div className="max-w-7xl mx-auto">
<HeroMockup />
</div>
</motion.div>
</div>
{/* Phone showcase */}
<PhoneShowcase />
{/* Content section */}
<div className="bg-black">
<main className="p-6 md:p-20 md:pt-8 max-w-5xl mx-auto">
<main className="p-6 md:p-20 md:pt-40 max-w-5xl mx-auto">
<div className="space-y-24">
<Features />
<MobileSection />
<MultiProviderSection />
<SelfHostedSection />
<ShortcutsSection />
<LocalVoiceSection />
<CLISection />
<FAQ />
<SponsorCTA />
@@ -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}
</motion.h1>
@@ -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 (
<div className="space-y-10">
{/* Primary differentiators - 2x2 */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-8">
{[
{
title: "Self-hosted",
description:
"Agents run on your machine with your full dev environment. Use your tools, your configs and your skills.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
<line x1="6" y1="6" x2="6.01" y2="6" />
<line x1="6" y1="18" x2="6.01" y2="18" />
</svg>
),
},
{
title: "Multi-provider",
description:
"Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 3h5v5" />
<path d="M8 3H3v5" />
<path d="M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3" />
<path d="m15 9 6-6" />
</svg>
),
},
{
title: "Voice control",
description:
"Dictate tasks or talk through problems in voice mode. Hands-free when you need it.",
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" x2="12" y1="19" y2="22" />
</svg>
),
},
{
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: (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12.01" y2="18" />
</svg>
),
},
].map((feature, i) => (
<motion.div
key={feature.title}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ duration: 0.4, delay: i * 0.06, ease: "easeOut" }}
className="space-y-2"
<motion.section
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="space-y-8"
>
<div className="space-y-2">
<h2 className="text-3xl font-medium">{title}</h2>
<p className="text-base text-muted-foreground max-w-lg">{description}</p>
</div>
{children}
</motion.section>
);
}
function PrinciplesSection() {
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="py-32 px-6 md:px-20 max-w-3xl mx-auto text-center"
>
<p className="text-2xl md:text-4xl font-medium text-white/90">
Here's what's under the hood.
</p>
</motion.div>
);
}
function MultiProviderSection() {
const providers = [
{ name: "Claude Code", icon: <ClaudeIcon size={28} /> },
{ name: "Codex", icon: <CodexIcon className="w-7 h-7" /> },
{ name: "OpenCode", icon: <OpenCodeIcon className="w-7 h-7" /> },
];
return (
<FeatureSection
title="Multi-provider"
description="Escape the vendor lock, mix and match frontier models through a single interface."
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{providers.map((p) => (
<div
key={p.name}
className="flex items-center justify-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4"
>
<div className="flex items-center gap-2">
<span className="text-white/40">{feature.icon}</span>
<p className="font-medium text-lg">{feature.title}</p>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">{feature.description}</p>
</motion.div>
<span className="text-white/80">{p.icon}</span>
<span className="font-medium">{p.name}</span>
</div>
))}
</div>
</FeatureSection>
);
}
function SelfHostedDiagram() {
const clients = [
{ name: "Desktop", icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40"><rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8M12 17v4" /></svg> },
{ name: "Web", icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40"><circle cx="12" cy="12" r="10" /><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg> },
{ name: "Mobile", icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40"><rect x="5" y="2" width="14" height="20" rx="2" /><path d="M12 18h.01" /></svg> },
{ name: "CLI", icon: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40"><polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" /></svg> },
];
const hosts = ["MacBook Pro", "Hetzner VM", "Dev server"];
const containerRef = React.useRef<HTMLDivElement>(null);
const clientRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const hostRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const centerRef = React.useRef<HTMLDivElement>(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 */}
<div className="md:hidden flex flex-col items-center gap-4 py-4">
<div className="space-y-2 w-full">
{clients.map((c) => (
<div key={c.name} className="flex items-center gap-2 rounded-lg border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm">
{c.icon}
{c.name}
</div>
))}
</div>
<div className="w-px h-6 border-l border-dashed border-white/25" />
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-6 py-5 text-center space-y-1">
<p className="text-xs font-medium text-white/50">E2E Encrypted Relay</p>
<p className="text-[10px] text-white/25">or</p>
<p className="text-xs font-medium text-white/50">Direct Connection</p>
</div>
<div className="w-px h-6 border-l border-dashed border-white/25" />
<div className="space-y-2 w-full">
{hosts.map((h) => (
<div key={h} className="flex items-center gap-3 rounded-lg border border-white/10 bg-white/[0.03] px-4 py-2.5 text-sm">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40">
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<circle cx="6" cy="6" r="1" />
<circle cx="6" cy="18" r="1" />
</svg>
{h}
</div>
))}
</div>
</div>
{/* Desktop: horizontal with bezier curves */}
<div ref={containerRef} className="relative hidden md:flex items-center py-4 gap-0">
{/* SVG curves */}
<svg className="absolute inset-0 w-full h-full pointer-events-none" style={{ overflow: "visible" }}>
{[...paths.left, ...paths.right].map((d, i) => (
d && <path key={i} d={d} fill="none" stroke="rgba(255,255,255,0.25)" strokeWidth="1" strokeDasharray="4 4" />
))}
</svg>
{/* Clients */}
<div className="space-y-3 flex-shrink-0 relative z-10">
{clients.map((c, i) => (
<div
key={c.name}
ref={(el) => { 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}
</div>
))}
</div>
{/* Also */}
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ duration: 0.4, delay: 0.25, ease: "easeOut" }}
className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-white/40"
>
<span>Multi-host</span>
<span>Built-in terminal</span>
<span>Git worktrees</span>
<span>E2E encrypted relay</span>
<span>Open source</span>
</motion.div>
{/* Spacer */}
<div className="flex-1" />
{/* Center label */}
<div ref={centerRef} className="flex-shrink-0 rounded-xl border border-white/10 bg-white/[0.03] px-6 py-5 text-center space-y-1 relative z-10 backdrop-blur-sm">
<p className="text-xs font-medium text-white/50">E2E Encrypted Relay</p>
<p className="text-[10px] text-white/25">or</p>
<p className="text-xs font-medium text-white/50">Direct Connection</p>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Hosts */}
<div className="space-y-3 flex-shrink-0 relative z-10">
{hosts.map((h, i) => (
<div
key={h}
ref={(el) => { 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"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-white/40">
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<circle cx="6" cy="6" r="1" />
<circle cx="6" cy="18" r="1" />
</svg>
{h}
</div>
))}
</div>
</div>
</>
);
}
function SelfHostedSection() {
return (
<FeatureSection
title="Self-hosted"
description="Run the daemon wherever you want and connect however you want. Orchestrate agents in multiple hosts from a single interface."
>
<SelfHostedDiagram />
</FeatureSection>
);
}
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 (
<FeatureSection
title="Keyboard-first"
description="Every action has a shortcut. Panels, splits, agents - all from the keyboard."
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{shortcuts.map((s) => (
<div
key={s.action}
className="flex items-center justify-between rounded-lg border border-white/10 bg-white/[0.03] px-4 py-2.5"
>
<span className="text-sm text-white/60">{s.action}</span>
<div className="flex items-center gap-1">
{s.keys.map((k) => (
<kbd
key={k}
className="text-xs px-1.5 py-0.5 rounded bg-white/10 text-white/50 font-mono"
>
{k}
</kbd>
))}
</div>
</div>
))}
</div>
</FeatureSection>
);
}
function VoiceWaveform() {
const barCount = 48;
return (
<div className="flex items-center justify-center gap-[3px] h-16">
{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 (
<div
key={i}
className="w-[3px] rounded-full bg-white/30"
style={{
height: h,
animationName: "voice-bar",
animationDuration: `${800 + (i % 5) * 200}ms`,
animationTimingFunction: "ease-in-out",
animationIterationCount: "infinite",
animationDirection: "alternate",
animationDelay: `${(i % 7) * 80}ms`,
}}
/>
);
})}
</div>
);
}
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<VoicePhase>("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 (
<div className="relative">
{/* Invisible full text to reserve height at any viewport width */}
<p className="text-sm leading-relaxed invisible" aria-hidden>
{words.join(" ")}
</p>
{/* Visible streaming text overlaid */}
<p className="text-sm leading-relaxed absolute inset-0">
{words.map((word, i) => {
if (i >= wordIndex) return null;
const confirmed = i < wordIndex - confirmLag;
return (
<span
key={i}
className={`transition-colors duration-300 ${confirmed ? "text-white/90" : "text-white/40"}`}
>
{word}{" "}
</span>
);
})}
</p>
</div>
);
}
function LocalVoiceSection() {
const { dictationWordIndex, responseWordIndex, showResponse } = useVoiceConversation();
return (
<FeatureSection
title="Local voice"
description="Fully local voice stack. Speech-to-text and text-to-speech run entirely on your machine, nothing leaves your network."
>
<div className="relative w-full rounded-2xl border border-white/10 bg-white/[0.02] overflow-hidden">
<div className="px-6 pt-8 pb-6 space-y-3">
{/* Waveform area */}
<div className="relative">
<VoiceWaveform />
</div>
{/* User dictation */}
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0">
<Mic size={16} className="text-white/60" />
</div>
<div className="pt-1">
<StreamingWords
words={USER_WORDS}
wordIndex={dictationWordIndex}
confirmLag={DICTATION_LAG}
/>
</div>
</div>
{/* Agent response — always rendered to reserve space */}
<div
className={`flex items-start gap-3 transition-opacity duration-300 ${showResponse ? "opacity-100" : "opacity-0"}`}
>
<div className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0">
<ClaudeIcon size={16} className="text-white/60" />
</div>
<div className="pt-1">
<StreamingWords
words={RESPONSE_WORDS}
wordIndex={responseWordIndex}
confirmLag={RESPONSE_LAG}
/>
</div>
</div>
</div>
</div>
</FeatureSection>
);
}
@@ -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<HTMLDivElement>(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 (
<section className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl font-medium">Mobile-first</h2>
<p className="text-sm text-muted-foreground max-w-lg">
The mobile app has full feature parity with desktop. Launch agents, review diffs, talk through problems with voice, all from your phone.
<div ref={containerRef} className="flex flex-col items-center pt-4 pb-16 gap-20">
{/* Arrow + text */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={textInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5 }}
className="flex flex-col items-center gap-1.5 px-6"
>
<svg width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24" className="text-white/20">
<path d="M12 5v14M5 12l7 7 7-7" />
</svg>
<p className="text-lg text-white/80 text-center">
When you want to step away from your desk,<br className="md:hidden" /> you can.
</p>
</div>
<div className="-mx-[calc(50vw-50%)] px-6 md:px-8 overflow-x-auto">
<div className="max-w-5xl lg:max-w-6xl mx-auto">
<p className="text-sm text-white/50 text-center">
The native mobile app has full feature parity with desktop.
</p>
</motion.div>
{/* Phone trio — side phones are absolute, start behind center, slide outward with perspective rotation */}
<div className="relative flex items-center justify-center overflow-x-clip w-full" style={{ minHeight: 480, perspective: 1200 }}>
{/* Left phone — rotated to face inward */}
<motion.div
style={{ opacity: sideOpacity, x: leftX, rotateY: -15, scale: 0.97 }}
className="w-[160px] md:w-[240px] absolute"
>
<img
src="/mobile-mockup.png"
alt="Paseo mobile app screens"
className="min-w-[900px] w-full rounded-lg"
src="/phone-1.png"
alt="Paseo sessions list"
className="w-full rounded-[40px] shadow-2xl border-[3px] border-black outline-[3px] outline-white/20"
/>
</div>
</motion.div>
{/* Center phone */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={textInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.1, ease: "easeOut" }}
className="w-[220px] md:w-[240px] relative z-10"
>
<img
src="/phone-2.png"
alt="Paseo agent chat"
className="w-full rounded-[40px] shadow-2xl border-[3px] border-black outline-[3px] outline-white/20"
/>
</motion.div>
{/* Right phone — rotated to face inward */}
<motion.div
style={{ opacity: sideOpacity, x: rightX, rotateY: 15, scale: 0.97 }}
className="w-[160px] md:w-[240px] absolute"
>
<img
src="/phone-3.png"
alt="Paseo diff view"
className="w-full rounded-[40px] shadow-2xl border-[3px] border-black outline-[3px] outline-white/20"
/>
</motion.div>
</div>
</section>
</div>
);
}
@@ -945,20 +1317,10 @@ function CLISection() {
const active = cliExamples[activeIndex];
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="space-y-6"
<FeatureSection
title="Fully scriptable"
description="Everything you can do in the app, you can do from the terminal."
>
<div className="space-y-2">
<h2 className="text-2xl font-medium">CLI</h2>
<p className="text-sm text-muted-foreground max-w-lg">
Everything you can do in the app, you can do from the terminal.
</p>
</div>
<div className="flex flex-wrap gap-2">
{cliExamples.map((example, i) => (
<button
@@ -976,7 +1338,6 @@ function CLISection() {
</div>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{active.description}</p>
<CLICodeBlock>{active.code}</CLICodeBlock>
</div>
@@ -999,7 +1360,7 @@ function CLISection() {
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</a>
</motion.div>
</FeatureSection>
);
}
@@ -1012,7 +1373,7 @@ function FAQ() {
transition={{ duration: 0.5, ease: "easeOut" }}
className="space-y-6"
>
<h2 className="text-2xl font-medium">FAQ</h2>
<h2 className="text-3xl font-medium">FAQ</h2>
<div className="space-y-6">
<FAQItem question="Is this free?">
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"
>
<p className="text-lg font-medium">Paseo is an independent project</p>
<div className="text-sm text-muted-foreground leading-relaxed space-y-3">
<p>
I believe that open source and freedom of choice always win for developer tools.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
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.
</p>
<p>- Mo</p>
</div>
@@ -1137,12 +1486,12 @@ function SponsorCTA() {
function FAQItem({ question, children }: { question: string; children: React.ReactNode }) {
return (
<details className="group">
<summary className="font-medium text-sm cursor-pointer list-none flex items-start gap-2">
<span className="font-mono text-white/40 group-open:hidden">+</span>
<span className="font-mono text-white/40 hidden group-open:inline">-</span>
<summary className="font-medium text-sm cursor-pointer list-none flex items-start gap-2 -ml-4">
<span className="font-mono text-white/40 flex-shrink-0 group-open:hidden">+</span>
<span className="font-mono text-white/40 flex-shrink-0 hidden group-open:inline"></span>
{question}
</summary>
<div className="text-sm text-muted-foreground space-y-2 mt-2 ml-4">{children}</div>
<div className="text-sm text-muted-foreground space-y-2 mt-2 prose">{children}</div>
</details>
);
}

View File

@@ -0,0 +1,28 @@
// Exact SVG paths from packages/app/src/components/icons/
export function ClaudeIcon({ size = 13, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" className={className}>
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
</svg>
);
}
export function CodexIcon({ size = 13, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" className={className}>
<path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z" />
</svg>
);
}
export function SourceControlIcon({ size = 14, className }: { size?: number; className?: string }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className}>
<rect x={3} y={3} width={18} height={18} rx={2} />
<line x1={9} y1={9.5} x2={15} y2={9.5} />
<line x1={12} y1={6.5} x2={12} y2={12.5} />
<line x1={9} y1={16} x2={15} y2={16} />
</svg>
);
}

View File

@@ -0,0 +1 @@
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";

View File

@@ -61,7 +61,7 @@ function DocsLayout() {
))}
</nav>
</aside>
<main className="flex-1 p-6 md:p-12 max-w-3xl">
<main className="flex-1 p-6 md:p-12 max-w-3xl prose">
<Outlet />
</main>
</div>

View File

@@ -15,12 +15,7 @@ export const Route = createFileRoute("/")({
function Home() {
return (
<LandingPage
title={
<>
All your coding agents,
<br className="hidden md:block" /> from anywhere
</>
}
title={<>The development environment<br />built for coding agents</>}
subtitle="Run any coding agent from your phone, desktop, or terminal. Self-hosted, multi-provider, open source."
/>
);

View File

@@ -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 {