{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "citation-inline-card",
  "title": "Citation Inline Card",
  "description": "An inline citation pill (hostname + source count, Geist Mono) that opens one bordered card with a title, excerpt and a 1/N stepper, instead of a popover per source.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/citation-inline-card/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useId,\n  useRef,\n  useState,\n  type KeyboardEvent as ReactKeyboardEvent,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\n\n// MarginCite — an inline citation primitive. The trigger is one monochrome\n// superscript pill (hostname of the primary source, plus a small source\n// count when there's more than one) sitting right in the text flow. Opening\n// it reveals ONE bordered card — title, excerpt, a \"1 / N\" stepper — instead\n// of a popover-per-source; three sources behind a claim is one card the\n// reader pages through, not three stacked callouts.\n//\n// The card is rendered through a portal into document.body and positioned\n// with `position: fixed`, coordinates read from the trigger's own\n// getBoundingClientRect on open, on scroll and on resize. That's the fix for\n// the same clipping hazard tooltips and menus hit: a card left as a plain\n// `absolute` child of the trigger gets silently cut off by the first\n// ancestor with overflow-hidden (a common wrapper around body copy). It also\n// flips above the trigger when there isn't room below, and clamps\n// horizontally so it never runs off the viewport edge.\n//\n// Fully keyboard-operable: the trigger is a real button (Enter/Space toggle\n// it natively), focus moves into the open card so Tab reaches Previous,\n// Next and the source link in order, Left/Right arrow keys step the reader\n// through sources without leaving the keyboard, and Escape closes the card\n// and returns focus to the trigger. A visually-hidden status region\n// announces \"Source 2 of 3\" on every step so paging is legible to assistive\n// tech, not just sighted users watching the \"1 / N\" readout update.\n//\n// Zero dependencies beyond react-dom's createPortal. No canvas — the card is\n// plain DOM, and every color is a token (--background --foreground --muted\n// --border --accent) so both themes render correctly. Under\n// prefers-reduced-motion the entrance animation is dropped via a CSS media\n// query; the card still opens instantly and is fully operable.\n\nexport type CiteSource = {\n  id: string;\n  title: string;\n  excerpt: string;\n  /** full URL; hostname is derived from it for both the pill and the card footer */\n  url: string;\n};\n\nexport interface MarginCiteProps {\n  sources: CiteSource[];\n  className?: string;\n}\n\nconst GAP = 8; // px between trigger and card\nconst CARD_WIDTH = 320;\nconst VIEWPORT_MARGIN = 12; // px clamp from viewport edges\n\nfunction hostnameOf(url: string): string {\n  try {\n    return new URL(url).hostname.replace(/^www\\./, \"\");\n  } catch {\n    // not a parseable absolute URL — fall back to a best-effort strip\n    return url.replace(/^https?:\\/\\//, \"\").split(/[/?#]/)[0] || url;\n  }\n}\n\nfunction ChevronIcon({ dir }: { dir: \"left\" | \"right\" }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      className=\"h-3.5 w-3.5\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n    >\n      <path d={dir === \"left\" ? \"m10 3-5 5 5 5\" : \"m6 3 5 5-5 5\"} />\n    </svg>\n  );\n}\n\nfunction CloseIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      className=\"h-3.5 w-3.5\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n      strokeLinecap=\"round\"\n      aria-hidden\n    >\n      <path d=\"M4 4l8 8M12 4l-8 8\" />\n    </svg>\n  );\n}\n\nfunction LinkOutIcon() {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      className=\"h-3 w-3\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.5\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n    >\n      <path d=\"M6.5 9.5 13 3M8.5 3H13v4.5M13 9.5V13a1 1 0 0 1-1 1H3.5a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1H7\" />\n    </svg>\n  );\n}\n\nconst iconBtnClass =\n  \"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-muted transition-colors duration-150 ease-out \" +\n  \"hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent \" +\n  \"disabled:pointer-events-none disabled:opacity-30\";\n\nexport function MarginCite({ sources, className = \"\" }: MarginCiteProps) {\n  const n = sources.length;\n  const [open, setOpen] = useState(false);\n  const [index, setIndex] = useState(0);\n  const [pos, setPos] = useState<{ top: number; left: number } | null>(null);\n\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const panelRef = useRef<HTMLDivElement>(null);\n  const uid = useId();\n  const titleId = `${uid}-title`;\n  const panelId = `${uid}-panel`;\n\n  // position the portaled card off the trigger's live rect — recomputed on\n  // open, on every step (the excerpt's length changes the card's height,\n  // which can change whether it fits below) and on scroll/resize\n  useEffect(() => {\n    if (!open) return;\n    const place = () => {\n      const trigger = triggerRef.current;\n      if (!trigger) return;\n      const tr = trigger.getBoundingClientRect();\n      const cardW = panelRef.current?.offsetWidth || CARD_WIDTH;\n      const cardH = panelRef.current?.offsetHeight || 140;\n\n      let left = tr.left;\n      const maxLeft = window.innerWidth - cardW - VIEWPORT_MARGIN;\n      if (left > maxLeft) left = maxLeft;\n      if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;\n\n      const spaceBelow = window.innerHeight - tr.bottom;\n      const flip = spaceBelow < cardH + GAP && tr.top > cardH + GAP;\n      const top = flip ? tr.top - cardH - GAP : tr.bottom + GAP;\n\n      setPos({ top, left });\n    };\n    place();\n    window.addEventListener(\"resize\", place);\n    document.addEventListener(\"scroll\", place, true);\n    return () => {\n      window.removeEventListener(\"resize\", place);\n      document.removeEventListener(\"scroll\", place, true);\n    };\n  }, [open, index]);\n\n  // outside pointerdown closes\n  useEffect(() => {\n    if (!open) return;\n    const onPointerDown = (e: PointerEvent) => {\n      const t = e.target as Node;\n      if (panelRef.current?.contains(t) || triggerRef.current?.contains(t)) return;\n      setOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown, true);\n  }, [open]);\n\n  // Escape closes from anywhere, not only while focus happens to sit inside the\n  // card. A non-modal popover that survives Escape because focus drifted (or was\n  // dropped entirely) is a real trap — it keeps floating over the prose with no\n  // keyboard way out.\n  useEffect(() => {\n    if (!open) return;\n    const onKeyDown = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\") return;\n      e.preventDefault();\n      setOpen(false);\n      triggerRef.current?.focus();\n    };\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => document.removeEventListener(\"keydown\", onKeyDown);\n  }, [open]);\n\n  // Focus lands inside the card on open, so Tab immediately reaches its\n  // controls in order rather than continuing through the surrounding text.\n  // This has to wait for `pos` — the panel isn't mounted at all until the\n  // placement effect has measured the trigger, so keying this on `open` alone\n  // ran while panelRef was still null and focus silently stayed on the pill.\n  const placed = pos !== null;\n  useEffect(() => {\n    if (!open || !placed) return;\n    const id = requestAnimationFrame(() => panelRef.current?.focus({ preventScroll: true }));\n    return () => cancelAnimationFrame(id);\n  }, [open, placed]);\n\n  if (n === 0) return null;\n\n  const clampedIndex = Math.min(index, n - 1);\n  const current = sources[clampedIndex];\n  const primaryHost = hostnameOf(sources[0].url);\n\n  const openCard = () => {\n    setIndex(0);\n    setOpen(true);\n  };\n  const closeCard = (focusTrigger: boolean) => {\n    setOpen(false);\n    if (focusTrigger) triggerRef.current?.focus();\n  };\n  const step = (delta: number) => {\n    setIndex((i) => Math.min(n - 1, Math.max(0, i + delta)));\n  };\n\n  const onPanelKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (e.key === \"Escape\") {\n      e.preventDefault();\n      closeCard(true);\n    } else if (e.key === \"ArrowLeft\" && clampedIndex > 0) {\n      e.preventDefault();\n      step(-1);\n    } else if (e.key === \"ArrowRight\" && clampedIndex < n - 1) {\n      e.preventDefault();\n      step(1);\n    }\n  };\n\n  return (\n    <span className={`ns-citation-inline-card relative inline whitespace-nowrap ${className}`}>\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        aria-controls={open ? panelId : undefined}\n        onClick={() => (open ? closeCard(false) : openCard())}\n        onKeyDown={(e) => {\n          if (open && e.key === \"Escape\") {\n            e.preventDefault();\n            closeCard(true);\n          }\n        }}\n        className={[\n          \"relative -top-[0.5em] ml-0.5 inline-flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 align-baseline\",\n          \"font-mono text-[0.65rem] leading-none\",\n          \"transition-colors duration-150 ease-out\",\n          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent\",\n          open\n            ? \"border-foreground/30 bg-surface text-foreground\"\n            : \"border-border bg-surface text-muted hover:border-foreground/30 hover:text-foreground\",\n        ].join(\" \")}\n      >\n        <span>{primaryHost}</span>\n        {n > 1 && <span className=\"opacity-70\">·{n}</span>}\n        <span className=\"sr-only\">\n          , {n} source{n === 1 ? \"\" : \"s\"} cited\n        </span>\n      </button>\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {open ? `Source ${clampedIndex + 1} of ${n}` : \"\"}\n      </span>\n\n      {open &&\n        pos &&\n        typeof document !== \"undefined\" &&\n        createPortal(\n          <div\n            ref={panelRef}\n            id={panelId}\n            role=\"dialog\"\n            aria-labelledby={titleId}\n            tabIndex={-1}\n            onKeyDown={onPanelKeyDown}\n            // Non-modal popover: once focus leaves the card entirely it closes,\n            // rather than being left floating over the prose while the reader is\n            // somewhere else on the page. Focus moving *within* the card (the\n            // stepper buttons, the source link) is not a leave.\n            onBlur={(e) => {\n              const next = e.relatedTarget as Node | null;\n              if (next && (panelRef.current?.contains(next) || triggerRef.current?.contains(next))) {\n                return;\n              }\n              setOpen(false);\n            }}\n            style={{ position: \"fixed\", top: pos.top, left: pos.left, width: CARD_WIDTH }}\n            className=\"ns-citation-inline-card-panel z-50 max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-md border border-border bg-surface text-foreground shadow-lg outline-none\"\n          >\n            <div className=\"flex items-center justify-between gap-2 border-b border-border px-3 py-2\">\n              <span aria-hidden className=\"font-mono text-[11px] tabular-nums tracking-wide text-muted\">\n                {clampedIndex + 1}\n                {/* --border is tuned to disappear against a surface, which is\n                    right for a hairline and wrong for a glyph: as `text-border`\n                    this slash vanished and the readout rendered as \"1  3\". */}\n                <span className=\"mx-0.5 text-muted/50\">/</span>\n                {n}\n              </span>\n              <div className=\"flex items-center gap-0.5\">\n                {n > 1 && (\n                  <>\n                    <button\n                      type=\"button\"\n                      aria-label=\"Previous source\"\n                      disabled={clampedIndex === 0}\n                      onClick={() => step(-1)}\n                      className={iconBtnClass}\n                    >\n                      <ChevronIcon dir=\"left\" />\n                    </button>\n                    <button\n                      type=\"button\"\n                      aria-label=\"Next source\"\n                      disabled={clampedIndex === n - 1}\n                      onClick={() => step(1)}\n                      className={iconBtnClass}\n                    >\n                      <ChevronIcon dir=\"right\" />\n                    </button>\n                  </>\n                )}\n                <button\n                  type=\"button\"\n                  aria-label=\"Close citation\"\n                  onClick={() => closeCard(true)}\n                  className={iconBtnClass}\n                >\n                  <CloseIcon />\n                </button>\n              </div>\n            </div>\n\n            <div className=\"flex flex-col gap-1.5 px-3.5 py-3\">\n              <p id={titleId} className=\"text-sm font-semibold leading-snug text-foreground\">\n                {current.title}\n              </p>\n              <p className=\"line-clamp-3 text-sm leading-relaxed text-muted\">{current.excerpt}</p>\n              <a\n                href={current.url}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"mt-0.5 inline-flex w-fit items-center gap-1 font-mono text-xs text-muted transition-colors duration-150 ease-out hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent\"\n              >\n                {hostnameOf(current.url)}\n                <LinkOutIcon />\n              </a>\n            </div>\n            <style>{`\n.ns-citation-inline-card-panel{animation:ns-mc-in 160ms cubic-bezier(0.16,1,0.3,1) both}\n@keyframes ns-mc-in{from{opacity:0;transform:translateY(-4px) scale(.98)}to{opacity:1;transform:none}}\n@media (prefers-reduced-motion: reduce){.ns-citation-inline-card-panel{animation:none}}\n`}</style>\n          </div>,\n          document.body\n        )}\n    </span>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/citation-inline-card.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "citation",
      "footnote",
      "popover",
      "stepper",
      "reference",
      "accessibility",
      "typography"
    ],
    "instruction": "An inline citation primitive for attributing a claim to one or more sources, taking a single `sources` array prop (`{ id, title, excerpt, url }[]`). The trigger is one monochrome superscript pill sitting in the text flow — the hostname of the first source in Geist Mono, raised and shrunk like a footnote marker, with a small `·N` suffix appended when more than one source backs the claim. Activating it (click, or Enter/Space since it's a real button) opens a single bordered card rather than one popover per source: a header row shows a `1 / N` mono readout plus Previous/Next chevron buttons (only rendered when N>1) and a close button, and the body shows the current source's title, a 3-line-clamped excerpt, and a hostname link that opens the source in a new tab. Paging through sources updates a visually-hidden `role=status aria-live=polite` region with 'Source 2 of 3' on every step, so the position change is announced to assistive tech exactly like the visible '2 / 3' readout is to sighted users. The card is rendered through a portal into `document.body` and positioned with `position: fixed`, coordinates read from the trigger's `getBoundingClientRect()` on open, on every step (the excerpt's length can change the card's height) and on scroll/resize — not laid out as a plain `absolute` child of the trigger, which is what lets an ordinary ancestor with `overflow-hidden` (a bordered content panel, a card, a table cell) silently clip it. It also flips above the trigger when there isn't room below and clamps horizontally to stay inside the viewport. Fully keyboard-operable: opening moves focus into the card so Tab reaches Previous, Next and the source link in visible order; Left/Right arrow keys step through sources without leaving the keyboard; Escape closes the card and returns focus to the trigger from anywhere on the page, not only while focus happens to sit inside the card — a non-modal popover that survives Escape because focus drifted is a real keyboard trap. An outside pointerdown closes it, and so does focus leaving the card entirely (moving *within* the card, between the stepper buttons and the source link, is not a leave), so the card never sits floating over the prose while the reader is somewhere else. The card is `role=\"dialog\"` with `aria-labelledby` pointing at the title, so it always carries an accessible name. Hostnames are derived from each source's `url` via the `URL` constructor with a `www.` strip, falling back to a best-effort string strip for a non-absolute URL rather than throwing. Zero dependencies beyond react-dom's `createPortal`; no canvas; every color is a token (`--background --foreground --muted --border --accent`) so both themes render correctly, and `--accent` only shows up on hover/focus states, never as decoration. Under `prefers-reduced-motion` the card's entrance animation is dropped via a CSS media query — it still opens instantly and is fully operable, nothing is lost."
  },
  "type": "registry:ui"
}