-
-
- {agent.name}
+
+
+
+
{agent.name}
))}
diff --git a/frontend/src/landing/components/LandingCTA.tsx b/frontend/src/landing/components/LandingCTA.tsx
deleted file mode 100644
index d1b2c3df..00000000
--- a/frontend/src/landing/components/LandingCTA.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-export function LandingCTA() {
- return (
-
-
-
- Stop babysitting.
-
-
- Start orchestrating.
-
-
- $ npm i -g @aoagents/ao
-
-
-
-
- );
-}
diff --git a/frontend/src/landing/components/LandingFeatures.tsx b/frontend/src/landing/components/LandingFeatures.tsx
index a70530f1..3e6a9c59 100644
--- a/frontend/src/landing/components/LandingFeatures.tsx
+++ b/frontend/src/landing/components/LandingFeatures.tsx
@@ -2,56 +2,61 @@
import { useEffect, useRef, useState } from "react";
-type DemoKind = "parallel" | "recovery" | "plugins" | "dashboard";
+type Asset =
+ | { type: "image"; src: string; w: number; h: number }
+ | { type: "video"; src: string; poster?: string; w: number; h: number };
-const features: { n: string; title: string; desc: string; demo: DemoKind }[] = [
+// A feature's product surface is either a real capture (image/video dropped
+// into /public/features) or an in-frame illustration for concept features
+// whose UI isn't shippable as a screenshot yet.
+type Surface =
+ | { kind: "capture"; asset: Asset }
+ | { kind: "render"; id: "parallel" | "recovery" | "slots" };
+
+type Feature = {
+ n: string;
+ title: string;
+ desc: string;
+ frameTitle: string;
+ cta?: string;
+ surface: Surface;
+};
+
+const features: Feature[] = [
{
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",
+ frameTitle: "reverbcode · sessions",
+ cta: "npx @aoagents/ao start",
+ surface: { kind: "render", id: "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",
+ frameTitle: "reverbcode · s-312",
+ surface: { kind: "render", id: "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",
+ frameTitle: "agent-orchestrator.yaml",
+ surface: { kind: "render", id: "slots" },
},
{
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",
+ frameTitle: "reverbcode · live",
+ surface: {
+ kind: "capture",
+ asset: { type: "video", src: "/features/live.webm", poster: "/features/live.png", w: 1280, h: 800 },
+ },
},
];
-// 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 (
-
-
- {kind === "parallel" &&
}
- {kind === "recovery" &&
}
- {kind === "plugins" &&
}
- {kind === "dashboard" &&
}
-
-
- {kind === "parallel" &&
}
- {kind === "recovery" &&
}
- {kind === "plugins" &&
}
- {kind === "dashboard" &&
}
-
-
- );
-}
-
// 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;
@@ -113,7 +118,7 @@ export function LandingFeatures() {
}, [stack]);
return (
-
+
Features
@@ -133,81 +138,196 @@ export function LandingFeatures() {
- {features.map((f, i) => (
-
{
- 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),
- }}
- >
-
-
- {f.n}
+ {features.map((f, i) => {
+ const flip = i % 2 === 1; // alternate product/text sides down the deck
+ return (
+
{
+ cardRefs.current[i] = el;
+ }}
+ className="landing-card rounded-2xl 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),
+ }}
+ >
+
+
+
+ {f.n}
+
+
+ {f.title}
+
+
+ {f.desc}
+
+ {f.cta && (
+
+
+
+ )}
+
+
-
- {f.title}
-
-
- {f.desc}
-
-
-
- ))}
+ );
+ })}
);
}
-/* ──────── 01 · Parallel ──────── */
+// macOS-style window chrome around the product surface — the universal "this is
+// real software" cue. Same chrome for captures and illustrations so all four
+// cards read as one coherent product.
+function ProductFrame({ feature }: { feature: Feature }) {
+ const s = feature.surface;
+ const ratio = s.kind === "capture" ? `${s.asset.w} / ${s.asset.h}` : "1280 / 800";
+ return (
+
+
+
+
+
+
+
+
+ {feature.frameTitle}
+
+
+
+
+ {s.kind === "capture" ? (
+
+ ) : s.id === "parallel" ? (
+
+ ) : s.id === "recovery" ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+// Renders the real asset; falls back to a labelled placeholder if it 404s, so
+// dropping a file into /public/features just works with no code change.
+function ProductMedia({ asset, title }: { asset: Asset; title: string }) {
+ const [failed, setFailed] = useState(false);
-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 },
- ];
+ if (failed) {
+ const name = asset.src.split("/").pop();
+ return (
+
+
{name}
+
+ {asset.w}×{asset.h}
+ {asset.type === "video" ? " · loop" : ""}
+
+
+ );
+ }
+
+ if (asset.type === "video") {
+ return (
+
setFailed(true)}
+ />
+ );
+ }
+
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+ setFailed(true)}
+ />
+ );
+}
+
+// Click-to-copy install command — primary conversion action, kept beside the
+// first feature where the eye already is.
+function CopyCommand({ text }: { text: string }) {
+ const [copied, setCopied] = useState(false);
+ return (
+ {
+ navigator.clipboard?.writeText(text).then(() => {
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1600);
+ });
+ }}
+ className="inline-flex items-center gap-2.5 rounded-lg border border-[var(--landing-border-default)] bg-[rgba(255,240,220,0.03)] px-3.5 py-2 font-mono text-[0.8125rem] text-[var(--landing-fg)]/85 hover:border-[var(--landing-border-strong)] transition-colors"
+ >
+ $
+ {text}
+
+ {copied ? "copied" : "copy"}
+
+
+ );
+}
+
+/* ──────── 01 · Parallel sessions (illustration) ──────── */
+
+const parallelAgents = [
+ { 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 },
+];
+
+function ParallelSurface() {
return (
-
+
-
+
4 sessions · parallel
-
-
+
+
live
-
- {agents.map((a) => (
+
+ {parallelAgents.map((a) => (
-
-
-
+
+
+
{a.name}
-
+
{a.task}
-
+
-
- Fleet · 5 agents
-
-
- {fleet.map((a) => (
-
-
-
- {a.name}
-
-
- ))}
-
-
- );
-}
-
-/* ──────── 02 · Recovery ──────── */
+/* ──────── 02 · Autonomous recovery (illustration) ──────── */
const recoveryStages: { time: string; text: string; kind: "info" | "fail" | "fix" | "ok" }[] = [
{ time: "10:42", text: "agent.spawn → s-312", kind: "info" },
@@ -271,24 +358,24 @@ const recoveryStages: { time: string; text: string; kind: "info" | "fail" | "fix
{ time: "10:47", text: "● ready to merge", kind: "ok" },
];
-function RecoveryBack() {
- const [count, setCount] = useState(3);
+function RecoverySurface() {
+ const [count, setCount] = useState(4);
useEffect(() => {
const id = setInterval(() => {
- setCount((c) => (c >= recoveryStages.length ? 3 : c + 1));
- }, 1000);
+ setCount((c) => (c >= recoveryStages.length ? 4 : c + 1));
+ }, 950);
return () => clearInterval(id);
}, []);
const visible = recoveryStages.slice(0, count);
return (
-
-
+
+
PR #312 · feat/user-auth
-
+
healing
-
+
{visible.map((s, i) => {
const isLast = i === visible.length - 1;
const color =
@@ -302,9 +389,9 @@ function RecoveryBack() {
return (
-
+
{s.time}
{s.text}
@@ -316,65 +403,43 @@ function RecoveryBack() {
);
}
-function RecoveryFront() {
- return (
-
-
-
- before
-
- ✗
- 12/48
-
-
-
- after
-
- ✓
- 48/48
-
-
- );
-}
+/* ──────── 03 · Swappable slots (illustration) ──────── */
-/* ──────── 03 · Plugins ──────── */
+const slots: { slot: string; values: string[] }[] = [
+ { 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"] },
+];
-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"] },
- ];
+function SlotsSurface() {
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 1600);
return () => clearInterval(id);
}, []);
return (
-
-
-
+
+
+
agent-orchestrator.yaml
-
+
7 slots
-
+
{slots.map((s, i) => {
const val = s.values[(tick + i) % s.values.length];
return (
-
- {s.slot}:
-
+ {s.slot}:
{val}
@@ -385,176 +450,3 @@ function PluginsBack() {
);
}
-
-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 (
-
-
- swap
-
-
-
- {p.from}
-
- ⇄
-
- {p.to}
-
-
-
- );
-}
-
-/* ──────── 04 · Dashboard ──────── */
-
-type KanbanCard = {
- id: number;
- col: 0 | 1 | 2;
- title: string;
- agent: string;
- color: string;
-};
-
-function DashboardBack() {
- const [cards, setCards] = useState
([
- { 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 (
-
-
-
- my-saas-app · 4 sessions
-
-
-
- sse
-
-
-
- {cols.map((name, col) => (
-
-
- {name}
-
- {cards
- .filter((c) => c.col === col)
- .map((c) => (
-
-
{c.title}
-
-
-
- {c.agent}
-
-
-
- ))}
-
- ))}
-
-
- );
-}
-
-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 (
-
-
- s-003 · attached
- tail -f
-
-
- {stream.map((l) => (
-
- ✓ {" "}
- {l.text}
-
- ))}
-
-
- );
-}
diff --git a/frontend/src/landing/components/LandingNav.tsx b/frontend/src/landing/components/LandingNav.tsx
index b9ed3b2e..6f2584fc 100644
--- a/frontend/src/landing/components/LandingNav.tsx
+++ b/frontend/src/landing/components/LandingNav.tsx
@@ -1,5 +1,7 @@
"use client";
+import { useEffect, useRef, useState } from "react";
+
function XIcon() {
return (
@@ -24,61 +26,166 @@ function GithubIcon() {
);
}
+const navLinks = [
+ { label: "Docs", href: "/docs" },
+ { label: "Features", href: "#features" },
+ { label: "How It Works", href: "#how" },
+];
+
export function LandingNav() {
+ // The logo + right cluster collapse away on scroll-down and return on
+ // scroll-up; the centered link pill stays put the whole time.
+ const [collapsed, setCollapsed] = useState(false);
+
+ // Scroll-spy: which hash section is currently in view (null over the hero).
+ // The cursor takes over via `hovered`; the sliding highlight follows
+ // whichever is set, falling back to the active section on mouse-out.
+ const [active, setActive] = useState(null);
+ const [hovered, setHovered] = useState(null);
+ const driver = hovered ?? active;
+
+ const itemRefs = useRef>([]);
+ const [pill, setPill] = useState({ left: 0, width: 0, visible: false });
+
+ useEffect(() => {
+ let lastY = window.scrollY;
+ let raf = 0;
+ const evaluate = () => {
+ raf = 0;
+ const y = window.scrollY;
+ if (y < 80) setCollapsed(false);
+ else if (y > lastY + 4) setCollapsed(true);
+ else if (y < lastY - 4) setCollapsed(false);
+ lastY = y;
+
+ // Active section = last hash link whose section top has crossed a probe
+ // line ~35% down the viewport. Route links (e.g. /docs) are skipped.
+ const probe = y + window.innerHeight * 0.35;
+ let found: number | null = null;
+ navLinks.forEach((link, i) => {
+ if (!link.href.startsWith("#")) return;
+ const el = document.getElementById(link.href.slice(1));
+ if (el && el.offsetTop <= probe) found = i;
+ });
+ setActive(found);
+ };
+ const onScroll = () => {
+ if (!raf) raf = requestAnimationFrame(evaluate);
+ };
+ window.addEventListener("scroll", onScroll, { passive: true });
+ evaluate();
+ return () => {
+ window.removeEventListener("scroll", onScroll);
+ if (raf) cancelAnimationFrame(raf);
+ };
+ }, []);
+
+ // Reposition the sliding highlight under the driver link. Recomputed on
+ // driver change and on resize (link offsets shift with viewport width).
+ useEffect(() => {
+ const place = () => {
+ if (driver == null) {
+ setPill((p) => ({ ...p, visible: false }));
+ return;
+ }
+ const el = itemRefs.current[driver];
+ if (!el) return;
+ setPill({ left: el.offsetLeft, width: el.offsetWidth, visible: true });
+ };
+ place();
+ window.addEventListener("resize", place);
+ return () => window.removeEventListener("resize", place);
+ }, [driver]);
+
+ // Fade + slide for the side clusters; pointer-events off when hidden so the
+ // collapsed (invisible) logo/icons aren't clickable.
+ const sideStyle = (dir: "left" | "right"): React.CSSProperties => ({
+ opacity: collapsed ? 0 : 1,
+ transform: collapsed
+ ? `translateX(${dir === "left" ? "-12px" : "12px"})`
+ : "translateX(0)",
+ pointerEvents: collapsed ? "none" : "auto",
+ transition: "opacity 0.35s ease, transform 0.45s cubic-bezier(0.22,1,0.36,1)",
+ });
+
return (
-
-
-
- Agent Orchestrator
-
-
-
-
-
-
+
+
);
diff --git a/frontend/src/landing/components/LandingStats.tsx b/frontend/src/landing/components/LandingStats.tsx
deleted file mode 100644
index 823ba517..00000000
--- a/frontend/src/landing/components/LandingStats.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-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 (
-
-
- {cards.map((stat) => (
-
-
- {stat.number}
-
-
- {stat.label}
-
-
- ))}
-
-
-
- );
-}
diff --git a/frontend/src/landing/components/LandingUseCases.tsx b/frontend/src/landing/components/LandingUseCases.tsx
index 9107aa21..1ebeb549 100644
--- a/frontend/src/landing/components/LandingUseCases.tsx
+++ b/frontend/src/landing/components/LandingUseCases.tsx
@@ -65,11 +65,53 @@ const cases: UseCase[] = [
cmd: "ao start",
outcome: "3 projects · one dashboard",
},
+ {
+ eyebrow: "Steer",
+ title: "Course-correct mid-run",
+ desc: "Drop a note to a running agent without stopping it — the new instruction folds straight into its work.",
+ prefix: "$",
+ cmd: 'ao send s-312 "use the v2 endpoint"',
+ outcome: "delivered · agent adjusts",
+ },
+ {
+ eyebrow: "Orchestrate",
+ title: "An agent that runs agents",
+ desc: "A lead reads the backlog, spawns a worker per issue, and supervises every one of them to merge.",
+ prefix: "$",
+ cmd: "ao orchestrator ls",
+ outcome: "1 lead · 6 workers",
+ },
+ {
+ eyebrow: "Attach",
+ title: "Jump into any session",
+ desc: "Stream an agent's live terminal in the browser and grab the keyboard the moment you want to.",
+ prefix: "⟡",
+ cmd: "attach · s-312 /mux",
+ outcome: "live pty · streaming",
+ },
+ {
+ eyebrow: "Notify",
+ title: "Pinged only when needed",
+ desc: "Agents run quietly and reach you only when a session genuinely needs a human in the loop.",
+ prefix: "⟡",
+ cmd: "notifier · slack",
+ outcome: "needs_you → DM",
+ },
+ {
+ eyebrow: "Resume",
+ title: "Pick up where it died",
+ desc: "A session dropped? Relaunch it with its branch, worktree, and context intact.",
+ prefix: "$",
+ cmd: "ao session restore s-204",
+ outcome: "terminated → working",
+ },
];
const N = cases.length;
const THETA = 360 / N;
-const RADIUS = 440;
+// Scale the ring with the card count so cards sit in a dense, near-continuous
+// band (~one card-width of arc each) instead of drifting apart with big gaps.
+const RADIUS = Math.round(N * 60);
const CARD_W = 360;
const CARD_H = 440;
@@ -101,8 +143,13 @@ export function LandingUseCases() {
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})`;
+ // Ease the focus so the growth + brightness concentrate near dead
+ // center — the card in the middle pops, then hands off as the next
+ // one rotates in.
+ const focus = Math.pow(vis, 1.6);
+ el.style.opacity = `${0.18 + 0.82 * vis}`;
+ el.style.zIndex = `${Math.round(vis * 100)}`;
+ el.style.transform = `rotateY(${i * THETA}deg) translateZ(${RADIUS}px) scale(${0.86 + 0.22 * focus})`;
});
raf = requestAnimationFrame(loop);
};
diff --git a/frontend/src/landing/components/LandingWorkflow.tsx b/frontend/src/landing/components/LandingWorkflow.tsx
index d524cd62..e1e92005 100644
--- a/frontend/src/landing/components/LandingWorkflow.tsx
+++ b/frontend/src/landing/components/LandingWorkflow.tsx
@@ -175,7 +175,7 @@ export function LandingWorkflow() {
setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
- {/* Active icon + label */}
+ {/* Stage-proof card — swaps per milestone, cross-fades up in place.
+ Keyed on the active key so the inner reveal (and the Merged pulse)
+ replays on every landing. */}
+
+
+
+
+
+ s-312 · {cur.key}
+
+
+
+
+
+
+
+
+
+ {/* Active label */}
-
-
+
{cur.label}
-
- {cur.key}
-
{/* Ruler */}
@@ -263,10 +288,103 @@ export function LandingWorkflow() {
);
}
-function LifecycleIcon({ kind }: { kind: Milestone["icon"] }) {
+const stageDot: Record
= {
+ spawning: "var(--landing-accent)",
+ working: "rgba(96,165,250,0.85)",
+ pr_open: "rgba(167,139,250,0.85)",
+ review: "rgba(251,191,36,0.85)",
+ mergeable: "rgba(134,239,172,0.85)",
+ merged: "rgba(134,239,172,0.9)",
+};
+
+const green = "rgba(134,239,172,0.85)";
+
+// Per-stage artifact — a tiny realistic slice of that moment, in the same mono
+// vocabulary as the rest of the page. Wrapped in landing-stream-line so it
+// reveals each time the card remounts on a new landing.
+function StageArtifact({ m }: { m: Milestone }) {
+ const muted = "text-[var(--landing-muted)]";
+ const dim = "text-[var(--landing-muted-dim)]";
+ switch (m.key) {
+ case "spawning":
+ return (
+
+
+ $ ao spawn #312
+
+
+ → worktree .ao/s-312 · branch issue-312
+
+
+ );
+ case "working":
+ return (
+
+
+ ⟩ writing src/auth.ts
+
+
✓ 48 tests pass
+
+ );
+ case "pr_open":
+ return (
+
+
PR #312 · feat/user-auth
+
+ opened against main
+
+
+ );
+ case "review":
+ return (
+
+
+
+ build ✓
+
+
+ tests ✓
+
+
+
+
+ lint · agent patching
+
+
+ );
+ case "mergeable":
+ return (
+
+ ✓ all checks green
+ · 2 approvals
+
+ );
+ case "merged":
+ return (
+
+
+
+
+
+
+
+ merged into main
+
+
worktree archived · session done
+
+ );
+ default:
+ return null;
+ }
+}
+
+function LifecycleIcon({ kind, size = 30 }: { kind: Milestone["icon"]; size?: number }) {
const common = {
- width: 30,
- height: 30,
+ width: size,
+ height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "var(--landing-accent)",
diff --git a/frontend/src/landing/public/features/live.png b/frontend/src/landing/public/features/live.png
new file mode 100644
index 00000000..64e653ec
Binary files /dev/null and b/frontend/src/landing/public/features/live.png differ
diff --git a/frontend/src/landing/public/features/live.webm b/frontend/src/landing/public/features/live.webm
new file mode 100644
index 00000000..a0bbb309
Binary files /dev/null and b/frontend/src/landing/public/features/live.webm differ
diff --git a/frontend/src/landing/styles/globals.css b/frontend/src/landing/styles/globals.css
index b36d2f7f..7b38a2f3 100644
--- a/frontend/src/landing/styles/globals.css
+++ b/frontend/src/landing/styles/globals.css
@@ -101,13 +101,32 @@ a {
}
.landing-card {
- background: var(--landing-card-bg);
+ /* Lit from above: a soft warm glow pools at the top, while the body grades
+ from a gently raised tone down to near the page colour, so the card melts
+ into the background at its base instead of sitting as a flat muddy patch. */
+ background:
+ radial-gradient(
+ 135% 100% at 50% 0%,
+ rgba(255, 255, 255, 0.035),
+ rgba(255, 255, 255, 0) 55%
+ ),
+ linear-gradient(180deg, #1a1918 0%, #141312 100%);
border: 1px solid var(--landing-border-subtle);
- transition: border-color 0.2s, background 0.2s;
+ /* Inner highlight lip at the top edge + a layered drop shadow for lift and
+ clean separation when the cards stack. */
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.05),
+ 0 1px 2px rgba(0, 0, 0, 0.35),
+ 0 18px 44px -16px rgba(0, 0, 0, 0.55);
+ transition: border-color 0.25s, box-shadow 0.25s, background 0.25s;
}
.landing-card:hover {
border-color: var(--landing-border-default);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.07),
+ 0 1px 2px rgba(0, 0, 0, 0.35),
+ 0 26px 60px -16px rgba(0, 0, 0, 0.62);
}
.landing-hero-grid {
@@ -275,6 +294,48 @@ a {
animation: landing-stream-in 0.26s cubic-bezier(0.16, 1, 0.3, 1);
}
+/* Lifecycle "Merged" payoff — a single green glow + the commit dot sliding
+ onto the graph line. Both play once when the dial lands on Merged. */
+@keyframes landing-merge-pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(134, 239, 172, 0);
+ }
+ 35% {
+ box-shadow:
+ inset 0 0 0 1px rgba(134, 239, 172, 0.35),
+ 0 0 26px -6px rgba(134, 239, 172, 0.4);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(134, 239, 172, 0);
+ }
+}
+
+.landing-merge-pulse {
+ animation: landing-merge-pulse 1.4s ease-out;
+}
+
+@keyframes landing-commit-dot {
+ from {
+ transform: translateX(-14px);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+.landing-commit-dot {
+ animation: landing-commit-dot 0.55s cubic-bezier(0.22, 1, 0.36, 1) 0.15s both;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .landing-merge-pulse,
+ .landing-commit-dot {
+ animation: none;
+ }
+}
+
@keyframes landing-sse-pulse {
0%,
100% {
@@ -289,6 +350,60 @@ a {
animation: landing-sse-pulse 1.6s ease-in-out infinite;
}
+/* Quietly drifting "chaos" cards — a few px of out-of-phase vertical float, so
+ the scattered pile reads as restless without ever looking like it shakes. */
+@keyframes landing-tab-float {
+ 0%,
+ 100% {
+ transform: translateY(0) rotate(var(--rot, 0deg));
+ }
+ 50% {
+ transform: translateY(-6px) rotate(var(--rot, 0deg));
+ }
+}
+
+.landing-tab {
+ transform: rotate(var(--rot, 0deg));
+ animation: landing-tab-float var(--dur, 5.5s) ease-in-out infinite;
+ animation-delay: var(--delay, 0s);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .landing-tab {
+ animation: none;
+ transform: rotate(var(--rot, 0deg));
+ }
+}
+
+/* Skeleton shimmer for asset placeholders awaiting a real capture */
+@keyframes landing-shimmer {
+ from {
+ transform: translateX(-100%);
+ }
+ to {
+ transform: translateX(100%);
+ }
+}
+
+.landing-shimmer::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.025),
+ transparent
+ );
+ animation: landing-shimmer 1.8s ease-in-out infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .landing-shimmer::after {
+ animation: none;
+ }
+}
+
/* Switcher progress bar */
@keyframes landing-switcher-progress {
from {