- );
-}
diff --git a/ui/src/components/OnboardingWizardVariant.test.tsx b/ui/src/components/OnboardingWizardVariant.test.tsx
index 0381e60698..b52aeda4a2 100644
--- a/ui/src/components/OnboardingWizardVariant.test.tsx
+++ b/ui/src/components/OnboardingWizardVariant.test.tsx
@@ -3,25 +3,51 @@
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// The shell reads dialog + company state and renders CloudOnboardingFlow. Mock those
+// so the test exercises the shell's gating + option→prop mapping in isolation.
+const dialogState = vi.hoisted(() => ({ value: null as unknown }));
+vi.mock("@/context/DialogContext", () => ({
+ useDialog: () => dialogState.value,
+}));
+vi.mock("@/context/CompanyContext", () => ({
+ useCompany: () => ({
+ companies: [{ id: "c1", issuePrefix: "ACME", name: "Acme" }],
+ loading: false,
+ }),
+}));
+vi.mock("@/lib/router", () => ({
+ useLocation: () => ({ pathname: "/dashboard" }),
+ useParams: () => ({}),
+}));
+vi.mock("./onboarding/CloudOnboardingFlow", () => ({
+ CloudOnboardingFlow: (props: { initialStep?: string; existingCompany?: { id: string } }) => (
+
+ ),
+}));
+
import { OnboardingWizardVariant } from "./OnboardingWizardVariant";
-const mockInstanceSettingsApi = vi.hoisted(() => ({
- getExperimental: vi.fn(),
-}));
+function baseDialog(overrides: Record = {}) {
+ return {
+ onboardingOpen: false,
+ onboardingOptions: {},
+ closeOnboarding: vi.fn(),
+ onboardingRouteDismissed: true,
+ setOnboardingRouteDismissed: vi.fn(),
+ ...overrides,
+ };
+}
-vi.mock("@/api/instanceSettings", () => ({
- instanceSettingsApi: mockInstanceSettingsApi,
-}));
-
-vi.mock("./OnboardingWizard", () => ({
- OnboardingWizard: () => ,
-}));
-
-describe("OnboardingWizardVariant (PAP-138)", () => {
+describe("OnboardingWizardVariant", () => {
let container: HTMLDivElement;
let root: Root | null = null;
- function renderVariant() {
+ function render() {
root = createRoot(container);
flushSync(() => {
root!.render();
@@ -42,11 +68,29 @@ describe("OnboardingWizardVariant (PAP-138)", () => {
vi.clearAllMocks();
});
- it("renders the capsule wizard without reading the chat flag", () => {
- mockInstanceSettingsApi.getExperimental.mockResolvedValue({});
- renderVariant();
+ it("renders nothing when onboarding is closed", () => {
+ dialogState.value = baseDialog();
+ render();
+ expect(container.querySelector('[data-testid="onboarding-flow"]')).toBeNull();
+ });
- expect(container.querySelector('[data-testid="wizard-capsule"]')).not.toBeNull();
- expect(mockInstanceSettingsApi.getExperimental).not.toHaveBeenCalled();
+ it("renders the flow at the start step for a fresh open", () => {
+ dialogState.value = baseDialog({ onboardingOpen: true, onboardingOptions: {} });
+ render();
+ const el = container.querySelector('[data-testid="onboarding-flow"]');
+ expect(el).not.toBeNull();
+ expect(el?.getAttribute("data-initial-step")).toBe("start");
+ expect(el?.getAttribute("data-company")).toBe("");
+ });
+
+ it("starts at the agent step with the company preset when adding to an existing company", () => {
+ dialogState.value = baseDialog({
+ onboardingOpen: true,
+ onboardingOptions: { initialStep: 2, companyId: "c1" },
+ });
+ render();
+ const el = container.querySelector('[data-testid="onboarding-flow"]');
+ expect(el?.getAttribute("data-initial-step")).toBe("agent");
+ expect(el?.getAttribute("data-company")).toBe("c1");
});
});
diff --git a/ui/src/components/OnboardingWizardVariant.tsx b/ui/src/components/OnboardingWizardVariant.tsx
index a6a6ee3cc0..1254edb84e 100644
--- a/ui/src/components/OnboardingWizardVariant.tsx
+++ b/ui/src/components/OnboardingWizardVariant.tsx
@@ -1,10 +1,72 @@
-import { OnboardingWizard } from "./OnboardingWizard";
+import { useEffect } from "react";
+import { useLocation, useParams } from "@/lib/router";
+import { useDialog } from "@/context/DialogContext";
+import { useCompany } from "@/context/CompanyContext";
+import { resolveRouteOnboardingOptions } from "@/lib/onboarding-route";
+import { CloudOnboardingFlow } from "./onboarding/CloudOnboardingFlow";
/**
- * Default onboarding wizard. Conference-room chat is now the only surface left
- * behind `enableConferenceRoomChat`; onboarding stays available without that
- * experimental flag.
+ * App-integration shell for onboarding. Owns the open/close + route logic and
+ * renders the CloudOnboardingFlow (the real app targets the cloud flow; the
+ * local flow is harness-only for now). Onboarding opens either explicitly via
+ * the dialog context (openOnboarding) or automatically on the /onboarding route
+ * (and its /:companyPrefix/onboarding "add an agent" variant).
*/
export function OnboardingWizardVariant() {
- return ;
+ const {
+ onboardingOpen,
+ onboardingOptions,
+ closeOnboarding,
+ onboardingRouteDismissed,
+ setOnboardingRouteDismissed,
+ } = useDialog();
+ const { companies, loading: companiesLoading } = useCompany();
+ const location = useLocation();
+ const { companyPrefix } = useParams<{ companyPrefix?: string }>();
+
+ // Reset the route-dismissed flag when navigating to a different path, so the
+ // route can re-open onboarding after the user has dismissed it elsewhere.
+ useEffect(() => {
+ setOnboardingRouteDismissed(false);
+ }, [location.pathname]);
+
+ // Support opening from the /onboarding route in addition to the dialog. Wait
+ // for companies to load before resolving a company-scoped route so we don't
+ // momentarily treat an existing-company entry as a fresh one.
+ const routeOptions =
+ companyPrefix && companiesLoading
+ ? null
+ : resolveRouteOnboardingOptions({
+ pathname: location.pathname,
+ companyPrefix,
+ companies,
+ });
+
+ const open = onboardingOpen || (routeOptions !== null && !onboardingRouteDismissed);
+ if (!open) return null;
+
+ const options = onboardingOpen ? onboardingOptions : routeOptions ?? {};
+ const existingCompany = options.companyId
+ ? companies.find((company) => company.id === options.companyId)
+ : undefined;
+
+ function handleClose() {
+ closeOnboarding();
+ // On the /onboarding route the shell is also kept open by the route itself,
+ // so closing must mark the route dismissed — otherwise `open` stays true and
+ // the flow re-renders instead of handing back to the launcher card (PAP-52).
+ setOnboardingRouteDismissed(true);
+ }
+
+ return (
+
+ );
}
diff --git a/ui/src/components/onboarding/AgentPreview.tsx b/ui/src/components/onboarding/AgentPreview.tsx
new file mode 100644
index 0000000000..0ca3a1c38a
--- /dev/null
+++ b/ui/src/components/onboarding/AgentPreview.tsx
@@ -0,0 +1,47 @@
+import { motion } from "motion/react";
+import { PREVIEW_REVEAL_DURATION, STEP_EASE } from "./onboarding-motion";
+
+/**
+ * Name/role preview under the capsule (agent + task steps). Collapsed (zero
+ * height) until an identity exists, so the step opens compact; on selection the
+ * block grows to full height — the viewport-centered card re-centers each
+ * frame, so the capsule slides smoothly up into place — while the labels fade
+ * in without moving. Slide and fade share one duration; the fade starts after
+ * 25% of it. Both lines keep fixed heights once visible so typing a name
+ * afterwards never shifts the layout again.
+ */
+export function AgentPreview({
+ agentName,
+ agentRole,
+}: {
+ agentName: string;
+ agentRole: string;
+}) {
+ const previewVisible = Boolean(agentName || agentRole);
+ return (
+
+
+
+ {agentName || " "}
+
+
+ {agentRole || " "}
+
+
+
+ );
+}
diff --git a/ui/src/components/onboarding/CloudOnboardingFlow.tsx b/ui/src/components/onboarding/CloudOnboardingFlow.tsx
new file mode 100644
index 0000000000..0bf5d3ac20
--- /dev/null
+++ b/ui/src/components/onboarding/CloudOnboardingFlow.tsx
@@ -0,0 +1,165 @@
+// Cloud onboarding flow — the thin container that owns state + backend wiring
+// and composes the shared step views inside the shared OnboardingScaffold.
+// Advances Start → Company → Agent → Task, then launches the first task and
+// opens the dashboard. The local flow (LocalOnboardingFlow) reuses the same
+// shared steps + shell and inserts its extra steps.
+import { useState } from "react";
+import { useNavigate } from "@/lib/router";
+import { useOnboardingFlow } from "@/hooks/useOnboardingFlow";
+import { firstTaskPayload, ROLE_ACRONYMS, type FirstTaskChoice } from "./onboarding-data";
+import { OnboardingScaffold } from "./OnboardingScaffold";
+import { StartStep } from "./steps/StartStep";
+import { CompanyStep } from "./steps/CompanyStep";
+import { AgentStep } from "./steps/AgentStep";
+import { TaskStep } from "./steps/TaskStep";
+
+type Step = "start" | "company" | "agent" | "task";
+
+const DEFAULT_ADAPTER = "claude_local";
+
+// Numbered steps drive the Stepper position ("Step N of M").
+const NUMBERED: Step[] = ["company", "agent", "task"];
+function stepper(s: Step) {
+ return { step: NUMBERED.indexOf(s) + 1, total: NUMBERED.length };
+}
+
+export interface CloudOnboardingFlowProps {
+ /** Called when the user dismisses onboarding. */
+ onClose?: () => void;
+ /** Starting step. Defaults to "start". Used by the standalone preview harness. */
+ initialStep?: Step;
+ /**
+ * Preview-only: skip all backend calls (company/agent/task creation) so the
+ * flow can be clicked end-to-end without a backend. Used by the standalone
+ * preview harness — never enabled in the real app.
+ */
+ previewMock?: boolean;
+ /**
+ * When set, onboarding runs against an already-created company (the "add an
+ * agent to an existing company" entry): company creation is skipped and the
+ * flow starts at the agent step.
+ */
+ existingCompany?: { id: string; prefix: string | null };
+}
+
+export function CloudOnboardingFlow({
+ onClose,
+ initialStep = "start",
+ previewMock = false,
+ existingCompany,
+}: CloudOnboardingFlowProps) {
+ const navigate = useNavigate();
+ const flow = useOnboardingFlow(
+ existingCompany
+ ? { createdCompanyId: existingCompany.id, createdCompanyPrefix: existingCompany.prefix }
+ : undefined,
+ );
+
+ const [step, setStep] = useState(initialStep);
+ const [companyName, setCompanyName] = useState("");
+ const [mission, setMission] = useState("");
+ const [agentRole, setAgentRole] = useState("");
+ const [agentName, setAgentName] = useState("");
+ const [taskChoice, setTaskChoice] = useState(null);
+ const [customTask, setCustomTask] = useState("");
+
+ function handleRoleChange(value: string) {
+ setAgentRole(value);
+ setAgentName(value in ROLE_ACRONYMS ? ROLE_ACRONYMS[value] : "");
+ }
+
+ async function handleCreateCompany() {
+ if (previewMock) {
+ setStep("agent");
+ return;
+ }
+ const result = await flow.createCompanyAndGoal({ companyName, companyGoal: mission });
+ if (result) setStep("agent");
+ }
+
+ async function handleCreateAgent() {
+ if (previewMock) {
+ setStep("task");
+ return;
+ }
+ const result = await flow.hireLeadAgent({
+ agentName: agentName || agentRole,
+ adapter: { adapterType: DEFAULT_ADAPTER, model: "", command: "", args: "", url: "" },
+ instructions: {
+ companyName,
+ companyGoal: mission,
+ growPath: false,
+ growWorkflows: "",
+ growPainPoints: "",
+ growAutomate: "",
+ q1: "",
+ q2: "",
+ q3: "",
+ q4: "",
+ },
+ // The cloud agent step has no environment probe; keep hire simple.
+ requireEnvProbe: false,
+ });
+ if (result) setStep("task");
+ }
+
+ async function handleGetStarted() {
+ if (!taskChoice) return;
+ if (previewMock) {
+ window.alert(
+ "Preview complete — in the real app this launches the first task and opens your dashboard.",
+ );
+ return;
+ }
+ const result = await flow.launchFirstTask(firstTaskPayload(taskChoice, customTask));
+ if (result) {
+ onClose?.();
+ navigate(result.companyPrefix ? `/${result.companyPrefix}/dashboard` : "/dashboard");
+ }
+ }
+
+ return (
+
+ {step === "start" && setStep("company")} />}
+ {step === "company" && (
+ setStep("start")}
+ onNext={handleCreateCompany}
+ loading={flow.loading}
+ />
+ )}
+ {step === "agent" && (
+ onClose?.() : () => setStep("company")}
+ onNext={handleCreateAgent}
+ loading={flow.loading}
+ />
+ )}
+ {step === "task" && (
+ setStep("agent")}
+ onGetStarted={handleGetStarted}
+ loading={flow.loading}
+ error={flow.error}
+ />
+ )}
+
+ );
+}
diff --git a/ui/src/components/onboarding/FooterNav.tsx b/ui/src/components/onboarding/FooterNav.tsx
new file mode 100644
index 0000000000..1f5bec15e6
--- /dev/null
+++ b/ui/src/components/onboarding/FooterNav.tsx
@@ -0,0 +1,50 @@
+import { ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+/**
+ * Shared footer navigation for onboarding step cards: a ghost pill "Back"
+ * button (visible on hover) and a primary pill CTA that shows a spinner +
+ * loading label while its action runs.
+ */
+export function FooterNav({
+ onBack,
+ primaryLabel,
+ primaryDisabled,
+ loading,
+ loadingLabel,
+ onPrimary,
+}: {
+ onBack: () => void;
+ primaryLabel: string;
+ primaryDisabled?: boolean;
+ loading?: boolean;
+ loadingLabel?: string;
+ onPrimary: () => void;
+}) {
+ return (
+
+ {/* has-[>svg]:pr-4 gives the "Back" text room from the pill's right edge
+ (overrides size="sm"'s has-[>svg]:px-2.5 for the right side only). */}
+
+
+
+ );
+}
diff --git a/ui/src/components/onboarding/GithubStarInterstitial.tsx b/ui/src/components/onboarding/GithubStarInterstitial.tsx
new file mode 100644
index 0000000000..526cc682ac
--- /dev/null
+++ b/ui/src/components/onboarding/GithubStarInterstitial.tsx
@@ -0,0 +1,44 @@
+import { Github, Star } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { OnboardingCard, OnboardingHeading } from "./OnboardingPrimitives";
+
+const PAPERCLIP_REPO_URL = "https://github.com/paperclipai/paperclip";
+
+/**
+ * Local-flow only: a warm "star us on GitHub" interstitial shown after "Get
+ * started", before landing in the app. "Star on GitHub" opens the repo in a new
+ * tab and continues; "Not right now" just continues. Both call onContinue,
+ * which runs the same completion as the cloud flow (launch task → dashboard).
+ */
+export function GithubStarInterstitial({ onContinue }: { onContinue: () => void }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/onboarding/LocalOnboardingFlow.tsx b/ui/src/components/onboarding/LocalOnboardingFlow.tsx
new file mode 100644
index 0000000000..da9d3666a1
--- /dev/null
+++ b/ui/src/components/onboarding/LocalOnboardingFlow.tsx
@@ -0,0 +1,228 @@
+// Local onboarding flow — the thin container for the local (self-hosted) app.
+// Reuses the shared step views + shell (OnboardingScaffold) and inserts the
+// local-only pieces: an optional email ask before the numbered steps, a
+// model/adapter step after naming the agent (which runs the env probe on hire),
+// and a "star us on GitHub" interstitial after "Get started".
+//
+// Order: Start → Email → Company(1) → Agent(2) → Adapter(3) → Task(4)
+// → [Get started] → GitHub-star interstitial → dashboard.
+import { useEffect, useRef, useState } from "react";
+import { useNavigate } from "@/lib/router";
+import { useOnboardingFlow } from "@/hooks/useOnboardingFlow";
+import { firstTaskPayload, ROLE_ACRONYMS, type FirstTaskChoice } from "./onboarding-data";
+import { OnboardingScaffold } from "./OnboardingScaffold";
+import { AUTH_EXIT_DURATION, OnboardingAuthBackdrop } from "./OnboardingAuthBackdrop";
+import { GithubStarInterstitial } from "./GithubStarInterstitial";
+import { StartStep } from "./steps/StartStep";
+import { EmailStep } from "./steps/EmailStep";
+import { CompanyStep } from "./steps/CompanyStep";
+import { AgentStep } from "./steps/AgentStep";
+import { AdapterStep } from "./steps/AdapterStep";
+import { TaskStep } from "./steps/TaskStep";
+
+type Step = "start" | "email" | "company" | "agent" | "adapter" | "task";
+
+const DEFAULT_ADAPTER = "claude_local";
+
+// Numbered steps drive the Stepper position ("Step N of M").
+const NUMBERED: Step[] = ["company", "agent", "adapter", "task"];
+function stepper(s: Step) {
+ return { step: NUMBERED.indexOf(s) + 1, total: NUMBERED.length };
+}
+
+export interface LocalOnboardingFlowProps {
+ onClose?: () => void;
+ initialStep?: Step;
+ previewMock?: boolean;
+}
+
+export function LocalOnboardingFlow({
+ onClose,
+ initialStep = "start",
+ previewMock = false,
+}: LocalOnboardingFlowProps) {
+ const navigate = useNavigate();
+ const flow = useOnboardingFlow();
+
+ const [step, setStep] = useState(initialStep);
+ const [showGithubStar, setShowGithubStar] = useState(false);
+ // The welcome screen is rendered by OnboardingAuthBackdrop — the same shell
+ // the cloud flow's auth screens use — so leaving it plays the identical
+ // hand-off: card lowers + fades while the paperclip fades out, and only once
+ // that finishes does the step shell mount. "exiting" holds that gap.
+ const [welcomePhase, setWelcomePhase] = useState<"welcome" | "exiting" | "done">(
+ initialStep === "start" ? "welcome" : "done",
+ );
+ const welcomeTimer = useRef(undefined);
+
+ useEffect(() => () => window.clearTimeout(welcomeTimer.current), []);
+
+ function leaveWelcome() {
+ setWelcomePhase("exiting");
+ welcomeTimer.current = window.setTimeout(() => {
+ setStep("email");
+ setWelcomePhase("done");
+ }, AUTH_EXIT_DURATION * 1000);
+ }
+
+ const [email, setEmail] = useState("");
+ const [companyName, setCompanyName] = useState("");
+ const [mission, setMission] = useState("");
+ const [agentRole, setAgentRole] = useState("");
+ const [agentName, setAgentName] = useState("");
+ const [adapterType, setAdapterType] = useState(DEFAULT_ADAPTER);
+ const [taskChoice, setTaskChoice] = useState(null);
+ const [customTask, setCustomTask] = useState("");
+
+ const adapterInput = { adapterType, model: "", command: "", args: "", url: "" };
+
+ // Switching adapters invalidates the previous adapter's environment probe:
+ // drop it so the retry re-probes and the step stops showing a verdict about
+ // an adapter the user is no longer hiring on.
+ function handleAdapterChange(value: string) {
+ setAdapterType(value);
+ flow.clearAdapterEnvResult();
+ }
+
+ function handleRoleChange(value: string) {
+ setAgentRole(value);
+ setAgentName(value in ROLE_ACRONYMS ? ROLE_ACRONYMS[value] : "");
+ }
+
+ async function handleCreateCompany() {
+ if (previewMock) {
+ setStep("agent");
+ return;
+ }
+ const result = await flow.createCompanyAndGoal({ companyName, companyGoal: mission });
+ if (result) setStep("agent");
+ }
+
+ // Adapter step → hire the lead agent on the chosen local adapter, requiring
+ // the environment probe to pass (the local flow's key difference from cloud).
+ async function handleHireAgent() {
+ if (previewMock) {
+ setStep("task");
+ return;
+ }
+ const result = await flow.hireLeadAgent({
+ agentName: agentName || agentRole,
+ adapter: adapterInput,
+ instructions: {
+ companyName,
+ companyGoal: mission,
+ growPath: false,
+ growWorkflows: "",
+ growPainPoints: "",
+ growAutomate: "",
+ q1: "",
+ q2: "",
+ q3: "",
+ q4: "",
+ },
+ requireEnvProbe: true,
+ });
+ if (result) setStep("task");
+ }
+
+ function handleGetStarted() {
+ if (!taskChoice) return;
+ setShowGithubStar(true);
+ }
+
+ // Runs from the interstitial's CTAs — the same completion as the cloud flow.
+ async function completeOnboarding() {
+ if (!taskChoice) return;
+ if (previewMock) {
+ window.alert(
+ "Preview complete — in the real app this launches the first task and opens your dashboard.",
+ );
+ return;
+ }
+ const result = await flow.launchFirstTask(firstTaskPayload(taskChoice, customTask));
+ if (result) {
+ onClose?.();
+ navigate(result.companyPrefix ? `/${result.companyPrefix}/dashboard` : "/dashboard");
+ }
+ }
+
+ // Welcome screen: same shell + exit transition as the cloud auth screens.
+ // The local flow skips sign-in, so this screen carries the orbiting paperclip.
+ if (welcomePhase !== "done") {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {showGithubStar ? (
+
+ ) : (
+ <>
+ {step === "email" && (
+ setStep("company")}
+ onSkip={() => setStep("company")}
+ />
+ )}
+ {step === "company" && (
+ setStep("email")}
+ onNext={handleCreateCompany}
+ loading={flow.loading}
+ />
+ )}
+ {step === "agent" && (
+ setStep("company")}
+ onNext={() => setStep("adapter")}
+ primaryLabel="Next"
+ />
+ )}
+ {step === "adapter" && (
+ setStep("agent")}
+ onNext={handleHireAgent}
+ loading={flow.loading}
+ />
+ )}
+ {step === "task" && (
+ setStep("adapter")}
+ onGetStarted={handleGetStarted}
+ loading={flow.loading}
+ error={flow.error}
+ />
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/ui/src/components/onboarding/OnboardingAuthBackdrop.tsx b/ui/src/components/onboarding/OnboardingAuthBackdrop.tsx
new file mode 100644
index 0000000000..dfeb4c0002
--- /dev/null
+++ b/ui/src/components/onboarding/OnboardingAuthBackdrop.tsx
@@ -0,0 +1,102 @@
+import { lazy, Suspense, type ReactNode } from "react";
+import { AnimatePresence, motion } from "motion/react";
+import { X } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { STEP_EASE } from "./onboarding-motion";
+
+// three.js is large, so the orbiting-paperclip canvas is code-split: it only
+// downloads when the auth screens actually render.
+const PaperclipOrbit3D = lazy(() =>
+ import("./PaperclipOrbit3D").then((m) => ({ default: m.PaperclipOrbit3D })),
+);
+
+/** Duration of the hand-off out of the auth screens (modal + backdrop together). */
+export const AUTH_EXIT_DURATION = 0.35;
+
+/**
+ * The orbiting-paperclip layer plus its scrim, as a non-interactive fill layer.
+ * Used behind the auth screens (cloud) and behind the welcome screen (local,
+ * which skips auth entirely). The scrim keeps the page calm without muting the
+ * gradient; panels above it are translucent so the motif reads through.
+ */
+export function PaperclipBackdropLayer({ className }: { className?: string }) {
+ return (
+
+
+
+
+ {/* Very slight darkening scrim — enough to settle the backdrop behind the
+ panels without muting the gradient. Applies to every screen that shows
+ the paperclip (cloud auth screens + the local welcome screen), since
+ both render through this layer. */}
+
+
+ );
+}
+
+/**
+ * Full-screen shell for the onboarding auth screens (create account / verify
+ * code), with the orbiting 3D paperclip behind the card.
+ *
+ * On completion (`visible` → false) the card settles down and fades while the
+ * backdrop fades out at the same time — a quick, subtle hand-off into whatever
+ * comes next. Render this around the auth screens and flip `visible` when the
+ * user finishes signing up or logging in.
+ */
+export function OnboardingAuthBackdrop({
+ visible,
+ onClose,
+ children,
+}: {
+ visible: boolean;
+ /** Optional dismiss affordance, matching OnboardingScaffold's close button. */
+ onClose?: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+ {visible ? (
+
+ {/* Orbiting paperclip backdrop — sits behind the card, non-interactive.
+ It fades with the container; a dimming overlay keeps the card
+ legible over the bright gradient. */}
+
+
+
+
+ {onClose ? (
+
+ ) : null}
+
+ {/* The card lowers slightly and fades on the way out. */}
+
+ {children}
+
+
+ ) : null}
+
+ );
+}
diff --git a/ui/src/components/onboarding/OnboardingAuthScreens.tsx b/ui/src/components/onboarding/OnboardingAuthScreens.tsx
new file mode 100644
index 0000000000..eeb06dc3b0
--- /dev/null
+++ b/ui/src/components/onboarding/OnboardingAuthScreens.tsx
@@ -0,0 +1,163 @@
+// Presentational-only auth screens (create account + email OTP), ported from
+// the prototype onto the repo's design tokens. These are NOT wired to real
+// auth — the app authenticates via better-auth upstream of onboarding. They
+// exist so the full onboarding visual arc can be previewed. See the preview
+// harness (onboarding-preview-main.tsx).
+import { useRef, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { cn } from "@/lib/utils";
+import { OnboardingCard, OnboardingHeading } from "./OnboardingPrimitives";
+
+function GoogleMark({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function GithubMark({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function SocialButton({ mark, label }: { mark: React.ReactNode; label: string }) {
+ return (
+
+ );
+}
+
+/** Create-account screen (visual only). */
+export function AccountScreen({ onContinue }: { onContinue?: () => void }) {
+ return (
+
+
+
+
+
+
+
+
+ We'll send you a 6-digit code to confirm your email.
+
+
+ Didn't get it?{" "}
+ Resend code
+ {" · "}
+
+ Use a different email
+
+
+
+
+ );
+}
diff --git a/ui/src/components/onboarding/OnboardingPrimitives.tsx b/ui/src/components/onboarding/OnboardingPrimitives.tsx
new file mode 100644
index 0000000000..8e866e6755
--- /dev/null
+++ b/ui/src/components/onboarding/OnboardingPrimitives.tsx
@@ -0,0 +1,188 @@
+// Shared presentational primitives for the onboarding flow. Ported from the
+// prototype's hand-written CSS onto the repo's design tokens (semantic colors,
+// Tailwind v4). No backend logic lives here — these are pure view pieces.
+import type { ReactNode } from "react";
+import { Check } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { ConnectorOption } from "./onboarding-data";
+
+/**
+ * The centered card frame every onboarding step sits in.
+ *
+ * `translucent` renders the panel at 95% so an animated backdrop (the orbiting
+ * paperclip on the auth + welcome screens) reads through it, per the design.
+ */
+export function OnboardingCard({
+ children,
+ className,
+ translucent,
+}: {
+ children: ReactNode;
+ className?: string;
+ translucent?: boolean;
+}) {
+ return (
+
+ );
+}
+
+/** Segmented progress bar with "Step N of M" meta. `total` defaults to 3. */
+export function Stepper({ step, total = 3 }: { step: number; total?: number }) {
+ return (
+
+
+ {Array.from({ length: total }, (_, i) => i + 1).map((s) => (
+
+ ))}
+
+ );
+}
diff --git a/ui/src/components/onboarding/OnboardingScaffold.tsx b/ui/src/components/onboarding/OnboardingScaffold.tsx
new file mode 100644
index 0000000000..4ef9ceef55
--- /dev/null
+++ b/ui/src/components/onboarding/OnboardingScaffold.tsx
@@ -0,0 +1,42 @@
+import type { ReactNode } from "react";
+import { AnimatePresence, MotionConfig, motion } from "motion/react";
+import { X } from "lucide-react";
+import { stepMotion } from "./onboarding-motion";
+
+/**
+ * Full-screen animated shell shared by both onboarding flows. Owns the close
+ * button and the single `AnimatePresence`/keyed `motion.div` crossfade, so both
+ * flows animate step transitions identically. `stepKey` must change whenever the
+ * rendered step changes (that's what drives the enter/exit).
+ */
+export function OnboardingScaffold({
+ stepKey,
+ onClose,
+ children,
+}: {
+ stepKey: string;
+ onClose?: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/onboarding/PaperclipOrbit3D.tsx b/ui/src/components/onboarding/PaperclipOrbit3D.tsx
new file mode 100644
index 0000000000..971ef51bfc
--- /dev/null
+++ b/ui/src/components/onboarding/PaperclipOrbit3D.tsx
@@ -0,0 +1,335 @@
+// Orbiting 3D paperclip — the animated backdrop behind the onboarding auth
+// screens. Ported from the Paperclip graphic generator's 3D tab
+// (paperclip-gen.vercel.app) with its default settings plus auto-orbit on:
+// the same SVG-derived clip path, tube/cap geometry, "soft" gradient shader,
+// and orbit math, so the motif matches the generator exactly.
+//
+// three.js is heavy, so this module is loaded lazily (see OnboardingBackdrop).
+import { useEffect, useRef } from "react";
+import * as THREE from "three";
+
+// ── Generator defaults (3D tab) ───────────────────────────────────────────
+const TUBE_RADIUS = 0.225;
+const TUBE_SEG = 48; // radial segments
+const PATH_SEG = 300; // segments along the clip path
+const CAPS = true; // hemispherical caps
+const LIGHT_SOFT = 0;
+const LIGHT_ANGLE = 3.87;
+const EMISSIVE = 1;
+const CAM_DIST = 5;
+const FOV = 45;
+/** Auto-orbit speed. 0.5 was the generator default; slowed 25% to 0.375. */
+const ROT_SPEED = 0.375;
+/** Tilt oscillation rate. Slowed 25% alongside ROT_SPEED so the orbit's
+ * rotation and tilt stay in the same relationship, just 25% calmer. */
+const TILT_RATE = 0.2625;
+/** Tilt amplitude of the orbit. Pace is set by the sin() frequency below, so
+ * raising this swings further without speeding the motion up. */
+const AXIS_TILT = 1.2;
+/** Clip is scaled up 30% from the generator's default framing. */
+const CLIP_UPSCALE = 1.3;
+
+// Colour: instead of the generator's "animate gradient" (which scrolls the
+// gradient ALONG the clip), the gradient stops hold their position and the two
+// stops slowly crossfade through this palette, so the whole clip cycles hue as
+// it orbits. Drawn from the brand agent-gradient stops, limited to the
+// saturated ones — the brand pastels (cream/peach/lavender) read as washed-out
+// grey against the dark backdrop.
+const PALETTE: Array<[string, string]> = [
+ ["#4fbcba", "#3aa35c"], // teal → green (the reference comp's colourway)
+ ["#3aa35c", "#f2d95f"], // green → yellow
+ ["#e3a21a", "#e94b27"], // amber → orange-red
+ ["#ee79a1", "#bd7ff0"], // pink → purple
+ ["#7eb6e3", "#3355ff"], // light blue → blue
+ ["#3355ff", "#cc3388"], // blue → magenta (generator default)
+];
+/** Seconds each palette pair holds before crossfading into the next. */
+const PALETTE_CYCLE_SECONDS = 9;
+/**
+ * Static gradient position along the path (no scrolling — "animate gradient" is
+ * off). Must stay 0: the shader wraps the gradient with mod(t, 1.0), and any
+ * non-zero shift puts that wrap seam partway along the clip, which makes the
+ * end caps (whose gradient-t is pinned to the path ends) sample across the seam
+ * and read as a different colour from the tube beside them.
+ */
+const GRADIENT_SHIFT = 0;
+
+// The clip motif: an SVG path sampled into a Catmull-Rom curve. Scale + center
+// come from the generator so proportions match.
+const CLIP_PATH_D =
+ "M5.00146 4.99569V21.005C5.00146 22.1084 5.89689 23.0028 7.00146 23.0028C8.10603 23.0028 9.00147 22.1084 9.00147 21.005L9 4.99569C9 2.78893 7.20914 1 5 1C2.79086 1 1 2.78893 1 4.99569V21.005C1 24.3159 3.68695 27 7.00146 27C10.316 27 13.0029 24.3159 13.0029 21.005L13.0029 4.99569";
+const CLIP_CENTER = { cx: 7, cy: 14 };
+const CLIP_SCALE = 1 / 6.5;
+const CURVE_SAMPLES = 200;
+
+const VERTEX_SHADER = `
+ attribute float aPathT;
+ attribute float aCapT; // -1.0 for tube verts; explicit gradient-t for cap verts
+ varying vec3 vWorldNormal;
+ varying float vPathT;
+ varying float vCapT;
+ void main() {
+ vWorldNormal = normalize((modelMatrix * vec4(normal, 0.0)).xyz);
+ vPathT = aPathT;
+ vCapT = aCapT;
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
+ }
+`;
+
+const FRAGMENT_SHADER = `
+ #define MAX_COLORS 8
+ uniform vec3 uColors[MAX_COLORS];
+ uniform int uNColors;
+ uniform float uShift;
+ uniform float uSoftness;
+ uniform vec3 uLightDir;
+ uniform float uEmissive;
+ varying vec3 vWorldNormal;
+ varying float vPathT;
+ varying float vCapT;
+
+ vec3 sampleGradAt(float t) {
+ float n = float(uNColors);
+ float nc = n;
+ float pos = clamp(t * (n - 1.0), 0.0, n - 1.0001);
+ int idx = int(pos);
+ float f = smoothstep(0.0, 1.0, pos - float(idx));
+ vec3 cols[MAX_COLORS];
+ cols[0]=uColors[0];cols[1]=uColors[1];cols[2]=uColors[2];cols[3]=uColors[3];
+ cols[4]=uColors[4];cols[5]=uColors[5];cols[6]=uColors[6];cols[7]=uColors[7];
+ int i0 = int(mod(float(idx), nc));
+ int i1 = int(mod(float(idx+1), nc));
+ return mix(cols[i0], cols[i1], f);
+ }
+
+ void main() {
+ float shiftT = uShift / (2.0 * 3.14159265);
+ float rawT = (vCapT >= 0.0) ? vCapT : vPathT;
+ float nCols = float(uNColors);
+ float scaled = rawT * (nCols - 1.0) / nCols;
+ float t = mod(scaled + shiftT, 1.0);
+ vec3 baseColor = sampleGradAt(t);
+
+ vec3 nor = normalize(vWorldNormal);
+ float wrap = mix(0.0, 0.6, uSoftness);
+ float NdotL = dot(nor, normalize(uLightDir));
+ float diff = smoothstep(0.0, 1.0, clamp((NdotL + wrap) / (1.0 + wrap), 0.0, 1.0));
+ float ambient = mix(0.3, 0.6, uSoftness);
+ vec3 lit = baseColor * (ambient + diff * (1.0 - ambient));
+ lit = mix(lit, baseColor, uEmissive * 0.85);
+ lit = pow(max(lit, vec3(0.0)), vec3(1.0 / 2.2));
+ gl_FragColor = vec4(lit, 1.0);
+ }
+`;
+
+/** Sample the clip SVG path into a centered, scaled Catmull-Rom curve. */
+function buildClipCurve(): THREE.CatmullRomCurve3 {
+ const svgNS = "http://www.w3.org/2000/svg";
+ const svg = document.createElementNS(svgNS, "svg");
+ svg.setAttribute("width", "0");
+ svg.setAttribute("height", "0");
+ const path = document.createElementNS(svgNS, "path");
+ path.setAttribute("d", CLIP_PATH_D);
+ svg.appendChild(path);
+ document.body.appendChild(svg);
+
+ const total = path.getTotalLength();
+ const points: THREE.Vector3[] = [];
+ for (let i = 0; i <= CURVE_SAMPLES; i++) {
+ const p = path.getPointAtLength((i / CURVE_SAMPLES) * total);
+ points.push(
+ new THREE.Vector3(
+ (p.x - CLIP_CENTER.cx) * CLIP_SCALE,
+ -(p.y - CLIP_CENTER.cy) * CLIP_SCALE,
+ 0,
+ ),
+ );
+ }
+ document.body.removeChild(svg);
+ return new THREE.CatmullRomCurve3(points, false, "catmullrom", 0.1);
+}
+
+/** Hemispherical end cap oriented along `normal`, tagged with its gradient t. */
+function buildCap(
+ center: THREE.Vector3,
+ normal: THREE.Vector3,
+ radius: number,
+ radialSeg: number,
+ pathT: number,
+): THREE.BufferGeometry {
+ const geo = new THREE.SphereGeometry(
+ radius,
+ radialSeg,
+ Math.ceil(radialSeg / 2),
+ 0,
+ Math.PI * 2,
+ 0,
+ Math.PI / 2,
+ );
+ const count = geo.attributes.position.count;
+ geo.setAttribute("aPathT", new THREE.BufferAttribute(new Float32Array(count).fill(pathT), 1));
+ geo.setAttribute("aCapT", new THREE.BufferAttribute(new Float32Array(count).fill(pathT), 1));
+ const up = new THREE.Vector3(0, 1, 0);
+ const quat = new THREE.Quaternion().setFromUnitVectors(up, normal.clone().normalize());
+ geo.applyMatrix4(new THREE.Matrix4().makeRotationFromQuaternion(quat));
+ geo.translate(center.x, center.y, center.z);
+ return geo;
+}
+
+export function PaperclipOrbit3D({ className }: { className?: string }) {
+ const hostRef = useRef(null);
+
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host) return;
+
+ // Respect reduced motion: render a single static frame instead of orbiting.
+ const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+
+ let renderer: THREE.WebGLRenderer;
+ try {
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
+ } catch {
+ return; // No WebGL — the backdrop simply stays empty.
+ }
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+ renderer.setClearColor(0x000000, 0);
+ host.appendChild(renderer.domElement);
+ renderer.domElement.style.width = "100%";
+ renderer.domElement.style.height = "100%";
+ renderer.domElement.style.display = "block";
+
+ const scene = new THREE.Scene();
+ const camera = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100);
+
+ // ── Material (generator's "soft" mode) ──
+ // Two live stops; the render loop crossfades them through PALETTE.
+ const uColors = Array.from({ length: 8 }, () => new THREE.Vector3(1, 1, 1));
+ const paletteColors = PALETTE.map(
+ ([a, b]) => [new THREE.Color(a), new THREE.Color(b)] as const,
+ );
+ const stopA = new THREE.Color();
+ const stopB = new THREE.Color();
+ // Stops are declared as [A, B, B] (nColors 3) rather than [A, B]: the
+ // shader maps path position through `rawT * (n-1)/n`, so with 2 colours the
+ // clip would only reach the A→B midpoint. The repeated final stop lets the
+ // gradient complete A→B across the clip while keeping t inside [0,1) — no
+ // mod() wrap, so the end caps still match the tube.
+ const material = new THREE.ShaderMaterial({
+ uniforms: {
+ uColors: { value: uColors },
+ uNColors: { value: 3 },
+ uShift: { value: GRADIENT_SHIFT },
+ uSoftness: { value: LIGHT_SOFT },
+ uLightDir: {
+ value: new THREE.Vector3(
+ Math.cos(LIGHT_ANGLE) * 0.7,
+ 0.8,
+ Math.sin(LIGHT_ANGLE) * 0.7,
+ ).normalize(),
+ },
+ uEmissive: { value: EMISSIVE },
+ },
+ vertexShader: VERTEX_SHADER,
+ fragmentShader: FRAGMENT_SHADER,
+ side: THREE.DoubleSide,
+ });
+
+ // ── Geometry: tube along the clip curve + hemispherical caps ──
+ const curve = buildClipCurve();
+ const group = new THREE.Group();
+ const geometries: THREE.BufferGeometry[] = [];
+
+ const tube = new THREE.TubeGeometry(curve, PATH_SEG, TUBE_RADIUS, TUBE_SEG, false);
+ // TubeGeometry lays out (PATH_SEG+1) rings of (TUBE_SEG+1) verts: aPathT is
+ // the ring's position along the path; aCapT -1 marks "not a cap".
+ {
+ const count = tube.attributes.position.count;
+ const pathT = new Float32Array(count);
+ const capT = new Float32Array(count).fill(-1);
+ const ring = TUBE_SEG + 1;
+ for (let i = 0; i < count; i++) pathT[i] = Math.floor(i / ring) / PATH_SEG;
+ tube.setAttribute("aPathT", new THREE.BufferAttribute(pathT, 1));
+ tube.setAttribute("aCapT", new THREE.BufferAttribute(capT, 1));
+ }
+ geometries.push(tube);
+
+ if (CAPS) {
+ const pts = curve.getPoints(PATH_SEG);
+ const startNormal = pts[1].clone().sub(pts[0]).normalize().negate();
+ const endNormal = pts[pts.length - 1]
+ .clone()
+ .sub(pts[pts.length - 2])
+ .normalize();
+ geometries.push(buildCap(pts[0], startNormal, TUBE_RADIUS, TUBE_SEG, 0));
+ geometries.push(buildCap(pts[pts.length - 1], endNormal, TUBE_RADIUS, TUBE_SEG, 1));
+ }
+
+ geometries.forEach((geo) => group.add(new THREE.Mesh(geo, material)));
+ group.scale.setScalar(CLIP_UPSCALE);
+ scene.add(group);
+
+ const target = new THREE.Vector3(0, 0, 0);
+
+ function resize() {
+ const w = host!.clientWidth;
+ const h = host!.clientHeight;
+ if (w === 0 || h === 0) return;
+ renderer.setSize(w, h, false);
+ camera.aspect = w / h;
+ camera.updateProjectionMatrix();
+ }
+ resize();
+ const observer = new ResizeObserver(resize);
+ observer.observe(host);
+
+ // ── Orbit + animated gradient (generator's loop, auto-orbit on) ──
+ const start = performance.now();
+ let raf = 0;
+ function frame() {
+ const t = reduceMotion ? 0 : (performance.now() - start) / 1000;
+ const theta = t * ROT_SPEED * 0.25;
+ const phi = Math.PI / 2 + Math.sin(t * TILT_RATE) * AXIS_TILT;
+ camera.position.set(
+ target.x + CAM_DIST * Math.sin(phi) * Math.sin(theta),
+ target.y + CAM_DIST * Math.cos(phi),
+ target.z + CAM_DIST * Math.sin(phi) * Math.cos(theta),
+ );
+ camera.lookAt(target);
+
+ // Slow colour cycle: ease between consecutive palette pairs so the whole
+ // clip crossfades hue over time (the gradient itself stays put on the
+ // path — the generator's scrolling "animate gradient" is off).
+ const pos = (t / PALETTE_CYCLE_SECONDS) % paletteColors.length;
+ const idx = Math.floor(pos);
+ const raw = pos - idx;
+ const f = raw * raw * (3 - 2 * raw); // smoothstep
+ const from = paletteColors[idx];
+ const to = paletteColors[(idx + 1) % paletteColors.length];
+ // lerpHSL rotates hue instead of crossing through grey, so the clip stays
+ // saturated mid-transition (plain RGB lerp muddies opposing hues).
+ stopA.copy(from[0]).lerpHSL(to[0], f);
+ stopB.copy(from[1]).lerpHSL(to[1], f);
+ uColors[0].set(stopA.r, stopA.g, stopA.b);
+ // Stops 1..7 all hold B (see the [A, B, B] note above).
+ for (let i = 1; i < 8; i++) uColors[i].set(stopB.r, stopB.g, stopB.b);
+
+ renderer.render(scene, camera);
+ if (!reduceMotion) raf = requestAnimationFrame(frame);
+ }
+ frame();
+
+ return () => {
+ cancelAnimationFrame(raf);
+ observer.disconnect();
+ geometries.forEach((g) => g.dispose());
+ material.dispose();
+ renderer.dispose();
+ if (renderer.domElement.parentNode === host) host.removeChild(renderer.domElement);
+ };
+ }, []);
+
+ return ;
+}
+
+export default PaperclipOrbit3D;
diff --git a/ui/src/components/onboarding/onboarding-data.ts b/ui/src/components/onboarding/onboarding-data.ts
new file mode 100644
index 0000000000..92a66d96f4
--- /dev/null
+++ b/ui/src/components/onboarding/onboarding-data.ts
@@ -0,0 +1,79 @@
+// Static option data for the onboarding flow, ported from the prototype
+// (paperclip-onboard/src/data.js). Connectors + models are presentational in
+// the first cut; wiring them to real integrations is a later refinement.
+import {
+ DEFAULT_TASK_DESCRIPTION,
+ DEFAULT_TASK_TITLE,
+} from "@/lib/onboarding-constants";
+
+export const MISSION_CHIPS = [
+ "Build a SaaS product",
+ "Launch a marketplace",
+ "Scale a content business",
+];
+
+export const ROLE_OPTIONS = [
+ "Chief of Staff",
+ "Chief Technical Officer",
+ "Head of Marketing",
+ "Researcher",
+ "Coder",
+ "Designer",
+ "Other",
+];
+
+export const ROLE_ACRONYMS: Record = {
+ "Chief of Staff": "COS",
+ "Chief Technical Officer": "CTO",
+ "Head of Marketing": "HOM",
+ Researcher: "RES",
+ Coder: "CDR",
+ Designer: "DES",
+ Other: "OTH",
+};
+
+export interface ConnectorOption {
+ name: string;
+ description: string;
+ /** Glyph background color. */
+ color: string;
+ /** Whether the glyph needs a border (for near-black glyphs). */
+ border?: boolean;
+ /** Short glyph text. */
+ glyph: string;
+}
+
+export const CONNECTORS: ConnectorOption[] = [
+ { name: "GitHub", description: "Code, PRs, issues", color: "#0a0a0a", border: true, glyph: "GH" },
+ { name: "Google", description: "Gmail, Drive, Calendar", color: "#4285f4", glyph: "G" },
+ { name: "Notion", description: "Docs and databases", color: "#0a0a0a", border: true, glyph: "N" },
+ { name: "Linear", description: "Issues and projects", color: "#5e6ad2", glyph: "L" },
+ { name: "Supabase", description: "Database and auth", color: "#3ecf8e", glyph: "S" },
+ { name: "Slack", description: "Team messaging", color: "#611f69", glyph: "#" },
+ { name: "Stripe", description: "Payments and billing", color: "#635bff", glyph: "$" },
+ { name: "Vercel", description: "Deploys and hosting", color: "#0a0a0a", border: true, glyph: "▲" },
+];
+
+export type FirstTaskChoice = "hiring" | "strategy" | "custom";
+
+/** Build the first-task title/description from the chosen option (shared by both flows). */
+export function firstTaskPayload(
+ choice: FirstTaskChoice,
+ custom: string,
+): { title: string; description: string } {
+ switch (choice) {
+ case "hiring":
+ return { title: DEFAULT_TASK_TITLE, description: DEFAULT_TASK_DESCRIPTION };
+ case "strategy":
+ return {
+ title: "Write a one-page team strategy",
+ description:
+ "You are the CEO. Turn the company mission into a one-page strategy: the goals, the bets you're making, and how you'll measure progress.",
+ };
+ case "custom":
+ return {
+ title: custom.trim() || "First task",
+ description: custom.trim(),
+ };
+ }
+}
diff --git a/ui/src/components/onboarding/onboarding-motion.ts b/ui/src/components/onboarding/onboarding-motion.ts
new file mode 100644
index 0000000000..706e96b111
--- /dev/null
+++ b/ui/src/components/onboarding/onboarding-motion.ts
@@ -0,0 +1,49 @@
+// Shared motion constants for the onboarding flows (cloud + local). Extracted
+// from the original OnboardingFlow so both flow containers and the shared step
+// views animate identically. Reduced-motion is honored globally via
+// in OnboardingScaffold.
+
+/** Step crossfade easing (also reused by in-step reveals). */
+export const STEP_EASE = [0.16, 1, 0.3, 1] as const;
+
+/** Per-step enter/exit crossfade used by the scaffold's keyed motion.div. */
+export const stepMotion = {
+ initial: { opacity: 0, y: 8 },
+ animate: { opacity: 1, y: 0 },
+ exit: { opacity: 0, y: -8 },
+ transition: { duration: 0.28, ease: STEP_EASE },
+};
+
+// Agent-step dashed-pill entrance: fades + scales up from 50% from its center
+// (no y offset, which would bias growth upward), with a spring bounce on
+// landing.
+export const CAPSULE_ENTER_DURATION = 1.0;
+export const capsuleMotion = {
+ initial: { opacity: 0, scale: 0.5 },
+ animate: { opacity: 1, scale: 1 },
+ transition: { type: "spring" as const, duration: CAPSULE_ENTER_DURATION, bounce: 0.4 },
+};
+
+// Agent-step name/role reveal: the capsule slide-up (height growth) and the
+// label fade share this duration; the fade is staggered by 25% of it.
+export const PREVIEW_REVEAL_DURATION = 0.45;
+
+// Step 2 → 3 capsule hand-off: on "Create" the capsule eases out (scale to
+// 50%, fade to 0) with the exiting step, then resurfaces on the task step —
+// scaling back to 100% on a spring so it lands into place, fade eased to match.
+// Exit duration mirrors the step transition so they travel together.
+export const capsuleHandoffExit = {
+ scale: 0.5,
+ opacity: 0,
+ transition: { duration: 0.28, ease: STEP_EASE },
+};
+export const capsuleHeroMotion = {
+ initial: { scale: 0.5, opacity: 0 },
+ animate: { scale: 1, opacity: 1 },
+ transition: {
+ // Softer spring = a slower, more noticeable scale-up that still lands with
+ // a natural settle; fade lengthened to travel with it.
+ scale: { type: "spring" as const, stiffness: 150, damping: 16 },
+ opacity: { duration: 0.55, ease: STEP_EASE },
+ },
+};
diff --git a/ui/src/components/onboarding/steps/AdapterStep.tsx b/ui/src/components/onboarding/steps/AdapterStep.tsx
new file mode 100644
index 0000000000..8164f4bdba
--- /dev/null
+++ b/ui/src/components/onboarding/steps/AdapterStep.tsx
@@ -0,0 +1,144 @@
+import { useMemo } from "react";
+import { motion } from "motion/react";
+import { cn } from "@/lib/utils";
+import { listUIAdapters } from "@/adapters";
+import { getAdapterDisplay } from "@/adapters/adapter-display-registry";
+import { isVisualAdapterChoice } from "@/adapters/metadata";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { AgentCapsule } from "../../AgentCapsule";
+import { OnboardingCard, OnboardingHeading, Stepper } from "../OnboardingPrimitives";
+import { FooterNav } from "../FooterNav";
+import { AgentPreview } from "../AgentPreview";
+import { capsuleHandoffExit, capsuleHeroMotion } from "../onboarding-motion";
+
+const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
+
+function visualAdapters() {
+ return listUIAdapters()
+ .filter((a) => !SYSTEM_ADAPTER_TYPES.has(a.type) && isVisualAdapterChoice(a.type))
+ .map((a) => ({ ...getAdapterDisplay(a.type), type: a.type }))
+ .filter((a) => !a.comingSoon);
+}
+
+/**
+ * Local-flow only: pick the model/adapter the first agent runs on. Recommended
+ * options are surfaced as cards; the rest are grouped in an "Additional models"
+ * dropdown. Selecting from either sets the same adapterType.
+ */
+export function AdapterStep({
+ adapterType,
+ onAdapterChange,
+ agentName,
+ agentRole,
+ onBack,
+ onNext,
+ loading,
+ step,
+ total,
+}: {
+ adapterType: string;
+ onAdapterChange: (type: string) => void;
+ agentName: string;
+ agentRole: string;
+ onBack: () => void;
+ onNext: () => void;
+ loading?: boolean;
+ step: number;
+ total?: number;
+}) {
+ const { recommended, additional } = useMemo(() => {
+ const all = visualAdapters();
+ return {
+ recommended: all.filter((a) => a.recommended),
+ // Top handful of other models beyond the recommended ones.
+ additional: all.filter((a) => !a.recommended).slice(0, 5),
+ };
+ }, []);
+
+ // The dropdown reflects the selection only when it isn't one of the cards.
+ const additionalValue = additional.some((a) => a.type === adapterType) ? adapterType : undefined;
+
+ return (
+
+
+
+ {/* Carried-over outlined capsule from the agent step: enters with the
+ same hero scale/fade as the task pill and hands off (scale down) on
+ exit, but stays "configured" (outlined) — no coming-alive here. */}
+
+
+
+ We'll never use this for marketing or share it with third parties — it's only for
+ the occasional product update.
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/onboarding/steps/StartStep.tsx b/ui/src/components/onboarding/steps/StartStep.tsx
new file mode 100644
index 0000000000..e0dcbdeab9
--- /dev/null
+++ b/ui/src/components/onboarding/steps/StartStep.tsx
@@ -0,0 +1,70 @@
+import { Mail, PlusCircle } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+/**
+ * Welcome / choose-a-path screen (unnumbered). "Set up" advances the flow (the
+ * container decides the next step); "Join an existing team" is stubbed.
+ *
+ * Cards are translucent so the orbiting-paperclip backdrop reads through them
+ * (local flow, which skips the auth screens and leads with this screen).
+ */
+export function StartStep({ onSetup }: { onSetup: () => void }) {
+ return (
+
+
+ Welcome to Paperclip!
+
+
+ }
+ title="Set up Paperclip for your company or team"
+ description="Create a new organization, build your first agent, and assign its first task."
+ onClick={onSetup}
+ />
+ }
+ title="Join an existing company or team"
+ description="Have an invite? Enter your team's join code to come aboard."
+ onClick={() => {}}
+ disabled
+ />
+
+ Where should we start? {agentLabel} is ready for instructions.>}
+ center
+ />
+ {/* The reveal sits OUTSIDE the gap-3 flex and owns its own top spacing
+ (pt-3), so collapsing to height 0 leaves no phantom flex gap to snap
+ away on unmount — the collapse mirrors the expand exactly. overflow
+ is hidden only while animating and visible at rest so the textarea's
+ focus ring isn't clipped. */}
+
+
+ }
+ title="Create a hiring plan"
+ description="A staffing plan for the agents your team needs — roles, order, and what each one owns."
+ selected={taskChoice === "hiring"}
+ onClick={() => onSelectChoice("hiring")}
+ />
+ }
+ title="Write a team strategy doc"
+ description="Turn your mission into a one-page strategy: goals, bets, and how you'll measure them."
+ selected={taskChoice === "strategy"}
+ onClick={() => onSelectChoice("strategy")}
+ />
+ }
+ title="Write your own task…"
+ description="Describe anything else you want done first."
+ selected={taskChoice === "custom"}
+ onClick={() => onSelectChoice("custom")}
+ />
+
+
+ {taskChoice === "custom" && (
+
+
+
+
+ )}
+
+
+ {error ?
{error}
: null}
+
+
+
+ );
+}
diff --git a/ui/src/hooks/useOnboardingFlow.test.tsx b/ui/src/hooks/useOnboardingFlow.test.tsx
new file mode 100644
index 0000000000..b51a90c409
--- /dev/null
+++ b/ui/src/hooks/useOnboardingFlow.test.tsx
@@ -0,0 +1,162 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { flushSync } from "react-dom";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// The hook only needs a company id and the two agent endpoints for these cases;
+// everything else is mocked so the probe-cache logic is exercised in isolation.
+const testEnvironment = vi.hoisted(() => vi.fn());
+const hire = vi.hoisted(() => vi.fn());
+
+vi.mock("@tanstack/react-query", () => ({
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}));
+vi.mock("@/context/CompanyContext", () => ({
+ useCompany: () => ({ setSelectedCompanyId: vi.fn() }),
+}));
+vi.mock("@/api/agents", () => ({
+ agentsApi: {
+ testEnvironment,
+ hire,
+ instructionsBundle: vi.fn(async () => ({ entryFile: "AGENTS.md" })),
+ saveInstructionsFile: vi.fn(async () => undefined),
+ update: vi.fn(async () => undefined),
+ },
+}));
+vi.mock("@/api/companies", () => ({ companiesApi: { create: vi.fn() } }));
+vi.mock("@/api/goals", () => ({ goalsApi: { create: vi.fn() } }));
+vi.mock("@/api/approvals", () => ({ approvalsApi: { approve: vi.fn() } }));
+vi.mock("@/api/issues", () => ({ issuesApi: { create: vi.fn() } }));
+vi.mock("@/api/projects", () => ({ projectsApi: { create: vi.fn(), list: vi.fn() } }));
+
+import { useOnboardingFlow, type OnboardingFlow } from "./useOnboardingFlow";
+
+const INSTRUCTIONS = {
+ companyName: "Acme",
+ companyGoal: "Build things",
+ growPath: false,
+ growWorkflows: "",
+ growPainPoints: "",
+ growAutomate: "",
+ q1: "",
+ q2: "",
+ q3: "",
+ q4: "",
+};
+
+function adapter(adapterType: string) {
+ return { adapterType, model: "", command: "", args: "", url: "" };
+}
+
+describe("useOnboardingFlow adapter environment probe", () => {
+ let container: HTMLDivElement;
+ let root: Root | null = null;
+ let flow: OnboardingFlow;
+
+ function Probe() {
+ flow = useOnboardingFlow({ createdCompanyId: "company-1" });
+ return null;
+ }
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ flushSync(() => {
+ root!.render();
+ });
+ testEnvironment.mockResolvedValue({ status: "pass" });
+ hire.mockResolvedValue({ agent: { id: "agent-1" }, approval: null });
+ });
+
+ afterEach(() => {
+ flushSync(() => {
+ root?.unmount();
+ });
+ root = null;
+ container.remove();
+ vi.clearAllMocks();
+ });
+
+ it("re-probes when the adapter changed since the cached probe ran", async () => {
+ // The local flow lets the user pick a different adapter and retry after a
+ // failed hire. The first adapter's verdict must not stand in for the second.
+ await act(async () => {
+ await flow.runAdapterEnvironmentTest(adapter("claude_local"));
+ });
+ expect(testEnvironment).toHaveBeenCalledTimes(1);
+
+ hire.mockRejectedValueOnce(new Error("hire failed"));
+ await act(async () => {
+ await flow.hireLeadAgent({
+ agentName: "COS",
+ adapter: adapter("claude_local"),
+ instructions: INSTRUCTIONS,
+ requireEnvProbe: true,
+ });
+ });
+ // Same adapter — the cached probe is reused rather than re-run.
+ expect(testEnvironment).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await flow.hireLeadAgent({
+ agentName: "COS",
+ adapter: adapter("codex_local"),
+ instructions: INSTRUCTIONS,
+ requireEnvProbe: true,
+ });
+ });
+ expect(testEnvironment).toHaveBeenCalledTimes(2);
+ expect(testEnvironment.mock.calls[1][1]).toBe("codex_local");
+ // The probed config and the hired config are the same object of record.
+ expect(hire.mock.calls[1][1].adapterConfig).toEqual(
+ testEnvironment.mock.calls[1][2].adapterConfig,
+ );
+ });
+
+ it("probes when no cached result exists", async () => {
+ await act(async () => {
+ await flow.hireLeadAgent({
+ agentName: "COS",
+ adapter: adapter("claude_local"),
+ instructions: INSTRUCTIONS,
+ requireEnvProbe: true,
+ });
+ });
+ expect(testEnvironment).toHaveBeenCalledTimes(1);
+ });
+
+ it("clearAdapterEnvResult forces the next hire to re-probe", async () => {
+ await act(async () => {
+ await flow.runAdapterEnvironmentTest(adapter("claude_local"));
+ });
+ act(() => {
+ flow.clearAdapterEnvResult();
+ });
+ expect(flow.adapterEnvResult).toBeNull();
+
+ await act(async () => {
+ await flow.hireLeadAgent({
+ agentName: "COS",
+ adapter: adapter("claude_local"),
+ instructions: INSTRUCTIONS,
+ requireEnvProbe: true,
+ });
+ });
+ expect(testEnvironment).toHaveBeenCalledTimes(2);
+ });
+
+ it("does not probe at all when requireEnvProbe is false (cloud flow)", async () => {
+ await act(async () => {
+ await flow.hireLeadAgent({
+ agentName: "COS",
+ adapter: adapter("claude_local"),
+ instructions: INSTRUCTIONS,
+ requireEnvProbe: false,
+ });
+ });
+ expect(testEnvironment).not.toHaveBeenCalled();
+ });
+});
diff --git a/ui/src/hooks/useOnboardingFlow.ts b/ui/src/hooks/useOnboardingFlow.ts
new file mode 100644
index 0000000000..189692a82a
--- /dev/null
+++ b/ui/src/hooks/useOnboardingFlow.ts
@@ -0,0 +1,483 @@
+// Backend orchestration for onboarding, extracted from OnboardingWizard so a
+// new presentational onboarding flow can reuse the exact company/goal/agent/
+// issue creation logic without duplicating API calls or query invalidation.
+//
+// This hook owns the "created entity" ids and the adapter-environment probe
+// state; it does NOT own user-entered form state (company name, mission, agent
+// name, adapter selection) — callers pass those into the action functions. It
+// also does not navigate: launchFirstTask returns the target company prefix and
+// lets the caller route, keeping routing a UI concern.
+import { useState } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import type { AdapterEnvironmentTestResult } from "@paperclipai/shared";
+import { useCompany } from "@/context/CompanyContext";
+import { companiesApi } from "@/api/companies";
+import { goalsApi } from "@/api/goals";
+import { agentsApi } from "@/api/agents";
+import { approvalsApi } from "@/api/approvals";
+import { issuesApi } from "@/api/issues";
+import { projectsApi } from "@/api/projects";
+import { queryKeys } from "@/lib/queryKeys";
+import { parseOnboardingGoalInput } from "@/lib/onboarding-goal";
+import { composeCeoInstructions } from "@/lib/ceo-instructions";
+import { buildNewAgentRuntimeConfig } from "@/lib/new-agent-runtime-config";
+import {
+ buildOnboardingIssuePayload,
+ buildOnboardingProjectPayload,
+ selectDefaultCompanyGoalId,
+ selectReusableOnboardingProject,
+} from "@/lib/onboarding-launch";
+import {
+ buildOnboardingAdapterConfig,
+ type OnboardingAdapterConfigInput,
+} from "@/lib/onboarding-adapter-config";
+import {
+ DEFAULT_TASK_DESCRIPTION,
+ DEFAULT_TASK_TITLE,
+} from "@/lib/onboarding-constants";
+
+/** Adapter selection inputs shared by the env probe and the hire action. */
+export type AdapterInput = Omit;
+
+/** Context used to seed the lead agent's instructions file. */
+export interface OnboardingInstructionsContext {
+ companyName: string;
+ companyGoal: string;
+ growPath: boolean;
+ growWorkflows: string;
+ growPainPoints: string;
+ growAutomate: string;
+ q1: string;
+ q2: string;
+ q3: string;
+ q4: string;
+}
+
+export interface HireLeadAgentInput {
+ agentName: string;
+ adapter: AdapterInput;
+ instructions: OnboardingInstructionsContext;
+ /**
+ * When true (local adapters), require a successful adapter-environment probe
+ * before hiring. Mirrors the wizard: hire is aborted only if the probe cannot
+ * run at all (returns null), not if it returns a fail status.
+ */
+ requireEnvProbe: boolean;
+}
+
+export interface CreatedOnboardingEntities {
+ createdCompanyId: string | null;
+ createdCompanyPrefix: string | null;
+ createdAgentId: string | null;
+ createdCompanyGoalId: string | null;
+ createdProjectId: string | null;
+ createdIssueRef: string | null;
+}
+
+export function useOnboardingFlow(initial?: Partial) {
+ const queryClient = useQueryClient();
+ const { setSelectedCompanyId } = useCompany();
+
+ const [createdCompanyId, setCreatedCompanyId] = useState(
+ initial?.createdCompanyId ?? null,
+ );
+ const [createdCompanyPrefix, setCreatedCompanyPrefix] = useState(
+ initial?.createdCompanyPrefix ?? null,
+ );
+ const [createdAgentId, setCreatedAgentId] = useState(
+ initial?.createdAgentId ?? null,
+ );
+ const [createdCompanyGoalId, setCreatedCompanyGoalId] = useState(
+ initial?.createdCompanyGoalId ?? null,
+ );
+ const [createdProjectId, setCreatedProjectId] = useState(
+ initial?.createdProjectId ?? null,
+ );
+ const [createdIssueRef, setCreatedIssueRef] = useState(
+ initial?.createdIssueRef ?? null,
+ );
+
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const [adapterEnvResult, setAdapterEnvResult] =
+ useState(null);
+ const [adapterEnvError, setAdapterEnvError] = useState(null);
+ const [adapterEnvLoading, setAdapterEnvLoading] = useState(false);
+ const [forceUnsetAnthropicApiKey, setForceUnsetAnthropicApiKey] = useState(false);
+ const [unsetAnthropicLoading, setUnsetAnthropicLoading] = useState(false);
+ /**
+ * Identifies the adapter selection that produced `adapterEnvResult`. The local
+ * flow lets the user pick a different adapter and retry after a failed hire,
+ * so a cached probe result is only evidence about the adapter it actually
+ * ran against — see `hireLeadAgent`.
+ */
+ const [adapterEnvProbedKey, setAdapterEnvProbedKey] = useState(null);
+
+ function buildAdapterConfig(adapter: AdapterInput): Record {
+ return buildOnboardingAdapterConfig({ ...adapter, forceUnsetAnthropicApiKey });
+ }
+
+ /**
+ * Stable identity for a probe: the adapter type plus the exact config posted
+ * to the environment-test endpoint. `buildOnboardingAdapterConfig` builds its
+ * object in a fixed key order for a given adapter, so `JSON.stringify` is
+ * stable across calls.
+ */
+ function adapterProbeKey(
+ adapterType: string,
+ adapterConfig: Record,
+ ): string {
+ return JSON.stringify([adapterType, adapterConfig]);
+ }
+
+ /**
+ * Forget any cached probe result. Callers invoke this when the user changes
+ * the adapter selection, so neither the UI nor `hireLeadAgent` keeps showing
+ * or trusting a verdict about the adapter that is no longer selected.
+ */
+ function clearAdapterEnvResult(): void {
+ setAdapterEnvResult(null);
+ setAdapterEnvError(null);
+ setAdapterEnvProbedKey(null);
+ }
+
+ /**
+ * Create the company + its company-level goal from the mission. Guarded so
+ * calling it again after a company exists is a no-op that returns the
+ * already-created ids.
+ */
+ async function createCompanyAndGoal(input: {
+ companyName: string;
+ companyGoal: string;
+ }): Promise<{ companyId: string; companyPrefix: string; goalId: string | null } | null> {
+ if (createdCompanyId) {
+ return {
+ companyId: createdCompanyId,
+ companyPrefix: createdCompanyPrefix ?? "",
+ goalId: createdCompanyGoalId,
+ };
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ const company = await companiesApi.create({ name: input.companyName.trim() });
+ setCreatedCompanyId(company.id);
+ setCreatedCompanyPrefix(company.issuePrefix);
+ setSelectedCompanyId(company.id);
+ queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
+
+ const parsedGoal = parseOnboardingGoalInput(input.companyGoal);
+ const goal = await goalsApi.create(company.id, {
+ title: parsedGoal.title,
+ ...(parsedGoal.description ? { description: parsedGoal.description } : {}),
+ level: "company",
+ status: "active",
+ });
+ setCreatedCompanyGoalId(goal.id);
+ queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(company.id) });
+
+ return { companyId: company.id, companyPrefix: company.issuePrefix, goalId: goal.id };
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create company");
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function runAdapterEnvironmentTest(
+ adapter: AdapterInput,
+ adapterConfigOverride?: Record,
+ ): Promise {
+ if (!createdCompanyId) {
+ setAdapterEnvError(
+ "Create or select a company before testing adapter environment.",
+ );
+ return null;
+ }
+ setAdapterEnvLoading(true);
+ setAdapterEnvError(null);
+ const adapterConfig = adapterConfigOverride ?? buildAdapterConfig(adapter);
+ try {
+ const result = await agentsApi.testEnvironment(createdCompanyId, adapter.adapterType, {
+ adapterConfig,
+ });
+ setAdapterEnvResult(result);
+ setAdapterEnvProbedKey(adapterProbeKey(adapter.adapterType, adapterConfig));
+ return result;
+ } catch (err) {
+ setAdapterEnvError(
+ err instanceof Error ? err.message : "Adapter environment test failed",
+ );
+ setAdapterEnvResult(null);
+ setAdapterEnvProbedKey(null);
+ return null;
+ } finally {
+ setAdapterEnvLoading(false);
+ }
+ }
+
+ /**
+ * Clear ANTHROPIC_API_KEY in the adapter config and re-probe. Mirrors the
+ * wizard's remediation when a stray key overrides the Claude subscription.
+ */
+ async function unsetAnthropicApiKeyAndRetry(adapter: AdapterInput): Promise {
+ if (!createdCompanyId || unsetAnthropicLoading) return;
+ setUnsetAnthropicLoading(true);
+ setError(null);
+ setAdapterEnvError(null);
+ setForceUnsetAnthropicApiKey(true);
+
+ const configWithUnset = (() => {
+ const config = buildAdapterConfig(adapter);
+ const env =
+ typeof config.env === "object" &&
+ config.env !== null &&
+ !Array.isArray(config.env)
+ ? { ...(config.env as Record) }
+ : {};
+ env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
+ config.env = env;
+ return config;
+ })();
+
+ try {
+ if (createdAgentId) {
+ await agentsApi.update(createdAgentId, { adapterConfig: configWithUnset }, createdCompanyId);
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) });
+ }
+
+ const result = await runAdapterEnvironmentTest(adapter, configWithUnset);
+ if (result?.status === "fail") {
+ setError(
+ "Retried with ANTHROPIC_API_KEY unset in adapter config, but the environment test is still failing.",
+ );
+ }
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Failed to unset ANTHROPIC_API_KEY and retry.",
+ );
+ } finally {
+ setUnsetAnthropicLoading(false);
+ }
+ }
+
+ /**
+ * Hire the lead agent (role: ceo), auto-approve any board approval, and seed
+ * its instructions file. Guarded so a second call after an agent exists is a
+ * no-op. Returns null on failure (error is set) or when a required env probe
+ * cannot run.
+ */
+ async function hireLeadAgent(
+ input: HireLeadAgentInput,
+ ): Promise<{ agentId: string } | null> {
+ if (!createdCompanyId) return null;
+ if (createdAgentId) return { agentId: createdAgentId };
+
+ setLoading(true);
+ setError(null);
+ const adapterConfig = buildAdapterConfig(input.adapter);
+ try {
+ if (input.requireEnvProbe) {
+ // Reuse the cached probe only when it ran against this exact adapter
+ // selection. After a failed hire the user can go back and pick a
+ // different adapter, and the previous adapter's verdict says nothing
+ // about the new one.
+ const cacheHit =
+ adapterEnvResult !== null &&
+ adapterEnvProbedKey === adapterProbeKey(input.adapter.adapterType, adapterConfig);
+ const result = cacheHit
+ ? adapterEnvResult
+ : await runAdapterEnvironmentTest(input.adapter, adapterConfig);
+ if (!result) return null;
+ }
+
+ const hire = await agentsApi.hire(createdCompanyId, {
+ name: input.agentName.trim(),
+ role: "ceo",
+ adapterType: input.adapter.adapterType,
+ adapterConfig,
+ runtimeConfig: buildNewAgentRuntimeConfig(),
+ });
+ if (hire.approval) {
+ await approvalsApi.approve(
+ hire.approval.id,
+ "Approved during onboarding first-agent setup.",
+ );
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.approvals.list(createdCompanyId),
+ });
+ }
+ const agent = hire.agent;
+ setCreatedAgentId(agent.id);
+ queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(createdCompanyId) });
+
+ // Seed the CEO's instructions file so the agent always has company
+ // context + a hiring-plan output rule. Non-fatal on failure.
+ try {
+ const bundle = await agentsApi.instructionsBundle(agent.id, createdCompanyId);
+ await agentsApi.saveInstructionsFile(
+ agent.id,
+ {
+ path: bundle.entryFile,
+ content: composeCeoInstructions({
+ companyName: input.instructions.companyName,
+ companyGoal: input.instructions.companyGoal,
+ growPath: input.instructions.growPath,
+ growWorkflows: input.instructions.growWorkflows,
+ growPainPoints: input.instructions.growPainPoints,
+ growAutomate: input.instructions.growAutomate,
+ q1: input.instructions.q1,
+ q2: input.instructions.q2,
+ q3: input.instructions.q3,
+ q4: input.instructions.q4,
+ }),
+ },
+ createdCompanyId,
+ );
+ } catch (err) {
+ console.warn("Failed to seed CEO instructions:", err);
+ }
+
+ return { agentId: agent.id };
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create agent");
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ /**
+ * Ensure the onboarding goal + project exist, create the first task assigned
+ * to the lead agent, select the company, and return the target company prefix
+ * for the caller to navigate to. Idempotent across the goal/project/issue it
+ * creates. Requires a created company + agent.
+ */
+ async function launchFirstTask(input?: {
+ title?: string;
+ description?: string;
+ }): Promise<{ companyId: string; companyPrefix: string | null } | null> {
+ if (!createdCompanyId || !createdAgentId) {
+ setError(
+ "Onboarding state is incomplete. Please restart onboarding and try again.",
+ );
+ return null;
+ }
+ setLoading(true);
+ setError(null);
+ try {
+ let goalId = createdCompanyGoalId;
+ if (!goalId) {
+ const goals = await goalsApi.list(createdCompanyId);
+ goalId = selectDefaultCompanyGoalId(goals);
+ setCreatedCompanyGoalId(goalId);
+ }
+
+ let projectId = createdProjectId;
+ if (!projectId) {
+ const projects = await projectsApi.list(createdCompanyId);
+ const existingOnboardingProject = selectReusableOnboardingProject(projects);
+ if (existingOnboardingProject) {
+ projectId = existingOnboardingProject.id;
+ } else {
+ const project = await projectsApi.create(
+ createdCompanyId,
+ buildOnboardingProjectPayload(goalId),
+ );
+ projectId = project.id;
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.projects.list(createdCompanyId),
+ });
+ }
+ setCreatedProjectId(projectId);
+ }
+
+ if (!createdIssueRef) {
+ const issue = await issuesApi.create(
+ createdCompanyId,
+ buildOnboardingIssuePayload({
+ title: input?.title ?? DEFAULT_TASK_TITLE,
+ description: input?.description ?? DEFAULT_TASK_DESCRIPTION,
+ assigneeAgentId: createdAgentId,
+ projectId,
+ goalId,
+ }),
+ );
+ setCreatedIssueRef(issue.identifier ?? issue.id);
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.issues.list(createdCompanyId),
+ });
+ }
+
+ setSelectedCompanyId(createdCompanyId);
+ return { companyId: createdCompanyId, companyPrefix: createdCompanyPrefix };
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to launch first task");
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function setCreatedCompany(company: { id: string; prefix: string | null }) {
+ setCreatedCompanyId(company.id);
+ setCreatedCompanyPrefix(company.prefix);
+ }
+
+ function reset() {
+ setCreatedCompanyId(null);
+ setCreatedCompanyPrefix(null);
+ setCreatedAgentId(null);
+ setCreatedCompanyGoalId(null);
+ setCreatedProjectId(null);
+ setCreatedIssueRef(null);
+ setLoading(false);
+ setError(null);
+ setAdapterEnvResult(null);
+ setAdapterEnvError(null);
+ setAdapterEnvLoading(false);
+ setAdapterEnvProbedKey(null);
+ setForceUnsetAnthropicApiKey(false);
+ setUnsetAnthropicLoading(false);
+ }
+
+ return {
+ // created entity state
+ createdCompanyId,
+ createdCompanyPrefix,
+ createdAgentId,
+ createdCompanyGoalId,
+ createdProjectId,
+ createdIssueRef,
+ setCreatedCompany,
+ setCreatedCompanyPrefix,
+
+ // status
+ loading,
+ error,
+ setError,
+
+ // adapter environment probe
+ adapterEnvResult,
+ adapterEnvError,
+ adapterEnvLoading,
+ forceUnsetAnthropicApiKey,
+ unsetAnthropicLoading,
+ buildAdapterConfig,
+ runAdapterEnvironmentTest,
+ clearAdapterEnvResult,
+ unsetAnthropicApiKeyAndRetry,
+
+ // actions
+ createCompanyAndGoal,
+ hireLeadAgent,
+ launchFirstTask,
+ reset,
+ };
+}
+
+export type OnboardingFlow = ReturnType;
diff --git a/ui/src/index.css b/ui/src/index.css
index 3b4b143346..ca5dd2d384 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -993,38 +993,57 @@
/* Agent capsule motif (PAP-119) — "the capsule is the agent".
Three states realized in the AgentCapsule component:
- - slot: dashed outline gently pulsing (an empty agent slot)
+ - slot: static dashed outline (an empty agent slot)
- configured: solid stroke (named/model picked, not yet live)
- - online: brand agent-gradient liquid rises to fill + green online-pulse
- prefers-reduced-motion skips the liquid rise + pulses and renders the
- final state. */
-@keyframes agent-cap-slot-pulse {
- 0%, 100% { opacity: 0.5; }
- 50% { opacity: 1; }
-}
-
-@keyframes agent-cap-rise {
- 0% { height: 0; }
- 100% { height: 100%; }
+ - online: brand agent-gradient fill radiating outward from the center
+ + online-pulse (the only pulsing state)
+ prefers-reduced-motion skips the fill + pulses and renders the final
+ state. */
+/* Radial outward reveal of the (linear-gradient) fill: a centered circular
+ clip grows past the half-diagonal (~71%) so it covers the whole capsule. */
+@keyframes agent-cap-fill-radial {
+ 0% { clip-path: circle(0% at 50% 50%); }
+ 100% { clip-path: circle(75% at 50% 50%); }
}
@keyframes agent-cap-online-pulse {
0%, 100% {
- box-shadow: 0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent);
+ box-shadow:
+ 0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent),
+ 0 0 0 0 color-mix(in oklab, #22c55e 0%, transparent);
transform: scale(1);
}
50% {
- box-shadow: 0 0 0 6px color-mix(in oklab, #22c55e 18%, transparent);
+ box-shadow:
+ 0 0 0 6px color-mix(in oklab, #22c55e 18%, transparent),
+ 0 0 0 14px color-mix(in oklab, #22c55e 7%, transparent);
transform: scale(1.03);
}
}
-.agent-cap-slot {
- animation: agent-cap-slot-pulse 1.6s ease-in-out infinite;
+.agent-cap-liquid {
+ /* Dramatic ease-in-out: the radial fill eases in, rushes, and eases out. */
+ animation: agent-cap-fill-radial var(--agent-cap-reveal-duration, 1.4s)
+ cubic-bezier(0.83, 0, 0.17, 1) forwards;
}
-.agent-cap-liquid {
- animation: agent-cap-rise 1.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
+/* Online carryover: the solid white outline begins fading as soon as the fill
+ starts so the two overlap toward the start (outline gone by the fill's
+ midpoint). Shares the reveal duration so an onboarding-scoped override slows
+ both together. */
+@keyframes agent-cap-stroke-carryover {
+ 0% {
+ opacity: 1;
+ }
+ 50%,
+ 100% {
+ opacity: 0;
+ }
+}
+
+.agent-cap-stroke-carryover {
+ animation: agent-cap-stroke-carryover var(--agent-cap-reveal-duration, 1.4s)
+ linear forwards;
}
.agent-cap-online {
@@ -1035,11 +1054,15 @@
Same breathing ring as the green pulse, recoloured to brand blue (#2563eb). */
@keyframes agent-cap-online-pulse-blue {
0%, 100% {
- box-shadow: 0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent);
+ box-shadow:
+ 0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent),
+ 0 0 0 0 color-mix(in oklab, #2563eb 0%, transparent);
transform: scale(1);
}
50% {
- box-shadow: 0 0 0 6px color-mix(in oklab, #2563eb 22%, transparent);
+ box-shadow:
+ 0 0 0 6px color-mix(in oklab, #2563eb 22%, transparent),
+ 0 0 0 14px color-mix(in oklab, #2563eb 9%, transparent);
transform: scale(1.03);
}
}
@@ -1055,7 +1078,6 @@
}
@media (prefers-reduced-motion: reduce) {
- .agent-cap-slot,
.agent-cap-online,
.agent-cap-online-blue {
animation: none;
@@ -1065,7 +1087,11 @@
}
.agent-cap-liquid {
animation: none;
- height: 100%;
+ clip-path: none;
+ }
+ .agent-cap-stroke-carryover {
+ animation: none;
+ opacity: 0;
}
}
@@ -2324,6 +2350,8 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
* allow ui/src/pages/RunTranscriptUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
* allow ui/src/pages/InviteUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
* allow ui/src/pages/IssueChatUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
+ * allow ui/src/components/onboarding/OnboardingAuthScreens.tsx — Google/GitHub official brand-mark SVG fills (third-party logo colors); brand identity values, not themeable roles
+ * allow ui/src/components/onboarding/onboarding-data.ts — connector glyph colors are third-party brand hexes (GitHub/Google/Notion/Linear/…) rendered as logo-tile backgrounds; brand identity values, not themeable roles
* allow ui/src/pages/SystemNoticeUxLab.tsx — first-party intentional one-off decoration (demo/UX-lab page): hero/card gradients + shadows reverted from singleton tokens per DECISION-SHEET.md B1 user ruling
Test-fixture hex-literal policy (Batch 4, resolving TOKEN-AUDIT.md section
diff --git a/ui/src/lib/onboarding-adapter-config.test.ts b/ui/src/lib/onboarding-adapter-config.test.ts
new file mode 100644
index 0000000000..91cb12fd35
--- /dev/null
+++ b/ui/src/lib/onboarding-adapter-config.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { buildOnboardingAdapterConfig } from "./onboarding-adapter-config";
+
+const baseInput = {
+ model: "",
+ command: "",
+ args: "",
+ url: "",
+};
+
+describe("buildOnboardingAdapterConfig", () => {
+ it("skips permissions for claude_local", () => {
+ const config = buildOnboardingAdapterConfig({
+ ...baseInput,
+ adapterType: "claude_local",
+ forceUnsetAnthropicApiKey: false,
+ });
+ expect(config.dangerouslySkipPermissions).toBe(true);
+ });
+
+ it("forces ANTHROPIC_API_KEY empty for claude_local when requested", () => {
+ const config = buildOnboardingAdapterConfig({
+ ...baseInput,
+ adapterType: "claude_local",
+ forceUnsetAnthropicApiKey: true,
+ });
+ const env = config.env as Record;
+ expect(env.ANTHROPIC_API_KEY).toEqual({ type: "plain", value: "" });
+ });
+
+ it("does not force ANTHROPIC_API_KEY when the flag is off", () => {
+ const config = buildOnboardingAdapterConfig({
+ ...baseInput,
+ adapterType: "claude_local",
+ forceUnsetAnthropicApiKey: false,
+ });
+ const env = config.env as Record | undefined;
+ expect(env?.ANTHROPIC_API_KEY).not.toEqual({ type: "plain", value: "" });
+ });
+
+ it("only applies the ANTHROPIC_API_KEY override to claude_local", () => {
+ const config = buildOnboardingAdapterConfig({
+ ...baseInput,
+ adapterType: "codex_local",
+ forceUnsetAnthropicApiKey: true,
+ });
+ const env = config.env as Record | undefined;
+ expect(env?.ANTHROPIC_API_KEY).not.toEqual({ type: "plain", value: "" });
+ });
+});
diff --git a/ui/src/lib/onboarding-adapter-config.ts b/ui/src/lib/onboarding-adapter-config.ts
new file mode 100644
index 0000000000..9ceb80f8b4
--- /dev/null
+++ b/ui/src/lib/onboarding-adapter-config.ts
@@ -0,0 +1,63 @@
+// Pure adapter-config builder for onboarding, extracted verbatim from
+// OnboardingWizard.buildAdapterConfig so the new onboarding flow and the
+// useOnboardingFlow hook can build an agent adapter config from user inputs
+// without duplicating the branch logic.
+import { getUIAdapter } from "@/adapters";
+import { defaultCreateValues } from "@/components/agent-config-defaults";
+import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local";
+import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
+import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
+import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
+
+export interface OnboardingAdapterConfigInput {
+ adapterType: string;
+ model: string;
+ command: string;
+ args: string;
+ url: string;
+ /**
+ * When true and the adapter is claude_local, force ANTHROPIC_API_KEY to an
+ * empty plain value so a subscription login is used instead of a stray env
+ * key that overrides it.
+ */
+ forceUnsetAnthropicApiKey: boolean;
+}
+
+export function buildOnboardingAdapterConfig(
+ input: OnboardingAdapterConfigInput,
+): Record {
+ const { adapterType, model, command, args, url, forceUnsetAnthropicApiKey } = input;
+ const adapter = getUIAdapter(adapterType);
+ const config = adapter.buildAdapterConfig({
+ ...defaultCreateValues,
+ adapterType,
+ model:
+ adapterType === "gemini_local"
+ ? model || DEFAULT_GEMINI_LOCAL_MODEL
+ : adapterType === "cursor"
+ ? model || DEFAULT_CURSOR_LOCAL_MODEL
+ : adapterType === "opencode_local"
+ ? model || DEFAULT_OPENCODE_LOCAL_MODEL
+ : model,
+ command,
+ args,
+ url,
+ dangerouslySkipPermissions:
+ adapterType === "claude_local" || adapterType === "opencode_local",
+ dangerouslyBypassSandbox:
+ adapterType === "codex_local"
+ ? DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX
+ : defaultCreateValues.dangerouslyBypassSandbox,
+ });
+ if (adapterType === "claude_local" && forceUnsetAnthropicApiKey) {
+ const env =
+ typeof config.env === "object" &&
+ config.env !== null &&
+ !Array.isArray(config.env)
+ ? { ...(config.env as Record) }
+ : {};
+ env.ANTHROPIC_API_KEY = { type: "plain", value: "" };
+ config.env = env;
+ }
+ return config;
+}
diff --git a/ui/src/lib/onboarding-constants.ts b/ui/src/lib/onboarding-constants.ts
new file mode 100644
index 0000000000..61b44a7864
--- /dev/null
+++ b/ui/src/lib/onboarding-constants.ts
@@ -0,0 +1,16 @@
+// Shared onboarding constants, extracted from OnboardingWizard so the new
+// presentational onboarding flow and the useOnboardingFlow hook share one
+// source of truth for storage keys, default task copy, and error messages.
+
+export const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
+
+export const DEFAULT_TASK_TITLE = "Hire your first engineer and create a hiring plan";
+
+export const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
+
+- hire a founding engineer
+- write a hiring plan
+- break the roadmap into concrete tasks and start delegating work`;
+
+export const INCOMPLETE_ONBOARDING_STATE_MESSAGE =
+ "Onboarding state is incomplete. Please restart onboarding and try again.";
diff --git a/ui/src/onboarding-preview-main.tsx b/ui/src/onboarding-preview-main.tsx
new file mode 100644
index 0000000000..f50add414d
--- /dev/null
+++ b/ui/src/onboarding-preview-main.tsx
@@ -0,0 +1,112 @@
+// Standalone preview harness for the onboarding flows (no app shell / left nav),
+// modeled on the repo's thread-components harness.
+//
+// - `?flow=cloud` (default) renders CloudOnboardingFlow; `?flow=local` renders
+// LocalOnboardingFlow. Test both side-by-side in two tabs.
+// - The cloud flow walks the full arc (account → OTP → welcome …). The LOCAL
+// flow has no sign-in at all — it opens on its welcome screen, which carries
+// the orbiting-paperclip backdrop the auth screens would have shown.
+// - `?step=` deep-links straight to one screen. Cloud: account | otp |
+// start | company | agent | task. Local: start | email | company | agent |
+// adapter | task.
+//
+// SAFETY: `previewMock` makes every wired CTA skip its backend call, so the
+// flow is clickable end-to-end with no database writes. This harness is for
+// visual review / sharing only.
+import { StrictMode, useEffect, useRef, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { MemoryRouter } from "@/lib/router";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { TooltipProvider } from "@/components/ui/tooltip";
+import { ThemeProvider } from "./context/ThemeContext";
+import { CompanyProvider } from "./context/CompanyContext";
+import { CloudOnboardingFlow } from "./components/onboarding/CloudOnboardingFlow";
+import { LocalOnboardingFlow } from "./components/onboarding/LocalOnboardingFlow";
+import { AccountScreen, OtpScreen } from "./components/onboarding/OnboardingAuthScreens";
+import {
+ AUTH_EXIT_DURATION,
+ OnboardingAuthBackdrop,
+} from "./components/onboarding/OnboardingAuthBackdrop";
+import "./index.css";
+
+// "exiting" holds the gap between the auth screens fading out and the flow
+// mounting, so the paperclip reaches 0% before the next screen appears.
+type Phase = "account" | "otp" | "exiting" | "flow";
+
+const params = new URLSearchParams(window.location.search);
+const isLocal = params.get("flow") === "local";
+const requestedStep = params.get("step");
+
+// Step names valid within the selected flow (drives deep-link vs. auth phase).
+const CLOUD_STEPS = ["start", "company", "agent", "task"] as const;
+const LOCAL_STEPS = ["start", "email", "company", "agent", "adapter", "task"] as const;
+const flowSteps: readonly string[] = isLocal ? LOCAL_STEPS : CLOUD_STEPS;
+const isFlowStep = requestedStep !== null && flowSteps.includes(requestedStep);
+
+const flowStart = isFlowStep ? requestedStep! : "start";
+// Which phase the harness opens on. The local flow skips sign-in entirely, so
+// it always opens on its welcome screen; cloud still walks account → OTP first.
+const initialPhase: Phase = isLocal
+ ? "flow"
+ : isFlowStep
+ ? "flow"
+ : requestedStep === "otp"
+ ? "otp"
+ : "account";
+
+function Preview() {
+ const [phase, setPhase] = useState(initialPhase);
+ const authVisible = phase === "account" || phase === "otp";
+ const exitTimer = useRef(undefined);
+
+ useEffect(() => () => window.clearTimeout(exitTimer.current), []);
+
+ // Finishing sign-in fades the auth card + orbiting paperclip all the way out
+ // FIRST, and only then mounts the flow — so the backdrop is fully gone before
+ // the next screen appears rather than the two overlapping.
+ function finishAuth() {
+ setPhase("exiting");
+ exitTimer.current = window.setTimeout(
+ () => setPhase("flow"),
+ AUTH_EXIT_DURATION * 1000,
+ );
+ }
+
+ return (
+ <>
+ {phase === "flow" &&
+ (isLocal ? (
+ {}} />
+ ) : (
+ {}} />
+ ))}
+
+ {phase === "otp" ? (
+
+ ) : (
+ setPhase("otp")} />
+ )}
+
+ >
+ );
+}
+
+const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
+});
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+
+
+
+
+
+
+ ,
+);
diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx
index d62334bef0..9529865165 100644
--- a/ui/src/pages/DesignGuide.tsx
+++ b/ui/src/pages/DesignGuide.tsx
@@ -120,6 +120,15 @@ import {
AvatarGroupCount,
} from "@/components/ui/avatar";
import { AgentCapsule, AGENT_GRADIENT_COUNT } from "@/components/AgentCapsule";
+import {
+ Chip,
+ ChoiceCard,
+ ConnectorRow,
+ OnboardingCard,
+ OnboardingHeading,
+ Stepper,
+} from "@/components/onboarding/OnboardingPrimitives";
+import { CONNECTORS, MISSION_CHIPS } from "@/components/onboarding/onboarding-data";
import { StatusBadge, IssueStatusBadge } from "@/components/StatusBadge";
import { StatusIcon } from "@/components/StatusIcon";
import { EnforcementBanner } from "@/components/EnforcementBanner";
@@ -253,6 +262,61 @@ function SubSection({ title, children }: { title: string; children: React.ReactN
);
}
+// Onboarding flow primitives (ported prototype): interactive demos need local
+// selection state, mirroring how OnboardingFlow drives them.
+function OnboardingChipsShowcase() {
+ const [active, setActive] = useState(MISSION_CHIPS[0] ?? null);
+ return (
+
+ }
+ title="Create a hiring plan"
+ description="A staffing plan for the agents your team needs — roles, order, and what each one owns."
+ selected={choice === "hiring"}
+ onClick={() => setChoice("hiring")}
+ />
+ }
+ title="Write a team strategy doc"
+ description="Turn your mission into a one-page strategy: goals, bets, and how you'll measure them."
+ selected={choice === "strategy"}
+ onClick={() => setChoice("strategy")}
+ />
+
+ );
+}
+
// Onboarding seam (design §6 + §12.5): the TeamCard tile in its "Pick a starter
// team" 3-col grid, with the first defaultInstall tile selected.
function TeamCardShowcase() {
@@ -432,6 +496,7 @@ export function DesignGuide() {
"FilterBar", "InlineEditor", "PageSkeleton", "Identity", "CommentThread", "MarkdownEditor",
"PropertiesPanel", "Sidebar", "CommandPalette", "EnvironmentVariablesEditor",
"InlineBanner", "BuiltInAgentGate", "BuiltInLifecycleChip",
+ "AgentCapsule", "OnboardingCard", "Stepper", "Chip", "ChoiceCard", "ConnectorRow",
].map((name) => (
{name}
@@ -766,6 +831,38 @@ export function DesignGuide() {
+ {/* ============================================================ */}
+ {/* ONBOARDING FLOW */}
+ {/* ============================================================ */}
+
+
+ Primitives for the full-screen onboarding flow (
+ components/onboarding/). Bespoke surfaces keep
+ their prototype dimensions via verbatim size tokens (
+ --sz-560px card,{" "}
+ --sz-320px start tiles); type, fields, and focus
+ treatment come from the shared primitives and scale.
+