feat(ui): add Storybook setup with foundation and product stories

Set up Storybook 10 with React-Vite, Tailwind, dark/light theme toggle,
and comprehensive provider wrapping. Includes five story files covering
UI primitives, status language, control-plane surfaces, and converted
UX lab pages with fixture-backed data.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-20 11:01:03 -05:00
1 parent 7a329fb8bb
commit fcf2b8efff
16 files changed
+2291 -17

No files matched your search

+11
View File
@@ -43,6 +43,17 @@ This starts:
`pnpm dev` and `pnpm dev:once` are now idempotent for the current repo and instance: if the matching Paperclip dev runner is already alive, Paperclip reports the existing process instead of starting a duplicate.
## Storybook
The board UI Storybook keeps stories and Storybook config under `ui/storybook/` so component review files stay out of the app source routes.
```sh
pnpm storybook
pnpm build-storybook
```
These run the `@paperclipai/ui` Storybook on port `6006` and build the static output to `ui/storybook-static/`.
Inspect or stop the current repo's managed dev runner:
```sh
+2
View File
@@ -11,6 +11,8 @@
"dev:stop": "pnpm --filter @paperclipai/server exec tsx ../scripts/dev-service.ts stop",
"dev:server": "pnpm --filter @paperclipai/server dev",
"dev:ui": "pnpm --filter @paperclipai/ui dev",
"storybook": "pnpm --filter @paperclipai/ui storybook",
"build-storybook": "pnpm --filter @paperclipai/ui build-storybook",
"build": "pnpm run preflight:workspace-links && pnpm -r build",
"typecheck": "pnpm run preflight:workspace-links && pnpm -r typecheck",
"test": "pnpm run test:run",
+616 -7
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -6,6 +6,15 @@ Published static assets for the Paperclip board UI.
The npm package contains the production build under `dist/`. It does not ship the UI source tree or workspace-only dependencies.
## Storybook
Storybook config, stories, and fixtures live under `ui/storybook/`.
```sh
pnpm --filter @paperclipai/ui storybook
pnpm --filter @paperclipai/ui build-storybook
```
## Typical use
Install the package, then serve or copy the built files from `node_modules/@paperclipai/ui/dist`.
+7 -1
View File
@@ -16,9 +16,11 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"storybook": "storybook dev -p 6006 -c storybook/.storybook",
"build-storybook": "storybook build -c storybook/.storybook -o storybook-static",
"preview": "vite preview",
"typecheck": "tsc -b",
"clean": "rm -rf dist tsconfig.tsbuildinfo",
"clean": "rm -rf dist storybook-static tsconfig.tsbuildinfo",
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../scripts/generate-ui-package-json.mjs",
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},
@@ -61,12 +63,16 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.7",
"@storybook/addon-a11y": "10.3.5",
"@storybook/addon-docs": "10.3.5",
"@storybook/react-vite": "10.3.5",
"@types/node": "^25.2.3",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.7",
"typescript": "^5.7.3",
"storybook": "10.3.5",
"vite": "^6.1.0",
"vitest": "^3.0.5"
}
-8
View File
@@ -40,9 +40,6 @@ import { PluginManager } from "./pages/PluginManager";
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";
import { AuthPage } from "./pages/Auth";
@@ -122,9 +119,6 @@ function boardRoutes() {
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
<Route path="u/:userSlug" element={<UserProfile />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="tests/ux/chat" element={<IssueChatUxLab />} />
<Route path="tests/ux/invites" element={<InviteUxLab />} />
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
<Route path="instance/settings/adapters" element={<AdapterManager />} />
<Route path=":pluginRoutePath" element={<PluginPage />} />
<Route path="*" element={<NotFoundPage scope="board" />} />
@@ -303,8 +297,6 @@ export function App() {
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/chat" element={<UnprefixedBoardRedirect />} />
<Route path="tests/ux/runs" element={<UnprefixedBoardRedirect />} />
<Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()}
</Route>
+32
View File
@@ -0,0 +1,32 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { StorybookConfig } from "@storybook/react-vite";
import tailwindcss from "@tailwindcss/vite";
import { mergeConfig } from "vite";
const storybookConfigDir = path.dirname(fileURLToPath(import.meta.url));
const config: StorybookConfig = {
stories: ["../stories/**/*.stories.@(ts|tsx|mdx)"],
staticDirs: ["../../public"],
addons: ["@storybook/addon-docs", "@storybook/addon-a11y"],
framework: {
name: "@storybook/react-vite",
options: {},
},
docs: {
autodocs: true,
},
viteFinal: async (baseConfig) =>
mergeConfig(baseConfig, {
plugins: [tailwindcss()],
resolve: {
alias: {
"@": path.resolve(storybookConfigDir, "../../src"),
lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/Lexical.mjs"),
},
},
}),
};
export default config;
+189
View File
@@ -0,0 +1,189 @@
import { useState, type ReactNode } from "react";
import type { Preview } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "@/lib/router";
import { BreadcrumbProvider } from "@/context/BreadcrumbContext";
import { CompanyProvider } from "@/context/CompanyContext";
import { DialogProvider } from "@/context/DialogContext";
import { EditorAutocompleteProvider } from "@/context/EditorAutocompleteContext";
import { PanelProvider } from "@/context/PanelContext";
import { SidebarProvider } from "@/context/SidebarContext";
import { ThemeProvider } from "@/context/ThemeContext";
import { ToastProvider } from "@/context/ToastContext";
import { TooltipProvider } from "@/components/ui/tooltip";
import "@mdxeditor/editor/style.css";
import "@/index.css";
import "./styles.css";
const storybookCompany = {
id: "company-storybook",
name: "Paperclip Storybook",
description: "Fixture company for isolated UI review.",
status: "active",
pauseReason: null,
pausedAt: null,
issuePrefix: "PAP",
issueCounter: 1641,
budgetMonthlyCents: 250_000,
spentMonthlyCents: 67_500,
requireBoardApprovalForNewAgents: true,
feedbackDataSharingEnabled: true,
feedbackDataSharingConsentAt: null,
feedbackDataSharingConsentByUserId: null,
feedbackDataSharingTermsVersion: null,
brandColor: "#0f766e",
logoAssetId: null,
logoUrl: null,
createdAt: new Date("2026-04-01T09:00:00.000Z"),
updatedAt: new Date("2026-04-20T12:00:00.000Z"),
};
function installStorybookApiFixtures() {
if (typeof window === "undefined") return;
const currentWindow = window as typeof window & {
__paperclipStorybookFetchInstalled?: boolean;
};
if (currentWindow.__paperclipStorybookFetchInstalled) return;
const originalFetch = window.fetch.bind(window);
currentWindow.__paperclipStorybookFetchInstalled = true;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const rawUrl =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const url = new URL(rawUrl, window.location.origin);
if (url.pathname === "/api/companies") {
return Response.json([storybookCompany]);
}
if (url.pathname.startsWith("/api/invites/") && url.pathname.endsWith("/logo")) {
return new Response(null, { status: 204 });
}
return originalFetch(input, init);
};
}
function applyStorybookTheme(theme: "light" | "dark") {
if (typeof document === "undefined") return;
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.style.colorScheme = theme;
}
function StorybookProviders({
children,
theme,
}: {
children: ReactNode;
theme: "light" | "dark";
}) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
},
},
}),
);
applyStorybookTheme(theme);
installStorybookApiFixtures();
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MemoryRouter initialEntries={["/PAP/storybook"]}>
<CompanyProvider>
<EditorAutocompleteProvider>
<ToastProvider>
<TooltipProvider>
<BreadcrumbProvider>
<SidebarProvider>
<PanelProvider>
<DialogProvider>{children}</DialogProvider>
</PanelProvider>
</SidebarProvider>
</BreadcrumbProvider>
</TooltipProvider>
</ToastProvider>
</EditorAutocompleteProvider>
</CompanyProvider>
</MemoryRouter>
</ThemeProvider>
</QueryClientProvider>
);
}
const preview: Preview = {
decorators: [
(Story, context) => {
const theme = context.globals.theme === "light" ? "light" : "dark";
return (
<StorybookProviders key={theme} theme={theme}>
<Story />
</StorybookProviders>
);
},
],
globalTypes: {
theme: {
description: "Paperclip color mode",
defaultValue: "dark",
toolbar: {
title: "Theme",
icon: "mirror",
items: [
{ value: "dark", title: "Dark" },
{ value: "light", title: "Light" },
],
dynamicTitle: true,
},
},
},
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
a11y: {
test: "todo",
},
backgrounds: {
disable: true,
},
controls: {
expanded: true,
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
docs: {
toc: true,
},
layout: "fullscreen",
viewport: {
viewports: {
mobile: {
name: "Mobile",
styles: { width: "390px", height: "844px" },
},
tablet: {
name: "Tablet",
styles: { width: "834px", height: "1112px" },
},
desktop: {
name: "Desktop",
styles: { width: "1440px", height: "960px" },
},
},
},
},
};
export default preview;
+49
View File
@@ -0,0 +1,49 @@
html,
body,
#storybook-root {
min-height: 100%;
}
body {
overflow: auto;
background: var(--background);
}
.sb-show-main {
background: var(--background);
color: var(--foreground);
}
.paperclip-story {
min-height: 100vh;
background:
linear-gradient(180deg, color-mix(in oklab, var(--muted) 28%, transparent), transparent 340px),
var(--background);
color: var(--foreground);
padding: 24px;
}
.paperclip-story__inner {
margin: 0 auto;
max-width: 1180px;
}
.paperclip-story__frame {
border: 1px solid var(--border);
background: color-mix(in oklab, var(--card) 86%, transparent);
box-shadow: 0 24px 90px color-mix(in oklab, black 16%, transparent);
}
.paperclip-story__label {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
@media (max-width: 640px) {
.paperclip-story {
padding: 16px;
}
}
+389
View File
@@ -0,0 +1,389 @@
import type {
ActivityEvent,
Agent,
Approval,
BudgetPolicySummary,
Issue,
} from "@paperclipai/shared";
const now = new Date("2026-04-20T12:00:00.000Z");
const recent = (minutesAgo: number) => new Date(now.getTime() - minutesAgo * 60_000);
export const storybookAgents: Agent[] = [
{
id: "agent-codex",
companyId: "company-storybook",
name: "CodexCoder",
urlKey: "codexcoder",
role: "engineer",
title: "Senior Product Engineer",
icon: "code",
status: "running",
reportsTo: "agent-cto",
capabilities: "Ships full-stack Paperclip product tasks, Storybook coverage, and verification.",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 125_000,
spentMonthlyCents: 43_200,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: recent(3),
metadata: null,
createdAt: recent(12_000),
updatedAt: recent(3),
},
{
id: "agent-qa",
companyId: "company-storybook",
name: "QAChecker",
urlKey: "qachecker",
role: "qa",
title: "QA Engineer",
icon: "shield",
status: "idle",
reportsTo: "agent-cto",
capabilities: "Validates browser flows, acceptance criteria, and release smoke tests.",
adapterType: "claude_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 80_000,
spentMonthlyCents: 18_900,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: recent(24),
metadata: null,
createdAt: recent(11_000),
updatedAt: recent(24),
},
{
id: "agent-cto",
companyId: "company-storybook",
name: "CTO",
urlKey: "cto",
role: "cto",
title: "CTO",
icon: "crown",
status: "active",
reportsTo: null,
capabilities: "Reviews architecture, quality gates, and engineering priority tradeoffs.",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 200_000,
spentMonthlyCents: 54_000,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: true },
lastHeartbeatAt: recent(41),
metadata: null,
createdAt: recent(14_000),
updatedAt: recent(41),
},
];
export const storybookAgentMap = new Map(storybookAgents.map((agent) => [agent.id, agent]));
export function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-storybook-1",
companyId: "company-storybook",
projectId: "project-board-ui",
projectWorkspaceId: "workspace-board-ui",
goalId: "goal-company",
parentId: null,
title: "Create super-detailed storybooks for the project",
description: "Set up Storybook and move UX review surfaces into stories.",
status: "in_progress",
priority: "high",
assigneeAgentId: "agent-codex",
assigneeUserId: null,
checkoutRunId: "run-storybook",
executionRunId: "run-storybook",
executionAgentNameKey: "codexcoder",
executionLockedAt: recent(28),
createdByAgentId: null,
createdByUserId: "user-board",
issueNumber: 1641,
identifier: "PAP-1641",
requestDepth: 0,
billingCode: "product",
assigneeAdapterOverrides: null,
executionWorkspaceId: "execution-workspace-storybook",
executionWorkspacePreference: "project",
executionWorkspaceSettings: null,
startedAt: recent(28),
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labelIds: ["label-ui", "label-docs"],
labels: [
{
id: "label-ui",
companyId: "company-storybook",
name: "UI",
color: "#0f766e",
createdAt: recent(20_000),
updatedAt: recent(20_000),
},
{
id: "label-docs",
companyId: "company-storybook",
name: "Design system",
color: "#f59e0b",
createdAt: recent(20_000),
updatedAt: recent(20_000),
},
],
blockedBy: [],
blocks: [],
planDocument: null,
documentSummaries: [],
legacyPlanDocument: null,
project: null,
goal: null,
currentExecutionWorkspace: null,
workProducts: [],
mentionedProjects: [],
myLastTouchAt: recent(8),
lastExternalCommentAt: recent(70),
lastActivityAt: recent(3),
isUnreadForMe: true,
createdAt: recent(90),
updatedAt: recent(3),
...overrides,
};
}
export const storybookIssues: Issue[] = [
createIssue(),
createIssue({
id: "issue-storybook-2",
title: "Add budget hard-stop incident review",
status: "blocked",
priority: "critical",
assigneeAgentId: "agent-qa",
checkoutRunId: null,
executionRunId: null,
executionLockedAt: null,
startedAt: null,
identifier: "PAP-1528",
issueNumber: 1528,
lastActivityAt: recent(18),
}),
createIssue({
id: "issue-storybook-3",
title: "QA invite flow on authenticated private mode",
status: "in_review",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: "user-board",
checkoutRunId: null,
executionRunId: null,
executionLockedAt: null,
identifier: "PAP-1602",
issueNumber: 1602,
completedAt: null,
lastActivityAt: recent(49),
isUnreadForMe: false,
}),
];
export const storybookApprovals: Approval[] = [
{
id: "approval-hire-designer",
companyId: "company-storybook",
type: "hire_agent",
requestedByAgentId: "agent-cto",
requestedByUserId: null,
status: "pending",
payload: {
name: "DesignSystemCoder",
role: "designer",
title: "Design System Engineer",
icon: "sparkles",
adapterType: "codex_local",
capabilities: "Owns component quality, visual regression stories, and design-system consolidation.",
desiredSkills: ["frontend-design", "web-design-guidelines"],
},
decisionNote: null,
decidedByUserId: null,
decidedAt: null,
createdAt: recent(42),
updatedAt: recent(42),
},
{
id: "approval-budget",
companyId: "company-storybook",
type: "budget_override_required",
requestedByAgentId: "agent-codex",
requestedByUserId: null,
status: "revision_requested",
payload: {
scopeName: "Paperclip App",
scopeType: "project",
windowKind: "calendar_month_utc",
metric: "billed_cents",
budgetAmount: 120_000,
observedAmount: 131_400,
guidance: "Raise the project budget only after current release smoke checks are green.",
},
decisionNote: "Need a tighter verification list before approving more spend.",
decidedByUserId: "user-board",
decidedAt: recent(12),
createdAt: recent(75),
updatedAt: recent(12),
},
{
id: "approval-board",
companyId: "company-storybook",
type: "request_board_approval",
requestedByAgentId: "agent-cto",
requestedByUserId: null,
status: "approved",
payload: {
title: "Publish the Storybook preview for design review",
summary: "Build the static Storybook and attach the generated URL to the release issue.",
recommendedAction: "Approve publishing the preview for internal board review.",
nextActionOnApproval: "Run build-storybook, upload the static artifact, and request QA visual review.",
risks: [
"Stories may expose stale fixture copy if not maintained with UI changes.",
"Preview screenshots can hide responsive regressions unless mobile viewports are checked.",
],
},
decisionNote: "Approved for internal-only review.",
decidedByUserId: "user-board",
decidedAt: recent(4),
createdAt: recent(55),
updatedAt: recent(4),
},
];
export const storybookBudgetSummaries: BudgetPolicySummary[] = [
{
policyId: "budget-company-ok",
companyId: "company-storybook",
scopeType: "company",
scopeId: "company-storybook",
scopeName: "Paperclip Storybook",
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: 250_000,
observedAmount: 67_500,
remainingAmount: 182_500,
utilizationPercent: 27,
warnPercent: 80,
hardStopEnabled: true,
notifyEnabled: true,
isActive: true,
status: "ok",
paused: false,
pauseReason: null,
windowStart: new Date("2026-04-01T00:00:00.000Z"),
windowEnd: new Date("2026-05-01T00:00:00.000Z"),
},
{
policyId: "budget-project-warning",
companyId: "company-storybook",
scopeType: "project",
scopeId: "project-board-ui",
scopeName: "Paperclip App",
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: 120_000,
observedAmount: 103_100,
remainingAmount: 16_900,
utilizationPercent: 86,
warnPercent: 80,
hardStopEnabled: true,
notifyEnabled: true,
isActive: true,
status: "warning",
paused: false,
pauseReason: null,
windowStart: new Date("2026-04-01T00:00:00.000Z"),
windowEnd: new Date("2026-05-01T00:00:00.000Z"),
},
{
policyId: "budget-agent-hard-stop",
companyId: "company-storybook",
scopeType: "agent",
scopeId: "agent-codex",
scopeName: "CodexCoder",
metric: "billed_cents",
windowKind: "calendar_month_utc",
amount: 40_000,
observedAmount: 43_200,
remainingAmount: 0,
utilizationPercent: 108,
warnPercent: 80,
hardStopEnabled: true,
notifyEnabled: true,
isActive: true,
status: "hard_stop",
paused: true,
pauseReason: "budget",
windowStart: new Date("2026-04-01T00:00:00.000Z"),
windowEnd: new Date("2026-05-01T00:00:00.000Z"),
},
];
export const storybookActivityEvents: ActivityEvent[] = [
{
id: "activity-1",
companyId: "company-storybook",
actorType: "agent",
actorId: "agent-codex",
action: "issue.status_changed",
entityType: "issue",
entityId: "issue-storybook-1",
agentId: "agent-codex",
runId: "run-storybook",
details: { from: "todo", to: "in_progress" },
createdAt: recent(28),
},
{
id: "activity-2",
companyId: "company-storybook",
actorType: "user",
actorId: "user-board",
action: "approval.revision_requested",
entityType: "approval",
entityId: "approval-budget",
agentId: null,
runId: null,
details: { type: "budget_override_required" },
createdAt: recent(12),
},
{
id: "activity-3",
companyId: "company-storybook",
actorType: "system",
actorId: "system",
action: "budget.hard_stop",
entityType: "agent",
entityId: "agent-codex",
agentId: "agent-codex",
runId: null,
details: { observedAmount: 43_200, budgetAmount: 40_000 },
createdAt: recent(7),
},
];
export const storybookEntityNameMap = new Map<string, string>([
["issue:issue-storybook-1", "PAP-1641"],
["issue:issue-storybook-2", "PAP-1528"],
["issue:issue-storybook-3", "PAP-1602"],
["approval:approval-budget", "Budget override"],
["agent:agent-codex", "CodexCoder"],
["agent:agent-qa", "QAChecker"],
["agent:agent-cto", "CTO"],
]);
export const storybookEntityTitleMap = new Map<string, string>([
["issue:issue-storybook-1", "Create super-detailed storybooks for the project"],
["issue:issue-storybook-2", "Add budget hard-stop incident review"],
["issue:issue-storybook-3", "QA invite flow on authenticated private mode"],
]);
@@ -0,0 +1,266 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AlertTriangle, CheckCircle2, Clock3, Eye, GitPullRequest, Inbox, WalletCards } from "lucide-react";
import { ActivityRow } from "@/components/ActivityRow";
import { ApprovalCard } from "@/components/ApprovalCard";
import { BudgetPolicyCard } from "@/components/BudgetPolicyCard";
import { Identity } from "@/components/Identity";
import { IssueRow } from "@/components/IssueRow";
import { PriorityIcon } from "@/components/PriorityIcon";
import { StatusBadge } from "@/components/StatusBadge";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
storybookActivityEvents,
storybookAgentMap,
storybookAgents,
storybookApprovals,
storybookBudgetSummaries,
storybookEntityNameMap,
storybookEntityTitleMap,
storybookIssues,
} from "../fixtures/paperclipData";
function Section({
eyebrow,
title,
children,
}: {
eyebrow: string;
title: string;
children: React.ReactNode;
}) {
return (
<section className="paperclip-story__frame overflow-hidden">
<div className="border-b border-border px-5 py-4">
<div className="paperclip-story__label">{eyebrow}</div>
<h2 className="mt-1 text-xl font-semibold">{title}</h2>
</div>
<div className="p-5">{children}</div>
</section>
);
}
function ControlPlaneSurfaces() {
return (
<div className="paperclip-story">
<main className="paperclip-story__inner space-y-6">
<section className="paperclip-story__frame p-6">
<div className="flex flex-wrap items-start justify-between gap-5">
<div>
<div className="paperclip-story__label">Product surfaces</div>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Control-plane boards and cards</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
Paperclip's common board surfaces are deliberately dense: task rows, approvals, budget policy cards,
and audit rows all need to scan quickly while preserving enough state to make autonomous work governable.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">company scoped</Badge>
<Badge variant="outline">single assignee</Badge>
<Badge variant="outline">auditable</Badge>
</div>
</div>
</section>
<Section eyebrow="Issues" title="Inbox/task rows across selection and unread states">
<div className="overflow-hidden rounded-xl border border-border bg-background/70">
{storybookIssues.map((issue, index) => (
<IssueRow
key={issue.id}
issue={issue}
selected={index === 0}
unreadState={index === 0 ? "visible" : index === 1 ? "hidden" : null}
onMarkRead={() => undefined}
onArchive={() => undefined}
desktopTrailing={
<span className="hidden items-center gap-2 lg:inline-flex">
<PriorityIcon priority={issue.priority} showLabel />
{issue.assigneeAgentId ? (
<Identity name={storybookAgentMap.get(issue.assigneeAgentId)?.name ?? "Unassigned"} size="sm" />
) : (
<span className="text-xs text-muted-foreground">Board</span>
)}
</span>
}
trailingMeta={index === 0 ? "3m ago" : index === 1 ? "blocked by budget" : "review requested"}
mobileMeta={<StatusBadge status={issue.status} />}
titleSuffix={
index === 0 ? (
<span className="ml-2 inline-flex align-middle">
<Badge variant="secondary">Storybook</Badge>
</span>
) : null
}
/>
))}
</div>
</Section>
<Section eyebrow="Approvals" title="Governance cards for pending, revision, and approved decisions">
<div className="grid gap-5 xl:grid-cols-3">
{storybookApprovals.map((approval) => (
<ApprovalCard
key={approval.id}
approval={approval}
requesterAgent={approval.requestedByAgentId ? storybookAgentMap.get(approval.requestedByAgentId) ?? null : null}
onApprove={approval.status === "pending" ? () => undefined : undefined}
onReject={approval.status === "pending" ? () => undefined : undefined}
detailLink={`/approvals/${approval.id}`}
/>
))}
</div>
</Section>
<Section eyebrow="Budgets" title="Healthy, warning, and hard-stop budget policy cards">
<div className="grid gap-5 xl:grid-cols-3">
{storybookBudgetSummaries.map((summary, index) => (
<BudgetPolicyCard
key={summary.policyId}
summary={summary}
compact={index === 0}
onSave={index === 1 ? () => undefined : undefined}
/>
))}
</div>
<div className="mt-5 rounded-xl border border-border bg-background/70 p-5">
<BudgetPolicyCard
summary={storybookBudgetSummaries[2]!}
variant="plain"
onSave={() => undefined}
/>
</div>
</Section>
<Section eyebrow="Activity" title="Audit rows with agent, user, and system actors">
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="overflow-hidden rounded-xl border border-border bg-background/70">
{storybookActivityEvents.map((event) => (
<ActivityRow
key={event.id}
event={event}
agentMap={storybookAgentMap}
entityNameMap={storybookEntityNameMap}
entityTitleMap={storybookEntityTitleMap}
/>
))}
</div>
<Card className="shadow-none">
<CardHeader>
<CardTitle>Run summary card language</CardTitle>
<CardDescription>Compact status treatments used around live work.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{[
{ icon: Clock3, label: "Running", detail: "CodexCoder is editing Storybook fixtures", tone: "text-cyan-600" },
{ icon: GitPullRequest, label: "Review", detail: "QAChecker requested browser screenshots", tone: "text-amber-600" },
{ icon: CheckCircle2, label: "Verified", detail: "Vitest and static Storybook build passed", tone: "text-emerald-600" },
{ icon: AlertTriangle, label: "Blocked", detail: "Budget hard stop paused a run", tone: "text-red-600" },
].map((item) => {
const Icon = item.icon;
return (
<div key={item.label} className="flex items-start gap-3 rounded-lg border border-border bg-background/70 p-3">
<Icon className={`mt-0.5 h-4 w-4 ${item.tone}`} />
<div>
<div className="text-sm font-medium">{item.label}</div>
<div className="text-xs leading-5 text-muted-foreground">{item.detail}</div>
</div>
</div>
);
})}
</CardContent>
</Card>
</div>
</Section>
<Section eyebrow="Agents" title="Org snippets and quick scan identity">
<div className="grid gap-4 md:grid-cols-3">
{storybookAgents.map((agent) => (
<Card key={agent.id} className="shadow-none">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<Identity name={agent.name} size="lg" />
<StatusBadge status={agent.status} />
</div>
<CardDescription>{agent.title}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<p className="leading-6 text-muted-foreground">{agent.capabilities}</p>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{agent.role}</Badge>
<Badge variant="outline">{agent.adapterType}</Badge>
<Badge variant="outline" className="gap-1">
<WalletCards className="h-3 w-3" />
${(agent.spentMonthlyCents / 100).toFixed(0)} spent
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
</Section>
<Section eyebrow="Quicklook" title="Side-panel density reference">
<div className="grid gap-4 lg:grid-cols-[320px_minmax(0,1fr)]">
<Card className="shadow-none">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Inbox className="h-4 w-4" />
Inbox slice
</CardTitle>
<CardDescription>Small panels should keep controls reachable without nested cards.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Unread</span>
<span className="font-mono">7</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Needs review</span>
<span className="font-mono">3</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Blocked</span>
<span className="font-mono">1</span>
</div>
</CardContent>
</Card>
<div className="rounded-xl border border-border bg-background/70 p-5">
<div className="mb-4 flex items-center gap-2 text-sm font-medium">
<Eye className="h-4 w-4 text-muted-foreground" />
Review target
</div>
<IssueRow
issue={storybookIssues[0]!}
selected
unreadState="visible"
onMarkRead={() => undefined}
desktopTrailing={<PriorityIcon priority="high" showLabel />}
trailingMeta="active run"
/>
</div>
</div>
</Section>
</main>
</div>
);
}
const meta = {
title: "Product/Control Plane Surfaces",
component: ControlPlaneSurfaces,
parameters: {
docs: {
description: {
component:
"Product-surface stories exercise the board UI components that carry Paperclip's task, approval, budget, activity, and agent governance workflows.",
},
},
},
} satisfies Meta<typeof ControlPlaneSurfaces>;
export default meta;
type Story = StoryObj<typeof meta>;
export const BoardStateMatrix: Story = {};
@@ -0,0 +1,300 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AlertTriangle, ArrowRight, Check, Copy, Play, Plus, Save, Search, Settings } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
const buttonVariants = ["default", "secondary", "outline", "ghost", "destructive", "link"] as const;
const buttonSizes = ["xs", "sm", "default", "lg", "icon", "icon-sm"] as const;
const badgeVariants = ["default", "secondary", "outline", "destructive", "ghost", "link"] as const;
function Section({
eyebrow,
title,
children,
}: {
eyebrow: string;
title: string;
children: React.ReactNode;
}) {
return (
<section className="paperclip-story__frame overflow-hidden">
<div className="border-b border-border px-5 py-4">
<div className="paperclip-story__label">{eyebrow}</div>
<h2 className="mt-1 text-xl font-semibold">{title}</h2>
</div>
<div className="p-5">{children}</div>
</section>
);
}
function FoundationsMatrix() {
const [autoMode, setAutoMode] = useState(true);
const [boardApproval, setBoardApproval] = useState(true);
return (
<div className="paperclip-story">
<main className="paperclip-story__inner space-y-6">
<section className="paperclip-story__frame p-6">
<div className="paperclip-story__label">Foundations</div>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Primitives and interaction states</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
A dense pass over the base controls that Paperclip pages use for operational actions, filtering,
approvals, and settings.
</p>
</section>
<Section eyebrow="Actions" title="Button variants and sizes">
<div className="space-y-6">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{buttonVariants.map((variant) => (
<div key={variant} className="rounded-lg border border-border bg-background/70 p-4">
<div className="mb-3 text-xs font-medium capitalize text-muted-foreground">{variant}</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant={variant}>
<Play className="h-4 w-4" />
Invoke run
</Button>
<Button variant={variant} disabled>
Disabled
</Button>
</div>
</div>
))}
</div>
<div className="rounded-lg border border-border bg-background/70 p-4">
<div className="mb-3 text-xs font-medium text-muted-foreground">Sizes and icon-only actions</div>
<div className="flex flex-wrap items-center gap-2">
{buttonSizes.map((size) => (
<Button key={size} size={size} variant={size.startsWith("icon") ? "outline" : "secondary"}>
{size.startsWith("icon") ? <Settings /> : size}
</Button>
))}
</div>
</div>
</div>
</Section>
<Section eyebrow="Status labels" title="Badges and compact metadata">
<div className="flex flex-wrap gap-2">
{badgeVariants.map((variant) => (
<Badge key={variant} variant={variant}>
{variant === "destructive" ? <AlertTriangle /> : variant === "default" ? <Check /> : null}
{variant}
</Badge>
))}
<Badge variant="outline" className="font-mono">
PAP-1641
</Badge>
<Badge variant="secondary">
<ArrowRight />
in review
</Badge>
</div>
</Section>
<Section eyebrow="Inputs" title="Form controls with real Paperclip copy">
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="story-title">Issue title</Label>
<Input id="story-title" defaultValue="Create Storybook coverage for the board UI" />
</div>
<div className="grid gap-2">
<Label htmlFor="story-summary">Comment</Label>
<Textarea
id="story-summary"
defaultValue={"Implemented the foundation stories.\nNext action: run static build verification."}
rows={5}
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2">
<Label>Priority</Label>
<Select defaultValue="high">
<SelectTrigger className="w-full">
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Assignee</Label>
<Select defaultValue="codexcoder">
<SelectTrigger className="w-full">
<SelectValue placeholder="Assignee" />
</SelectTrigger>
<SelectContent>
<SelectItem value="codexcoder">CodexCoder</SelectItem>
<SelectItem value="qachecker">QAChecker</SelectItem>
<SelectItem value="board">Board</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Card className="shadow-none">
<CardHeader>
<CardTitle>Governed settings</CardTitle>
<CardDescription>Switches, checkboxes, and validation copy in one compact panel.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between gap-4 rounded-lg border border-border p-3">
<div>
<div className="text-sm font-medium">Auto mode</div>
<div className="text-xs text-muted-foreground">Let agents continue after review approval.</div>
</div>
<ToggleSwitch checked={autoMode} onCheckedChange={setAutoMode} />
</div>
<label className="flex items-start gap-3 rounded-lg border border-border p-3 text-sm">
<Checkbox checked={boardApproval} onCheckedChange={(value) => setBoardApproval(value === true)} />
<span>
<span className="font-medium">Require board approval for new agents</span>
<span className="mt-1 block text-xs text-muted-foreground">
Mirrors the company-level governance control.
</span>
</span>
</label>
</CardContent>
</Card>
</div>
</Section>
<Section eyebrow="Navigation" title="Tabs, overlays, and modal affordances">
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
<Tabs defaultValue="details" className="rounded-lg border border-border bg-background/70 p-4">
<TabsList variant="line">
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="budget">Budget</TabsTrigger>
</TabsList>
<TabsContent value="details" className="pt-5 text-sm leading-6 text-muted-foreground">
The line tab style is used on dense detail pages where the content, not the tab chrome, needs to dominate.
</TabsContent>
<TabsContent value="activity" className="pt-5 text-sm leading-6 text-muted-foreground">
Activity copy stays compact and pairs with timestamped rows in the product stories.
</TabsContent>
<TabsContent value="budget" className="pt-5 text-sm leading-6 text-muted-foreground">
Budget controls surface warning and hard-stop states in the control-plane stories.
</TabsContent>
</Tabs>
<div className="space-y-3">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" className="w-full justify-start">
<Search className="h-4 w-4" />
Hover for tooltip
</Button>
</TooltipTrigger>
<TooltipContent>Search issues, agents, projects, and approvals.</TooltipContent>
</Tooltip>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start">
<Copy className="h-4 w-4" />
Open popover
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-72">
<div className="text-sm font-medium">Copy-safe detail</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
Popovers should keep quick metadata close to the control that opened them.
</p>
</PopoverContent>
</Popover>
<Dialog>
<DialogTrigger asChild>
<Button className="w-full justify-start">
<Plus className="h-4 w-4" />
Open dialog
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create issue</DialogTitle>
<DialogDescription>
Dialogs should keep the primary decision and risk clear without leaving the current board context.
</DialogDescription>
</DialogHeader>
<Separator />
<div className="grid gap-2">
<Label htmlFor="dialog-title">Title</Label>
<Input id="dialog-title" defaultValue="Review Storybook visual coverage" />
</div>
<DialogFooter>
<Button variant="outline">Cancel</Button>
<Button>
<Save className="h-4 w-4" />
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</Section>
</main>
</div>
);
}
const meta = {
title: "Foundations/Primitive Matrix",
component: FoundationsMatrix,
parameters: {
docs: {
description: {
component:
"Foundation stories keep base shadcn/Radix primitives visible in every variant and key interaction state used by Paperclip.",
},
},
},
} satisfies Meta<typeof FoundationsMatrix>;
export default meta;
type Story = StoryObj<typeof meta>;
export const AllPrimitives: Story = {};
+130
View File
@@ -0,0 +1,130 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { BookOpen, CheckCircle2, FlaskConical, Layers3, Route, ShieldCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
const storyGroups = [
{
title: "Foundations",
icon: Layers3,
stories: "Buttons, badges, form controls, tabs, cards, dialogs, overlays",
why: "Baseline tokens and primitives before product components add state.",
},
{
title: "Control Plane Surfaces",
icon: ShieldCheck,
stories: "Issue rows, approvals, budget cards, activity rows, metrics",
why: "The V1 board UI depends on these dense operational patterns staying legible.",
},
{
title: "UX Labs",
icon: FlaskConical,
stories: "Issue chat, run transcripts, invite/access flows",
why: "The old `/tests/ux/*` pages are fixture-backed Storybook stories now.",
},
];
const coverageRows = [
["System primitives", "Covered", "State matrix across size, variant, disabled, icon, and overlay behavior"],
["Status language", "Covered", "Issue/agent lifecycle badges, priorities, quota thresholds, and empty states"],
["Task surfaces", "Covered", "Inbox-style rows with unread, selected, archive, and trailing metadata states"],
["Governance", "Covered", "Pending, revision-requested, approved, and budget-specific approval payloads"],
["Budget controls", "Covered", "Healthy, warning, hard-stop, compact, plain, and editable card variants"],
["Execution UX", "Covered", "Run transcript detail, live widget, dashboard card, streaming and settled views"],
["Invite UX", "Covered", "Fixture-backed access roles, invite landing, pending, accepted, expired, and error states"],
["Full app pages", "Deferred", "API-driven route stories should be added after page data loaders can be fixture-injected"],
];
function StorybookGuide() {
return (
<div className="paperclip-story">
<main className="paperclip-story__inner space-y-8">
<section className="paperclip-story__frame overflow-hidden p-6 sm:p-8">
<div className="flex flex-wrap items-start justify-between gap-6">
<div className="max-w-3xl">
<div className="paperclip-story__label flex items-center gap-2">
<BookOpen className="h-4 w-4" />
Paperclip Storybook
</div>
<h1 className="mt-3 text-3xl font-semibold tracking-tight sm:text-4xl">
Board UI stories for real control-plane states
</h1>
<p className="mt-4 text-sm leading-6 text-muted-foreground sm:text-base">
This Storybook is organized as a review workspace for Paperclip's operator UI: primitives first,
product surfaces second, and the former UX test routes as isolated fixture-backed stories.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">React 19</Badge>
<Badge variant="outline">Vite</Badge>
<Badge variant="outline">Tailwind 4</Badge>
<Badge variant="outline">Fixture backed</Badge>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
{storyGroups.map((group) => {
const Icon = group.icon;
return (
<Card key={group.title} className="paperclip-story__frame shadow-none">
<CardHeader>
<div className="flex h-10 w-10 items-center justify-center border border-border bg-background">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<CardTitle>{group.title}</CardTitle>
<CardDescription>{group.stories}</CardDescription>
</CardHeader>
<CardContent className="text-sm leading-6 text-muted-foreground">
{group.why}
</CardContent>
</Card>
);
})}
</section>
<section className="paperclip-story__frame overflow-hidden">
<div className="border-b border-border px-5 py-4">
<div className="paperclip-story__label flex items-center gap-2">
<Route className="h-4 w-4" />
Coverage Map
</div>
</div>
<div className="divide-y divide-border">
{coverageRows.map(([area, status, detail]) => (
<div key={area} className="grid gap-3 px-5 py-4 text-sm sm:grid-cols-[180px_120px_minmax(0,1fr)]">
<div className="font-medium">{area}</div>
<div>
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">
{status === "Covered" ? <CheckCircle2 className="h-3 w-3 text-emerald-500" /> : null}
{status}
</span>
</div>
<div className="text-muted-foreground">{detail}</div>
</div>
))}
</div>
</section>
</main>
</div>
);
}
const meta = {
title: "Overview/Storybook Guide",
component: StorybookGuide,
parameters: {
docs: {
description: {
component:
"The overview story explains the local organization and the coverage contract for Paperclip's Storybook.",
},
},
},
} satisfies Meta<typeof StorybookGuide>;
export default meta;
type Story = StoryObj<typeof meta>;
export const CoverageGuide: Story = {};
@@ -0,0 +1,211 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AGENT_STATUSES, ISSUE_PRIORITIES, ISSUE_STATUSES } from "@paperclipai/shared";
import { Bot, CheckCircle2, Clock3, DollarSign, FolderKanban, Inbox, MessageSquare, Users } from "lucide-react";
import { CopyText } from "@/components/CopyText";
import { EmptyState } from "@/components/EmptyState";
import { Identity } from "@/components/Identity";
import { MetricCard } from "@/components/MetricCard";
import { PriorityIcon } from "@/components/PriorityIcon";
import { QuotaBar } from "@/components/QuotaBar";
import { StatusBadge } from "@/components/StatusBadge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
function Section({
eyebrow,
title,
children,
}: {
eyebrow: string;
title: string;
children: React.ReactNode;
}) {
return (
<section className="paperclip-story__frame overflow-hidden">
<div className="border-b border-border px-5 py-4">
<div className="paperclip-story__label">{eyebrow}</div>
<h2 className="mt-1 text-xl font-semibold">{title}</h2>
</div>
<div className="p-5">{children}</div>
</section>
);
}
function StatusLanguage() {
const [priority, setPriority] = useState("high");
return (
<div className="paperclip-story">
<main className="paperclip-story__inner space-y-6">
<section className="paperclip-story__frame p-6">
<div className="paperclip-story__label">Language</div>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Status, priority, identity, and metrics</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
These components carry the operational vocabulary of the board: who is acting, what state work is in,
how urgent it is, and whether capacity or spend needs attention.
</p>
</section>
<Section eyebrow="Lifecycle" title="Issue and agent statuses">
<div className="grid gap-4 md:grid-cols-2">
<Card className="shadow-none">
<CardHeader>
<CardTitle>Issue statuses</CardTitle>
<CardDescription>Every task transition state in the V1 issue lifecycle.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{ISSUE_STATUSES.map((status) => (
<StatusBadge key={status} status={status} />
))}
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader>
<CardTitle>Agent statuses</CardTitle>
<CardDescription>Runtime and governance states shown in org, sidebar, and detail surfaces.</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{AGENT_STATUSES.map((status) => (
<StatusBadge key={status} status={status} />
))}
</CardContent>
</Card>
</div>
</Section>
<Section eyebrow="Priority" title="Static labels and editable popover trigger">
<div className="grid gap-4 lg:grid-cols-[1fr_320px]">
<div className="grid gap-3 sm:grid-cols-2">
{ISSUE_PRIORITIES.map((item) => (
<div key={item} className="flex items-center justify-between rounded-lg border border-border bg-background/70 p-4">
<PriorityIcon priority={item} showLabel />
<span className="font-mono text-xs text-muted-foreground">{item}</span>
</div>
))}
</div>
<Card className="shadow-none">
<CardHeader>
<CardTitle>Editable priority</CardTitle>
<CardDescription>Click the control to inspect the same popover used in issue rows.</CardDescription>
</CardHeader>
<CardContent>
<PriorityIcon priority={priority} onChange={setPriority} showLabel />
<div className="mt-3 text-xs text-muted-foreground">Current value: {priority}</div>
</CardContent>
</Card>
</div>
</Section>
<Section eyebrow="Identity" title="Agent and user chips">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="shadow-none">
<CardHeader>
<CardTitle>XS</CardTitle>
</CardHeader>
<CardContent>
<Identity name="CodexCoder" size="xs" />
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader>
<CardTitle>Small</CardTitle>
</CardHeader>
<CardContent>
<Identity name="Board User" size="sm" initials="BU" />
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader>
<CardTitle>Default</CardTitle>
</CardHeader>
<CardContent>
<Identity name="DesignSystemCoder" />
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader>
<CardTitle>Long label</CardTitle>
</CardHeader>
<CardContent className="max-w-[220px]">
<Identity name="Senior Product Engineering Reviewer" size="lg" />
</CardContent>
</Card>
</div>
</Section>
<Section eyebrow="Dashboard" title="Metrics, quota bars, empty states, and copy affordances">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard icon={Users} value={8} label="Active agents" description="3 running right now" to="/agents/active" />
<MetricCard icon={FolderKanban} value={27} label="Open issues" description="5 in review" to="/issues" />
<MetricCard icon={DollarSign} value="$675" label="MTD spend" description="27% of budget" to="/costs" />
<MetricCard icon={Clock3} value="14m" label="P95 run age" description="last 24 hours" />
</div>
<Card className="shadow-none">
<CardHeader>
<CardTitle>Copyable identifiers</CardTitle>
<CardDescription>Click values to exercise the status tooltip.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Issue</span>
<CopyText text="PAP-1641" className="font-mono">PAP-1641</CopyText>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Run</span>
<CopyText text="49442f05-f1c1-45c5-88d3-1e5b871dbb8b" className="font-mono">
49442f05
</CopyText>
</div>
</CardContent>
</Card>
</div>
<div className="mt-5 grid gap-5 lg:grid-cols-[minmax(0,1fr)_360px]">
<Card className="shadow-none">
<CardHeader>
<CardTitle>Quota thresholds</CardTitle>
<CardDescription>Green, warning, and hard-stop-adjacent progress treatments.</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<QuotaBar label="Company budget" percentUsed={27} leftLabel="$675 used" rightLabel="$2,500 cap" />
<QuotaBar label="Project budget" percentUsed={86} leftLabel="$1,031 used" rightLabel="$1,200 cap" />
<QuotaBar label="Agent budget" percentUsed={108} leftLabel="$432 used" rightLabel="$400 cap" showDeficitNotch />
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader>
<CardTitle>Empty state</CardTitle>
<CardDescription>Used when a list has no meaningful rows yet.</CardDescription>
</CardHeader>
<CardContent>
<EmptyState icon={Inbox} message="No assigned work is waiting in this queue." action="Create issue" onAction={() => undefined} />
</CardContent>
</Card>
</div>
</Section>
</main>
</div>
);
}
const meta = {
title: "Foundations/Status Language",
component: StatusLanguage,
parameters: {
docs: {
description: {
component:
"Status-language stories show the reusable operational labels, identity chips, metrics, and capacity indicators used throughout the board.",
},
},
},
} satisfies Meta<typeof StatusLanguage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const FullMatrix: Story = {};
+79
View File
@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { IssueChatUxLab } from "@/pages/IssueChatUxLab";
import { InviteUxLab } from "@/pages/InviteUxLab";
import { RunTranscriptUxLab } from "@/pages/RunTranscriptUxLab";
function StoryFrame({ children }: { children: React.ReactNode }) {
return (
<div className="paperclip-story">
<main className="paperclip-story__inner">{children}</main>
</div>
);
}
const meta = {
title: "UX Labs/Converted Test Pages",
parameters: {
docs: {
description: {
component:
"The former in-app UX test routes are represented here as Storybook stories so fixture-backed review surfaces stay out of production routing.",
},
},
},
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
export const IssueChatReviewSurface: Story = {
name: "Issue Chat Review Surface",
render: () => (
<StoryFrame>
<IssueChatUxLab />
</StoryFrame>
),
parameters: {
docs: {
description: {
story:
"Exercises assistant-ui issue chat states: timeline events, live run stream, queued message, feedback controls, submitting bubble, empty state, and disabled composer.",
},
},
},
};
export const RunTranscriptFixtures: Story = {
name: "Run Transcript Fixtures",
render: () => (
<StoryFrame>
<RunTranscriptUxLab />
</StoryFrame>
),
parameters: {
docs: {
description: {
story:
"Exercises run transcript presentation across the run detail page, issue live widget, and dashboard card density.",
},
},
},
};
export const InviteAndAccessFlow: Story = {
name: "Invite And Access Flow",
render: () => (
<StoryFrame>
<InviteUxLab />
</StoryFrame>
),
parameters: {
docs: {
description: {
story:
"Exercises invitation and access UX states with fixture-backed role choices, landing frames, history, and failure treatments.",
},
},
},
};
+1 -1
View File
@@ -17,5 +17,5 @@
"lexical": ["./node_modules/lexical/index.d.ts"]
}
},
"include": ["src"]
"include": ["src", "storybook"]
}