{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "entity-chip",
  "type": "registry:ui",
  "title": "Entity Chip",
  "description": "Inline mention of a person, repo or issue with a lazy-loading hover card.",
  "categories": [
    "display"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/entity-chip.tsx",
      "content": "\"use client\";\nimport { Avatar } from \"@base-ui/react/avatar\";\nimport { PreviewCard } from \"@base-ui/react/preview-card\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { Alert, Lock, User, Users } from \"@/lib/icons\";\n\nexport type EntityKind = \"user\" | \"team\" | \"repo\" | \"issue\";\nexport type IssueStatus = \"open\" | \"active\" | \"done\" | \"closed\";\n\nexport const issueStatusLabel: Record<IssueStatus, string> = {\n  open: \"Open\",\n  active: \"In progress\",\n  done: \"Done\",\n  closed: \"Closed\",\n};\n\nexport type EntityDetails = {\n  /** One line under the name: a handle and role, an owner, a project. */\n  subtitle?: string;\n  /** A bio, a repository description, the first line of an issue. Clamped to two lines. */\n  description?: string;\n  /** Small facts with an optional 14px icon: a local time, a star count, an assignee. */\n  meta?: { icon?: React.ReactNode; label: React.ReactNode }[];\n};\n\ntype LoadStatus = \"idle\" | \"loading\" | \"ready\" | \"error\";\n\n/**\n * The lazy-load lifecycle behind the card, on its own. `prefetch` is safe to call on every\n * hover: it runs once, and a failed load runs again on the next call. Stale answers never land.\n */\nexport function useEntityDetails(load: (() => Promise<EntityDetails>) | undefined, initial?: EntityDetails) {\n  const [state, setState] = useState<{\n    status: LoadStatus;\n    details: EntityDetails | undefined;\n  }>({\n    status: load && !initial ? \"idle\" : \"ready\",\n    details: initial,\n  });\n  const status = useRef<LoadStatus>(state.status);\n  const loadRef = useRef(load);\n  const request = useRef(0);\n  useEffect(() => {\n    loadRef.current = load;\n  });\n  useEffect(() => () => void request.current++, []);\n\n  const prefetch = useCallback(() => {\n    const fn = loadRef.current;\n    if (!fn || status.current === \"loading\" || status.current === \"ready\") return;\n    status.current = \"loading\";\n    setState((s) => ({ ...s, status: \"loading\" }));\n    const id = ++request.current;\n    Promise.resolve()\n      .then(fn)\n      .then(\n        (details) => {\n          if (id !== request.current) return;\n          status.current = \"ready\";\n          setState({ status: \"ready\", details });\n        },\n        () => {\n          if (id !== request.current) return;\n          status.current = \"error\";\n          setState((s) => ({ ...s, status: \"error\" }));\n        },\n      );\n  }, []);\n\n  return { ...state, prefetch };\n}\n\nexport type EntityChipProps = Omit<PreviewCard.Trigger.Props, \"className\" | \"children\" | \"delay\" | \"closeDelay\"> & {\n  /** What the chip points at. Sets the leading glyph and the card's header. */\n  kind?: EntityKind;\n  /** The display name. Truncates in the chip, wraps in full in the card. */\n  name: string;\n  /** Where the chip goes. Mentions are links, so middle-click and copy link work. */\n  href?: string;\n  /** A short key shown before the name in mono: \"ENG-482\", \"#1290\". */\n  reference?: string;\n  /** Profile picture for users and teams. Falls back to initials. */\n  avatarSrc?: string;\n  /** Issue state, drawn as a glyph whose shape carries the meaning as well as its color. */\n  status?: IssueStatus;\n  /** Replaces the leading glyph. */\n  icon?: React.ReactNode;\n  /** What the hover card shows, when you already have it. */\n  details?: EntityDetails;\n  /** Fetches the card's details. Starts when the pointer or focus arrives, so the open delay hides the wait. */\n  load?: () => Promise<EntityDetails>;\n  /** A fully custom card body, instead of the details layout. */\n  card?: React.ReactNode;\n  /** `sm` sits inside a line of 13px text; `md` stands alone in rows and fields. */\n  size?: \"sm\" | \"md\";\n  /** A mention of the person reading. Drawn one step stronger so it's found at a glance. */\n  self?: boolean;\n  /** Deleted, private or otherwise out of reach: drawn muted, not a link, no card. */\n  unavailable?: boolean;\n  /** Milliseconds of hover or focus before the card opens. */\n  delay?: number;\n  closeDelay?: number;\n  side?: \"top\" | \"bottom\";\n  /** Where the card portals to. */\n  container?: PreviewCard.Portal.Props[\"container\"];\n  className?: string;\n};\n\nexport function EntityChip({\n  kind = \"user\",\n  name,\n  href,\n  reference,\n  avatarSrc,\n  status,\n  icon,\n  details,\n  load,\n  card,\n  size = \"sm\",\n  self = false,\n  unavailable = false,\n  delay = 500,\n  closeDelay = 200,\n  side = \"bottom\",\n  container,\n  className,\n  onPointerEnter,\n  onFocus,\n  ...rest\n}: EntityChipProps) {\n  const lifecycle = useEntityDetails(load, details);\n  const hasCard = !unavailable && !!(card || details || load);\n\n  const glyph = icon ?? (\n    <Glyph\n      kind={unavailable ? (kind === \"user\" ? \"gone\" : \"locked\") : kind}\n      name={name}\n      src={unavailable ? undefined : avatarSrc}\n      status={status}\n      px={size === \"sm\" ? 16 : 18}\n    />\n  );\n  const body = (\n    <>\n      <span className=\"grid shrink-0 place-items-center\">{glyph}</span>\n      {reference && <span className=\"shrink-0 font-mono text-[0.9em] font-normal tracking-normal text-fg-3\">{reference}</span>}\n      <span className=\"min-w-0 truncate\">{name}</span>\n    </>\n  );\n\n  const chip = cn(\n    \"relative inline-flex max-w-[min(16rem,100%)] items-center rounded-md font-medium tracking-[-0.005em] align-middle whitespace-nowrap\",\n    size === \"sm\" ? \"-my-px h-5 gap-1 pr-1.5 pl-0.5 text-[12.5px] leading-5\" : \"h-6 gap-1.5 pr-2 pl-1 text-[12.5px]\",\n    // Icons that aren't round avatars sit in a square box; give them the same inset as the text.\n    kind !== \"user\" && kind !== \"team\" && (size === \"sm\" ? \"pl-1\" : \"pl-1.5\"),\n    className,\n  );\n\n  if (unavailable) {\n    return (\n      <span\n        data-kind={kind}\n        data-size={size}\n        data-unavailable=\"\"\n        className={cn(chip, \"border border-dashed border-line-2 font-normal text-fg-3\", size === \"sm\" ? \"pl-1\" : \"pl-1.5\")}\n      >\n        {body}\n      </span>\n    );\n  }\n\n  const trigger = cn(\n    chip,\n    \"cursor-pointer select-none text-fg outline-none no-underline\",\n    // Inline chips sit between words, so their ring hugs closer than a button's.\n    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\",\n    size === \"sm\" ? \"focus-visible:outline-offset-1\" : \"focus-visible:outline-offset-2\",\n    \"transition-[background-color,box-shadow,scale] duration-150 ease-out active:scale-[0.97] active:duration-75 motion-reduce:active:scale-100\",\n    self\n      ? \"bg-fg/[0.13] shadow-[inset_0_0_0_1px_var(--line-2)] hover:bg-fg/[0.17] data-popup-open:bg-fg/[0.17]\"\n      : \"bg-fg/[0.06] hover:bg-fg/[0.1] data-popup-open:bg-fg/[0.1]\",\n    // Standalone chips are small; on touch the hit area grows without changing the drawing.\n    size === \"md\" && \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden\",\n  );\n\n  return (\n    <PreviewCard.Root\n      // Without anything to preview the chip is a plain link; the root stays so `render` still works.\n      open={hasCard ? undefined : false}\n      onOpenChange={(open) => {\n        if (open) lifecycle.prefetch();\n      }}\n    >\n      <PreviewCard.Trigger\n        href={href}\n        delay={delay}\n        closeDelay={closeDelay}\n        data-kind={kind}\n        data-size={size}\n        data-self={self ? \"\" : undefined}\n        onPointerEnter={(e) => {\n          lifecycle.prefetch();\n          onPointerEnter?.(e);\n        }}\n        onFocus={(e) => {\n          lifecycle.prefetch();\n          onFocus?.(e);\n        }}\n        className={trigger}\n        {...rest}\n      >\n        {body}\n      </PreviewCard.Trigger>\n      {hasCard && (\n        <PreviewCard.Portal container={container}>\n          <PreviewCard.Positioner side={side} align=\"start\" sideOffset={6} collisionPadding={8} className=\"z-(--z-popover)\">\n            <PreviewCard.Popup\n              className={cn(\n                \"w-72 max-w-[var(--available-width)] rounded-xl border border-line-2 bg-raised text-[13px] text-fg shadow-pop outline-none\",\n                \"origin-[var(--transform-origin)] transition-[opacity,scale,translate,filter] duration-200 ease-out-expo\",\n                \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n                \"data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1\",\n                \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-out-quart\",\n                \"data-instant:transition-none\",\n                \"motion-reduce:data-starting-style:translate-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none motion-reduce:data-ending-style:scale-100\",\n              )}\n            >\n              {card ?? (\n                <Card\n                  kind={kind}\n                  name={name}\n                  reference={reference}\n                  avatarSrc={avatarSrc}\n                  status={status}\n                  icon={icon}\n                  state={lifecycle.status}\n                  details={lifecycle.details}\n                />\n              )}\n            </PreviewCard.Popup>\n          </PreviewCard.Positioner>\n        </PreviewCard.Portal>\n      )}\n    </PreviewCard.Root>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The card\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Card({\n  kind,\n  name,\n  reference,\n  avatarSrc,\n  status,\n  icon,\n  state,\n  details,\n}: {\n  kind: EntityKind;\n  name: string;\n  reference?: string;\n  avatarSrc?: string;\n  status?: IssueStatus;\n  icon?: React.ReactNode;\n  state: LoadStatus;\n  details?: EntityDetails;\n}) {\n  const reduce = useReducedMotion();\n  const skeleton = useLoadingGate(state === \"loading\" || state === \"idle\");\n  // The header is known from the chip, so it's there on the first frame. Only the body waits.\n  const phase = skeleton ? \"skeleton\" : state === \"ready\" && details ? \"ready\" : state === \"error\" ? \"error\" : \"none\";\n  const round = kind === \"user\" || kind === \"team\";\n  const fade = {\n    initial: { opacity: 0 },\n    animate: { opacity: 1 },\n    exit: { opacity: 0, transition: { duration: 0.1 } },\n    transition: { duration: reduce ? 0.12 : 0.2, ease: ease.out },\n  };\n  const subtitle = phase === \"ready\" ? details?.subtitle : undefined;\n  const hasBody = phase === \"skeleton\" || phase === \"error\" || (phase === \"ready\" && !!(details?.description || details?.meta?.length));\n\n  return (\n    <AutoHeight>\n      <div className=\"p-3.5\">\n        <div className=\"flex items-start gap-3\">\n          {icon ? (\n            <span className=\"grid size-9 shrink-0 place-items-center rounded-lg bg-fg/[0.06] text-fg-2\">{icon}</span>\n          ) : round ? (\n            <Glyph kind={kind} name={name} src={avatarSrc} px={36} />\n          ) : (\n            <span className=\"grid size-9 shrink-0 place-items-center rounded-lg bg-fg/[0.06] text-fg-2\">\n              <Glyph kind={kind} name={name} status={status} px={20} />\n            </span>\n          )}\n          <div className=\"flex min-h-9 min-w-0 flex-1 flex-col justify-center\">\n            {(reference || (kind === \"issue\" && status)) && (\n              <div className=\"mb-0.5 flex items-center gap-1.5 text-[11.5px] leading-4 text-fg-3\">\n                {reference && <span className=\"font-mono\">{reference}</span>}\n                {reference && kind === \"issue\" && status && (\n                  <span aria-hidden className=\"text-fg-4\">\n                    ·\n                  </span>\n                )}\n                {kind === \"issue\" && status && <span>{issueStatusLabel[status]}</span>}\n              </div>\n            )}\n            <p className=\"text-[14px] leading-[1.3] font-medium tracking-[-0.015em] text-pretty break-words\">{name}</p>\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {phase === \"skeleton\" ? (\n                <motion.span key=\"bar\" {...fade} aria-hidden className=\"mt-0.5 flex h-4 items-center\">\n                  <span className=\"h-2.5 w-28 animate-pulse-soft rounded-[4px] bg-fg/[0.07] motion-reduce:animate-none\" />\n                </motion.span>\n              ) : subtitle ? (\n                <motion.p key=\"subtitle\" {...fade} className=\"mt-0.5 truncate text-[12px] leading-4 text-fg-3\">\n                  {subtitle}\n                </motion.p>\n              ) : null}\n            </AnimatePresence>\n          </div>\n        </div>\n\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {hasBody && (\n            <motion.div key={phase} {...fade} className=\"pt-3\">\n              {phase === \"skeleton\" && <Skeleton />}\n              {phase === \"error\" && (\n                <p className=\"flex items-center gap-1.5 text-[12px] text-fg-3\">\n                  <Alert size={14} className=\"shrink-0\" />\n                  Couldn’t load details. Hover again to retry.\n                </p>\n              )}\n              {phase === \"ready\" && details && <Details details={details} />}\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </AutoHeight>\n  );\n}\n\nfunction Details({ details }: { details: EntityDetails }) {\n  const { description, meta } = details;\n  return (\n    <div className=\"flex flex-col gap-2\">\n      {description && <p className=\"line-clamp-2 text-[12.5px] leading-[18px] text-fg-2\">{description}</p>}\n      {meta && meta.length > 0 && (\n        <ul className=\"flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-line pt-2 text-[12px] leading-[18px] text-fg-3\">\n          {meta.map((m, i) => (\n            <li key={i} className=\"flex min-w-0 items-center gap-1.5 tabular\">\n              {m.icon && <span className=\"grid shrink-0 place-items-center text-fg-4 [&>svg]:size-3.5\">{m.icon}</span>}\n              <span className=\"truncate\">{m.label}</span>\n            </li>\n          ))}\n        </ul>\n      )}\n    </div>\n  );\n}\n\n// Built on Details' own line boxes (two 18px description lines, a divider, an 18px meta row),\n// so the swap to real content is a crossfade with nothing moving.\nfunction Skeleton() {\n  const line = \"flex h-[18px] items-center\";\n  const bar = \"h-2.5 rounded-[4px] bg-fg/[0.07] animate-pulse-soft motion-reduce:animate-none\";\n  return (\n    <div aria-hidden className=\"flex flex-col gap-2\">\n      <div>\n        <span className={line}>\n          <span className={cn(bar, \"w-full\")} />\n        </span>\n        <span className={line}>\n          <span className={cn(bar, \"w-3/5\")} />\n        </span>\n      </div>\n      <div className=\"flex gap-3 border-t border-line pt-2\">\n        <span className={line}>\n          <span className={cn(bar, \"w-16\")} />\n        </span>\n        <span className={line}>\n          <span className={cn(bar, \"w-20\")} />\n        </span>\n      </div>\n    </div>\n  );\n}\n\n/**\n * Loading that doesn't flash: the skeleton waits 150ms before it shows (most loads are done by\n * then), and once shown it stays at least 300ms so it never blinks.\n */\nfunction useLoadingGate(loading: boolean) {\n  const [shown, setShown] = useState(false);\n  const since = useRef(0);\n  useEffect(() => {\n    if (loading && !shown) {\n      const t = window.setTimeout(() => {\n        since.current = performance.now();\n        setShown(true);\n      }, 150);\n      return () => window.clearTimeout(t);\n    }\n    if (!loading && shown) {\n      const left = Math.max(0, 300 - (performance.now() - since.current));\n      const t = window.setTimeout(() => setShown(false), left);\n      return () => window.clearTimeout(t);\n    }\n  }, [loading, shown]);\n  return shown;\n}\n\n/** Animates its own height to fit its content, so the card grows when details arrive instead of jumping. */\nfunction AutoHeight({ children }: { children: React.ReactNode }) {\n  const inner = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  const reduce = useReducedMotion();\n  useEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setHeight(el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return (\n    <motion.div\n      initial={false}\n      animate={{ height }}\n      transition={reduce ? { duration: 0 } : { duration: 0.24, ease: ease.inOut }}\n      className=\"overflow-hidden\"\n    >\n      <div ref={inner} className=\"relative\">\n        {children}\n      </div>\n    </motion.div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Glyphs\n * -----------------------------------------------------------------------------------------------*/\n\nfunction initials(name: string) {\n  const words = name\n    .replace(/\\(.*?\\)/g, \" \")\n    .split(\"@\")[0]\n    .trim()\n    .split(/[\\s._]+/)\n    .filter(Boolean);\n  if (!words.length) return \"\";\n  const first = (w: string) => Array.from(w)[0] ?? \"\";\n  return (words.length === 1 ? first(words[0]) : first(words[0]) + first(words[words.length - 1])).toUpperCase();\n}\n\n// Neutral fills stepped by a stable hash of the name, so the same person is the same tone everywhere.\nconst tones = [\"bg-fg/[0.08] text-fg-2\", \"bg-fg/[0.12] text-fg-2\", \"bg-fg/[0.16] text-fg\", \"bg-fg/[0.2] text-fg\"] as const;\nfunction tone(key: string) {\n  let h = 0x811c9dc5;\n  for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 0x01000193);\n  return tones[(h >>> 0) % tones.length];\n}\n\nfunction Glyph({\n  kind,\n  name,\n  src,\n  status,\n  px,\n}: {\n  kind: EntityKind | \"locked\" | \"gone\";\n  name: string;\n  src?: string;\n  status?: IssueStatus;\n  px: number;\n}) {\n  if (kind === \"user\" || kind === \"team\") {\n    return (\n      <Avatar.Root\n        className={cn(\n          \"relative grid shrink-0 place-items-center overflow-hidden select-none\",\n          kind === \"team\" ? \"rounded-[28%]\" : \"rounded-full\",\n          tone(name),\n        )}\n        style={{ width: px, height: px }}\n      >\n        <Avatar.Image\n          src={src}\n          alt=\"\"\n          width={px}\n          height={px}\n          className=\"col-start-1 row-start-1 size-full object-cover transition-opacity duration-200 ease-out data-starting-style:opacity-0\"\n        />\n        <Avatar.Fallback\n          aria-hidden\n          className=\"col-start-1 row-start-1 font-medium tracking-normal\"\n          style={{\n            fontSize: Math.max(7, Math.round(px * (px < 20 ? 0.5 : 0.38))),\n            lineHeight: 1,\n          }}\n        >\n          {kind === \"team\" && px < 20 ? <Users size={px - 4} /> : px < 20 ? initials(name).slice(0, 1) : initials(name)}\n        </Avatar.Fallback>\n      </Avatar.Root>\n    );\n  }\n  const size = px <= 18 ? 14 : 16;\n  const common = {\n    width: size,\n    height: size,\n    viewBox: \"0 0 16 16\",\n    fill: \"none\",\n    stroke: \"currentColor\",\n    strokeWidth: 1.4,\n    strokeLinecap: \"round\" as const,\n    strokeLinejoin: \"round\" as const,\n    \"aria-hidden\": true,\n    className: \"shrink-0\",\n  };\n  if (kind === \"locked\") return <Lock size={size} className=\"shrink-0\" />;\n  if (kind === \"gone\") return <User size={size} className=\"shrink-0\" />;\n  if (kind === \"repo\")\n    return (\n      <svg {...common} className=\"shrink-0 text-fg-2\">\n        <path d=\"M4 12.25V3.9c0-.63.52-1.15 1.15-1.15h6.85v8.25H5.25A1.25 1.25 0 0 0 4 12.25zm0 0c0 .69.56 1.25 1.25 1.25H12\" />\n        <path d=\"M6.75 2.75v4l1.1-.8 1.1.8v-4\" />\n      </svg>\n    );\n  // Issue states differ by shape, not only color: hollow, half, checked, crossed.\n  const s = status ?? \"open\";\n  return (\n    <svg\n      {...common}\n      data-status={s}\n      className={cn(\n        \"shrink-0\",\n        s === \"open\" && \"text-fg-2\",\n        s === \"active\" && \"text-warning\",\n        s === \"done\" && \"text-success\",\n        s === \"closed\" && \"text-fg-3\",\n      )}\n    >\n      {s === \"done\" ? (\n        <>\n          <circle cx=\"8\" cy=\"8\" r=\"5.75\" fill=\"currentColor\" stroke=\"none\" />\n          <path d=\"m5.6 8.2 1.6 1.6 3.2-3.5\" stroke=\"var(--raised)\" strokeWidth={1.5} />\n        </>\n      ) : (\n        <circle cx=\"8\" cy=\"8\" r=\"5.25\" />\n      )}\n      {s === \"active\" && <path d=\"M8 4.75a3.25 3.25 0 0 1 0 6.5z\" fill=\"currentColor\" stroke=\"none\" />}\n      {s === \"closed\" && <path d=\"m6.25 6.25 3.5 3.5M9.75 6.25l-3.5 3.5\" />}\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/entity-chip"
}
