diff --git a/ui/src/components/ExternalObjectPill.tsx b/ui/src/components/ExternalObjectPill.tsx
new file mode 100644
index 00000000000..bdd0b6e6c37
--- /dev/null
+++ b/ui/src/components/ExternalObjectPill.tsx
@@ -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 ?? (
+ <>
+ {providerLabel.toLowerCase()}
+ {typeLabel}
+ >
+ );
+ const countSuffix = typeof sourceCount === "number" && sourceCount > 1 ? (
+ ×{sourceCount}
+ ) : null;
+ const innerContent = (
+ <>
+
+
+ {labelText}
+
+ {countSuffix}
+ >
+ );
+
+ if (interactive && object.url) {
+ return (
+
+ {innerContent}
+
+ );
+ }
+
+ return (
+
+ {innerContent}
+
+ );
+}
diff --git a/ui/src/components/ExternalObjectStatusIcon.tsx b/ui/src/components/ExternalObjectStatusIcon.tsx
new file mode 100644
index 00000000000..1cd97b17dcc
--- /dev/null
+++ b/ui/src/components/ExternalObjectStatusIcon.tsx
@@ -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 (
+
+ c.startsWith("text-")).join(" "), animateClass)}
+ />
+ {liveness === "stale" ? (
+
+ ) : null}
+
+ );
+}
diff --git a/ui/src/components/ExternalObjectStatusSummary.tsx b/ui/src/components/ExternalObjectStatusSummary.tsx
new file mode 100644
index 00000000000..8b012fc3e35
--- /dev/null
+++ b/ui/src/components/ExternalObjectStatusSummary.tsx
@@ -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 (
+
+
+ {dominantCount > 0 ? dominantCount : total}
+
+ );
+}
diff --git a/ui/src/hooks/usePrefersReducedMotion.ts b/ui/src/hooks/usePrefersReducedMotion.ts
new file mode 100644
index 00000000000..88bdd6a611d
--- /dev/null
+++ b/ui/src/hooks/usePrefersReducedMotion.ts
@@ -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(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;
+}
diff --git a/ui/src/index.css b/ui/src/index.css
index 6c5e2b7c581..f3da45a2ce0 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -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,
diff --git a/ui/src/lib/external-object-href.ts b/ui/src/lib/external-object-href.ts
new file mode 100644
index 00000000000..eeb2d3b6606
--- /dev/null
+++ b/ui/src/lib/external-object-href.ts
@@ -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}`;
+}
diff --git a/ui/src/lib/external-objects.ts b/ui/src/lib/external-objects.ts
new file mode 100644
index 00000000000..ca7bb558c14
--- /dev/null
+++ b/ui/src/lib/external-objects.ts
@@ -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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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(
+ 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 | 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 | 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;
+}
diff --git a/ui/src/lib/status-colors.ts b/ui/src/lib/status-colors.ts
index bcedcedca75..267ade68303 100644
--- a/ui/src/lib/status-colors.ts
+++ b/ui/src/lib/status-colors.ts
@@ -217,3 +217,86 @@ export const priorityColor: Record = {
};
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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ muted: 0,
+ neutral: 1,
+ success: 2,
+ info: 3,
+ warning: 4,
+ danger: 5,
+};
+
+export const externalObjectStatusToneSeverityDefault = 0;