Files
PaperClipAI/ui/src/pages/InviteUxLab.tsx
T
Devin Foley bc1a21564f Remove the company brand color and per-company attachment limit (#12291)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A company is the top-level container, and the company General page
holds its settings
> - Two of those settings did almost nothing: the brand color only
tinted the generated company icon, and the attachment size limit sat
under the deployment-level `PAPERCLIP_ATTACHMENT_MAX_BYTES` cap that
already bounded every upload
> - A setting that changes one icon hue, and a setting that can only
lower a limit the operator already set, are not worth the page space or
the code that carries them
> - This pull request deletes both settings from the UI, the validators,
the API contract, the server, and the database
> - With the deployment cap as the only limit left, the message a person
sees when an upload is rejected has to name that limit in terms they can
act on, so the raw byte count becomes a human-readable size
> - The benefit is a shorter company General page for every deployment,
one attachment limit instead of two, and less code between an upload and
its ceiling

## Linked Issues or Issue Description

No existing issue. The description below follows
`.github/ISSUE_TEMPLATE/enhancement.yml`.

**What existing behavior does this improve?**

The company General page (`/company/settings`), the `PATCH
/api/companies/{companyId}` and `PATCH
/api/companies/{companyId}/branding` request contracts, and the
attachment upload limit on task, case, and company-import uploads.

**Subsystem affected**

Cross-cutting: `ui/`, `server/`, `packages/shared`, `packages/db`.

**Current behavior**

The company General page shows an "Appearance" section with three
controls: Logo, Brand color, and Attachment size limit. The brand color
is a hex value that feeds one thing — the hue of the generated company
pattern icon. Companies that never set one already get a hue derived
from the company name. The attachment size limit is a per-company byte
count stored on `companies.attachment_max_bytes`. Every upload path
clamps it against the deployment-level `PAPERCLIP_ATTACHMENT_MAX_BYTES`
cap, so the per-company value can only lower a limit the operator
already chose.

**Proposed behavior**

The Appearance section keeps the Logo control only. The company pattern
icon always derives its hue from the company name. Every attachment path
reads the deployment cap directly, so `PAPERCLIP_ATTACHMENT_MAX_BYTES`
is the single limit. An upload rejected by that limit says so in human
units — "File is larger than the 10 MB limit" rather than a raw byte
count. The `companies.brand_color` and `companies.attachment_max_bytes`
columns are dropped, and both fields leave the company API contract.

**Reason and benefit**

Both settings ask an operator to make a decision that changes almost
nothing. The brand color moves one icon hue on a page that also lets you
upload a real logo, which overrides the icon entirely. The attachment
limit reads as a real control but cannot raise anything, so it is a
second place to look when an upload is rejected. Removing both shortens
the page every deployment sees, removes a company-scoped read from the
task attachment upload path, and leaves one attachment limit to reason
about instead of two.

**Breaking changes**

The company API responses no longer include `brandColor` or
`attachmentMaxBytes`, and `GET /api/invites/{token}` no longer includes
`companyBrandColor`. `PATCH /api/companies/{companyId}/branding` is
strict, so a request that sends `brandColor` now returns 400; the
non-strict `PATCH /api/companies/{companyId}` schema strips it. Company
packages exported by older versions still import: the portability
company manifest schema is non-strict, so the retired keys are stripped
and ignored rather than rejected. Companies that stored a brand color
lose it — their icon reverts to the name-derived hue that every company
without a color already used.

## What Changed

- Removed the "Brand color" and "Attachment size limit" fields from the
company General page, along with their state, dirty checks, save
payload, and Save-button gating.
- Removed `brandColor` and `attachmentMaxBytes` from
`createCompanySchema`, `updateCompanySchema`, and
`updateCompanyBrandingSchema`, and deleted the now-orphaned
`DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES` and
`MAX_COMPANY_ATTACHMENT_MAX_BYTES` constants.
- Removed both fields from the `Company` type, the portability manifest
type and schema, and the `companiesApi.update` payload allowlist.
- Dropped `brandColor` from `CompanyPatternIcon` and its callers, so the
icon hue always comes from the company name. Deleted the now-unused
`hexToHue` helper and the now-unused `pickTextColorForSolidBg` export.
- Stopped emitting `brandColor` from the company service selection and
from the invite-summary and invite-branding payloads in
`server/src/routes/access.ts`.
- Replaced `normalizeIssueAttachmentMaxBytes` with the deployment cap:
task attachments, case attachments, and company import now use
`MAX_ATTACHMENT_BYTES` directly. The helper is deleted.
- Added `formatAttachmentSize()` next to `MAX_ATTACHMENT_BYTES` and
routed every over-limit message through it, so a rejected upload names
the limit in human units instead of raw bytes: `Image exceeds 10485760
bytes` becomes `Image is larger than the 10 MB limit`. Enforcement is
unchanged — the same single cap, the same multer limits, the same status
codes and response shapes.
- Added migration
`0229_drop_company_brand_color_and_attachment_max_bytes.sql` and removed
both columns from the Drizzle `companies` schema.
- Kept legacy imports working: the portability company manifest schema
is non-strict, so older packages carrying the retired keys still import
with the keys ignored.
- Updated the skill API reference and the implementation spec, and
pruned the token-extraction allowlist entries that the removed code made
stale.

## Verification

Commands run from the repository root:

- `pnpm --filter @paperclipai/shared typecheck` — pass
- `pnpm --filter @paperclipai/db typecheck` — pass (includes
`check:migrations`, which validates the new migration number and journal
entry)
- `pnpm --filter @paperclipai/ui typecheck` — pass
- server typecheck via `node_modules/.bin/tsc --noEmit` in `server/` —
pass. `pnpm --filter @paperclipai/server typecheck` could not run
locally because it builds the Rust runner first and `cargo` is not
installed on this machine; the TypeScript step it wraps is the command
above.
- `npx vitest run packages/shared/src/validators/company.test.ts` — 6
passed
- `npx vitest run server/src/__tests__/company-portability.test.ts` — 90
passed
- `npx vitest run server/src/__tests__/attachment-types.test.ts
server/src/__tests__/assets.test.ts
server/src/__tests__/issue-attachment-routes.test.ts
server/src/__tests__/company-portability.test.ts
server/src/__tests__/cases-routes.test.ts` — 165 passed (the
human-readable limit messages)
- `npx vitest run server/src/__tests__/company-branding-route.test.ts
server/src/__tests__/issue-attachment-routes.test.ts
server/src/__tests__/invite-summary-route.test.ts
server/src/__tests__/openclaw-invite-prompt-route.test.ts
server/src/__tests__/companies-route-cross-company-authz.test.ts` — all
passed
- `npx vitest run cli/src/__tests__/company.test.ts
cli/src/__tests__/company-delete.test.ts` — 27 passed
- `npx vitest run` in `ui/` — 4425 passed, 1 pre-existing failure
unrelated to this change (`OnboardingWizard.test.tsx` "renders instead
of throwing when the browser denies storage access", which also fails on
`master`)
- `npx vitest run` in `server/` — see the note below
- `node scripts/check-token-gates.mjs` — no new violations; the only
reported violations are the pre-existing `PillGuy.tsx` ones present on
`master`

New tests added:

- `packages/shared/src/validators/company.test.ts` — the create and
update schemas strip the retired keys, the strict branding schema
rejects `brandColor`, and the portability manifest schema accepts a
legacy entry carrying both keys and drops them.
- `server/src/__tests__/company-branding-route.test.ts` — `PATCH
/api/companies/{companyId}/branding` returns 400 for `brandColor` and
does not call the company service.
- `server/src/__tests__/company-portability.test.ts` — a legacy package
that declares `brandColor` and `attachmentMaxBytes` imports
successfully, and neither key reaches `companies.create`.
- `server/src/__tests__/issue-attachment-routes.test.ts` — the effective
task attachment limit is the deployment cap, and the route no longer
loads the company to size an upload.
- `server/src/__tests__/attachment-types.test.ts` —
`formatAttachmentSize()` renders the default cap as `10 MB`, keeps one
decimal place for fractional sizes and drops a trailing `.0`, falls back
to KB and bytes for small caps, steps up to GB, and never emits `NaN`
for a degenerate input.
- `server/src/__tests__/assets.test.ts` — the asset-image and
company-logo routes both return the human-readable limit message on an
over-cap upload.

## Merge with master

`master` moved while this was open, and the merge needed two
resolutions:

- **`ui/src/pages/CompanySettings.tsx`.** #12243 reworded the
user-facing
copy from "company" to "organization", and that rewording landed inside
  the "Brand color" and "Attachment size limit" hints — the two fields
this change deletes. Both fields are removed, so the conflicted block is
dropped whole. The Logo field and every other copy change from #12243
are
  kept.
- **Migration renumbered 0228 -> 0229.** #12307 landed
  `0228_nasty_grim_reaper`, so this migration is now
`0229_drop_company_brand_color_and_attachment_max_bytes`. Its snapshot
is
rebuilt from master's `0228_snapshot.json` with only the two `companies`
  columns removed, and `meta/_journal.json` is master's journal plus a
single `idx: 229` entry. `pnpm --filter @paperclipai/db
check:migrations`
  passes.

The snapshot was rebuilt by hand rather than taken from `drizzle-kit
generate`, because master's `0228_snapshot.json` has drifted from
master's
own schema: `issue_question_response_deliveries.error_count` is created
by
master's 0228 SQL but missing from its snapshot, and the snapshot still
carries `decision_archive_notification_outbox.error_count`. Regenerating
folds both into this migration, and the resulting `ADD COLUMN
error_count`
would fail on a fresh database where master's 0228 already created that
column. Rebuilding from master's snapshot leaves that drift exactly
where
it is and keeps this migration to the two column drops. The drift is
pre-existing on master and is not addressed here.

## Risks

- **The migration is a destructive column drop.**
`0229_drop_company_brand_color_and_attachment_max_bytes.sql` removes
`companies.brand_color` and `companies.attachment_max_bytes`. It is safe
because both features are removed in the same change and nothing reads
either column after it. The statements use `DROP COLUMN IF EXISTS`,
matching the convention of the recent drop migrations in this
repository. The drop is not reversible: a downgrade after this migration
loses any stored values.
- **Stored brand colors are lost.** A company that had set a color now
renders the name-derived icon hue that every company without a color
already used. No other surface changes, and an uploaded logo still
overrides the icon.
- **API response shape narrows.** `brandColor` and `attachmentMaxBytes`
leave the company payloads, and `companyBrandColor` leaves the invite
summary payload. A client reading those fields now sees `undefined`. The
bundled UI and CLI are updated in this change.
- **Legacy imports are covered.** Packages exported by older versions
still carry both keys. The manifest schema is non-strict, so the keys
are stripped rather than rejected, and a test locks that in.
- **The over-limit message strings changed.** Anything matching on the
old `... exceeds N bytes` text — a test, a script, or a client that
string-matches `body.error` — needs updating. The status codes (422) and
response shapes are unchanged, so structured clients are unaffected.
- **Attachment limits can only widen.** A deployment that had lowered a
company below the deployment cap now allows uploads up to the cap for
that company. Lower `PAPERCLIP_ATTACHMENT_MAX_BYTES` if a smaller
ceiling is needed.
- **Storybook visual baselines shift** for the `CompanyPatternIcon`
matrix story, because those fixtures had brand colors. That workflow
runs only on a PR labeled `storybook-visual`, so it does not gate this
PR; regenerate the baselines if the label is added.

## Model Used

Claude (Anthropic), Claude Opus, agentic tool use via Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-27 12:11:05 -07:00

930 lines
37 KiB
TypeScript

// token-extraction: allowlisted — intentional one-off decoration (DECISION-SHEET.md B1
// user ruling). The bg-[...gradient...] / shadow-[...] literals in this demo/UX-lab page
// are deliberate one-off decoration, reverted from --gradient-extract-*/--shadow-extract-*
// tokens; the file is on the check-token-gates allowlist in ui/src/index.css.
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 organization work and follow along.",
gets: "View-only organization membership.",
},
{
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 organization access, including membership management.",
gets: "Everything in Admin, plus managing members.",
},
] 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 (
<section
className={cn(
"rounded-(--rad-28) border border-border/70 bg-background/80 p-4 shadow-[0_24px_60px_rgba(15,23,42,0.08)] sm:p-5",
accentClassName,
)}
>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-caps) text-muted-foreground">
{eyebrow}
</div>
<h2 className="mt-1 text-xl font-semibold tracking-tight">{title}</h2>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
</div>
</div>
{children}
</section>
);
}
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 (
<Card className={cn("rounded-(--rad-24) shadow-none", toneClassName)}>
<CardHeader className="space-y-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-current/10 bg-background/70 text-muted-foreground">
{icon}
</div>
<div>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription className="mt-2 text-sm leading-6">{body}</CardDescription>
</div>
</CardHeader>
</Card>
);
}
function InviteLandingShell({
left,
right,
}: {
left: ReactNode;
right: ReactNode;
}) {
return (
<div className="overflow-hidden rounded-(--rad-28) border border-zinc-800 bg-zinc-950 shadow-[0_30px_80px_rgba(2,6,23,0.55)]">
<div className="grid gap-px bg-zinc-800 lg:grid-cols-(--gtc-37)">
<section className={cn(panelClassName, "space-y-6 bg-zinc-950")}>{left}</section>
<section className={cn(panelClassName, "h-full bg-zinc-950")}>{right}</section>
</div>
</div>
);
}
function InviteSummaryPanel({
title,
description,
inviteMessage,
requestedAccess,
signedInLabel,
}: {
title: string;
description: string;
inviteMessage?: string;
requestedAccess: string;
signedInLabel?: string;
}) {
return (
<>
<div className="flex items-start gap-4">
<CompanyPatternIcon
companyName="Acme Robotics"
logoUrl="/api/invites/pcp_invite_test/logo"
className="h-16 w-16 rounded-none border border-zinc-800"
/>
<div className="min-w-0">
<p className="text-xs uppercase tracking-(--tracking-caps) text-zinc-500">You&apos;ve been invited to join Paperclip</p>
<h3 className="mt-2 text-2xl font-semibold text-zinc-100">{title}</h3>
<p className="mt-2 max-w-2xl text-sm leading-6 text-zinc-300">{description}</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<MetaCard label="Organization" value="Acme Robotics" />
<MetaCard label="Invited by" value="Board User" />
<MetaCard label="Requested access" value={requestedAccess} />
<MetaCard label="Invite expires" value="Mar 7, 2027" />
</div>
{inviteMessage ? (
<div className="border border-amber-500/40 bg-amber-500/10 p-4">
<div className="text-xs uppercase tracking-(--tracking-caps) text-amber-200/80">Message from inviter</div>
<p className="mt-2 text-sm leading-6 text-amber-50">{inviteMessage}</p>
</div>
) : null}
{signedInLabel ? (
<div className="border border-emerald-500/40 bg-emerald-500/10 p-4 text-sm text-emerald-50">
Signed in as <span className="font-medium">{signedInLabel}</span>.
</div>
) : null}
</>
);
}
function MetaCard({ label, value }: { label: string; value: string }) {
return (
<div className="border border-zinc-800 p-3">
<div className="text-xs uppercase tracking-(--tracking-caps) text-zinc-500">{label}</div>
<div className="mt-1 text-sm text-zinc-100">{value}</div>
</div>
);
}
function InlineAuthPreview({
mode,
feedback,
working,
}: {
mode: "sign_up" | "sign_in";
feedback?: { tone: "info" | "error"; text: string };
working?: boolean;
}) {
return (
<div className="space-y-5">
<div>
<h3 className="text-lg font-semibold text-zinc-100">
{mode === "sign_up" ? "Create your account" : "Sign in to continue"}
</h3>
<p className="mt-1 text-sm text-zinc-400">
{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."}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
className={cn(
"flex-1 border px-3 py-2 text-sm transition-colors",
mode === "sign_up"
? "border-zinc-100 bg-zinc-100 text-zinc-950"
: "border-zinc-800 text-zinc-300 hover:border-zinc-600",
)}
>
Create account
</button>
<button
type="button"
className={cn(
"flex-1 border px-3 py-2 text-sm transition-colors",
mode === "sign_in"
? "border-zinc-100 bg-zinc-100 text-zinc-950"
: "border-zinc-800 text-zinc-300 hover:border-zinc-600",
)}
>
I already have an account
</button>
</div>
<form className="space-y-4">
{mode === "sign_up" ? (
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Name</span>
<input name="name" className={fieldClassName} defaultValue="Jane Example" readOnly />
</label>
) : null}
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Email</span>
<input name="email" type="email" className={fieldClassName} defaultValue="jane@example.com" readOnly />
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Password</span>
<input name="password" type="password" className={fieldClassName} defaultValue="supersecret" readOnly />
</label>
{feedback ? (
<p className={cn("text-xs", feedback.tone === "info" ? "text-amber-300" : "text-red-400")}>
{feedback.text}
</p>
) : null}
<Button type="button" className="w-full rounded-none" disabled={working}>
{working ? "Working..." : mode === "sign_in" ? "Sign in and continue" : "Create account and continue"}
</Button>
</form>
<p className="text-xs leading-5 text-zinc-500">
{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."}
</p>
</div>
);
}
function AgentRequestPreview() {
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-zinc-100">Submit agent details</h3>
<p className="mt-1 text-sm text-zinc-400">
This invite will create an approval request for a new agent in Acme Robotics.
</p>
</div>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Agent name</span>
<input className={fieldClassName} defaultValue="Acme Ops Agent" readOnly />
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Adapter type</span>
<select className={fieldClassName} defaultValue="codex_local" disabled>
<option value="codex_local">Codex</option>
<option value="claude_local">Claude Code</option>
<option value="cursor">Cursor</option>
</select>
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-400">Capabilities</span>
<textarea
className={fieldClassName}
rows={4}
defaultValue="Reviews invites, triages requests, and keeps the board queue moving."
readOnly
/>
</label>
<Button type="button" className="w-full rounded-none">
Submit request
</Button>
</div>
);
}
function AcceptInvitePreview({
autoAccept,
isCurrentMember,
error,
}: {
autoAccept?: boolean;
isCurrentMember?: boolean;
error?: string;
}) {
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold text-zinc-100">Accept organization invite</h3>
<p className="mt-1 text-sm text-zinc-400">
{autoAccept
? "Granting your access to Acme Robotics."
: isCurrentMember
? "This account already belongs to Acme Robotics."
: "This will grant or complete your access to Acme Robotics."}
</p>
</div>
{error ? <p className="text-xs text-red-400">{error}</p> : null}
{autoAccept ? (
<div className="text-sm text-zinc-400">Submitting request...</div>
) : (
<Button type="button" className="w-full rounded-none" disabled={isCurrentMember}>
Accept invite
</Button>
)}
</div>
);
}
function InviteResultPreview({
title,
description,
claimSecret,
onboardingTextUrl,
joinedNow = false,
}: {
title: string;
description: string;
claimSecret?: string;
onboardingTextUrl?: string;
joinedNow?: boolean;
}) {
return (
<div className="mx-auto max-w-md border border-zinc-800 bg-zinc-950 p-6 text-zinc-100">
<div className="flex items-center gap-3">
<CompanyPatternIcon
companyName="Acme Robotics"
logoUrl="/api/invites/pcp_invite_test/logo"
className="h-12 w-12 rounded-none border border-zinc-800"
/>
<h3 className="text-lg font-semibold">{title}</h3>
</div>
<div className="mt-4 space-y-3">
<p className="text-sm text-zinc-400">{description}</p>
{joinedNow ? (
<Button type="button" className="w-full rounded-none">
Open board
</Button>
) : (
<>
<div className="border border-zinc-800 p-3">
<p className="mb-1 text-xs text-zinc-500">Approval page</p>
<a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/members">
Settings → Members
</a>
</div>
<p className="text-xs text-zinc-500">
Refresh this page after you&apos;ve been approved — you&apos;ll be redirected automatically.
</p>
</>
)}
{claimSecret ? (
<div className="space-y-1 border border-zinc-800 p-3 text-xs text-zinc-400">
<div className="text-zinc-200">Claim secret</div>
<div className="font-mono break-all">{claimSecret}</div>
<div className="font-mono break-all">POST /api/agents/claim-api-key</div>
</div>
) : null}
{onboardingTextUrl ? (
<div className="text-xs text-zinc-400">
Onboarding: <span className="font-mono break-all">{onboardingTextUrl}</span>
</div>
) : null}
</div>
</div>
);
}
function AuthScreenPreview({ mode, error }: { mode: "sign_in" | "sign_up"; error?: string }) {
return (
<div className="overflow-hidden rounded-(--rad-28) border border-border/70 bg-background shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
<div className="grid gap-px bg-border/60 md:grid-cols-2">
<div className="flex min-h-(--sz-420px) flex-col justify-center bg-background px-8 py-10">
<div className="mx-auto w-full max-w-md">
<div className="mb-8 flex items-center gap-2">
<FlaskConical className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Paperclip</span>
</div>
<h3 className="text-xl font-semibold">
{mode === "sign_in" ? "Sign in to Paperclip" : "Create your Paperclip account"}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{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."}
</p>
<div className="mt-6 space-y-4">
{mode === "sign_up" ? (
<label className="block">
<span className="mb-1 block text-xs text-muted-foreground">Name</span>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm"
defaultValue="Jane Example"
readOnly
/>
</label>
) : null}
<label className="block">
<span className="mb-1 block text-xs text-muted-foreground">Email</span>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm"
defaultValue="jane@example.com"
readOnly
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted-foreground">Password</span>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm"
defaultValue="supersecret"
readOnly
/>
</label>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<Button type="button" className="w-full">
{mode === "sign_in" ? "Sign In" : "Create Account"}
</Button>
</div>
<div className="mt-5 text-sm text-muted-foreground">
{mode === "sign_in" ? "Need an account?" : "Already have an account?"}{" "}
<span className="font-medium text-foreground underline underline-offset-2">
{mode === "sign_in" ? "Create one" : "Sign in"}
</span>
</div>
</div>
</div>
<div className="hidden min-h-(--sz-420px) items-center justify-center bg-[radial-gradient(circle_at_top,rgba(8,145,178,0.18),transparent_48%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,1))] px-8 py-10 md:flex">
<div className="max-w-sm space-y-4 text-zinc-200">
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-400/30 bg-cyan-500/[0.08] px-3 py-1 text-(length:--text-nano) uppercase tracking-(--tracking-caps) text-cyan-200">
Auth preview
</div>
<div className="text-2xl font-semibold">Side-by-side signup styling review</div>
<p className="text-sm leading-6 text-zinc-400">
This frame mirrors the production auth surface so spacing, label density, button treatments, and desktop composition are easy to compare.
</p>
</div>
</div>
</div>
</div>
);
}
function CompanyInvitesPreview() {
return (
<div className="grid gap-5 xl:grid-cols-(--gtc-38)">
<Card className="rounded-(--rad-28) shadow-none">
<CardHeader className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<MailPlus className="h-4 w-4" />
Organization Invites
</div>
<div>
<CardTitle>Create invite</CardTitle>
<CardDescription className="mt-2">
Generate a human invite link and choose the default access it should request.
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<fieldset className="space-y-3">
<legend className="text-sm font-medium">Choose a role</legend>
<div className="rounded-2xl border border-border">
{inviteRoleOptions.map((option, index) => (
<label
key={option.value}
className={cn("flex cursor-default gap-3 px-4 py-4", index > 0 && "border-t border-border")}
>
<input
type="radio"
readOnly
checked={option.value === "operator"}
className="mt-1 h-4 w-4 border-border text-foreground"
/>
<span className="min-w-0 space-y-1">
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{option.label}</span>
{option.value === "operator" ? (
<Badge variant="outline" className="border-border text-muted-foreground">
Default
</Badge>
) : null}
</span>
<span className="block max-w-2xl text-sm text-muted-foreground">{option.description}</span>
<span className="block text-sm text-foreground">{option.gets}</span>
</span>
</label>
))}
</div>
</fieldset>
<div className="rounded-xl border border-border px-4 py-3 text-sm text-muted-foreground">
Each invite link is single-use. Human invitees get the selected role immediately after sign-in; agent invites still create a join request for approval.
</div>
<div className="flex flex-wrap items-center gap-3">
<Button type="button">Create invite</Button>
<span className="text-sm text-muted-foreground">Invite history below keeps the audit trail.</span>
</div>
<div className="space-y-3 rounded-2xl border border-border px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">Latest invite link</div>
<div className="text-sm text-muted-foreground">
This URL includes the current Paperclip domain returned by the server.
</div>
</div>
<div className="inline-flex items-center gap-1 text-xs font-medium text-foreground">
<Check className="h-3.5 w-3.5" />
Copied
</div>
</div>
<button
type="button"
className="w-full rounded-md border border-border bg-muted/60 px-3 py-2 text-left text-sm break-all"
>
https://paperclip.local/invite/new-token
</button>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline">
<ExternalLink className="h-4 w-4" />
Open invite
</Button>
</div>
</div>
</CardContent>
</Card>
<Card className="rounded-(--rad-28) shadow-none">
<CardHeader className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle>Invite history</CardTitle>
<CardDescription className="mt-2">
Review invite status, role, inviter, and any linked join request.
</CardDescription>
</div>
<a href="/inbox/requests" className="text-sm underline underline-offset-4">
Open join request queue
</a>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="overflow-x-auto rounded-2xl border border-border">
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-border">
<th className="px-5 py-3 font-medium text-muted-foreground">State</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Role</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Invited by</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Created</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Join request</th>
<th className="px-5 py-3 text-right font-medium text-muted-foreground">Action</th>
</tr>
</thead>
<tbody>
{inviteHistory.map((invite) => (
<tr key={invite.id} className="border-b border-border last:border-b-0">
<td className="px-5 py-3 align-top">
<Badge variant="outline" className="border-border text-muted-foreground">
{invite.state}
</Badge>
</td>
<td className="px-5 py-3 align-top">{invite.humanRole}</td>
<td className="px-5 py-3 align-top">
<div>{invite.invitedBy}</div>
<div className="text-xs text-muted-foreground">{invite.email}</div>
</td>
<td className="px-5 py-3 align-top text-muted-foreground">{invite.createdAt}</td>
<td className="px-5 py-3 align-top">
{invite.relatedLabel === "Review request" ? (
<a href="/inbox/requests" className="underline underline-offset-4">
{invite.relatedLabel}
</a>
) : (
<span className="text-muted-foreground">{invite.relatedLabel}</span>
)}
</td>
<td className="px-5 py-3 text-right align-top">
{invite.action === "Revoke" ? (
<Button type="button" size="sm" variant="outline">
Revoke
</Button>
) : (
<span className="text-xs text-muted-foreground">Inactive</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-border p-4">
<div className="text-sm font-medium">Empty history state</div>
<div className="mt-2 text-sm text-muted-foreground">
No invites have been created for this organization yet.
</div>
</div>
<div className="rounded-2xl border border-rose-400/40 bg-rose-500/[0.07] p-4">
<div className="text-sm font-medium text-foreground">Permission error</div>
<div className="mt-2 text-sm text-muted-foreground">
You do not have permission to manage organization invites.
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export function InviteUxLab() {
return (
<div className="space-y-6">
<div className="overflow-hidden rounded-(--rad-32) border border-border/70 bg-[linear-gradient(135deg,rgba(8,145,178,0.10),transparent_28%),linear-gradient(180deg,rgba(245,158,11,0.10),transparent_44%),var(--background)] shadow-[0_30px_80px_rgba(15,23,42,0.10)]">
<div className="grid gap-6 lg:grid-cols-(--gtc-39)">
<div className="p-6 sm:p-7">
<div className="inline-flex items-center gap-2 rounded-full border border-cyan-500/25 bg-cyan-500/[0.08] px-3 py-1 text-(length:--text-nano) font-semibold uppercase tracking-(--tracking-caps) text-cyan-700 dark:text-cyan-300">
<FlaskConical className="h-3.5 w-3.5" />
Invite UX Lab
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-tight">Invite and signup UX review surface</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
This page collects the current invite landing, signup, approval-result, and organization invite-management states in one place so styling changes can be reviewed without recreating each backend condition by hand.
</p>
<div className="mt-5 flex flex-wrap items-center gap-2">
<Badge variant="outline" className="rounded-full px-3 py-1 text-(length:--text-nano) uppercase tracking-(--tracking-caps)">
/tests/ux/invites
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-(length:--text-nano) uppercase tracking-(--tracking-caps)">
signup + invite states
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-(length:--text-nano) uppercase tracking-(--tracking-caps)">
fixture-backed preview
</Badge>
</div>
</div>
<aside className="border-t border-border/60 bg-background/70 p-6 lg:border-l lg:border-t-0">
<div className="mb-4 text-(length:--text-micro) font-semibold uppercase tracking-(--tracking-caps) text-muted-foreground">
Covered states
</div>
<div className="space-y-3">
{[
"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",
"Organization invite creation, copied-link, history, empty, and permission-error states",
].map((highlight) => (
<div
key={highlight}
className="rounded-2xl border border-border/70 bg-background/85 px-4 py-3 text-sm text-muted-foreground"
>
{highlight}
</div>
))}
</div>
</aside>
</div>
</div>
<LabSection
eyebrow="Top-level states"
title="Landing state coverage"
description="Small cards for the fast-return invite states that do not render the full split-screen layout."
accentClassName="bg-[linear-gradient(180deg,rgba(59,130,246,0.05),transparent_30%),var(--background)]"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatusCard
icon={<Loader2 className="h-4 w-4 animate-spin" />}
title="Loading invite"
body="Shown while invite summary, deployment mode, or auth session data is still loading."
/>
<StatusCard
icon={<Clock3 className="h-4 w-4" />}
title="Checking your access"
body="Shown after sign-in while the app verifies whether the current user already belongs to the invited organization."
/>
<StatusCard
icon={<KeyRound className="h-4 w-4" />}
title="Invalid invite token"
body="The token is missing entirely, so the page short-circuits before any invite lookup."
tone="error"
/>
<StatusCard
icon={<Link2 className="h-4 w-4" />}
title="Invite not available"
body="Used for expired, revoked, already-consumed, or otherwise missing invites."
tone="warn"
/>
<StatusCard
icon={<ShieldCheck className="h-4 w-4" />}
title="Bootstrap complete"
body="Result screen for bootstrap CEO invites after setup has been accepted successfully."
tone="success"
/>
<StatusCard
icon={<ArrowRight className="h-4 w-4" />}
title="Auto-accept in progress"
body="Signed-in human users skip the extra button click and move straight into join submission."
/>
<StatusCard
icon={<Users className="h-4 w-4" />}
title="Already a member"
body="Acceptance stays disabled and the page redirects into the organization once membership is confirmed."
/>
<StatusCard
icon={<UserPlus className="h-4 w-4" />}
title="Invite result surfaces"
body="Both pending-approval and joined-now confirmations are included below with claim and onboarding extras."
tone="success"
/>
</div>
</LabSection>
<LabSection
eyebrow="Invite landing"
title="Split-screen invite flows"
description="These frames mirror the production invite surface closely enough to review spacing, hierarchy, and control states while keeping data fixture-driven."
accentClassName="bg-[linear-gradient(180deg,rgba(234,179,8,0.06),transparent_28%),var(--background)]"
>
<div className="space-y-5">
<InviteLandingShell
left={
<InviteSummaryPanel
title="Join Acme Robotics"
description="Create your Paperclip account first. If you already have one, switch to sign in and continue the invite with the same email."
inviteMessage="Welcome aboard."
requestedAccess="Operator"
/>
}
right={<InlineAuthPreview mode="sign_up" />}
/>
<InviteLandingShell
left={
<InviteSummaryPanel
title="Join Acme Robotics"
description="Create your Paperclip account first. If you already have one, switch to sign in and continue the invite with the same email."
inviteMessage="Welcome aboard."
requestedAccess="Operator"
/>
}
right={
<InlineAuthPreview
mode="sign_in"
feedback={{
tone: "info",
text: "An account already exists for jane@example.com. Sign in below to continue with this invite.",
}}
/>
}
/>
<InviteLandingShell
left={
<InviteSummaryPanel
title="Join Acme Robotics"
description="Your account is ready. Review the invite details, then accept it to continue."
inviteMessage="Welcome aboard."
requestedAccess="Operator"
signedInLabel="Jane Example"
/>
}
right={<AcceptInvitePreview autoAccept />}
/>
<InviteLandingShell
left={
<InviteSummaryPanel
title="Join Acme Robotics"
description="Review the invite details, then submit the agent information below to start the join request."
requestedAccess="Agent join request"
/>
}
right={<AgentRequestPreview />}
/>
<InviteLandingShell
left={
<InviteSummaryPanel
title="Join Acme Robotics"
description="Your account is ready. Review the invite details, then accept it to continue."
requestedAccess="Operator"
signedInLabel="Jane Example"
/>
}
right={<AcceptInvitePreview error="This account already belongs to the organization." isCurrentMember />}
/>
</div>
</LabSection>
<LabSection
eyebrow="Result states"
title="Approval and completion screens"
description="These are the post-submit states returned from invite acceptance, including optional claim and onboarding metadata."
accentClassName="bg-[linear-gradient(180deg,rgba(16,185,129,0.06),transparent_30%),var(--background)]"
>
<div className="grid gap-5 xl:grid-cols-3">
<InviteResultPreview
title="Request to join Acme Robotics"
description="Board User must approve your request to join."
claimSecret="pcp_claim_secret_demo"
onboardingTextUrl="/api/invites/pcp_invite_test/onboarding.txt"
/>
<InviteResultPreview
title="You joined the organization"
description="Your account already matched the approved invite, so the board can be opened immediately."
joinedNow
/>
<InviteResultPreview
title="Request to join Acme Robotics"
description="Ask them to visit Settings → Members to approve your request."
/>
</div>
</LabSection>
<LabSection
eyebrow="Standalone auth"
title="Auth page states"
description="The general `/auth` page uses a different composition from invite landing. These previews keep both sign-in and sign-up variants visible."
accentClassName="bg-[linear-gradient(180deg,rgba(168,85,247,0.06),transparent_28%),var(--background)]"
>
<div className="space-y-5">
<AuthScreenPreview mode="sign_in" error="Invalid email or password" />
<AuthScreenPreview mode="sign_up" />
</div>
</LabSection>
<LabSection
eyebrow="Settings"
title="Organization invite management"
description="This section captures the board-side invite creation flow, copied-link state, audit table, and the edge states that are otherwise tedious to stage."
accentClassName="bg-[linear-gradient(180deg,rgba(244,114,182,0.06),transparent_28%),var(--background)]"
>
<CompanyInvitesPreview />
</LabSection>
</div>
);
}