Add external object status tokens and pill primitives

- Extend status-colors with category, badge, liveness, and severity tokens
  shared by every external-object surface
- Bump .paperclip-mention-chip min-height to 24px on touch viewports for
  WCAG 2.5.5 target size and add an external-object data-mention-kind hook
- Introduce host-owned helpers (label, icon, severity ranking, tone fallback)
  and a usePrefersReducedMotion hook so the running spinner respects motion
  preferences
- New presentational primitives: ExternalObjectStatusIcon (with stale clock
  overlay), ExternalObjectPill, and ExternalObjectStatusSummary


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

No files matched your search

+137
View File
@@ -0,0 +1,137 @@
import type { ReactNode } from "react";
import type {
ExternalObjectLivenessState,
ExternalObjectStatusCategory,
} from "@paperclipai/shared";
import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon";
import {
externalObjectStatusIcon,
externalObjectStatusIconDefault,
externalObjectLivenessOverlay,
} from "../lib/status-colors";
import {
externalObjectCategoryLabel,
externalObjectLivenessLabel,
externalObjectProviderLabel,
externalObjectTypeLabel,
} from "../lib/external-objects";
import { cn } from "../lib/utils";
export interface ExternalObjectPillData {
providerKey: string | null;
objectType: string | null;
statusCategory: ExternalObjectStatusCategory;
liveness: ExternalObjectLivenessState;
displayTitle?: string | null;
statusLabel?: string | null;
url?: string | null;
}
interface ExternalObjectPillProps {
object: ExternalObjectPillData;
/** Optional external mention count (renders as `×N` superscript when > 1). */
sourceCount?: number;
/** Optional source-mention summary used as the pill's `title` attribute. */
sourceSummary?: string | null;
className?: string;
/** Optional rendered label override. Defaults to `provider object-type`. */
children?: ReactNode;
/**
* If true the pill renders without a hover treatment (used inside
* non-interactive contexts like the property panel). Defaults to false.
*/
inert?: boolean;
}
/**
* External-object equivalent of `IssueReferencePill`. Same `paperclip-mention-chip`
* base so external references feel native to readers (Jakob's Law).
*/
export function ExternalObjectPill({
object,
sourceCount,
sourceSummary,
className,
children,
inert,
}: ExternalObjectPillProps) {
const tone = externalObjectStatusIcon[object.statusCategory] ?? externalObjectStatusIconDefault;
const overlay = externalObjectLivenessOverlay[object.liveness] ?? "";
const providerLabel = externalObjectProviderLabel(object.providerKey);
const typeLabel = externalObjectTypeLabel(object.objectType);
const statusLabel = object.statusLabel ?? externalObjectCategoryLabel(object.statusCategory);
const livenessLabel = externalObjectLivenessLabel(object.liveness);
const ariaLabel = `${providerLabel} ${typeLabel} — ${statusLabel}${
object.liveness === "fresh" || object.liveness === "unknown" ? "" : ` (${livenessLabel})`
}${object.displayTitle ? `: ${object.displayTitle}` : ""}`;
const interactive = !inert && Boolean(object.url);
const classNames = cn(
"paperclip-mention-chip paperclip-mention-chip--external-object",
"inline-flex items-center gap-1 rounded-full border border-border px-2 py-0.5 text-xs no-underline",
// Tone is applied as text classes only — the border style comes from the
// overlay (dashed for stale/auth/unreachable).
tone.split(" ").filter((c) => c.startsWith("text-")).join(" "),
overlay,
interactive
&& "hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring",
className,
);
const titleAttr = sourceSummary
? `${object.displayTitle ?? `${providerLabel} ${typeLabel}`} — ${sourceSummary}`
: object.displayTitle ?? `${providerLabel} ${typeLabel}`;
const labelText = children ?? (
<>
<span className="font-medium">{providerLabel.toLowerCase()}</span>
<span className="text-muted-foreground/80">{typeLabel}</span>
</>
);
const countSuffix = typeof sourceCount === "number" && sourceCount > 1 ? (
<span className="tabular-nums text-[10px] font-medium opacity-80">×{sourceCount}</span>
) : null;
const innerContent = (
<>
<ExternalObjectStatusIcon
category={object.statusCategory}
liveness={object.liveness}
sizeClassName="h-3 w-3"
label={`${providerLabel}: ${statusLabel}`}
/>
<span className="inline-flex items-center gap-1">
{labelText}
</span>
{countSuffix}
</>
);
if (interactive && object.url) {
return (
<a
href={object.url}
target="_blank"
rel="noopener noreferrer"
data-mention-kind="external-object"
data-external-status={object.statusCategory}
data-external-liveness={object.liveness}
className={classNames}
title={titleAttr}
aria-label={ariaLabel}
>
{innerContent}
</a>
);
}
return (
<span
data-mention-kind="external-object"
data-external-status={object.statusCategory}
data-external-liveness={object.liveness}
className={classNames}
title={titleAttr}
aria-label={ariaLabel}
>
{innerContent}
</span>
);
}
@@ -0,0 +1,89 @@
import type {
ExternalObjectLivenessState,
ExternalObjectStatusCategory,
} from "@paperclipai/shared";
import { Clock } from "lucide-react";
import {
externalObjectStatusIcon,
externalObjectStatusIconDefault,
} from "../lib/status-colors";
import {
externalObjectCategoryLabel,
externalObjectIconForCategory,
externalObjectLivenessLabel,
} from "../lib/external-objects";
import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion";
import { cn } from "../lib/utils";
interface ExternalObjectStatusIconProps {
category: ExternalObjectStatusCategory;
liveness: ExternalObjectLivenessState;
/** Optional override label used in `aria-label` (e.g. provider-specific copy). */
label?: string | null;
className?: string;
/** Tailwind size class — defaults to `h-3.5 w-3.5` (compact pill use). */
sizeClassName?: string;
/**
* When true the icon renders inline at 12 px with the `mr-1`/`align`
* adjustments used by `MarkdownIssueLink`. Used by the markdown decorator.
*/
inline?: boolean;
}
/**
* Pure presentational icon for an external object's status. Combines:
*
* - A category icon (lucide) from `externalObjectIconForCategory`.
* - Tone classes (text + border) from `externalObjectStatusIcon`.
* - An overlaid clock micro-mark when liveness is `stale`.
* - Reduced-motion-aware spinner for the `running` category.
*
* Never mounts plugin React; the host is the sole renderer of identity glyphs.
*/
export function ExternalObjectStatusIcon({
category,
liveness,
label,
className,
sizeClassName = "h-3.5 w-3.5",
inline = false,
}: ExternalObjectStatusIconProps) {
const reducedMotion = usePrefersReducedMotion();
const Icon = externalObjectIconForCategory(category);
const tone = externalObjectStatusIcon[category] ?? externalObjectStatusIconDefault;
const livenessSuffix = liveness === "fresh" || liveness === "unknown"
? ""
: ` (${externalObjectLivenessLabel(liveness)})`;
const ariaLabel = `${label ?? externalObjectCategoryLabel(category)}${livenessSuffix}`;
// The clock overlay needs a positioned wrapper. Inline mode keeps the icon
// tight to the surrounding text; pill mode expects to size by sizeClassName.
const wrapperBase = inline
? "relative mr-1 inline-flex shrink-0 align-[-0.125em]"
: "relative inline-flex shrink-0";
const iconSize = inline ? "h-3 w-3" : sizeClassName;
const isSpinner = category === "running";
const animateClass = isSpinner && !reducedMotion ? "animate-spin" : "";
return (
<span
role="img"
aria-label={ariaLabel}
className={cn(wrapperBase, className)}
>
<Icon
aria-hidden="true"
className={cn(iconSize, tone.split(" ").filter((c) => c.startsWith("text-")).join(" "), animateClass)}
/>
{liveness === "stale" ? (
<Clock
aria-hidden="true"
className={cn(
"absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full bg-background text-muted-foreground",
inline ? "" : "p-px",
)}
/>
) : null}
</span>
);
}
@@ -0,0 +1,77 @@
import type { ExternalObjectSummary } from "@paperclipai/shared";
import {
dominantExternalObjectTone,
externalObjectCategoryLabel,
externalObjectDominantCount,
externalObjectIconForCategory,
} from "../lib/external-objects";
import { externalObjectStatusBadge, externalObjectStatusBadgeDefault } from "../lib/status-colors";
import { usePrefersReducedMotion } from "../hooks/usePrefersReducedMotion";
import { cn } from "../lib/utils";
interface ExternalObjectStatusSummaryProps {
summary: ExternalObjectSummary | null | undefined;
/** Compact mode trims everything down to the icon + count, no label text. */
compact?: boolean;
className?: string;
}
function dominantCategory(summary: ExternalObjectSummary): string {
// Prefer the first object that matches the highestSeverity tone, since the
// server has already ranked them server-side.
const match = summary.objects.find((object) => object.statusTone === summary.highestSeverity);
return match?.statusCategory ?? "unknown";
}
function buildBreakdownTitle(summary: ExternalObjectSummary): string {
const parts: string[] = [];
for (const [category, count] of Object.entries(summary.byStatusCategory)) {
if (!count) continue;
parts.push(`${count} ${externalObjectCategoryLabel(category).toLowerCase()}`);
}
if (summary.staleCount > 0) parts.push(`${summary.staleCount} stale`);
parts.push(`${summary.total} total`);
return `External objects: ${parts.join(", ")}`;
}
/**
* Compact rollup marker used by sidebar projects and issue list rows. Renders
* the dominant severity icon plus a count badge. Hidden when there are zero
* external objects or every object is in a muted tone.
*/
export function ExternalObjectStatusSummary({
summary,
compact,
className,
}: ExternalObjectStatusSummaryProps) {
const reducedMotion = usePrefersReducedMotion();
const tone = dominantExternalObjectTone(summary);
const total = summary?.total ?? 0;
if (!summary || total === 0 || !tone) return null;
const category = dominantCategory(summary);
const Icon = externalObjectIconForCategory(category);
const badgeClass = externalObjectStatusBadge[category] ?? externalObjectStatusBadgeDefault;
const dominantCount = externalObjectDominantCount(summary);
const title = buildBreakdownTitle(summary);
const animateClass = category === "running" && !reducedMotion ? "animate-spin" : "";
return (
<span
role="img"
aria-label={title}
title={title}
className={cn(
"inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none",
badgeClass,
compact && "px-1 py-0.5",
className,
)}
data-external-status={category}
data-external-tone={tone}
>
<Icon aria-hidden="true" className={cn("h-3 w-3 shrink-0", animateClass)} />
<span>{dominantCount > 0 ? dominantCount : total}</span>
</span>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
const QUERY = "(prefers-reduced-motion: reduce)";
function getInitialValue(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia(QUERY).matches;
}
export function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(getInitialValue);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const media = window.matchMedia(QUERY);
const handler = (event: MediaQueryListEvent) => setReduced(event.matches);
setReduced(media.matches);
media.addEventListener("change", handler);
return () => media.removeEventListener("change", handler);
}, []);
return reduced;
}
+26
View File
@@ -1097,6 +1097,13 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
white-space: nowrap;
}
/* External object URL decorations follow the same pattern: the host renders
the icon prefix, the link text is left untouched (Postel's Law). */
.paperclip-markdown-external-ref {
display: inline;
white-space: nowrap;
}
.dark .paperclip-markdown a {
color: color-mix(in oklab, var(--foreground) 80%, #58a6ff 20%);
}
@@ -1206,6 +1213,25 @@ span.paperclip-project-mention-chip {
white-space: nowrap;
}
/* Touch target floor: WCAG 2.5.5 expects ≥24px on coarse pointers. The base
chip is 22px; bump it on touch viewports without disturbing inline metrics. */
@media (pointer: coarse) {
a.paperclip-mention-chip,
a.paperclip-project-mention-chip,
span.paperclip-mention-chip,
span.paperclip-project-mention-chip {
min-height: 24px;
}
}
/* External object pills: variant marker the host renders for known-resolved
external URLs. Inline status icon is rendered by ExternalObjectStatusIcon. */
a.paperclip-mention-chip[data-mention-kind="external-object"],
span.paperclip-mention-chip[data-mention-kind="external-object"] {
/* Inherit base chip styling; status colour comes from inline classes so the
host can vary tone per category without defining a CSS variant per state. */
}
/* When the identifier inside a chip is backtick-wrapped in markdown, strip the
inline-code monospace/gray styling so the pill label reads cleanly. */
.paperclip-markdown a.paperclip-mention-chip code,
+32
View File
@@ -0,0 +1,32 @@
/**
* Browser-safe URL key for matching markdown hrefs against the
* `external_objects.sanitizedCanonicalUrl` returned by the host API.
*
* The shared/server canonicalizer is the source of truth for the canonical
* URL string, but it imports from `node:crypto` (it also produces an identity
* hash) and therefore cannot run in the browser. We replicate just the URL
* normalization here:
*
* - protocol must be http or https
* - reject userinfo (`username:password@`)
* - lowercase host
* - drop query string + fragment
* - default empty pathname to `/`
*/
export function normalizeExternalObjectHref(value: string | null | undefined): string | null {
if (!value) return null;
let url: URL;
try {
url = new URL(value.trim());
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.username || url.password) return null;
const scheme = url.protocol === "https:" ? "https" : "http";
const path = url.pathname || "/";
return `${scheme}://${url.host.toLowerCase()}${path}`;
}
+211
View File
@@ -0,0 +1,211 @@
import {
AlertCircle,
AlertOctagon,
Archive,
CheckCircle2,
Circle,
CircleDashed,
CircleDot,
Clock,
CloudOff,
KeyRound,
Loader2,
XCircle,
type LucideIcon,
} from "lucide-react";
import type {
ExternalObjectLivenessState,
ExternalObjectStatusCategory,
ExternalObjectStatusTone,
ExternalObjectSummary,
ExternalObjectSummaryItem,
} from "@paperclipai/shared";
/**
* Lucide icon for each status category. The mapping is host-owned per the
* Phase 1B security review — providers never inject inline React.
*/
export const externalObjectCategoryIcon: Record<string, LucideIcon> = {
unknown: CircleDashed,
open: CircleDot,
waiting: Clock,
running: Loader2,
succeeded: CheckCircle2,
failed: XCircle,
blocked: AlertOctagon,
closed: Circle,
archived: Archive,
auth_required: KeyRound,
unreachable: CloudOff,
};
export const externalObjectCategoryIconDefault: LucideIcon = CircleDashed;
export function externalObjectIconForCategory(category: string): LucideIcon {
return externalObjectCategoryIcon[category] ?? externalObjectCategoryIconDefault;
}
export function externalObjectIconForLiveness(liveness: string): LucideIcon | null {
if (liveness === "auth_required") return KeyRound;
if (liveness === "unreachable") return CloudOff;
return null;
}
const CATEGORY_LABELS: Record<string, string> = {
unknown: "Not yet resolved",
open: "Open",
waiting: "Waiting",
running: "Running",
succeeded: "Succeeded",
failed: "Failed",
blocked: "Blocked",
closed: "Closed",
archived: "Archived",
auth_required: "Authorization required",
unreachable: "Unreachable",
};
export function externalObjectCategoryLabel(category: string): string {
return CATEGORY_LABELS[category] ?? category.replace(/_/g, " ");
}
const LIVENESS_LABELS: Record<string, string> = {
unknown: "Not yet refreshed",
fresh: "Fresh",
stale: "Stale",
auth_required: "Requires auth",
unreachable: "Unreachable",
};
export function externalObjectLivenessLabel(liveness: string): string {
return LIVENESS_LABELS[liveness] ?? liveness.replace(/_/g, " ");
}
/**
* Higher number = more attention-worthy. The rollups in §5 sort by tone first.
* Mirrors `externalObjectStatusToneSeverity` in `status-colors.ts`.
*/
const TONE_SEVERITY: Record<string, number> = {
muted: 0,
neutral: 1,
success: 2,
info: 3,
warning: 4,
danger: 5,
};
export function externalObjectToneSeverity(tone: string | null | undefined): number {
if (!tone) return 0;
return TONE_SEVERITY[tone] ?? 0;
}
const CATEGORY_TONE_FALLBACK: Record<string, ExternalObjectStatusTone> = {
unknown: "muted",
open: "info",
waiting: "warning",
running: "info",
succeeded: "success",
failed: "danger",
blocked: "danger",
closed: "muted",
archived: "muted",
auth_required: "warning",
unreachable: "danger",
};
export function externalObjectFallbackTone(
category: ExternalObjectStatusCategory,
): ExternalObjectStatusTone {
return CATEGORY_TONE_FALLBACK[category] ?? "neutral";
}
const PROVIDER_LABELS: Record<string, string> = {
github: "GitHub",
github_pull_request: "GitHub",
github_issue: "GitHub",
hubspot: "HubSpot",
linear: "Linear",
jira: "Jira",
notion: "Notion",
asana: "Asana",
};
export function externalObjectProviderLabel(providerKey: string | null | undefined): string {
if (!providerKey) return "External";
const lookup = PROVIDER_LABELS[providerKey];
if (lookup) return lookup;
return providerKey
.split(/[._-]/)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
const OBJECT_TYPE_LABELS: Record<string, string> = {
pull_request: "pull request",
issue: "issue",
deployment: "deployment",
workflow_run: "workflow run",
ticket: "ticket",
lead: "lead",
};
export function externalObjectTypeLabel(objectType: string | null | undefined): string {
if (!objectType) return "object";
return OBJECT_TYPE_LABELS[objectType] ?? objectType.replace(/_/g, " ");
}
/**
* Sort summary items by severity-first ordering: danger → warning → info →
* success → muted/neutral. Within a tone, items keep their incoming order so
* server-side ordering (e.g. most recent change first) is preserved.
*/
export function sortExternalObjectsBySeverity<T extends ExternalObjectSummaryItem>(
items: readonly T[],
): T[] {
return [...items]
.map((item, index) => ({ item, index }))
.sort((a, b) => {
const aTone = externalObjectToneSeverity(a.item.statusTone);
const bTone = externalObjectToneSeverity(b.item.statusTone);
if (aTone !== bTone) return bTone - aTone;
return a.index - b.index;
})
.map(({ item }) => item);
}
/**
* Compute the dominant tone in a summary — used by sidebar / list rollups.
* Falls back to `null` when no objects are present or every tone is `muted`.
*/
export function dominantExternalObjectTone(
summary: Pick<ExternalObjectSummary, "highestSeverity" | "objects"> | null | undefined,
): ExternalObjectStatusTone | null {
if (!summary) return null;
const tone = summary.highestSeverity;
if (!tone) return null;
if (externalObjectToneSeverity(tone) <= TONE_SEVERITY.muted) return null;
return tone;
}
/**
* For the sidebar / list rollup we want the count of objects matching the
* dominant severity (e.g. "3 failed PRs"), not the global total.
*/
export function externalObjectDominantCount(
summary: Pick<ExternalObjectSummary, "highestSeverity" | "objects"> | null | undefined,
): number {
if (!summary) return 0;
const tone = summary.highestSeverity;
if (!tone) return 0;
return summary.objects.filter((object) => object.statusTone === tone).length;
}
/**
* Reduced motion support — match `prefers-reduced-motion: reduce` so the
* spinning Loader2 stays static when requested. Hooks consume this via React
* to react to runtime changes; non-hook callers can use the helper directly.
*/
export function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
+83
View File
@@ -217,3 +217,86 @@ export const priorityColor: Record<string, string> = {
};
export const priorityColorDefault = "text-yellow-600 dark:text-yellow-400";
// ---------------------------------------------------------------------------
// External object status — colors & severity ranking
// ---------------------------------------------------------------------------
//
// Categories come from `EXTERNAL_OBJECT_STATUS_CATEGORIES` in @paperclipai/shared.
// The map keys here intentionally mirror the union — keep them in sync.
//
// Tone reuse rationale (see UX spec §1):
// unknown → backlog hue (muted, dashed circle)
// open → todo / blue
// waiting → amber (distinct from internal in_progress yellow)
// running → cyan, animated when motion is allowed
// succeeded → done / green
// failed → red
// blocked → red
// closed → muted neutral
// archived → muted neutral
// auth_required → amber + dashed
// unreachable → red + dashed
export const externalObjectStatusIcon: Record<string, string> = {
unknown: "text-muted-foreground border-muted-foreground",
open: "text-blue-600 border-blue-600 dark:text-blue-400 dark:border-blue-400",
waiting: "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400",
running: "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400",
succeeded: "text-green-600 border-green-600 dark:text-green-400 dark:border-green-400",
failed: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400",
blocked: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400",
closed: "text-neutral-500 border-neutral-500",
archived: "text-neutral-500 border-neutral-500",
auth_required: "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400",
unreachable: "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400",
};
export const externalObjectStatusIconDefault = "text-muted-foreground border-muted-foreground";
export const externalObjectStatusBadge: Record<string, string> = {
unknown: "bg-muted text-muted-foreground",
open: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300",
waiting: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
running: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/50 dark:text-cyan-300",
succeeded: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300",
failed: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
blocked: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
closed: "bg-muted text-muted-foreground",
archived: "bg-muted text-muted-foreground",
auth_required: "bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300",
unreachable: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300",
};
export const externalObjectStatusBadgeDefault = "bg-muted text-muted-foreground";
/**
* Liveness overlay applied on top of the base status tone. We deliberately
* encode it as utility classes (not a tone change) so callers can append the
* overlay to any pill, icon, or marker without redefining colors.
*
* The dashed border + reduced opacity guarantees a non-color differentiator
* for stale / auth_required / unreachable per WCAG 1.4.1.
*/
export const externalObjectLivenessOverlay: Record<string, string> = {
unknown: "",
fresh: "",
stale: "opacity-70 [border-style:dashed]",
auth_required: "[border-style:dashed]",
unreachable: "[border-style:dashed]",
};
/**
* Severity ranking used by sidebar/list rollups. Higher number = more
* attention-worthy. Anything ≤ `muted` should be hidden when summarising.
*/
export const externalObjectStatusToneSeverity: Record<string, number> = {
muted: 0,
neutral: 1,
success: 2,
info: 3,
warning: 4,
danger: 5,
};
export const externalObjectStatusToneSeverityDefault = 0;