mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
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:
1 parent
b66cc65035
commit
fe3d3550a0
8 files changed
+678
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user