Storybook coverage and tests for external object surfaces

- New foundations/external-objects story renders the §6 state matrix,
  pill examples, inline markdown decoration, severity-sorted related-work
  panel, and the projects sidebar rollup
- Make storybook bundle by aliasing node:crypto to a browser-safe stub so
  shared/external-objects.ts (server-only canonicalizer) can be statically
  imported without breaking Vite
- Inline a browser-safe mention-source label helper in
  useIssueExternalObjects so the UI does not pull node:crypto at runtime
- externalObjectDominantCount now returns 0 when the highest severity is
  muted, matching the rollup-hide rule
- Backfill inbox-test fixtures for the new externalObjectStatuses field
- Add vitest coverage for external-objects helpers, the inline markdown
  decorator (known/unknown URL, code exclusion, casing tolerance, liveness
  copy), and the pill / status summary components


Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-23 00:35:04 +00:00
1 parent 58d5adc1c7
commit aa9e782ea3
8 files changed
+878 -12

No files matched your search

@@ -0,0 +1,145 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ExternalObjectPill } from "./ExternalObjectPill";
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
describe("ExternalObjectPill", () => {
it("renders a clickable anchor when a URL is provided", () => {
const html = renderToStaticMarkup(
<ExternalObjectPill
object={{
providerKey: "github",
objectType: "pull_request",
statusCategory: "succeeded",
liveness: "fresh",
displayTitle: "Add external refs",
url: "https://github.com/acme/web/pull/241",
}}
sourceCount={4}
sourceSummary="description, 3 comments"
/>,
);
expect(html).toContain('href="https://github.com/acme/web/pull/241"');
expect(html).toContain('data-mention-kind="external-object"');
expect(html).toContain('data-external-status="succeeded"');
expect(html).toContain('data-external-liveness="fresh"');
expect(html).toContain("×4");
expect(html).toContain('aria-label="GitHub pull request — Succeeded: Add external refs"');
});
it("falls back to a non-interactive span when no URL is supplied", () => {
const html = renderToStaticMarkup(
<ExternalObjectPill
object={{
providerKey: null,
objectType: null,
statusCategory: "unknown",
liveness: "unknown",
url: null,
}}
/>,
);
expect(html).toContain('data-mention-kind="external-object"');
expect(html).not.toContain("<a ");
expect(html).toContain('aria-label="External object — Not yet resolved"');
});
it("applies the dashed-border liveness overlay when stale or auth_required", () => {
const stale = renderToStaticMarkup(
<ExternalObjectPill
object={{
providerKey: "github",
objectType: "pull_request",
statusCategory: "failed",
liveness: "stale",
url: "https://github.com/acme/web/pull/242",
}}
/>,
);
expect(stale).toContain("opacity-70");
expect(stale).toContain("[border-style:dashed]");
const auth = renderToStaticMarkup(
<ExternalObjectPill
object={{
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
url: "https://app.hubspot.com/leads/99",
}}
/>,
);
expect(auth).toContain("[border-style:dashed]");
});
it("does not show a source count when only a single mention is present", () => {
const html = renderToStaticMarkup(
<ExternalObjectPill
object={{
providerKey: "github",
objectType: "pull_request",
statusCategory: "succeeded",
liveness: "fresh",
url: "https://github.com/acme/web/pull/241",
}}
sourceCount={1}
/>,
);
expect(html).not.toContain("×");
});
});
describe("ExternalObjectStatusSummary", () => {
it("hides itself when there are no external objects", () => {
const html = renderToStaticMarkup(<ExternalObjectStatusSummary summary={null} />);
expect(html).toBe("");
});
it("hides itself when the highest severity is muted", () => {
const html = renderToStaticMarkup(
<ExternalObjectStatusSummary
summary={{
total: 2,
byStatusCategory: { closed: 2 },
byLiveness: { fresh: 2 },
highestSeverity: "muted",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [],
}}
/>,
);
expect(html).toBe("");
});
it("shows the dominant-severity icon and count", () => {
const html = renderToStaticMarkup(
<ExternalObjectStatusSummary
summary={{
total: 5,
byStatusCategory: { failed: 3, succeeded: 2 },
byLiveness: { fresh: 5 },
highestSeverity: "danger",
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [
{ id: "a", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", statusTone: "danger", liveness: "fresh", isTerminal: false },
{ id: "b", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", statusTone: "danger", liveness: "fresh", isTerminal: false },
{ id: "c", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", statusTone: "danger", liveness: "fresh", isTerminal: false },
{ id: "d", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded", statusTone: "success", liveness: "fresh", isTerminal: true },
{ id: "e", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded", statusTone: "success", liveness: "fresh", isTerminal: true },
],
}}
/>,
);
expect(html).toContain('data-external-status="failed"');
expect(html).toContain('data-external-tone="danger"');
expect(html).toContain(">3<");
expect(html).toContain("aria-label=\"External objects: 3 failed, 2 succeeded, 5 total\"");
});
});
@@ -0,0 +1,91 @@
// @vitest-environment node
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
vi.mock("@/lib/router", () => ({
Link: ({
children,
to,
...props
}: { children: ReactNode; to: string } & React.ComponentProps<"a">) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../api/issues", () => ({
issuesApi: { get: vi.fn() },
}));
function render(children: string, externalReferences?: MarkdownExternalReferenceMap) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<MarkdownBody externalReferences={externalReferences}>{children}</MarkdownBody>
</ThemeProvider>
</QueryClientProvider>,
);
}
describe("MarkdownBody external object decoration", () => {
const successReference: MarkdownExternalReferenceMap = {
"https://github.com/acme/web/pull/241": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "succeeded",
liveness: "fresh",
statusLabel: "Merged",
displayTitle: "Add external refs",
},
};
it("decorates a known URL with the external status icon and metadata attributes", () => {
const html = render("Take a look: https://github.com/acme/web/pull/241", successReference);
expect(html).toContain('class="paperclip-markdown-external-ref"');
expect(html).toContain('data-external-link="resolved"');
expect(html).toContain('data-external-status="succeeded"');
expect(html).toContain('data-external-liveness="fresh"');
expect(html).toContain('aria-label="GitHub Merged: Add external refs"');
expect(html).toContain("github.com/acme/web/pull/241");
});
it("matches a URL even when the user pasted it with different host case or trailing punctuation", () => {
const html = render("see HTTPS://Github.com/acme/web/pull/241#frag.", successReference);
expect(html).toContain('data-external-status="succeeded"');
});
it("renders an unresolved URL as a plain external link with no status affordance", () => {
const html = render("https://random.example.com/path");
expect(html).not.toContain("paperclip-markdown-external-ref");
expect(html).toContain('href="https://random.example.com/path"');
});
it("never decorates a URL that lives inside a fenced or inline code block", () => {
const html = render(
"```\nhttps://github.com/acme/web/pull/241\n```\n\nInline: `https://github.com/acme/web/pull/241`",
successReference,
);
// The fenced/inline literal should still be present as text but the
// decorated anchor should not appear since no `<a>` is created in code.
expect(html).not.toContain('class="paperclip-markdown-external-ref"');
});
it("shows liveness suffix in the aria-label when the object is stale or auth_required", () => {
const html = render("Auth-blocked: https://app.hubspot.com/leads/99", {
"https://app.hubspot.com/leads/99": {
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
statusLabel: "Reconnect",
displayTitle: "Acme deal",
},
});
expect(html).toContain('aria-label="HubSpot Reconnect (Requires auth): Acme deal"');
});
});
+26 -10
View File
@@ -1,18 +1,40 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import type {
ExternalObjectMention,
ExternalObjectMentionGroup,
ExternalObjectSummary,
} from "@paperclipai/shared";
import { externalObjectsApi } from "../api/externalObjects";
import { queryKeys } from "../lib/queryKeys";
import { normalizeExternalObjectHref } from "../lib/external-object-href";
import {
formatExternalObjectMentionSourceLabel,
} from "@paperclipai/shared";
import type { MarkdownExternalReferenceMap } from "../components/MarkdownBody";
import type { ExternalObjectPillData } from "../components/ExternalObjectPill";
/**
* Browser-safe mention-source label. Mirrors the shared/server helper but
* avoids importing `@paperclipai/shared/external-objects.ts` (which pulls in
* `node:crypto`). Keep in sync with the shared formatter.
*/
function formatMentionSourceLabel(mention: ExternalObjectMention): string {
switch (mention.sourceKind) {
case "title":
return "Title";
case "description":
return "Description";
case "comment":
return "Comment";
case "document":
return mention.documentKey ? `Document: ${mention.documentKey}` : "Document";
case "property":
return mention.propertyKey ? `Property: ${mention.propertyKey}` : "Property";
case "plugin":
return "Plugin";
default:
return "Source";
}
}
export interface IssueExternalObjectGroup {
pill: ExternalObjectPillData;
mentionCount: number;
@@ -52,13 +74,7 @@ export function useIssueExternalObjects(issueId: string | null | undefined): Iss
const object = entry.object!;
const sourceLabels = entry.sourceLabels && entry.sourceLabels.length > 0
? entry.sourceLabels
: Array.from(new Set(entry.mentions.map((mention) =>
formatExternalObjectMentionSourceLabel({
sourceKind: mention.sourceKind,
documentKey: mention.documentKey,
propertyKey: mention.propertyKey,
}),
)));
: Array.from(new Set(entry.mentions.map(formatMentionSourceLabel)));
return {
group: entry,
mentionCount: entry.mentionCount ?? entry.mentions.length,
+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import {
dominantExternalObjectTone,
externalObjectCategoryLabel,
externalObjectDominantCount,
externalObjectFallbackTone,
externalObjectIconForCategory,
externalObjectLivenessLabel,
externalObjectProviderLabel,
externalObjectToneSeverity,
externalObjectTypeLabel,
sortExternalObjectsBySeverity,
} from "./external-objects";
import { normalizeExternalObjectHref } from "./external-object-href";
describe("normalizeExternalObjectHref", () => {
it("lowercases the host (case preserving the path) and drops query/fragment", () => {
expect(
normalizeExternalObjectHref("HTTPS://Github.com/Acme/Web/pull/241?token=abc#frag"),
).toBe("https://github.com/Acme/Web/pull/241");
});
it("rejects non-http(s) and userinfo-bearing URLs", () => {
expect(normalizeExternalObjectHref("javascript:alert(1)")).toBeNull();
expect(normalizeExternalObjectHref("ftp://example.com/file")).toBeNull();
expect(normalizeExternalObjectHref("https://user:pass@example.com/")).toBeNull();
expect(normalizeExternalObjectHref(null)).toBeNull();
expect(normalizeExternalObjectHref(undefined)).toBeNull();
expect(normalizeExternalObjectHref("not a url")).toBeNull();
});
it("defaults pathless URLs to /", () => {
expect(normalizeExternalObjectHref("https://example.com")).toBe("https://example.com/");
});
});
describe("external-objects helpers", () => {
it("labels categories with copy from the UX spec", () => {
expect(externalObjectCategoryLabel("auth_required")).toBe("Authorization required");
expect(externalObjectCategoryLabel("succeeded")).toBe("Succeeded");
expect(externalObjectCategoryLabel("unknown")).toBe("Not yet resolved");
});
it("labels liveness states with non-tech copy", () => {
expect(externalObjectLivenessLabel("stale")).toBe("Stale");
expect(externalObjectLivenessLabel("auth_required")).toBe("Requires auth");
expect(externalObjectLivenessLabel("fresh")).toBe("Fresh");
});
it("falls back to a humanised label for unknown providers and types", () => {
expect(externalObjectProviderLabel("github")).toBe("GitHub");
expect(externalObjectProviderLabel("hubspot_marketing")).toBe("Hubspot Marketing");
expect(externalObjectProviderLabel(null)).toBe("External");
expect(externalObjectTypeLabel("workflow_run")).toBe("workflow run");
expect(externalObjectTypeLabel(null)).toBe("object");
});
it("orders tones from danger down to muted", () => {
expect(externalObjectToneSeverity("danger")).toBeGreaterThan(externalObjectToneSeverity("warning"));
expect(externalObjectToneSeverity("warning")).toBeGreaterThan(externalObjectToneSeverity("info"));
expect(externalObjectToneSeverity("info")).toBeGreaterThan(externalObjectToneSeverity("success"));
expect(externalObjectToneSeverity("success")).toBeGreaterThan(externalObjectToneSeverity("muted"));
expect(externalObjectToneSeverity(null)).toBe(0);
expect(externalObjectToneSeverity("nonsense")).toBe(0);
});
it("maps every spec category to a fallback tone", () => {
expect(externalObjectFallbackTone("failed")).toBe("danger");
expect(externalObjectFallbackTone("waiting")).toBe("warning");
expect(externalObjectFallbackTone("running")).toBe("info");
expect(externalObjectFallbackTone("succeeded")).toBe("success");
expect(externalObjectFallbackTone("auth_required")).toBe("warning");
expect(externalObjectFallbackTone("unreachable")).toBe("danger");
});
it("returns the spec lucide icon names for every category", () => {
expect(externalObjectIconForCategory("succeeded").displayName ?? "").toMatch(/CheckCircle2|Check/);
expect(externalObjectIconForCategory("failed").displayName ?? "").toMatch(/XCircle|X/);
expect(externalObjectIconForCategory("auth_required").displayName ?? "").toMatch(/KeyRound|Key/);
expect(externalObjectIconForCategory("unreachable").displayName ?? "").toMatch(/CloudOff|Cloud/);
expect(externalObjectIconForCategory("running").displayName ?? "").toMatch(/Loader2|Loader/);
});
it("sorts items by severity first, preserving insertion order within a tone", () => {
const items = [
{ id: "a", statusTone: "info", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "running", liveness: "fresh", isTerminal: false },
{ id: "b", statusTone: "danger", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed", liveness: "fresh", isTerminal: false },
{ id: "c", statusTone: "warning", providerKey: "hubspot", objectType: "lead", displayTitle: null, statusCategory: "waiting", liveness: "fresh", isTerminal: false },
{ id: "d", statusTone: "danger", providerKey: "github", objectType: "issue", displayTitle: null, statusCategory: "blocked", liveness: "fresh", isTerminal: false },
] as const;
const sorted = sortExternalObjectsBySeverity(items as never);
expect(sorted.map((item) => item.id)).toEqual(["b", "d", "c", "a"]);
});
it("hides the rollup when every item is in a muted tone", () => {
const summary = {
total: 2,
byStatusCategory: { closed: 2 },
byLiveness: { fresh: 2 },
highestSeverity: "muted" as const,
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [
{ id: "a", providerKey: "x", objectType: "y", displayTitle: null, statusCategory: "closed" as const, statusTone: "muted" as const, liveness: "fresh" as const, isTerminal: true },
{ id: "b", providerKey: "x", objectType: "y", displayTitle: null, statusCategory: "closed" as const, statusTone: "muted" as const, liveness: "fresh" as const, isTerminal: true },
],
};
expect(dominantExternalObjectTone(summary)).toBeNull();
expect(externalObjectDominantCount(summary)).toBe(0);
});
it("counts only the dominant-severity items in the rollup", () => {
const summary = {
total: 5,
byStatusCategory: { failed: 3, succeeded: 2 },
byLiveness: { fresh: 5 },
highestSeverity: "danger" as const,
staleCount: 0,
authRequiredCount: 0,
unreachableCount: 0,
objects: [
{ id: "a", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false },
{ id: "b", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false },
{ id: "c", providerKey: "ci", objectType: "deployment", displayTitle: null, statusCategory: "failed" as const, statusTone: "danger" as const, liveness: "fresh" as const, isTerminal: false },
{ id: "d", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded" as const, statusTone: "success" as const, liveness: "fresh" as const, isTerminal: true },
{ id: "e", providerKey: "github", objectType: "pull_request", displayTitle: null, statusCategory: "succeeded" as const, statusTone: "success" as const, liveness: "fresh" as const, isTerminal: true },
],
};
expect(dominantExternalObjectTone(summary)).toBe("danger");
expect(externalObjectDominantCount(summary)).toBe(3);
});
});
+4 -2
View File
@@ -189,13 +189,15 @@ export function dominantExternalObjectTone(
/**
* For the sidebar / list rollup we want the count of objects matching the
* dominant severity (e.g. "3 failed PRs"), not the global total.
* dominant severity (e.g. "3 failed PRs"), not the global total. Returns 0
* whenever the dominant tone is muted so callers can render based on the
* count without double-checking the rollup-hide rule.
*/
export function externalObjectDominantCount(
summary: Pick<ExternalObjectSummary, "highestSeverity" | "objects"> | null | undefined,
): number {
if (!summary) return 0;
const tone = summary.highestSeverity;
const tone = dominantExternalObjectTone(summary);
if (!tone) return 0;
return summary.objects.filter((object) => object.statusTone === tone).length;
}
+5
View File
@@ -24,6 +24,11 @@ const config: StorybookConfig = {
alias: {
"@": path.resolve(storybookConfigDir, "../../src"),
lexical: path.resolve(storybookConfigDir, "../../node_modules/lexical/Lexical.mjs"),
// Vite's bundled `node:crypto` polyfill omits `createHash`, which
// `@paperclipai/shared/external-objects.ts` imports server-side. Use
// a no-op browser shim so the import resolves; the canonicalizer
// only runs server-side.
"node:crypto": path.resolve(storybookConfigDir, "node-crypto-browser-shim.ts"),
},
},
}),
@@ -0,0 +1,31 @@
/**
* Browser-safe shim for the slice of `node:crypto` that
* `@paperclipai/shared/external-objects.ts` imports.
*
* The shared canonicalizer runs server-side and never executes in the browser,
* but the static `import { createHash } from "node:crypto"` is still pulled
* into Storybook's module graph. Vite's built-in `node:crypto` polyfill does
* not export `createHash`, so the build fails before our code runs. This shim
* provides a no-op implementation so the bundler can resolve the import; if a
* code path ever reaches it in the browser we throw loudly so we notice.
*/
class BrowserHash {
update(_value: string): this {
throw new Error(
"createHash from node:crypto is not available in the browser bundle",
);
}
digest(_encoding: string): string {
throw new Error(
"createHash from node:crypto is not available in the browser bundle",
);
}
}
export function createHash(_algorithm: string): BrowserHash {
return new BrowserHash();
}
export default { createHash };
@@ -0,0 +1,443 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
EXTERNAL_OBJECT_LIVENESS_STATES,
EXTERNAL_OBJECT_STATUS_CATEGORIES,
type ExternalObject,
type ExternalObjectLivenessState,
type ExternalObjectMention,
type ExternalObjectStatusCategory,
type ExternalObjectSummary,
} from "@paperclipai/shared";
import { ExternalObjectPill } from "@/components/ExternalObjectPill";
import { ExternalObjectStatusIcon } from "@/components/ExternalObjectStatusIcon";
import { ExternalObjectStatusSummary } from "@/components/ExternalObjectStatusSummary";
import { IssueRelatedWorkPanel } from "@/components/IssueRelatedWorkPanel";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "@/components/MarkdownBody";
import {
externalObjectCategoryLabel,
externalObjectFallbackTone,
externalObjectLivenessLabel,
externalObjectProviderLabel,
externalObjectTypeLabel,
} from "@/lib/external-objects";
import type { IssueExternalObjectGroup } from "@/hooks/useIssueExternalObjects";
function makeObject(args: {
id: string;
providerKey: string;
objectType: string;
statusCategory: ExternalObjectStatusCategory;
liveness: ExternalObjectLivenessState;
displayTitle?: string;
url: string;
statusLabel?: string;
}): ExternalObject {
return {
id: args.id,
companyId: "company-1",
providerKey: args.providerKey,
pluginId: null,
objectType: args.objectType,
externalId: args.id,
sanitizedCanonicalUrl: args.url,
canonicalIdentityHash: args.id,
displayTitle: args.displayTitle ?? null,
statusKey: args.statusCategory,
statusLabel: args.statusLabel ?? externalObjectCategoryLabel(args.statusCategory),
statusCategory: args.statusCategory,
statusTone: externalObjectFallbackTone(args.statusCategory),
liveness: args.liveness,
isTerminal: ["succeeded", "failed", "closed", "archived"].includes(args.statusCategory),
data: {},
remoteVersion: null,
etag: null,
lastResolvedAt: "2026-04-24T22:45:00.000Z",
lastChangedAt: "2026-04-24T22:45:00.000Z",
lastErrorAt: args.liveness === "unreachable" ? "2026-04-24T22:50:00.000Z" : null,
nextRefreshAt: null,
lastErrorCode: null,
lastErrorMessage: null,
createdAt: "2026-04-24T20:00:00.000Z",
updatedAt: "2026-04-24T22:45:00.000Z",
};
}
function makeMention(args: {
id: string;
objectId: string;
sourceKind: ExternalObjectMention["sourceKind"];
documentKey?: string | null;
}): ExternalObjectMention {
return {
id: args.id,
companyId: "company-1",
sourceIssueId: "issue-1",
sourceKind: args.sourceKind,
sourceRecordId: null,
documentKey: args.documentKey ?? null,
propertyKey: null,
matchedTextRedacted: null,
sanitizedDisplayUrl: "https://example.com/object",
canonicalIdentityHash: args.objectId,
canonicalIdentity: null,
objectId: args.objectId,
providerKey: null,
detectorKey: null,
objectType: null,
confidence: "exact",
createdByPluginId: null,
createdAt: "2026-04-24T20:00:00.000Z",
updatedAt: "2026-04-24T22:45:00.000Z",
};
}
function makeGroup(args: {
object: ExternalObject;
mentionCount?: number;
sourceLabels?: string[];
}): IssueExternalObjectGroup {
return {
group: {
object: args.object,
mentions: [makeMention({ id: `${args.object.id}-m`, objectId: args.object.id, sourceKind: "description" })],
mentionCount: args.mentionCount ?? 1,
sourceLabels: args.sourceLabels ?? ["description"],
},
pill: {
providerKey: args.object.providerKey,
objectType: args.object.objectType,
statusCategory: args.object.statusCategory,
liveness: args.object.liveness,
displayTitle: args.object.displayTitle,
statusLabel: args.object.statusLabel,
url: args.object.sanitizedCanonicalUrl,
},
mentionCount: args.mentionCount ?? 1,
sourceLabels: args.sourceLabels ?? ["description"],
};
}
function StateMatrix() {
return (
<div className="paperclip-story space-y-8">
<header className="space-y-2">
<h1 className="text-2xl font-semibold">External object status matrix</h1>
<p className="text-sm text-muted-foreground">
Every status category × liveness combination from the UX spec §6, used as the canonical
presentational reference for inline markdown, pills, properties, and rollups.
</p>
</header>
<table className="w-full table-auto border-collapse text-sm">
<thead>
<tr className="border-b border-border text-left text-xs uppercase tracking-wide text-muted-foreground">
<th className="px-3 py-2">Category</th>
{EXTERNAL_OBJECT_LIVENESS_STATES.map((liveness) => (
<th key={liveness} className="px-3 py-2">{externalObjectLivenessLabel(liveness)}</th>
))}
</tr>
</thead>
<tbody>
{EXTERNAL_OBJECT_STATUS_CATEGORIES.map((category) => (
<tr key={category} className="border-b border-border">
<td className="px-3 py-3 font-mono text-xs text-muted-foreground">{category}</td>
{EXTERNAL_OBJECT_LIVENESS_STATES.map((liveness) => (
<td key={liveness} className="px-3 py-3">
<ExternalObjectStatusIcon
category={category}
liveness={liveness}
sizeClassName="h-5 w-5"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
<section className="space-y-3">
<h2 className="text-lg font-medium">Pills (host-rendered identity, no plugin React)</h2>
<div className="flex flex-wrap items-center gap-2">
<ExternalObjectPill
object={{
providerKey: "github",
objectType: "pull_request",
statusCategory: "succeeded",
liveness: "fresh",
displayTitle: "Add external refs",
url: "https://github.com/acme/web/pull/241",
}}
sourceCount={4}
sourceSummary="description, 3 comments"
/>
<ExternalObjectPill
object={{
providerKey: "github",
objectType: "pull_request",
statusCategory: "failed",
liveness: "stale",
displayTitle: "Bad CI run",
url: "https://github.com/acme/web/pull/242",
}}
sourceCount={2}
/>
<ExternalObjectPill
object={{
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
displayTitle: "Acme deal",
url: "https://app.hubspot.com/leads/99",
}}
/>
<ExternalObjectPill
object={{
providerKey: "linear",
objectType: "issue",
statusCategory: "running",
liveness: "fresh",
displayTitle: "Spike: queues",
url: "https://linear.app/acme/issue/INF-44",
}}
/>
<ExternalObjectPill
object={{
providerKey: "ci",
objectType: "deployment",
statusCategory: "unreachable",
liveness: "unreachable",
displayTitle: "deploy prod-0412",
url: "https://ci.example.com/runs/88421",
}}
/>
</div>
</section>
</div>
);
}
function inlineMarkdownStory() {
const references: MarkdownExternalReferenceMap = {
"https://github.com/acme/web/pull/241": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "succeeded",
liveness: "fresh",
statusLabel: "Merged",
displayTitle: "Add external refs",
},
"https://github.com/acme/web/pull/242": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "failed",
liveness: "stale",
statusLabel: "CI failed",
displayTitle: "Flaky tests",
},
"https://github.com/acme/web/pull/243": {
providerKey: "github",
objectType: "pull_request",
statusCategory: "waiting",
liveness: "fresh",
statusLabel: "Awaiting review",
displayTitle: "Add liveness overlay",
},
"https://app.hubspot.com/leads/99": {
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
statusLabel: "Reconnect",
displayTitle: "Acme deal",
},
"https://ci.example.com/runs/88421": {
providerKey: "ci",
objectType: "deployment",
statusCategory: "unreachable",
liveness: "unreachable",
statusLabel: "Unreachable",
displayTitle: "Prod-0412",
},
};
const markdown = `Status of recent integrations:\n\n- Merged PR: https://github.com/acme/web/pull/241\n- Stale failed CI: https://github.com/acme/web/pull/242\n- Awaiting review: https://github.com/acme/web/pull/243\n- Auth required: https://app.hubspot.com/leads/99\n- Unreachable deploy: https://ci.example.com/runs/88421\n- Unrelated control link: https://random.example.com/path\n\n\`\`\`\n# Code blocks must be left alone — https://github.com/acme/web/pull/241\n\`\`\`\n\nInline code stays plain too: \`https://github.com/acme/web/pull/241\`.`;
return (
<div className="paperclip-story space-y-4 text-sm">
<h2 className="text-lg font-medium">Inline markdown decoration</h2>
<MarkdownBody externalReferences={references}>{markdown}</MarkdownBody>
</div>
);
}
function relatedWorkStory() {
const externalObjects = [
makeGroup({
object: makeObject({
id: "obj-1",
providerKey: "github",
objectType: "pull_request",
statusCategory: "failed",
liveness: "fresh",
displayTitle: "Add external refs",
url: "https://github.com/acme/web/pull/241",
}),
mentionCount: 4,
sourceLabels: ["description", "comments", "plan document"],
}),
makeGroup({
object: makeObject({
id: "obj-2",
providerKey: "hubspot",
objectType: "lead",
statusCategory: "auth_required",
liveness: "auth_required",
displayTitle: "Acme deal",
url: "https://app.hubspot.com/leads/99",
}),
mentionCount: 1,
sourceLabels: ["External links property"],
}),
makeGroup({
object: makeObject({
id: "obj-3",
providerKey: "ci",
objectType: "deployment",
statusCategory: "running",
liveness: "fresh",
displayTitle: "deploy prod-0412",
url: "https://ci.example.com/runs/88421",
}),
mentionCount: 2,
sourceLabels: ["comments"],
}),
makeGroup({
object: makeObject({
id: "obj-4",
providerKey: "github",
objectType: "issue",
statusCategory: "succeeded",
liveness: "stale",
displayTitle: "Closed parent issue",
url: "https://github.com/acme/web/issues/15",
}),
mentionCount: 1,
sourceLabels: ["comments"],
}),
];
return (
<IssueRelatedWorkPanel
relatedWork={{
outbound: [],
inbound: [],
}}
externalObjects={externalObjects}
/>
);
}
function projectsRollupStory() {
function summary(args: {
failed?: number;
waiting?: number;
running?: number;
succeeded?: number;
auth?: number;
stale?: number;
}): ExternalObjectSummary {
const objects: ExternalObjectSummary["objects"] = [];
function pushMany(category: ExternalObjectStatusCategory, count: number) {
for (let i = 0; i < count; i += 1) {
objects.push({
id: `obj-${category}-${i}`,
providerKey: "github",
objectType: "pull_request",
displayTitle: null,
statusCategory: category,
statusTone: externalObjectFallbackTone(category),
liveness: args.stale && i % 2 === 0 ? "stale" : "fresh",
isTerminal: false,
});
}
}
pushMany("failed", args.failed ?? 0);
pushMany("waiting", args.waiting ?? 0);
pushMany("running", args.running ?? 0);
pushMany("succeeded", args.succeeded ?? 0);
pushMany("auth_required", args.auth ?? 0);
const tones = objects.map((o) => o.statusTone);
const dominant: ExternalObjectSummary["highestSeverity"] =
tones.includes("danger") ? "danger"
: tones.includes("warning") ? "warning"
: tones.includes("info") ? "info"
: tones.includes("success") ? "success"
: "muted";
const byStatusCategory: Record<string, number> = {};
for (const obj of objects) {
byStatusCategory[obj.statusCategory] = (byStatusCategory[obj.statusCategory] ?? 0) + 1;
}
return {
total: objects.length,
byStatusCategory,
byLiveness: { fresh: objects.length, stale: 0, auth_required: 0, unreachable: 0, unknown: 0 },
highestSeverity: dominant,
staleCount: args.stale ?? 0,
authRequiredCount: args.auth ?? 0,
unreachableCount: 0,
objects,
};
}
const projects = [
{ name: "Paperclip App", color: "#6366f1", summary: summary({ failed: 3, running: 12 }) },
{ name: "Marketing site", color: "#22c55e", summary: summary({ waiting: 2 }) },
{ name: "Experimental", color: "#a855f7", summary: summary({ succeeded: 6 }) },
{ name: "Auth provider", color: "#f97316", summary: summary({ auth: 1 }) },
];
return (
<div className="paperclip-story w-72 rounded border border-border bg-background p-2">
<div className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">Projects (sidebar)</div>
<ul className="flex flex-col">
{projects.map((project) => (
<li
key={project.name}
className="flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium hover:bg-accent/50"
>
<span className="h-3.5 w-3.5 shrink-0 rounded-sm" style={{ backgroundColor: project.color }} />
<span className="flex-1 truncate">{project.name}</span>
<ExternalObjectStatusSummary summary={project.summary} compact />
</li>
))}
</ul>
</div>
);
}
function StateMatrixStory() {
return (
<div className="paperclip-story space-y-12 p-6">
<StateMatrix />
{inlineMarkdownStory()}
{relatedWorkStory()}
{projectsRollupStory()}
</div>
);
}
const meta = {
title: "Foundations/External Objects",
component: StateMatrixStory,
parameters: {
docs: {
description: {
component:
"External-object surface gallery: the §6 state matrix, the inline markdown decoration, the related-work section, and the project-rollup sidebar marker. Mirrors the Phase 6 acceptance set from the UX spec.",
},
},
},
} satisfies Meta<typeof StateMatrixStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const FullSurface: Story = {};