Files
PaperClipAI/ui/src/components/ExternalObjectPill.tsx
T
scotttongandClaude Fable 5 be63424987 refactor(ui): extract size/spacing/radius/shadow values to tokens (Phase 2 batch 3/4)
Batch 3/4 of the design-token extraction (design/token-extraction). Replaces
hardcoded Tailwind arbitrary-bracket values for width/height/min/max,
padding/margin/gap/inset/translate, radius (incl. directional), ring/outline
width, and box-shadow in ui/src/components/** and ui/src/pages/** (including
*.test.tsx companions) with references to new CSS custom-property tokens in
ui/src/index.css. Zero visual change, proven by the Phase 0 Storybook
snapshot baseline.

Counts:
- 407 sites rewritten across 140 files (127 component/page + 13 test files
  updated in lockstep with the components they assert against).
- 163 new tokens minted: 127 --sz-* (one shared family across
  width/height/spacing/gap/inset/translate lengths, including 42
  --sz-calc-N compound calc()/min()/max()/clamp() expressions and 2
  --sz-safe-* env(safe-area-inset-*) wrappers), 8 --rad-* (shared across
  radius/ring-width/outline-width), 5 --pct-* (incl. --pct-neg-50 kept
  distinct per the source bracket's own negative sign), 23
  --shadow-extract-* (38 sites, deduped by exact value).
- 11 var()-only passthrough sites (--radix-*-trigger-width/height,
  --new-issue-dialog-height) rewritten to the bare paren form with no new
  token minted, per DESIGN.md's runtime-library-variable special case.
- 3 sites resolved Tailwind's build-time calc(theme(spacing.N)-Mpx) into a
  byte-equivalent runtime calc() (components/IssueRow.tsx), verified via
  headless-browser computed-style comparison against the pre-change built
  CSS.
- 2 sites skipped/allowlisted: rounded-[inherit] (CSS keyword, not a
  literal) and drop-shadow-[...] (different utility than this batch's
  shadow-[...] mandate) — logged as a Needs-human-decision item.

Three gotchas found and fixed during verification (all documented in the
codemod header and TOKEN-AUDIT.md's Batch 3 log): a boundary regex bug that
false-positived inside Tailwind's own slide-out-to-top-[1%] compound
utility, a calc()-operator-spacing bug that could corrupt
env(safe-area-inset-*) identifiers, and a generated-CSS-comment bug where a
literal "*/" substring in prose silently dropped the entire token block
from the compiled CSS (root cause of an initial 222-test snapshot failure,
fully resolved to 510/510 passing).

Verify: rg gates clean, pnpm build-storybook exit 0, Storybook visual
snapshot suite 510/510 (509 clean + 1 known-benign flaky retry), pnpm
typecheck exit 0, codemod re-run confirmed idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 15:15:08 -07:00

201 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
externalObjectDisplayLabel,
externalObjectLivenessLabel,
externalObjectIconForKey,
externalObjectProviderLabel,
externalObjectTypeLabel,
} from "../lib/external-objects";
import { cn } from "../lib/utils";
export interface ExternalObjectPillData {
providerKey: string | null;
objectType: string | null;
displayKey?: string | null;
iconKey?: string | null;
statusCategory: ExternalObjectStatusCategory;
liveness: ExternalObjectLivenessState;
displayTitle?: string | null;
statusLabel?: string | null;
statusIconKey?: string | null;
url?: string | null;
}
function githubObjectLabel(url: string | null | undefined): string | null {
if (!url) return null;
try {
const parsed = new URL(url);
if (parsed.hostname !== "github.com") return null;
const [, owner, repo, kind, number] = parsed.pathname.split("/");
if (!owner || !repo || !number) return null;
if (kind === "pull") return `PR ${number}`;
if (kind === "issues") return `Issue ${number}`;
return null;
} catch {
return null;
}
}
function externalObjectValueLabel(
object: ExternalObjectPillData,
fallback: string,
statusLabel: string,
): string {
const githubLabel = object.providerKey === "github" ? githubObjectLabel(object.url) : null;
const base = githubLabel ?? object.displayTitle?.trim() ?? fallback;
return statusLabel ? `${base} - ${statusLabel}` : base;
}
function isMergedExternalObject(object: ExternalObjectPillData, statusLabel: string): boolean {
return object.statusIconKey === "git-merge" || statusLabel.toLowerCase() === "merged";
}
function externalObjectPillTone(object: ExternalObjectPillData, statusLabel: string): string {
if (isMergedExternalObject(object, statusLabel)) {
return "text-violet-600 border-violet-600 dark:text-violet-400 dark:border-violet-400";
}
return externalObjectStatusIcon[object.statusCategory] ?? externalObjectStatusIconDefault;
}
function externalObjectStatusIconKey(
object: ExternalObjectPillData,
statusLabel: string,
): string | null | undefined {
if (isMergedExternalObject(object, statusLabel)) return object.statusIconKey ?? "git-merge";
return object.statusIconKey;
}
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;
/**
* Hide the provider icon when the surrounding UI already names the provider.
* The status icon still renders as the single state glyph inside the pill.
*/
showProviderIcon?: 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,
showProviderIcon = true,
}: ExternalObjectPillProps) {
const overlay = externalObjectLivenessOverlay[object.liveness] ?? "";
const providerLabel = externalObjectProviderLabel(object.providerKey);
const typeLabel = externalObjectTypeLabel(object.objectType);
const displayKey = externalObjectDisplayLabel(object.providerKey, object.objectType, object.displayKey);
const statusLabel = object.statusLabel ?? externalObjectCategoryLabel(object.statusCategory);
const tone = externalObjectPillTone(object, statusLabel);
const valueLabel = externalObjectValueLabel(object, displayKey, statusLabel);
const statusIconKey = externalObjectStatusIconKey(object, statusLabel);
const livenessLabel = externalObjectLivenessLabel(object.liveness);
const ProviderIcon = externalObjectIconForKey(object.iconKey);
const ariaKey = displayKey;
const ariaLabel = `${ariaKey} — ${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 max-w-full 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-(length:--rad-3) focus-visible:ring-ring",
className,
);
const titleAttr = sourceSummary
? `${object.displayTitle ?? displayKey} — ${sourceSummary}`
: object.displayTitle ?? displayKey;
const labelText = children ?? (
<>
<ExternalObjectStatusIcon
category={object.statusCategory}
liveness={object.liveness}
statusIconKey={statusIconKey}
sizeClassName="h-3 w-3"
label={`${providerLabel}: ${statusLabel}`}
/>
<span className="max-w-(--sz-16rem) truncate font-medium">{valueLabel}</span>
</>
);
const countSuffix = typeof sourceCount === "number" && sourceCount > 1 ? (
<span className="tabular-nums text-(length:--fs-10) font-medium opacity-80">×{sourceCount}</span>
) : null;
const innerContent = (
<>
{showProviderIcon && ProviderIcon ? (
<ProviderIcon aria-hidden="true" className="h-3 w-3 shrink-0" />
) : null}
<span className="inline-flex min-w-0 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>
);
}