feat: add landing page section components
This commit is contained in:
parent
fbaa6d17a5
commit
d0c053e99e
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"> </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"> </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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)]"
|
||||
>
|
||||
“{t.quote}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}</>;
|
||||
}
|
||||
Loading…
Reference in New Issue