diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 894b86b750b..f81b44592c9 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -39,6 +39,7 @@ import { PluginSettings } from "./pages/PluginSettings";
import { AdapterManager } from "./pages/AdapterManager";
import { PluginPage } from "./pages/PluginPage";
import { IssueChatUxLab } from "./pages/IssueChatUxLab";
+import { InviteUxLab } from "./pages/InviteUxLab";
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
import { OrgChart } from "./pages/OrgChart";
import { NewAgent } from "./pages/NewAgent";
@@ -117,6 +118,7 @@ function boardRoutes() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/ui/src/pages/InviteUxLab.test.tsx b/ui/src/pages/InviteUxLab.test.tsx
new file mode 100644
index 00000000000..0eb7e8d85ed
--- /dev/null
+++ b/ui/src/pages/InviteUxLab.test.tsx
@@ -0,0 +1,52 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { InviteUxLab } from "./InviteUxLab";
+
+vi.mock("@/components/CompanyPatternIcon", () => ({
+ CompanyPatternIcon: ({ companyName }: { companyName: string }) => (
+
{companyName}
+ ),
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+describe("InviteUxLab", () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ container.remove();
+ document.body.innerHTML = "";
+ vi.clearAllMocks();
+ });
+
+ it("renders the invite/signup review sections", async () => {
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render( );
+ });
+
+ expect(container.textContent).toContain("Invite and signup UX review surface");
+ expect(container.textContent).toContain("/tests/ux/invites");
+ expect(container.textContent).toContain("Landing state coverage");
+ expect(container.textContent).toContain("Split-screen invite flows");
+ expect(container.textContent).toContain("Approval and completion screens");
+ expect(container.textContent).toContain("Auth page states");
+ expect(container.textContent).toContain("Company invite management");
+ expect(container.textContent).toContain("Create your account");
+ expect(container.textContent).toContain("Invite history");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+});
diff --git a/ui/src/pages/InviteUxLab.tsx b/ui/src/pages/InviteUxLab.tsx
new file mode 100644
index 00000000000..941a4875512
--- /dev/null
+++ b/ui/src/pages/InviteUxLab.tsx
@@ -0,0 +1,927 @@
+import type { ReactNode } from "react";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { CompanyPatternIcon } from "@/components/CompanyPatternIcon";
+import { cn } from "@/lib/utils";
+import {
+ ArrowRight,
+ Check,
+ Clock3,
+ ExternalLink,
+ FlaskConical,
+ KeyRound,
+ Link2,
+ Loader2,
+ MailPlus,
+ ShieldCheck,
+ UserPlus,
+ Users,
+} from "lucide-react";
+
+const inviteRoleOptions = [
+ {
+ value: "viewer",
+ label: "Viewer",
+ description: "Can view company work and follow along without operational permissions.",
+ gets: "No built-in grants.",
+ },
+ {
+ value: "operator",
+ label: "Operator",
+ description: "Recommended for people who need to help run work without managing access.",
+ gets: "Can assign tasks.",
+ },
+ {
+ value: "admin",
+ label: "Admin",
+ description: "Recommended for operators who need to invite people, create agents, and approve joins.",
+ gets: "Can create agents, invite users, assign tasks, and approve join requests.",
+ },
+ {
+ value: "owner",
+ label: "Owner",
+ description: "Full company access, including membership and permission management.",
+ gets: "Everything in Admin, plus managing members and permission grants.",
+ },
+] as const;
+
+const inviteHistory = [
+ {
+ id: "invite-active",
+ state: "Active",
+ humanRole: "operator",
+ invitedBy: "Board User 25",
+ email: "board25@paperclip.local",
+ createdAt: "Apr 25, 2026, 9:00 AM",
+ action: "Revoke",
+ relatedLabel: "Review request",
+ },
+ {
+ id: "invite-accepted",
+ state: "Accepted",
+ humanRole: "viewer",
+ invitedBy: "Board User 24",
+ email: "board24@paperclip.local",
+ createdAt: "Apr 24, 2026, 8:15 AM",
+ action: "Inactive",
+ relatedLabel: "—",
+ },
+ {
+ id: "invite-revoked",
+ state: "Revoked",
+ humanRole: "admin",
+ invitedBy: "Board User 20",
+ email: "board20@paperclip.local",
+ createdAt: "Apr 20, 2026, 2:45 PM",
+ action: "Inactive",
+ relatedLabel: "—",
+ },
+ {
+ id: "invite-expired",
+ state: "Expired",
+ humanRole: "owner",
+ invitedBy: "Board User 19",
+ email: "board19@paperclip.local",
+ createdAt: "Apr 19, 2026, 7:10 PM",
+ action: "Inactive",
+ relatedLabel: "—",
+ },
+] as const;
+
+const fieldClassName =
+ "w-full border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-zinc-500";
+const panelClassName = "border border-zinc-800 bg-zinc-950/95 p-6";
+
+function LabSection({
+ eyebrow,
+ title,
+ description,
+ accentClassName,
+ children,
+}: {
+ eyebrow: string;
+ title: string;
+ description: string;
+ accentClassName?: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+
+ {eyebrow}
+
+
{title}
+
{description}
+
+
+ {children}
+
+ );
+}
+
+function StatusCard({
+ icon,
+ title,
+ body,
+ tone = "default",
+}: {
+ icon: ReactNode;
+ title: string;
+ body: string;
+ tone?: "default" | "warn" | "success" | "error";
+}) {
+ const toneClassName = {
+ default: "border-border/70 bg-background/85",
+ warn: "border-amber-400/40 bg-amber-500/[0.08]",
+ success: "border-emerald-400/40 bg-emerald-500/[0.08]",
+ error: "border-rose-400/40 bg-rose-500/[0.08]",
+ }[tone];
+
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+ {body}
+
+
+
+ );
+}
+
+function InviteLandingShell({
+ left,
+ right,
+}: {
+ left: ReactNode;
+ right: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function InviteSummaryPanel({
+ title,
+ description,
+ inviteMessage,
+ requestedAccess,
+ signedInLabel,
+}: {
+ title: string;
+ description: string;
+ inviteMessage?: string;
+ requestedAccess: string;
+ signedInLabel?: string;
+}) {
+ return (
+ <>
+
+
+
+
You've been invited to join Paperclip
+
{title}
+
{description}
+
+
+
+
+
+
+
+
+
+
+ {inviteMessage ? (
+
+
Message from inviter
+
{inviteMessage}
+
+ ) : null}
+
+ {signedInLabel ? (
+
+ Signed in as {signedInLabel} .
+
+ ) : null}
+ >
+ );
+}
+
+function MetaCard({ label, value }: { label: string; value: string }) {
+ return (
+
+ );
+}
+
+function InlineAuthPreview({
+ mode,
+ feedback,
+ working,
+}: {
+ mode: "sign_up" | "sign_in";
+ feedback?: { tone: "info" | "error"; text: string };
+ working?: boolean;
+}) {
+ return (
+
+
+
+ {mode === "sign_up" ? "Create your account" : "Sign in to continue"}
+
+
+ {mode === "sign_up"
+ ? "Start with a Paperclip account. After that, you'll come right back here to accept the invite for Acme Robotics."
+ : "Use the Paperclip account that already matches this invite. If you do not have one yet, switch back to create account."}
+
+
+
+
+
+ Create account
+
+
+ I already have an account
+
+
+
+
+
+
+ {mode === "sign_up"
+ ? "Already signed up before? Use the existing-account option instead so the invite lands on the right Paperclip user."
+ : "No account yet? Switch back to create account so you can accept the invite with a new login."}
+
+
+ );
+}
+
+function AgentRequestPreview() {
+ return (
+
+
+
Submit agent details
+
+ This invite will create an approval request for a new agent in Acme Robotics.
+
+
+
+ Agent name
+
+
+
+ Adapter type
+
+ Codex
+ Claude Code
+ Cursor
+
+
+
+ Capabilities
+
+
+
+ Submit request
+
+
+ );
+}
+
+function AcceptInvitePreview({
+ autoAccept,
+ isCurrentMember,
+ error,
+}: {
+ autoAccept?: boolean;
+ isCurrentMember?: boolean;
+ error?: string;
+}) {
+ return (
+
+
+
Accept company invite
+
+ {autoAccept
+ ? "Submitting your join request for Acme Robotics."
+ : isCurrentMember
+ ? "This account already belongs to Acme Robotics."
+ : "This will submit or complete your join request for Acme Robotics."}
+
+
+ {error ?
{error}
: null}
+ {autoAccept ? (
+
Submitting request...
+ ) : (
+
+ Accept invite
+
+ )}
+
+ );
+}
+
+function InviteResultPreview({
+ title,
+ description,
+ claimSecret,
+ onboardingTextUrl,
+ joinedNow = false,
+}: {
+ title: string;
+ description: string;
+ claimSecret?: string;
+ onboardingTextUrl?: string;
+ joinedNow?: boolean;
+}) {
+ return (
+
+
+
+
{title}
+
+
+
{description}
+ {joinedNow ? (
+
+ Open board
+
+ ) : (
+ <>
+
+
+ Refresh this page after you've been approved — you'll be redirected automatically.
+
+ >
+ )}
+ {claimSecret ? (
+
+
Claim secret
+
{claimSecret}
+
POST /api/agents/claim-api-key
+
+ ) : null}
+ {onboardingTextUrl ? (
+
+ Onboarding: {onboardingTextUrl}
+
+ ) : null}
+
+
+ );
+}
+
+function AuthScreenPreview({ mode, error }: { mode: "sign_in" | "sign_up"; error?: string }) {
+ return (
+
+
+
+
+
+
+ Paperclip
+
+
+ {mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"}
+
+
+ {mode === "sign_in"
+ ? "Use your email and password to access this instance."
+ : "Create an account for this instance. Email confirmation is not required in v1."}
+
+
+
+ {mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "}
+
+ {mode === "sign_in" ? "Create one" : "Sign in"}
+
+
+
+
+
+
+
+ Auth preview
+
+
Side-by-side signup styling review
+
+ This frame mirrors the production auth surface so spacing, label density, button treatments, and desktop composition are easy to compare.
+
+
+
+
+
+ );
+}
+
+function CompanyInvitesPreview() {
+ return (
+
+
+
+
+
+ Company Invites
+
+
+ Create invite
+
+ Generate a human invite link and choose the default access it should request.
+
+
+
+
+
+ Choose a role
+
+ {inviteRoleOptions.map((option, index) => (
+ 0 && "border-t border-border")}
+ >
+
+
+
+ {option.label}
+ {option.value === "operator" ? (
+
+ Default
+
+ ) : null}
+
+ {option.description}
+ {option.gets}
+
+
+ ))}
+
+
+
+
+ Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval.
+
+
+
+ Create invite
+ Invite history below keeps the audit trail.
+
+
+
+
+
+
Latest invite link
+
+ This URL includes the current Paperclip domain returned by the server.
+
+
+
+
+ Copied
+
+
+
+ https://paperclip.local/invite/new-token
+
+
+
+
+ Open invite
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ State
+ Role
+ Invited by
+ Created
+ Join request
+ Action
+
+
+
+ {inviteHistory.map((invite) => (
+
+
+
+ {invite.state}
+
+
+ {invite.humanRole}
+
+ {invite.invitedBy}
+ {invite.email}
+
+ {invite.createdAt}
+
+ {invite.relatedLabel === "Review request" ? (
+
+ {invite.relatedLabel}
+
+ ) : (
+ {invite.relatedLabel}
+ )}
+
+
+ {invite.action === "Revoke" ? (
+
+ Revoke
+
+ ) : (
+ Inactive
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
Empty history state
+
+ No invites have been created for this company yet.
+
+
+
+
Permission error
+
+ You do not have permission to manage company invites.
+
+
+
+
+
+
+ );
+}
+
+export function InviteUxLab() {
+ return (
+
+
+
+
+
+
+ Invite UX Lab
+
+
Invite and signup UX review surface
+
+ This page collects the current invite landing, signup, approval-result, and company invite-management states in one place so styling changes can be reviewed without recreating each backend condition by hand.
+
+
+
+
+ /tests/ux/invites
+
+
+ signup + invite states
+
+
+ fixture-backed preview
+
+
+
+
+
+
+ Covered states
+
+
+ {[
+ "Invite loading, access-check, missing-token, and unavailable states",
+ "Inline account creation and sign-in variants, including feedback/error copy",
+ "Human accept, agent request, and auto-accept transitions",
+ "Pending approval, joined-now, claim secret, and onboarding result screens",
+ "Company invite creation, copied-link, history, empty, and permission-error states",
+ ].map((highlight) => (
+
+ {highlight}
+
+ ))}
+
+
+
+
+
+
+
+ }
+ title="Loading invite"
+ body="Shown while invite summary, deployment mode, or auth session data is still loading."
+ />
+ }
+ title="Checking your access"
+ body="Shown after sign-in while the app verifies whether the current user already belongs to the invited company."
+ />
+ }
+ title="Invalid invite token"
+ body="The token is missing entirely, so the page short-circuits before any invite lookup."
+ tone="error"
+ />
+ }
+ title="Invite not available"
+ body="Used for expired, revoked, already-consumed, or otherwise missing invites."
+ tone="warn"
+ />
+ }
+ title="Bootstrap complete"
+ body="Result screen for bootstrap CEO invites after setup has been accepted successfully."
+ tone="success"
+ />
+ }
+ title="Auto-accept in progress"
+ body="Signed-in human users skip the extra button click and move straight into join submission."
+ />
+ }
+ title="Already a member"
+ body="Acceptance stays disabled and the page redirects into the company once membership is confirmed."
+ />
+ }
+ title="Invite result surfaces"
+ body="Both pending-approval and joined-now confirmations are included below with claim and onboarding extras."
+ tone="success"
+ />
+
+
+
+
+
+
+ }
+ right={
}
+ />
+
+
+ }
+ right={
+
+ }
+ />
+
+
+ }
+ right={
}
+ />
+
+
+ }
+ right={
}
+ />
+
+
+ }
+ right={
}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}