feat: add landing page section components

This commit is contained in:
codebanditssss 2026-06-03 21:57:38 +05:30
parent fbaa6d17a5
commit d0c053e99e
16 changed files with 2298 additions and 0 deletions

View File

@ -0,0 +1,54 @@
export function LandingAbout() {
return (
<div className="bg-[radial-gradient(ellipse_at_top,rgba(255,240,220,0.015)_0%,transparent_70%)]">
<section className="landing-reveal py-[100px] px-6 max-w-[72rem] mx-auto">
<div className="text-xs tracking-[0.15em] uppercase text-[var(--landing-muted-dim)] mb-6 font-mono">
The problem
</div>
<h2 className="font-sans font-[680] text-[clamp(1.375rem,3vw,2rem)] leading-[1.1] tracking-[-1.5px] mb-10 max-w-[48rem]">
You&apos;re running AI agents in 10 browser tabs.{" "}
<span className="text-[var(--landing-muted)]">
Checking if PRs landed. Re-running failed CI. Copy-pasting error logs.
</span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
<p className="text-[0.9375rem] text-[var(--landing-muted)] leading-[1.8] max-w-[28rem]">
Agent Orchestrator replaces that with one YAML file. Point it at
your GitHub issues, pick your agents, and walk away. Each agent
spawns in its own git worktree, creates PRs, fixes CI failures,
addresses review comments, and moves toward merge. If you are new, start with the <a href="/docs/" className="underline decoration-[var(--landing-border-default)] underline-offset-4 hover:text-white">docs quickstart and configuration guides</a>.
</p>
{/* Config preview — show how simple setup is */}
<div className="landing-card rounded-2xl overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-[var(--landing-border-subtle)]">
<div className="w-2 h-2 rounded-full bg-[rgba(255,240,220,0.12)]" />
<div className="w-2 h-2 rounded-full bg-[rgba(255,240,220,0.12)]" />
<div className="w-2 h-2 rounded-full bg-[rgba(255,240,220,0.12)]" />
<span className="ml-1.5 font-mono text-[0.5625rem] text-[var(--landing-muted-dim)]">
agent-orchestrator.yaml
</span>
</div>
<pre className="px-5 py-4 font-mono text-[0.75rem] leading-[1.9] overflow-x-auto">
<span className="text-[var(--landing-muted-dim)]">agent:</span>{" "}
<span className="text-[var(--landing-fg)]">claude-code</span>
{"\n"}
<span className="text-[var(--landing-muted-dim)]">tracker:</span>{" "}
<span className="text-[var(--landing-fg)]">github</span>
{"\n"}
<span className="text-[var(--landing-muted-dim)]">workspace:</span>{" "}
<span className="text-[var(--landing-fg)]">worktree</span>
{"\n"}
<span className="text-[var(--landing-muted-dim)]">runtime:</span>{" "}
<span className="text-[var(--landing-fg)]">tmux</span>
{"\n"}
<span className="text-[var(--landing-muted-dim)]">notifier:</span>{" "}
<span className="text-[var(--landing-fg)]">slack</span>
</pre>
</div>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,51 @@
const agents = [
{
name: "Claude Code",
src: "/docs/logos/claude-code.svg",
alt: "Anthropic",
},
{
name: "Codex",
src: "/docs/logos/codex.svg",
alt: "OpenAI",
},
{
name: "Cursor",
src: "/docs/logos/cursor.svg",
alt: "Cursor",
},
{
name: "Aider",
src: "https://aider.chat/assets/logo.svg",
alt: "Aider",
},
{
name: "OpenCode",
src: "/docs/logos/opencode.svg",
alt: "OpenCode",
},
];
export function LandingAgentsBar() {
return (
<div className="landing-reveal text-center px-6 pt-[60px]">
<div className="text-[0.6875rem] tracking-[0.15em] uppercase text-[var(--landing-muted)] opacity-40 mb-5">
Works with your favorite AI agents
</div>
<div className="flex items-center justify-center gap-6 flex-wrap">
{agents.map((agent) => (
<div key={agent.name} className="flex flex-col items-center gap-2">
<img
src={agent.src}
alt={agent.alt}
className="w-8 h-8 rounded-md object-contain"
/>
<div className="text-[0.6875rem] font-mono text-[var(--landing-muted)] opacity-50">
{agent.name}
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
export function LandingCTA() {
return (
<section className="text-center py-40 px-6 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.015)_0%,transparent_60%)]">
<div className="landing-reveal">
<p className="text-[var(--landing-muted)] opacity-50 text-2xl font-sans font-[680] tracking-tight mb-4">
Stop babysitting.
</p>
<h2 className="font-sans font-[680] tracking-tight font-normal text-[clamp(1.375rem,3vw,2rem)] leading-[1.05] tracking-[-2px] mb-4">
Start <em className="italic text-[var(--landing-muted)]">orchestrating.</em>
</h2>
<div className="landing-card inline-flex items-center gap-3 rounded-lg px-6 py-3 font-mono text-[0.9375rem] text-white mb-8">
<span className="text-[var(--landing-muted)] opacity-40">$</span> npm i -g @aoagents/ao
</div>
<div className="flex items-center justify-center gap-4 flex-wrap">
<a
href="/docs"
className="landing-card rounded-lg px-6 py-3 text-[0.9375rem] text-[var(--landing-muted)] no-underline transition-colors hover:text-white"
>
Read Docs
</a>
<a
href="https://github.com/ComposioHQ/agent-orchestrator"
target="_blank"
rel="noopener noreferrer"
className="liquid-glass-solid rounded-lg px-6 py-3 text-[0.9375rem] no-underline transition-transform hover:scale-[1.03]"
>
View on GitHub
</a>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,64 @@
const rows = [
{ feature: "Web-based dashboard", others: "Native Mac apps only" },
{ feature: "Open source (MIT)", others: "Closed source" },
{ feature: "Multi-agent (Claude, Codex, Aider, OpenCode)", others: "Single agent" },
{ feature: "Auto CI failure recovery", others: "Manual" },
{ feature: "Plugin architecture (7 slots)", others: "Fixed integrations" },
{ feature: "Git worktree isolation", others: "Shared workspace" },
];
export function LandingDifferentiators() {
return (
<section className="py-[100px] px-6 max-w-[72rem] mx-auto">
<div className="landing-reveal">
<div className="text-xs tracking-[0.15em] uppercase text-[var(--landing-muted)] opacity-60 mb-6">
Why Agent Orchestrator
</div>
<h2 className="font-sans font-[680] tracking-tight font-normal text-[clamp(1.375rem,3vw,2rem)] leading-[1.1] tracking-[-1.5px] mb-6 max-w-[42rem]">
The only{" "}
<em className="italic text-[var(--landing-muted)]">open-source, web-based</em>{" "}
agent orchestrator
</h2>
<p className="text-[0.9375rem] text-[var(--landing-muted)] leading-[1.7] max-w-[36rem] mb-12">
Conductor, T3 Code, and Codex App are native Mac apps. AO runs in
your browser, works on any OS, and you can self-host or extend it.
</p>
</div>
<div className="landing-reveal landing-card rounded-2xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--landing-border-subtle)]">
<th className="text-left px-6 py-4 font-mono text-[0.625rem] tracking-[0.1em] uppercase text-[var(--landing-muted)] opacity-40">
Feature
</th>
<th className="text-center px-6 py-4 font-mono text-[0.625rem] tracking-[0.1em] uppercase text-[var(--landing-fg)] opacity-80">
AO
</th>
<th className="text-center px-6 py-4 font-mono text-[0.625rem] tracking-[0.1em] uppercase text-[var(--landing-muted)] opacity-40">
Others
</th>
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
key={row.feature}
className={i < rows.length - 1 ? "border-b border-[var(--landing-border-subtle)]" : ""}
>
<td className="px-6 py-3.5 text-[0.8125rem] text-[var(--landing-fg)]/80">
{row.feature}
</td>
<td className="px-6 py-3.5 text-center text-[rgba(134,239,172,0.8)]">
</td>
<td className="px-6 py-3.5 text-center text-[0.75rem] text-[var(--landing-muted)] opacity-40">
{row.others}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}

View File

@ -0,0 +1,560 @@
"use client";
import { useEffect, useRef, useState } from "react";
type DemoKind = "parallel" | "recovery" | "plugins" | "dashboard";
const features: { n: string; title: string; desc: string; demo: DemoKind }[] = [
{
n: "01",
title: "Multi-agent execution",
desc: "Run Claude Code, Codex, Cursor, Aider, and OpenCode in parallel. Each agent in its own git worktree, branch, and context.",
demo: "parallel",
},
{
n: "02",
title: "Autonomous CI + review handling",
desc: "CI fails? The agent reads the logs and pushes a fix. Review comments land? The agent addresses them. You sleep, your agents ship.",
demo: "recovery",
},
{
n: "03",
title: "Seven swappable slots",
desc: "Runtime, Agent, Workspace, Tracker, SCM, Notifier, Terminal. Use tmux or process. GitHub or GitLab. Slack or webhooks.",
demo: "plugins",
},
{
n: "04",
title: "Real-time Kanban + terminal",
desc: "Every agent's state in one view. Attach to any terminal via the browser. SSE updates every 5 seconds. WebSocket for live I/O.",
demo: "dashboard",
},
];
// The feature's animated demo — the stacked back panel + a smaller front peek,
// reused as-is from the original switcher so each card stays rich.
function FeatureDemo({ kind }: { kind: DemoKind }) {
return (
<div className="relative h-[460px]">
<div className="absolute top-0 right-0 w-[90%] h-[390px] bg-[rgba(255,240,220,0.04)] rounded-2xl border border-[var(--landing-border-subtle)] overflow-hidden landing-feat-card">
{kind === "parallel" && <ParallelBack />}
{kind === "recovery" && <RecoveryBack />}
{kind === "plugins" && <PluginsBack />}
{kind === "dashboard" && <DashboardBack />}
</div>
<div className="absolute bottom-0 left-0 w-[58%] h-[230px] bg-[rgba(255,240,220,0.06)] rounded-2xl border border-[var(--landing-border-default)] overflow-hidden landing-feat-card-front">
{kind === "parallel" && <ParallelFront />}
{kind === "recovery" && <RecoveryFront />}
{kind === "plugins" && <PluginsFront />}
{kind === "dashboard" && <DashboardFront />}
</div>
</div>
);
}
// Sticky offset from the top of the viewport where each card pins (leaves room
// for the fixed nav); each successive card pins STACK_GAP lower so the tops peek.
const BASE_TOP = 120;
const STACK_GAP = 26;
export function LandingFeatures() {
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const [stack, setStack] = useState(false);
// Scroll-stack only on desktop; on narrow screens cards read as a plain list.
useEffect(() => {
const mq = window.matchMedia("(min-width: 768px)");
const apply = () => setStack(mq.matches);
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, []);
// As later cards pin on top, shrink + dim the cards beneath them so the deck
// reads as a stack. CSS transition smooths the steps; rAF throttles scroll.
useEffect(() => {
const els = cardRefs.current;
if (!stack) {
els.forEach((el) => {
if (el) {
el.style.transform = "";
el.style.opacity = "";
}
});
return;
}
let raf = 0;
const update = () => {
raf = 0;
els.forEach((el, i) => {
if (!el) return;
let depth = 0;
for (let j = i + 1; j < els.length; j++) {
const ej = els[j];
if (ej && ej.getBoundingClientRect().top <= BASE_TOP + j * STACK_GAP + 0.5) {
depth += 1;
}
}
el.style.transform = `scale(${1 - depth * 0.05})`;
el.style.opacity = `${Math.max(1 - depth * 0.16, 0.55)}`;
});
};
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(update);
};
update();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
if (raf) cancelAnimationFrame(raf);
};
}, [stack]);
return (
<section className="py-[96px] px-6 md:px-16 max-w-[68rem] mx-auto" id="features">
<div className="landing-reveal text-center">
<span className="inline-block border border-[var(--landing-border-strong)] rounded-full px-4 py-[5px] text-[13px] text-[var(--landing-muted)] mb-5">
Features
</span>
</div>
<h2
className="landing-reveal text-center mx-auto mb-[72px] max-w-[36rem] text-[var(--landing-fg)]"
style={{
fontFamily: "var(--font-instrument-serif), ui-serif, Georgia, serif",
fontSize: "clamp(2.25rem, 5vw, 3.5rem)",
lineHeight: 1.08,
fontWeight: 400,
}}
>
A unified orchestrator <em className="italic text-[var(--landing-muted)]">that scales.</em>
</h2>
<div className="relative">
{features.map((f, i) => (
<div
key={f.n}
ref={(el) => {
cardRefs.current[i] = el;
}}
className="landing-card rounded-2xl grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 items-center overflow-hidden"
style={{
padding: "clamp(1.5rem, 3vw, 2.5rem)",
marginBottom: "1.5rem",
transformOrigin: "center top",
transition: "transform 0.4s ease, opacity 0.4s ease, border-color 0.2s ease",
...(stack
? { position: "sticky", top: `${BASE_TOP + i * STACK_GAP}px`, zIndex: i + 1 }
: null),
}}
>
<div>
<div className="font-mono text-xs tracking-[0.1em] text-[var(--landing-muted)] opacity-50 mb-4">
{f.n}
</div>
<h3 className="font-sans font-[680] tracking-tight text-[1.375rem] mb-4">
{f.title}
</h3>
<p className="text-[var(--landing-muted)] text-[0.9375rem] leading-[1.7] max-w-[28rem]">
{f.desc}
</p>
</div>
<FeatureDemo kind={f.demo} />
</div>
))}
</div>
</section>
);
}
/* ──────── 01 · Parallel ──────── */
function ParallelBack() {
const agents = [
{ name: "claude-code", task: "#42 auth", color: "rgba(255,159,102,0.7)", dur: 3.4, delay: 0 },
{ name: "codex", task: "#43 pagination", color: "rgba(134,239,172,0.65)", dur: 4.2, delay: 0.5 },
{ name: "aider", task: "#44 rate limit", color: "rgba(167,139,250,0.65)", dur: 3.6, delay: 1.0 },
{ name: "opencode", task: "#46 db refactor", color: "rgba(96,165,250,0.65)", dur: 4.8, delay: 0.3 },
];
return (
<div className="p-5 h-full flex flex-col">
<div className="flex items-center justify-between mb-4">
<span className="font-mono text-[0.625rem] tracking-[0.12em] uppercase text-[var(--landing-muted-dim)]">
4 sessions · parallel
</span>
<span className="font-mono text-[0.5625rem] text-[var(--landing-muted-dim)] flex items-center gap-1.5">
<span className="w-1 h-1 rounded-full bg-[rgba(134,239,172,0.7)] landing-sse-pulse" />
live
</span>
</div>
<div className="grid grid-cols-2 gap-2.5 flex-1">
{agents.map((a) => (
<div
key={a.name}
className="bg-[rgba(255,240,220,0.035)] border border-[var(--landing-border-subtle)] rounded-xl p-3 flex flex-col"
>
<div className="flex items-center gap-1.5 mb-1">
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: a.color }}
/>
<span className="font-mono text-[0.6875rem] text-[var(--landing-fg)]/85 truncate">
{a.name}
</span>
</div>
<div className="font-mono text-[0.625rem] text-[var(--landing-muted)] opacity-65 mb-auto truncate">
{a.task}
</div>
<div className="h-[3px] rounded-full bg-[var(--landing-border-subtle)] overflow-hidden mt-2">
<div
className="h-full landing-feature-bar"
style={{
background: a.color,
animationDuration: `${a.dur}s`,
animationDelay: `${a.delay}s`,
}}
/>
</div>
</div>
))}
</div>
</div>
);
}
function ParallelFront() {
const fleet = [
{ name: "claude-code", color: "rgba(255,159,102,0.85)" },
{ name: "codex", color: "rgba(134,239,172,0.75)" },
{ name: "aider", color: "rgba(167,139,250,0.75)" },
{ name: "opencode", color: "rgba(96,165,250,0.75)" },
{ name: "cursor", color: "rgba(244,114,182,0.65)" },
];
return (
<div className="flex flex-col p-5 w-full">
<div className="font-mono text-[0.5625rem] tracking-[0.12em] uppercase text-[var(--landing-muted-dim)] mb-2.5">
Fleet · 5 agents
</div>
<div className="flex flex-col gap-1.5">
{fleet.map((a) => (
<div
key={a.name}
className="flex items-center gap-2 bg-[rgba(255,240,220,0.04)] border border-[var(--landing-border-subtle)] rounded-md px-2.5 py-1.5"
>
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: a.color }}
/>
<span className="font-mono text-[0.6875rem] text-[var(--landing-fg)]/85 truncate">
{a.name}
</span>
</div>
))}
</div>
</div>
);
}
/* ──────── 02 · Recovery ──────── */
const recoveryStages: { time: string; text: string; kind: "info" | "fail" | "fix" | "ok" }[] = [
{ time: "10:42", text: "agent.spawn → s-312", kind: "info" },
{ time: "10:43", text: "✗ tests/auth failed", kind: "fail" },
{ time: "10:44", text: "agent.investigate()", kind: "info" },
{ time: "10:44", text: "patch · re-running ci", kind: "fix" },
{ time: "10:45", text: "✓ tests/auth (48/48)", kind: "ok" },
{ time: "10:45", text: "✗ lint failed", kind: "fail" },
{ time: "10:46", text: "patch · eslint --fix", kind: "fix" },
{ time: "10:47", text: "✓ lint passed", kind: "ok" },
{ time: "10:47", text: "● ready to merge", kind: "ok" },
];
function RecoveryBack() {
const [count, setCount] = useState(3);
useEffect(() => {
const id = setInterval(() => {
setCount((c) => (c >= recoveryStages.length ? 3 : c + 1));
}, 1000);
return () => clearInterval(id);
}, []);
const visible = recoveryStages.slice(0, count);
return (
<div className="p-5 h-full flex flex-col font-mono text-[0.6875rem]">
<div className="flex items-center justify-between mb-3 pb-3 border-b border-[var(--landing-border-subtle)]">
<span className="text-[var(--landing-fg)]/80">PR #312 · feat/user-auth</span>
<span className="text-[0.5625rem] uppercase tracking-[0.1em] text-[var(--landing-muted-dim)]">
healing
</span>
</div>
<div className="flex-1 space-y-1.5 overflow-hidden">
{visible.map((s, i) => {
const isLast = i === visible.length - 1;
const color =
s.kind === "fail"
? "text-[rgba(248,113,113,0.85)]"
: s.kind === "ok"
? "text-[rgba(134,239,172,0.85)]"
: s.kind === "fix"
? "text-[rgba(251,191,36,0.85)]"
: "text-[var(--landing-muted)]";
return (
<div
key={`${i}-${s.text}`}
className={`flex items-baseline gap-2.5 ${isLast ? "landing-stream-line" : ""}`}
>
<span className="text-[var(--landing-muted-dim)] opacity-50 w-9 shrink-0">
{s.time}
</span>
<span className={`${color} truncate`}>{s.text}</span>
</div>
);
})}
</div>
</div>
);
}
function RecoveryFront() {
return (
<div className="grid grid-cols-2 gap-2.5 p-5 w-full h-full items-stretch">
<div className="bg-[rgba(248,113,113,0.05)] border border-[rgba(248,113,113,0.18)] rounded-xl p-3 flex flex-col items-center justify-center gap-1">
<span className="font-mono text-[0.5rem] tracking-[0.12em] uppercase text-[rgba(248,113,113,0.7)]">
before
</span>
<span className="text-[1.75rem] leading-none text-[rgba(248,113,113,0.85)]"></span>
<span className="font-mono text-[0.625rem] text-[var(--landing-fg)]/70">12/48</span>
</div>
<div className="bg-[rgba(134,239,172,0.05)] border border-[rgba(134,239,172,0.2)] rounded-xl p-3 flex flex-col items-center justify-center gap-1">
<span className="font-mono text-[0.5rem] tracking-[0.12em] uppercase text-[rgba(134,239,172,0.7)]">
after
</span>
<span className="text-[1.75rem] leading-none text-[rgba(134,239,172,0.85)]"></span>
<span className="font-mono text-[0.625rem] text-[var(--landing-fg)]/70">48/48</span>
</div>
</div>
);
}
/* ──────── 03 · Plugins ──────── */
function PluginsBack() {
const slots = [
{ slot: "agent", values: ["claude-code", "codex", "aider", "opencode"] },
{ slot: "tracker", values: ["github", "linear", "gitlab"] },
{ slot: "runtime", values: ["tmux", "process"] },
{ slot: "workspace", values: ["worktree", "clone"] },
{ slot: "scm", values: ["github", "gitlab"] },
{ slot: "notifier", values: ["slack", "webhook", "desktop"] },
{ slot: "terminal", values: ["iterm2", "web"] },
];
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1600);
return () => clearInterval(id);
}, []);
return (
<div className="p-5 h-full flex flex-col">
<div className="flex items-center justify-between mb-3 pb-3 border-b border-[var(--landing-border-subtle)]">
<span className="font-mono text-[0.6875rem] text-[var(--landing-fg)]/80">
agent-orchestrator.yaml
</span>
<span className="font-mono text-[0.5625rem] tracking-[0.1em] uppercase text-[var(--landing-muted-dim)]">
7 slots
</span>
</div>
<div className="flex flex-col gap-1.5 font-mono text-[0.6875rem]">
{slots.map((s, i) => {
const val = s.values[(tick + i) % s.values.length];
return (
<div key={s.slot} className="flex items-center gap-3">
<span className="text-[var(--landing-muted-dim)] w-[4.5rem] shrink-0">
{s.slot}:
</span>
<span
key={val}
className="landing-chip-swap inline-block px-2 py-[1px] rounded-md bg-[rgba(255,240,220,0.05)] text-[var(--landing-fg)]/85 border border-[var(--landing-border-subtle)]"
>
{val}
</span>
</div>
);
})}
</div>
</div>
);
}
function PluginsFront() {
const pairs = [
{ from: "tmux", to: "process" },
{ from: "github", to: "linear" },
{ from: "slack", to: "webhook" },
{ from: "worktree", to: "clone" },
];
const [idx, setIdx] = useState(0);
useEffect(() => {
const id = setInterval(() => setIdx((i) => (i + 1) % pairs.length), 1800);
return () => clearInterval(id);
}, []);
const p = pairs[idx];
return (
<div className="flex flex-col items-center justify-center gap-3 p-5 w-full h-full">
<span className="font-mono text-[0.5625rem] tracking-[0.12em] uppercase text-[var(--landing-muted-dim)]">
swap
</span>
<div className="flex items-center gap-3">
<span
key={`from-${idx}`}
className="landing-chip-swap font-mono text-[0.8125rem] px-2.5 py-1 rounded-md bg-[rgba(255,240,220,0.05)] text-[var(--landing-fg)]/85 border border-[var(--landing-border-subtle)]"
>
{p.from}
</span>
<span className="text-[var(--landing-muted)] text-base"></span>
<span
key={`to-${idx}`}
className="landing-chip-swap font-mono text-[0.8125rem] px-2.5 py-1 rounded-md bg-[rgba(255,240,220,0.08)] text-[var(--landing-fg)]/90 border border-[var(--landing-border-default)]"
>
{p.to}
</span>
</div>
</div>
);
}
/* ──────── 04 · Dashboard ──────── */
type KanbanCard = {
id: number;
col: 0 | 1 | 2;
title: string;
agent: string;
color: string;
};
function DashboardBack() {
const [cards, setCards] = useState<KanbanCard[]>([
{ id: 1, col: 0, title: "Add user auth", agent: "claude-code", color: "rgba(255,159,102,0.7)" },
{ id: 2, col: 0, title: "Fix pagination", agent: "codex", color: "rgba(134,239,172,0.65)" },
{ id: 3, col: 1, title: "Add rate limit", agent: "aider", color: "rgba(167,139,250,0.65)" },
{ id: 4, col: 2, title: "Refactor DB", agent: "opencode", color: "rgba(96,165,250,0.65)" },
]);
useEffect(() => {
const id = setInterval(() => {
setCards((prev) => {
const advanceable = prev.filter((c) => c.col < 2);
if (advanceable.length === 0) {
return prev.map((c) => ({ ...c, col: 0 as 0 | 1 | 2 }));
}
const oldest = advanceable[0];
return prev.map((c) =>
c.id === oldest.id ? { ...c, col: (c.col + 1) as 0 | 1 | 2 } : c,
);
});
}, 2400);
return () => clearInterval(id);
}, []);
const cols = ["Working", "Review", "Merged"];
return (
<div className="p-5 h-full flex flex-col">
<div className="flex items-center justify-between mb-3 pb-3 border-b border-[var(--landing-border-subtle)]">
<span className="font-mono text-[0.6875rem] text-[var(--landing-fg)]/80">
my-saas-app · 4 sessions
</span>
<span className="font-mono text-[0.5625rem] tracking-[0.1em] uppercase text-[var(--landing-muted-dim)] flex items-center gap-1.5">
<span className="w-1 h-1 rounded-full bg-[rgba(134,239,172,0.7)] landing-sse-pulse" />
sse
</span>
</div>
<div className="grid grid-cols-3 gap-2 flex-1">
{cols.map((name, col) => (
<div key={name} className="space-y-1.5">
<div className="font-mono text-[0.5rem] tracking-[0.12em] uppercase text-[var(--landing-muted-dim)] mb-1.5">
{name}
</div>
{cards
.filter((c) => c.col === col)
.map((c) => (
<div
key={c.id}
className="bg-[rgba(255,240,220,0.035)] border border-[var(--landing-border-subtle)] rounded-lg p-2 text-[0.625rem] leading-tight landing-stream-line"
>
<div className="text-[var(--landing-fg)]/85 truncate mb-1">{c.title}</div>
<div className="flex items-center gap-1">
<span
className="w-1 h-1 rounded-full shrink-0"
style={{ background: c.color }}
/>
<span className="font-mono text-[0.5rem] text-[var(--landing-muted-dim)] truncate">
{c.agent}
</span>
</div>
</div>
))}
</div>
))}
</div>
</div>
);
}
const streamPool = [
"tests/auth.py::test_login",
"tests/api.py::test_pagination",
"tests/db.py::test_migration",
"tests/queue.py::test_dequeue",
"tests/auth.py::test_logout",
"tests/api.py::test_cursor",
"tests/db.py::test_index",
"tests/queue.py::test_retry",
];
function DashboardFront() {
const [stream, setStream] = useState(() =>
streamPool.slice(0, 4).map((text, i) => ({ id: i, text, exiting: false })),
);
const nextRef = useRef(4);
useEffect(() => {
const id = setInterval(() => {
setStream((prev) => {
const marked = prev.map((l, i) => (i === 0 ? { ...l, exiting: true } : l));
const next = [
...marked,
{
id: nextRef.current,
text: streamPool[nextRef.current % streamPool.length],
exiting: false,
},
];
nextRef.current += 1;
return next;
});
setTimeout(() => {
setStream((prev) => prev.filter((l) => !l.exiting));
}, 240);
}, 1300);
return () => clearInterval(id);
}, []);
return (
<div className="p-4 w-full h-full font-mono flex flex-col">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-[var(--landing-border-subtle)]">
<span className="text-[0.6875rem] text-[var(--landing-fg)]/80">s-003 · attached</span>
<span className="text-[0.5rem] text-[var(--landing-muted-dim)]">tail -f</span>
</div>
<div className="space-y-[2px] text-[0.625rem] text-[var(--landing-muted)] leading-[1.6] flex-1 overflow-hidden">
{stream.map((l) => (
<div
key={l.id}
className={`truncate transition-opacity duration-200 ${
l.exiting ? "opacity-0" : "landing-stream-line"
}`}
>
<span className="text-[rgba(134,239,172,0.7)]"></span>{" "}
<span className="opacity-70">{l.text}</span>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,102 @@
interface LandingHeroProps {
starsLabel: string;
}
export function LandingHero({ starsLabel }: LandingHeroProps) {
return (
<div className="relative min-h-screen overflow-hidden">
<section className="relative z-10 flex flex-col items-center justify-center text-center px-6 pt-32 pb-20 min-h-screen">
<div className="landing-fade-rise landing-card inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs text-[var(--landing-muted)] mb-8">
<span className="w-1.5 h-1.5 rounded-full bg-[rgba(134,239,172,0.7)]" />
Open Source · MIT Licensed · {starsLabel} GitHub Stars
</div>
<h1 className="landing-fade-rise font-sans font-[680] text-[clamp(1.75rem,4vw,2.75rem)] leading-[1] tracking-[-2px] max-w-[56rem]">
Run 30 AI agents in parallel.
<br />
<span className="text-[var(--landing-muted)]">One dashboard.</span>
</h1>
<p className="landing-fade-rise-d1 text-[var(--landing-muted)] text-[0.9375rem] max-w-[38rem] mt-6 leading-[1.7]">
Agent Orchestrator spawns Claude Code, Codex, Cursor, Aider, and OpenCode
in isolated git worktrees. Each agent gets its own branch, creates PRs,
fixes CI, and addresses reviews autonomously.
</p>
<div className="landing-fade-rise-d2 flex items-center gap-3 mt-10 flex-wrap justify-center">
<div className="landing-card rounded-lg px-6 py-3 font-mono text-sm">
<span className="text-[var(--landing-muted)] opacity-40">$</span> npx @aoagents/ao start
</div>
<a
href="/docs"
className="landing-card rounded-lg px-6 py-3 text-sm no-underline transition-colors hover:text-white"
>
Read Docs
</a>
<a
href="https://github.com/ComposioHQ/agent-orchestrator"
target="_blank"
rel="noopener noreferrer"
className="liquid-glass-solid rounded-lg px-6 py-3 text-sm no-underline transition-colors"
>
View on GitHub
</a>
</div>
<div className="landing-fade-rise-d2 w-full max-w-[72rem] mt-16">
<div style={{ maxWidth: "62rem", margin: "0 auto" }}>
{/* Laptop screen / lid */}
<div
className="rounded-[14px]"
style={{
padding: 10,
background: "linear-gradient(180deg, #211f1c 0%, #161513 100%)",
border: "1px solid var(--landing-border-default)",
boxShadow: "0 30px 70px -24px rgba(0,0,0,0.65)",
}}
>
<div
className="overflow-hidden"
style={{
borderRadius: 6,
aspectRatio: "16 / 10",
background: "#0c0b0a",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/hero-dashboard.png"
alt="Agent Orchestrator dashboard — live agent sessions flowing from work to review to merge"
className="w-full h-full"
style={{ objectFit: "cover", objectPosition: "top", display: "block" }}
/>
</div>
</div>
{/* Laptop base / hinge */}
<div style={{ width: "112%", marginLeft: "-6%" }}>
<div
style={{
height: 16,
background: "linear-gradient(180deg, #2a2823 0%, #131210 100%)",
borderRadius: "0 0 12px 12px",
borderTop: "1px solid var(--landing-border-default)",
position: "relative",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: "50%",
transform: "translateX(-50%)",
width: "15%",
height: 6,
background: "#0c0b0a",
borderRadius: "0 0 8px 8px",
}}
/>
</div>
</div>
</div>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,316 @@
"use client";
import { useEffect, useRef, useState } from "react";
const DURATION_MS = 3000;
const steps = [
{
n: "01",
title: "Configure & assign",
titleEm: "assign",
desc: "Point Agent Orchestrator at your repo with a YAML config. Choose your agent, set up trackers and notifiers. One file, full control.",
tags: ["YAML", "Plugins", "Trackers"],
kind: "cli" as const,
},
{
n: "02",
title: "Agents work",
titleEm: "work",
desc: "Each agent spawns in an isolated worktree. They write code, create PRs, run tests, and fix failures. Monitor everything from the live dashboard, or let them run.",
tags: ["Worktrees", "Live dashboard", "Parallel"],
kind: "dashboard" as const,
},
{
n: "03",
title: "PRs land",
titleEm: "land",
desc: "Agents create pull requests, address review comments, fix CI failures, and get them to mergeable state. Your morning starts with merged PRs, not a backlog.",
tags: ["Pull requests", "CI fixes", "Review"],
kind: "prs" as const,
},
];
export function LandingHowItWorks() {
const [active, setActive] = useState(0);
const [progress, setProgress] = useState(0);
const [isDesktop, setIsDesktop] = useState(true);
const pausedRef = useRef(false);
const startRef = useRef<number | null>(null);
useEffect(() => {
const mq = window.matchMedia("(min-width: 768px)");
const apply = () => setIsDesktop(mq.matches);
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, []);
useEffect(() => {
let raf = 0;
const tick = (now: number) => {
if (startRef.current === null) startRef.current = now;
if (!pausedRef.current) {
const p = Math.min((now - startRef.current) / DURATION_MS, 1);
setProgress(p);
if (p >= 1) {
startRef.current = now;
setActive((a) => (a + 1) % steps.length);
setProgress(0);
}
} else {
startRef.current = now - progress * DURATION_MS;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
const select = (i: number) => {
if (i === active) return;
startRef.current = null;
setProgress(0);
setActive(i);
};
return (
<section className="py-[120px] px-6 max-w-[72rem] mx-auto" id="how">
<div className="landing-reveal">
<div className="text-xs tracking-[0.15em] uppercase text-[var(--landing-muted)] opacity-60 mb-6">
Process
</div>
<h2 className="font-sans font-[680] tracking-tight font-normal text-[clamp(1.375rem,3vw,2rem)] leading-[1.05] tracking-[-1.5px] mb-6">
Three steps to{" "}
<em className="italic text-[var(--landing-muted)]">orchestration</em>
</h2>
</div>
<div
className="landing-reveal mt-16 flex flex-col md:flex-row"
style={isDesktop ? { minHeight: 540 } : undefined}
onMouseEnter={() => (pausedRef.current = true)}
onMouseLeave={() => (pausedRef.current = false)}
>
{steps.map((step, i) => {
const isActive = i === active;
return (
<div
key={step.n}
role="button"
tabIndex={0}
aria-expanded={isActive}
onClick={() => select(i)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
select(i);
}
}}
className="relative min-w-0 cursor-pointer overflow-hidden border-l border-[var(--landing-border-subtle)] pl-7 pr-5 py-2 first:border-l-0 first:pl-0 md:first:pl-7"
style={{
flex: isDesktop
? isActive
? "1 1 0%"
: "0 1 15rem"
: "0 0 auto",
transition: "flex 0.6s cubic-bezier(0.22,1,0.36,1)",
}}
>
{/* Header — always visible */}
<div
className="font-mono text-[2.75rem] leading-none font-[680] tracking-tight mb-5"
style={{
color: "var(--landing-muted)",
opacity: isActive ? 0.85 : 0.32,
transition: "opacity 0.4s ease",
}}
>
{step.n}
</div>
<h3
className="font-sans font-[680] tracking-tight text-[1.375rem] leading-[1.15]"
style={{
color: isActive
? "var(--landing-fg)"
: "var(--landing-muted)",
transition: "color 0.4s ease",
maxWidth: isActive ? "100%" : "11rem",
}}
>
{step.title.replace(` ${step.titleEm}`, "")}{" "}
<em className="italic text-[var(--landing-muted)]">
{step.titleEm}
</em>
</h3>
{/* Expanding body */}
<div
aria-hidden={!isActive}
style={{
opacity: isActive ? 1 : 0,
maxHeight: isActive ? (isDesktop ? 1000 : 900) : 0,
transition: isActive
? "opacity 0.5s ease 0.15s, max-height 0.6s ease"
: "opacity 0.25s ease, max-height 0.4s ease",
overflow: "hidden",
}}
>
<div className="flex gap-5 pt-7" style={{ minWidth: isDesktop ? "30rem" : undefined }}>
{/* Vertical progress bar */}
<div
className="shrink-0 w-[3px] rounded-full overflow-hidden self-stretch"
style={{ background: "var(--landing-border-default)" }}
>
<div
style={{
height: `${(isActive ? progress : 0) * 100}%`,
background: "var(--landing-accent)",
transition: "height 0.08s linear",
}}
/>
</div>
<div className="min-w-0">
<p className="text-[var(--landing-muted)] text-[0.9375rem] leading-[1.7] max-w-[30rem] mb-6">
{step.desc}
</p>
<div className="mb-6">
{step.kind === "cli" && <CliDemo />}
{step.kind === "dashboard" && <DashboardDemo />}
{step.kind === "prs" && <PrsDemo />}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-[0.6875rem] tracking-[0.05em] uppercase text-[var(--landing-muted)] opacity-60">
{step.tags.map((t, ti) => (
<span key={t} className="flex items-center gap-3">
{ti > 0 && <span className="opacity-40">·</span>}
{t}
</span>
))}
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</section>
);
}
function CliDemo() {
return (
<div className="bg-black/40 rounded-xl overflow-hidden font-mono text-[0.8125rem]">
<div className="flex items-center gap-2 px-4 py-3 bg-[var(--landing-surface)]">
<div className="w-2.5 h-2.5 rounded-full bg-[rgba(255,240,220,0.12)]" />
<div className="w-2.5 h-2.5 rounded-full bg-[rgba(255,240,220,0.12)]" />
<div className="w-2.5 h-2.5 rounded-full bg-[rgba(255,240,220,0.12)]" />
</div>
<div className="px-5 py-4 leading-[1.8]">
<div>
<span className="text-[var(--landing-muted)]">$</span>{" "}
<span className="text-white">ao batch-spawn 42 43 44 45 46</span>
</div>
<div className="text-[var(--landing-muted)] opacity-60">&nbsp;</div>
<div className="text-[var(--landing-muted)] opacity-60"> Loading config from agent-orchestrator.yaml</div>
<div className="text-[var(--landing-muted)] opacity-60"> Resolving 5 issues from GitHub</div>
<div className="text-[var(--landing-muted)] opacity-60"> Spawning sessions in worktrees...</div>
<div className="text-[rgba(134,239,172,0.8)]"> Session s-001 spawned issue #42</div>
<div className="text-[rgba(134,239,172,0.8)]"> Session s-002 spawned issue #43</div>
<div className="text-[rgba(134,239,172,0.8)]"> Session s-003 spawned issue #44</div>
<div className="text-[rgba(134,239,172,0.8)]"> Session s-004 spawned issue #45</div>
<div className="text-[rgba(134,239,172,0.8)]"> Session s-005 spawned issue #46</div>
<div className="text-[var(--landing-muted)] opacity-60">&nbsp;</div>
<div>
<span className="landing-agent-dot mr-1.5" />
<span className="text-[var(--landing-muted)] opacity-60">5 agents working · Dashboard http://localhost:3000</span>
</div>
</div>
</div>
);
}
function DashboardDemo() {
return (
<div className="rounded-2xl overflow-hidden bg-black/30">
<div className="flex items-center gap-2 px-4 py-2.5 bg-[var(--landing-card-bg)] border-b border-[var(--landing-border-subtle)]">
<div className="w-2.5 h-2.5 rounded-full bg-[rgba(255,240,220,0.12)]" />
<div className="w-2.5 h-2.5 rounded-full bg-[rgba(255,240,220,0.12)]" />
<div className="w-2.5 h-2.5 rounded-full bg-[rgba(255,240,220,0.12)]" />
<span className="text-[0.6875rem] text-[var(--landing-muted)] opacity-50 ml-2">my-saas-app · 5 sessions</span>
</div>
<div className="grid grid-cols-4 gap-2 p-3">
<DashColumn title="Working" cards={[
{ title: "Add user auth flow", meta: "#42 · feat/auth", agent: "claude-code" },
{ title: "Fix pagination bug", meta: "#43 · fix/pagination", agent: "codex" },
]} />
<DashColumn title="Pending" cards={[
{ title: "Add rate limiting", meta: "#44 · PR #312", agent: "aider" },
]} />
<DashColumn title="Review" cards={[
{ title: "Update API tests", meta: "#45 · PR #310", agent: "claude-code", amber: true },
]} />
<DashColumn title="Merged" cards={[
{ title: "Refactor DB layer", meta: "#46 · PR #308", agent: "opencode", done: true },
]} />
</div>
</div>
);
}
function PrsDemo() {
return (
<div className="flex flex-col gap-2.5">
{[
{ branch: "feat/user-auth", title: "Add user authentication flow" },
{ branch: "fix/pagination-offset", title: "Fix off-by-one in cursor pagination" },
{ branch: "feat/rate-limiting", title: "Add Redis-backed rate limiter" },
{ branch: "refactor/db-layer", title: "Extract repository pattern from services" },
].map((pr) => (
<div key={pr.branch} className="bg-[var(--landing-surface)] border border-[var(--landing-border-subtle)] rounded-xl px-5 py-4 flex items-center justify-between">
<div className="flex flex-col gap-1">
<div className="font-mono text-xs text-[var(--landing-fg)]/70">{pr.branch}</div>
<div className="text-[0.8125rem] text-[var(--landing-muted)]">{pr.title}</div>
</div>
<div className="font-mono text-[0.625rem] tracking-[0.05em] px-3 py-1 rounded-full bg-[rgba(134,239,172,0.08)] text-[rgba(134,239,172,0.7)]">
Merged
</div>
</div>
))}
</div>
);
}
interface DashCardData {
title: string;
meta: string;
agent: string;
amber?: boolean;
done?: boolean;
}
function DashColumn({ title, cards }: { title: string; cards: DashCardData[] }) {
return (
<div>
<div className="font-mono text-[0.625rem] tracking-[0.1em] uppercase text-[var(--landing-muted)] opacity-40 px-2 mb-1">
{title}
</div>
{cards.map((card) => (
<div key={card.meta} className="bg-[var(--landing-surface)] border border-[var(--landing-border-subtle)] rounded-lg p-2.5 mb-1.5 text-[0.6875rem]">
<div className="text-[var(--landing-fg)]/70 mb-1">{card.title}</div>
<div className="font-mono text-[0.5625rem] text-[var(--landing-muted)] opacity-50">{card.meta}</div>
<div className="flex items-center gap-1 mt-1 font-mono text-[0.5625rem] text-[var(--landing-muted)] opacity-60">
{card.done ? (
<span></span>
) : (
<span className="inline-block w-1.5 h-1.5 rounded-full" style={{ background: card.amber ? "rgba(251,191,36,0.7)" : "rgba(134,239,172,0.7)" }} />
)}
{card.agent}
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,85 @@
"use client";
function XIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
}
function DiscordIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
</svg>
);
}
function GithubIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}
export function LandingNav() {
return (
<nav className="fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-8 py-6 max-w-[80rem] mx-auto bg-[var(--landing-bg)]/90 backdrop-blur-sm">
<a
href="#"
className="inline-flex items-center gap-2 text-base font-semibold text-white no-underline font-sans font-[680] tracking-tight"
>
<img src="/ao-logo.svg" alt="" aria-hidden="true" width={28} height={28} className="h-7 w-7" />
Agent Orchestrator
</a>
<ul className="hidden md:flex items-center gap-8 list-none">
<li>
<a href="/docs" className="text-sm text-[var(--landing-muted)] no-underline hover:text-white transition-colors">
Docs
</a>
</li>
<li>
<a href="#features" className="text-sm text-[var(--landing-muted)] no-underline hover:text-white transition-colors">
Features
</a>
</li>
<li>
<a href="#how" className="text-sm text-[var(--landing-muted)] no-underline hover:text-white transition-colors">
How It Works
</a>
</li>
</ul>
<div className="flex items-center gap-2">
<a
href="https://x.com/aoagents"
target="_blank"
rel="noopener noreferrer"
aria-label="X (Twitter)"
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-white/80 transition-colors hover:text-white"
>
<XIcon />
</a>
<a
href="https://discord.gg/UZv7JjxbwG"
target="_blank"
rel="noopener noreferrer"
aria-label="Discord"
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-white/80 transition-colors hover:text-white"
>
<DiscordIcon />
</a>
<a
href="https://github.com/ComposioHQ/agent-orchestrator"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="inline-flex h-9 w-9 items-center justify-center rounded-md text-white/80 transition-colors hover:text-white"
>
<GithubIcon />
</a>
</div>
</nav>
);
}

View File

@ -0,0 +1,44 @@
const steps = [
{ num: "STEP 01", title: "Install", desc: "One command. No dependencies beyond Node.js.", cmd: "npm i -g @aoagents/ao" },
{ num: "STEP 02", title: "Configure", desc: "Create an agent-orchestrator.yaml. Pick your agents, tracker, and notifiers.", cmd: "ao start" },
{ num: "STEP 03", title: "Launch", desc: "Assign issues and watch agents spawn.", cmd: "ao batch-spawn 1 2 3" },
];
export function LandingQuickStart() {
return (
<section className="py-[120px] px-6 max-w-[72rem] mx-auto bg-[radial-gradient(ellipse_at_bottom,rgba(255,255,255,0.015)_0%,transparent_60%)]">
<div className="landing-reveal">
<div className="text-xs tracking-[0.15em] uppercase text-[var(--landing-muted)] opacity-60 mb-6">
Get started in 60 seconds
</div>
<h2 className="font-sans font-[680] tracking-tight font-normal text-[clamp(1.375rem,3vw,2rem)] leading-[1.05] tracking-[-1.5px] mb-6">
Three commands to{" "}
<em className="italic text-[var(--landing-muted)]">launch</em>
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 mt-12">
{steps.map((s) => (
<div key={s.num} className="landing-reveal landing-card rounded-2xl p-7">
<div className="font-mono text-[0.625rem] tracking-[0.1em] text-[var(--landing-muted)] opacity-40 mb-3">
{s.num}
</div>
<h3 className="font-sans font-[680] tracking-tight text-xl mb-2 tracking-tight">
{s.title}
</h3>
<p className="text-[var(--landing-muted)] text-[0.8125rem] leading-[1.6] mb-4">
{s.desc}
</p>
<div className="font-mono text-xs text-[var(--landing-fg)]/70 bg-black/30 px-3.5 py-2.5 rounded-lg">
<span className="text-[var(--landing-muted)] opacity-40">$</span> {s.cmd}
</div>
</div>
))}
</div>
<div className="landing-reveal mt-8 text-center">
<a href="/docs/" className="landing-card inline-flex rounded-lg px-4 py-2 text-[0.8125rem] text-[var(--landing-muted)] no-underline hover:text-white transition-colors">
Explore docs for setup and workflows
</a>
</div>
</section>
);
}

View File

@ -0,0 +1,51 @@
import type { GitHubRepoStats } from "@/lib/github-repo";
interface LandingStatsProps {
stats: GitHubRepoStats;
}
export function LandingStats({ stats }: LandingStatsProps) {
const cards = [
{ number: stats.stars.toLocaleString(), label: "GitHub Stars" },
{ number: stats.forks.toLocaleString(), label: "Forks" },
{ number: stats.openIssues.toLocaleString(), label: "Open Issues" },
{ number: stats.watchers.toLocaleString(), label: "Watchers" },
];
return (
<section className="py-20 px-6 max-w-[72rem] mx-auto">
<div className="landing-reveal grid grid-cols-2 md:grid-cols-4 gap-5">
{cards.map((stat) => (
<div
key={stat.label}
className="landing-card rounded-2xl py-8 px-6 text-center"
>
<div className="font-sans font-[680] tracking-tight text-[clamp(2rem,4vw,3rem)] tracking-tight mb-1">
{stat.number}
</div>
<div className="text-xs text-[var(--landing-muted)] opacity-60">
{stat.label}
</div>
</div>
))}
</div>
<div className="landing-reveal text-center mt-8">
<a
href="https://github.com/ComposioHQ/agent-orchestrator"
target="_blank"
rel="noopener noreferrer"
className="landing-card inline-flex items-center gap-2 rounded-lg px-4 py-2 text-[0.8125rem] text-[var(--landing-muted)] no-underline transition-all hover:text-white mb-3"
>
<span className="text-[rgba(251,191,36,0.7)] text-sm"></span>
<span className="font-mono text-xs text-[var(--landing-fg)] opacity-80">{stats.stars.toLocaleString()}</span>
<span>stars on GitHub</span>
</a>
<br />
<div className="landing-card inline-flex items-center gap-2 rounded-lg px-4 py-2 text-[0.8125rem] text-[var(--landing-muted)]">
<span className="w-2 h-2 rounded-full bg-[rgba(134,239,172,0.7)] animate-pulse" />
Built with itself this repo is managed by Agent Orchestrator
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,172 @@
"use client";
import { useEffect, useState } from "react";
const testimonials = [
{
quote:
"Set up 12 agents on our backlog before lunch. By end of day, 8 PRs were merged.",
img: "https://i.pravatar.cc/120?img=13",
name: "Staff Engineer",
role: "Series B Startup",
},
{
quote:
"The auto CI recovery alone saves me hours a week. Agents fix their own broken tests. I just review and merge.",
img: "https://i.pravatar.cc/120?img=32",
name: "Solo Founder",
role: "Indie SaaS",
},
{
quote:
"We went from 3 PRs/day to 15 PRs/day. The plugin system means we swapped in GitLab and Linear without changing our workflow.",
img: "https://i.pravatar.cc/120?img=8",
name: "Eng Lead",
role: "20-person team",
},
];
const ROTATE_MS = 5500;
export function LandingTestimonials() {
const [active, setActive] = useState(0);
const [show, setShow] = useState(true);
const [paused, setPaused] = useState(false);
const change = (next: number) => {
if (next === active) return;
setShow(false);
window.setTimeout(() => {
setActive(next);
setShow(true);
}, 240);
};
useEffect(() => {
if (paused) return;
const t = window.setTimeout(
() => change((active + 1) % testimonials.length),
ROTATE_MS,
);
return () => window.clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, paused]);
const t = testimonials[active];
return (
<section className="py-20 px-6 pb-[120px] max-w-[72rem] mx-auto">
<div className="landing-reveal">
<div className="text-xs tracking-[0.15em] uppercase text-[var(--landing-muted)] opacity-60 mb-6">
What engineers say
</div>
<h2 className="font-sans font-[680] tracking-tight font-normal text-[clamp(1.375rem,3vw,2rem)] leading-[1.05] tracking-[-1.5px]">
Trusted by <em className="italic text-[var(--landing-muted)]">builders</em>
</h2>
</div>
<div
className="landing-reveal mt-16"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{/* Quote — fades on change */}
<div className="min-h-[8rem] max-w-[58rem]">
<blockquote
style={{
opacity: show ? 1 : 0,
transform: show ? "translateY(0)" : "translateY(8px)",
transition: "opacity 0.4s ease, transform 0.4s cubic-bezier(0.22,1,0.36,1)",
}}
className="font-sans font-[680] tracking-tight text-[clamp(1.5rem,3.2vw,2.375rem)] leading-[1.3] tracking-[-0.75px] text-[var(--landing-fg)]"
>
&ldquo;{t.quote}&rdquo;
</blockquote>
</div>
{/* Bottom row — author cluster on the left, step counter on the right */}
<div className="flex items-center justify-between gap-6 mt-14 flex-wrap">
<div className="flex items-center gap-5">
<div className="flex items-center">
{testimonials.map((item, i) => {
const isActive = i === active;
const size = isActive ? 56 : 44;
return (
<button
key={item.name}
onClick={() => change(i)}
aria-label={`Show testimonial from ${item.name}`}
aria-pressed={isActive}
className="rounded-full overflow-hidden cursor-pointer shrink-0 p-0"
style={{
width: size,
height: size,
marginLeft: i === 0 ? 0 : -14,
zIndex: isActive ? 30 : 10 - i,
border: `2px solid ${isActive ? "var(--landing-accent)" : "var(--landing-card-bg)"}`,
opacity: isActive ? 1 : 0.7,
boxShadow: isActive ? "0 4px 16px rgba(0,0,0,0.35)" : "none",
transition:
"width 0.4s cubic-bezier(0.22,1,0.36,1), height 0.4s cubic-bezier(0.22,1,0.36,1), opacity 0.4s ease, border-color 0.4s ease",
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.img}
alt={item.name}
width={size}
height={size}
className="w-full h-full object-cover"
style={{
filter: isActive ? "grayscale(0)" : "grayscale(1)",
transition: "filter 0.4s ease",
}}
/>
</button>
);
})}
</div>
{/* Vertical divider */}
<div
className="w-px h-10 shrink-0"
style={{ background: "var(--landing-border-default)" }}
/>
{/* Author — fades on change */}
<div
style={{
opacity: show ? 1 : 0,
transition: "opacity 0.4s ease 0.05s",
}}
>
<div className="text-[0.9375rem] font-medium text-[var(--landing-fg)]">
{t.name}
</div>
<div className="text-[0.8125rem] text-[var(--landing-muted)] opacity-60">
{t.role}
</div>
</div>
</div>
{/* Step counter — fills the right side */}
<div className="flex items-baseline gap-2 font-mono">
<span
className="text-[clamp(2.25rem,5vw,3.5rem)] font-[680] tracking-tight leading-none tabular-nums"
style={{
color: "var(--landing-fg)",
opacity: show ? 1 : 0.4,
transition: "opacity 0.3s ease",
}}
>
{String(active + 1).padStart(2, "0")}
</span>
<span className="text-[var(--landing-muted)] opacity-40 text-lg">
/ {String(testimonials.length).padStart(2, "0")}
</span>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,235 @@
"use client";
import { useEffect, useRef, type PointerEvent as ReactPointerEvent } from "react";
const dim = "text-[var(--landing-muted-dim)]";
const fg = "text-[var(--landing-fg)]/80";
const ok = "text-[rgba(134,239,172,0.8)]";
type UseCase = {
eyebrow: string;
title: string;
desc: string;
prefix: "$" | "⟡";
cmd: string;
outcome: string;
};
// Real, grounded use cases — real ao commands, reaction keys, and lifecycle states.
const cases: UseCase[] = [
{
eyebrow: "Backlog",
title: "Clear it overnight",
desc: "One agent per issue, each in its own git worktree, all running at once.",
prefix: "$",
cmd: "ao batch-spawn 142 143 144 145",
outcome: "4 worktrees · 4 PRs",
},
{
eyebrow: "CI recovery",
title: "Self-healing builds",
desc: "A check goes red; the agent reads the logs, pushes a fix, and waits for green.",
prefix: "⟡",
cmd: "reaction · ci-failed",
outcome: "ci_failed → mergeable",
},
{
eyebrow: "Review loop",
title: "Answers its own reviews",
desc: "Comments land; the agent addresses each one and re-requests review.",
prefix: "⟡",
cmd: "reaction · changes-requested",
outcome: "changes_requested → approved",
},
{
eyebrow: "Migration",
title: "Grinds through the long ones",
desc: "Hand one agent a sweeping change and let it work file by file until tests pass.",
prefix: "$",
cmd: "ao spawn 305 --agent claude-code",
outcome: "23 files · tests green",
},
{
eyebrow: "Per-role",
title: "Right model per job",
desc: "Claude Code orchestrates, Codex does the work. Pick the tool per task.",
prefix: "$",
cmd: "ao spawn 88 --agent codex",
outcome: "codex #88 · claude-code #91",
},
{
eyebrow: "Multi-project",
title: "Every repo, one screen",
desc: "Register all your repos and supervise their agents from a single dashboard.",
prefix: "$",
cmd: "ao start",
outcome: "3 projects · one dashboard",
},
];
const N = cases.length;
const THETA = 360 / N;
const RADIUS = 440;
const CARD_W = 360;
const CARD_H = 440;
export function LandingUseCases() {
const viewportRef = useRef<HTMLDivElement>(null);
const ringRef = useRef<HTMLDivElement>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const angle = useRef(0);
const dragging = useRef(false);
const paused = useRef(false);
const reduced = useRef(false);
const start = useRef({ x: 0, a: 0 });
// rAF loop — rotate the ring and fade/scale each card by how far it faces the
// camera. Imperative (no setState) so 60fps stays smooth and re-render-free.
useEffect(() => {
reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let raf = 0;
const loop = () => {
if (!dragging.current && !paused.current && !reduced.current) {
angle.current += 0.12;
}
const a = angle.current;
if (ringRef.current) {
ringRef.current.style.transform = `translateZ(-${RADIUS}px) rotateY(${a}deg)`;
}
cardRefs.current.forEach((el, i) => {
if (!el) return;
const facing = Math.cos(((i * THETA + a) * Math.PI) / 180);
const vis = Math.max(facing, 0);
el.style.opacity = `${0.2 + 0.8 * vis}`;
el.style.transform = `rotateY(${i * THETA}deg) translateZ(${RADIUS}px) scale(${0.9 + 0.1 * vis})`;
});
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
dragging.current = true;
start.current = { x: e.clientX, a: angle.current };
e.currentTarget.setPointerCapture(e.pointerId);
if (viewportRef.current) viewportRef.current.style.cursor = "grabbing";
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragging.current) return;
angle.current = start.current.a + (e.clientX - start.current.x) * 0.4;
};
const onPointerUp = () => {
dragging.current = false;
if (viewportRef.current) viewportRef.current.style.cursor = "grab";
};
return (
<section className="py-[100px] px-6 max-w-[72rem] mx-auto">
<div className="landing-reveal text-center">
<div className="text-xs tracking-[0.15em] uppercase text-[var(--landing-muted-dim)] mb-6 font-mono">
Use cases
</div>
<h2 className="font-sans font-[680] text-[clamp(1.375rem,3vw,2rem)] leading-[1.1] tracking-[-1.5px] mb-4">
One orchestrator, many jobs
</h2>
<p className="text-[var(--landing-muted)] text-[0.9375rem] leading-[1.6] max-w-[34rem] mx-auto mb-12">
Point AO at the work and walk away drag to explore what a single
run can do.
</p>
</div>
<div
ref={viewportRef}
onMouseEnter={() => (paused.current = true)}
onMouseLeave={() => {
paused.current = false;
onPointerUp();
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
className="landing-reveal relative mx-auto select-none"
style={{
perspective: "1900px",
height: `${CARD_H + 80}px`,
maxWidth: "1120px",
cursor: "grab",
touchAction: "pan-y",
WebkitMaskImage:
"linear-gradient(to right, transparent, #000 16%, #000 84%, transparent)",
maskImage:
"linear-gradient(to right, transparent, #000 16%, #000 84%, transparent)",
}}
>
<div
ref={ringRef}
style={{
position: "absolute",
inset: 0,
transformStyle: "preserve-3d",
}}
>
{cases.map((c, i) => (
<div
key={c.eyebrow}
ref={(el) => {
cardRefs.current[i] = el;
}}
style={{
position: "absolute",
left: "50%",
top: "50%",
width: `${CARD_W}px`,
height: `${CARD_H}px`,
marginLeft: `-${CARD_W / 2}px`,
marginTop: `-${CARD_H / 2}px`,
backfaceVisibility: "hidden",
}}
>
<div
className="landing-card rounded-2xl"
style={{
width: "100%",
height: "100%",
padding: "1.875rem",
display: "flex",
flexDirection: "column",
}}
>
<div className="font-mono text-[0.6875rem] tracking-[0.12em] uppercase text-[var(--landing-accent)] opacity-80">
{c.eyebrow}
</div>
<h3
className="font-sans font-[680] text-[1.3125rem] tracking-tight"
style={{ marginTop: "1rem" }}
>
{c.title}
</h3>
<p
className="text-[var(--landing-muted)] text-[0.9375rem] leading-[1.65]"
style={{ marginTop: "0.625rem" }}
>
{c.desc}
</p>
<div
className="font-mono text-[0.6875rem] leading-[2] bg-black/30 rounded-lg overflow-hidden"
style={{ marginTop: "auto", padding: "0.75rem 0.875rem" }}
>
<div className="whitespace-nowrap overflow-hidden text-ellipsis">
<span className={dim}>{c.prefix}</span>{" "}
<span className={fg}>{c.cmd}</span>
</div>
<div className={`whitespace-nowrap overflow-hidden text-ellipsis ${ok}`}>
{c.outcome}
</div>
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,20 @@
export function LandingVideo() {
return (
<section className="landing-reveal px-6 pb-[120px] pt-10 max-w-[72rem] mx-auto">
<div className="text-center mb-5">
<span className="text-[0.6875rem] tracking-[0.12em] uppercase text-[var(--landing-muted)] opacity-50">
See it in action
</span>
</div>
<div className="landing-card rounded-2xl overflow-hidden aspect-video">
<iframe
src="https://www.youtube.com/embed/QdwaeEXOmDs?autoplay=0&rel=0&modestbranding=1"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="w-full h-full border-none"
title="Agent Orchestrator Launch Demo"
/>
</div>
</section>
);
}

View File

@ -0,0 +1,330 @@
"use client";
import { useEffect, useRef, useState } from "react";
// The session lifecycle as a scrubbing ruler-dial. One agent's journey from a
// fresh issue to a merged PR — the real canonical states. The strip slides so
// the active milestone sits under the fixed center line; auto-loops, draggable,
// arrow-key navigable.
type Milestone = {
key: string;
label: string;
desc: string;
icon: "spawn" | "work" | "pr" | "review" | "mergeable" | "merged";
};
const milestones: Milestone[] = [
{ key: "spawning", label: "Spawn", icon: "spawn", desc: "Each issue spawns an agent in its own git worktree — isolated branch, isolated context." },
{ key: "working", label: "Work", icon: "work", desc: "The agent writes code, runs the test suite, and commits. Watch it live or let it run." },
{ key: "pr_open", label: "Open PR", icon: "pr", desc: "Work is pushed and a pull request opens against main with a summary of the changes." },
{ key: "review", label: "CI & review", icon: "review", desc: "CI fails? It reads the logs and pushes a fix. Review comments land? It addresses them." },
{ key: "mergeable", label: "Mergeable", icon: "mergeable", desc: "Green checks, approvals in. The PR settles into a clean, mergeable state." },
{ key: "merged", label: "Merged", icon: "merged", desc: "It lands on main, the worktree is archived, and the session is marked done." },
];
// Ruler geometry (viewBox units)
const W = 760;
const H = 150;
const CENTER_X = W / 2;
const TICKS_PER_STEP = 16;
const TICK_GAP = 11;
const PEAK_Y = 34;
const CURV = 0.00026;
const MINOR_LEN = 13;
const MAJOR_LEN = 26;
const MAX_K = (milestones.length - 1) * TICKS_PER_STEP;
const STEP_MS = 2800;
const arcTop = (dx: number) => PEAK_Y + CURV * dx * dx;
export function LandingWorkflow() {
const [active, setActive] = useState(0);
const [pos, setPos] = useState(0); // current center position in tick units
const [show, setShow] = useState(true);
const [paused, setPaused] = useState(false);
const [inView, setInView] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const svgWrapRef = useRef<HTMLDivElement>(null);
const posRef = useRef(0);
const rafRef = useRef(0);
const drag = useRef<{ active: boolean; startX: number; startPos: number; moved: boolean }>({
active: false,
startX: 0,
startPos: 0,
moved: false,
});
useEffect(() => {
const el = wrapRef.current;
if (!el) return;
const ob = new IntersectionObserver(
([entry]) => entry.isIntersecting && setInView(true),
{ threshold: 0.25 },
);
ob.observe(el);
return () => ob.disconnect();
}, []);
const tweenTo = (target: number) => {
cancelAnimationFrame(rafRef.current);
const start = posRef.current;
const dist = target - start;
if (Math.abs(dist) < 0.001) return;
const dur = 600;
let t0: number | null = null;
const step = (now: number) => {
if (t0 === null) t0 = now;
const p = Math.min((now - t0) / dur, 1);
const e = 1 - Math.pow(1 - p, 3);
const v = start + dist * e;
posRef.current = v;
setPos(v);
if (p < 1) rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
};
// Slide to the active milestone whenever it changes
useEffect(() => {
tweenTo(active * TICKS_PER_STEP);
setShow(false);
const id = window.setTimeout(() => setShow(true), 200);
return () => window.clearTimeout(id);
}, [active]);
// Auto-loop
useEffect(() => {
if (!inView || paused) return;
const t = window.setTimeout(
() => setActive((a) => (a + 1) % milestones.length),
STEP_MS,
);
return () => window.clearTimeout(t);
}, [active, paused, inView]);
const settle = (rawPos: number) => {
const nearest = Math.max(0, Math.min(milestones.length - 1, Math.round(rawPos / TICKS_PER_STEP)));
if (nearest === active) tweenTo(nearest * TICKS_PER_STEP);
setActive(nearest);
};
// Drag to scrub
const onPointerDown = (e: React.PointerEvent) => {
drag.current = { active: true, startX: e.clientX, startPos: posRef.current, moved: false };
(e.target as HTMLElement).setPointerCapture?.(e.pointerId);
setPaused(true);
cancelAnimationFrame(rafRef.current);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!drag.current.active) return;
const widthPx = svgWrapRef.current?.clientWidth ?? W;
const scale = W / widthPx;
const dxTicks = ((e.clientX - drag.current.startX) * scale) / TICK_GAP;
if (Math.abs(dxTicks) > 0.4) drag.current.moved = true;
const v = Math.max(-2, Math.min(MAX_K + 2, drag.current.startPos - dxTicks));
posRef.current = v;
setPos(v);
};
const onPointerUp = () => {
if (!drag.current.active) return;
drag.current.active = false;
settle(posRef.current);
setPaused(false);
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowRight") {
e.preventDefault();
setActive((a) => Math.min(milestones.length - 1, a + 1));
} else if (e.key === "ArrowLeft") {
e.preventDefault();
setActive((a) => Math.max(0, a - 1));
}
};
// Build the visible ticks
const ticks: { k: number; x: number; yTop: number; len: number; major: boolean; opacity: number }[] = [];
const lo = Math.floor(pos) - 40;
const hi = Math.ceil(pos) + 40;
for (let k = lo; k <= hi; k++) {
if (k < 0 || k > MAX_K) continue;
const x = CENTER_X + (k - pos) * TICK_GAP;
if (x < 24 || x > W - 24) continue;
const dx = x - CENTER_X;
const major = k % TICKS_PER_STEP === 0;
const edgeFade = Math.max(0.08, 1 - Math.abs(dx) / (W / 2 + 40));
ticks.push({ k, x, yTop: arcTop(dx), len: major ? MAJOR_LEN : MINOR_LEN, major, opacity: edgeFade });
}
const cur = milestones[active];
return (
<section ref={wrapRef} className="py-[100px] px-6 max-w-[72rem] mx-auto">
<div className="landing-reveal">
<div className="text-xs tracking-[0.15em] uppercase text-[var(--landing-muted-dim)] mb-6 font-mono">
Lifecycle
</div>
<h2 className="font-sans font-[680] text-[clamp(1.375rem,3vw,2rem)] leading-[1.1] tracking-[-1.5px] mb-4">
From issue to merged PR
</h2>
<p className="text-[var(--landing-muted)] text-[0.9375rem] leading-[1.6] max-w-[34rem] mb-12">
Every session walks the same path spawned in isolation, working in parallel, landing on{" "}
<span className="font-mono text-[var(--landing-fg)]">main</span> on its own.
</p>
</div>
<div
className="landing-card rounded-2xl border-[var(--landing-border-subtle)] px-6 sm:px-10 pt-10 pb-12 select-none outline-none"
tabIndex={0}
role="group"
aria-label="Session lifecycle timeline"
onKeyDown={onKeyDown}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{/* Active icon + label */}
<div
className="flex flex-col items-center text-center mb-2"
style={{ opacity: show ? 1 : 0, transition: "opacity 0.35s ease" }}
>
<LifecycleIcon kind={cur.icon} />
<div className="font-sans font-[680] tracking-tight text-[1.5rem] tracking-[-0.5px] mt-3">
{cur.label}
</div>
<div className="font-mono text-[0.6875rem] tracking-[0.08em] text-[var(--landing-accent)] opacity-80 mt-1">
{cur.key}
</div>
</div>
{/* Ruler */}
<div
ref={svgWrapRef}
className="cursor-grab active:cursor-grabbing touch-none"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-auto" role="img" aria-label="Lifecycle scrubber">
{/* center indicator */}
<line x1={CENTER_X} y1={4} x2={CENTER_X} y2={H - 8} stroke="var(--landing-accent)" strokeWidth={2} strokeLinecap="round" />
<circle cx={CENTER_X} cy={arcTop(0)} r={4} fill="var(--landing-accent)" />
{/* ticks */}
{ticks.map((t) => (
<line
key={t.k}
x1={t.x}
y1={t.yTop}
x2={t.x}
y2={t.yTop + t.len}
stroke={t.major ? "var(--landing-border-strong)" : "var(--landing-border-default)"}
strokeWidth={t.major ? 1.6 : 1}
strokeLinecap="round"
style={{ opacity: t.opacity }}
/>
))}
{/* milestone labels under their major tick (except the centered one) */}
{ticks
.filter((t) => t.major)
.map((t) => {
const idx = t.k / TICKS_PER_STEP;
const isActive = idx === active;
return (
<text
key={`lbl-${t.k}`}
x={t.x}
y={t.yTop + MAJOR_LEN + 16}
textAnchor="middle"
className="font-mono"
fontSize={9}
fill="var(--landing-muted)"
style={{ opacity: isActive ? 0 : t.opacity * 0.7, transition: "opacity 0.3s ease" }}
>
{milestones[idx].label}
</text>
);
})}
</svg>
</div>
{/* Active description */}
<div
className="text-center max-w-[34rem] mx-auto mt-2"
style={{ opacity: show ? 1 : 0, transition: "opacity 0.35s ease" }}
>
<p className="text-[var(--landing-muted)] text-[0.9375rem] leading-[1.6]">{cur.desc}</p>
</div>
</div>
</section>
);
}
function LifecycleIcon({ kind }: { kind: Milestone["icon"] }) {
const common = {
width: 30,
height: 30,
viewBox: "0 0 24 24",
fill: "none",
stroke: "var(--landing-accent)",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
switch (kind) {
case "spawn":
return (
<svg {...common}>
<path d="M12 20v-7" />
<path d="M12 13c0-3 2-5 5-5 0 3-2 5-5 5Z" />
<path d="M12 14c0-2.5-1.8-4.2-4.2-4.2C7.8 12.3 9.6 14 12 14Z" />
</svg>
);
case "work":
return (
<svg {...common}>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M7 9l3 3-3 3" />
<path d="M13 15h4" />
</svg>
);
case "pr":
return (
<svg {...common}>
<circle cx="6" cy="18" r="2.5" />
<circle cx="6" cy="6" r="2.5" />
<circle cx="18" cy="8" r="2.5" />
<path d="M6 8.5v7" />
<path d="M18 10.5c0 4-6 2.5-6 6" />
<path d="M18 5.5V3.5M16.5 5h3" />
</svg>
);
case "review":
return (
<svg {...common}>
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6Z" />
<circle cx="12" cy="12" r="2.5" />
</svg>
);
case "mergeable":
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="M8 12l2.5 2.5L16 9" />
</svg>
);
case "merged":
return (
<svg {...common}>
<circle cx="6" cy="6" r="2.5" />
<circle cx="6" cy="18" r="2.5" />
<circle cx="18" cy="12" r="2.5" />
<path d="M6 8.5v7" />
<path d="M8.5 6.5c5 0 2 5.5 7 5.5" />
</svg>
);
}
}

View File

@ -0,0 +1,155 @@
"use client";
import { useEffect, useRef } from "react";
export function PageConstellation() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
type Dot = { x: number; y: number; vx: number; vy: number };
let dots: Dot[] = [];
let width = 0;
let height = 0;
const mouse = { x: -10000, y: -10000, active: false };
const seed = () => {
const target = Math.min(140, Math.max(40, Math.floor((width * height) / 18000)));
dots = Array.from({ length: target }, () => ({
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * 0.1,
vy: (Math.random() - 0.5) * 0.1,
}));
};
const resize = () => {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = Math.floor(width * dpr);
canvas.height = Math.floor(height * dpr);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
seed();
};
resize();
const onMove = (e: MouseEvent) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
mouse.active = true;
};
const onLeave = () => {
mouse.active = false;
mouse.x = -10000;
mouse.y = -10000;
};
window.addEventListener("resize", resize);
window.addEventListener("mousemove", onMove);
document.addEventListener("mouseleave", onLeave);
window.addEventListener("blur", onLeave);
const LINK_DIST = 105;
const MOUSE_DIST = 165;
const draw = () => {
ctx.clearRect(0, 0, width, height);
for (const d of dots) {
d.x += d.vx;
d.y += d.vy;
if (d.x < 0 || d.x > width) d.vx *= -1;
if (d.y < 0 || d.y > height) d.vy *= -1;
}
ctx.lineWidth = 0.5;
for (let i = 0; i < dots.length; i++) {
for (let j = i + 1; j < dots.length; j++) {
const a = dots[i];
const b = dots[j];
const dx = a.x - b.x;
const dy = a.y - b.y;
const distSq = dx * dx + dy * dy;
if (distSq < LINK_DIST * LINK_DIST) {
const dist = Math.sqrt(distSq);
const op = (1 - dist / LINK_DIST) * 0.05;
ctx.strokeStyle = `rgba(240, 236, 232, ${op})`;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.stroke();
}
}
}
if (mouse.active) {
ctx.lineWidth = 0.6;
for (const d of dots) {
const dx = d.x - mouse.x;
const dy = d.y - mouse.y;
const distSq = dx * dx + dy * dy;
if (distSq < MOUSE_DIST * MOUSE_DIST) {
const dist = Math.sqrt(distSq);
const op = (1 - dist / MOUSE_DIST) * 0.15;
ctx.strokeStyle = `rgba(240, 236, 232, ${op})`;
ctx.beginPath();
ctx.moveTo(d.x, d.y);
ctx.lineTo(mouse.x, mouse.y);
ctx.stroke();
}
}
}
for (const d of dots) {
let op = 0.11;
if (mouse.active) {
const dx = d.x - mouse.x;
const dy = d.y - mouse.y;
const distSq = dx * dx + dy * dy;
if (distSq < MOUSE_DIST * MOUSE_DIST) {
op += (1 - Math.sqrt(distSq) / MOUSE_DIST) * 0.22;
}
}
ctx.fillStyle = `rgba(240, 236, 232, ${op})`;
ctx.beginPath();
ctx.arc(d.x, d.y, 1.0, 0, Math.PI * 2);
ctx.fill();
}
};
let raf = 0;
const loop = () => {
draw();
raf = requestAnimationFrame(loop);
};
if (reducedMotion) draw();
else loop();
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
window.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseleave", onLeave);
window.removeEventListener("blur", onLeave);
};
}, []);
return (
<canvas
ref={canvasRef}
className="fixed inset-0 pointer-events-none"
style={{ zIndex: 0 }}
aria-hidden="true"
/>
);
}

View File

@ -0,0 +1,26 @@
"use client";
import { useEffect } from "react";
export function ScrollRevealProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("visible");
}
});
},
{ threshold: 0.1, rootMargin: "-50px" }
);
document.querySelectorAll(".landing-reveal").forEach((el) => {
observer.observe(el);
});
return () => observer.disconnect();
}, []);
return <>{children}</>;
}