{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "answer-sources",
  "title": "Answer Sources",
  "description": "The numbered favicon cards behind one AI answer — a scrolling strip or an expandable list, the retrieved excerpt one hover or one Tab away, dead sources that refuse to link, and four data states.",
  "dependencies": [
    "lucide-react",
    "zod"
  ],
  "registryDependencies": [
    "tooltip",
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/answer-sources.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { AlertCircle, ChevronDown, ExternalLink, Globe, Link2Off, RefreshCcw, SearchX } from \"lucide-react\"\n\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\"\nimport { cn } from \"@/lib/utils\"\nimport type { AnswerSourcesItem, AnswerSourcesStatus } from \"./answer-sources.contract\"\n\n/**\n * Fixed locale AND fixed time zone. A source list is rendered on the server, in\n * the browser and into a screenshot fixture; `toLocaleDateString()` would print\n * three different strings and hydrate with a mismatch warning. A published date\n * is also not a clock — nothing here reads `Date.now()`, so \"3 days ago\" is\n * deliberately not on offer (see `formatDate` if you want it).\n */\nconst dateFormatter = new Intl.DateTimeFormat(\"en-US\", {\n  day: \"numeric\",\n  month: \"short\",\n  timeZone: \"UTC\",\n  year: \"numeric\",\n})\n\nfunction defaultFormatDate(iso: string): string | null {\n  // Date.parse, not `new Date(...)`: a pure call, safe to run during render.\n  const ms = Date.parse(iso)\n  return Number.isNaN(ms) ? null : dateFormatter.format(ms)\n}\n\n/**\n * `www.` is noise in every source row ever written. A scheme is stripped too, in\n * case the data layer handed over a URL where a host was asked for. Everything\n * else is left alone — a subdomain is frequently the whole point (`arxiv.org` is\n * not `blog.arxiv.org`, `docs.stripe.com` is not `stripe.com`).\n */\nfunction displayDomain(domain: string): string {\n  return domain\n    .trim()\n    .replace(/^[a-z][a-z0-9+.-]*:\\/\\//i, \"\")\n    .replace(/^www\\./i, \"\")\n    .replace(/\\/+$/, \"\")\n}\n\n/** First alphanumeric of the host, for the favicon fallback tile. */\nfunction monogram(domain: string): string {\n  const match = displayDomain(domain).match(/[a-z0-9]/i)\n  return match ? match[0].toUpperCase() : \"\"\n}\n\n/**\n * Read once, then kept in sync. The scroll-into-view below is the one behaviour\n * that has to branch on it at CALL time (`behavior: \"smooth\"` is a JS argument,\n * not a class), so a `motion-reduce:` utility cannot reach it.\n */\nfunction usePrefersReducedMotion(): boolean {\n  const [reduced, setReduced] = React.useState(false)\n\n  React.useEffect(() => {\n    if (typeof window === \"undefined\" || typeof window.matchMedia !== \"function\") return\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n    const update = () => setReduced(query.matches)\n    update()\n    query.addEventListener(\"change\", update)\n    return () => query.removeEventListener(\"change\", update)\n  }, [])\n\n  return reduced\n}\n\n/* -------------------------------------------------------------------- pieces */\n\n/**\n * A favicon that is allowed to fail. `failedSrc` stores the URL that broke\n * rather than a boolean, so a card recycled onto a different source retries the\n * new image instead of inheriting the previous one's failure — and no effect is\n * needed to reset it.\n */\nfunction SourceFavicon({\n  className,\n  dead,\n  source,\n}: {\n  className?: string\n  dead: boolean\n  source: AnswerSourcesItem\n}) {\n  const src = source.faviconUrl\n  const [failedSrc, setFailedSrc] = React.useState<string | null>(null)\n  const broken = src !== undefined && failedSrc === src\n  const letter = monogram(source.domain)\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"relative flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted text-[10px] font-semibold text-muted-foreground\",\n        className,\n      )}\n      data-dead={dead || undefined}\n    >\n      {src !== undefined && !broken ? (\n        // eslint-disable-next-line @next/next/no-img-element -- consumer-supplied remote URL, not a local optimizable asset\n        <img\n          alt=\"\"\n          className={cn(\"size-full object-cover\", dead && \"opacity-40 grayscale\")}\n          decoding=\"async\"\n          loading=\"lazy\"\n          onError={() => setFailedSrc(src)}\n          src={src}\n        />\n      ) : letter ? (\n        <span className={cn(dead && \"opacity-40\")}>{letter}</span>\n      ) : (\n        <Globe className={cn(\"size-3.5\", dead && \"opacity-40\")} />\n      )}\n      {dead && (\n        // Corner to corner: a 45°-rotated bar needs √2 ≈ 141% of the box.\n        <span className=\"absolute left-1/2 top-1/2 h-px w-[145%] -translate-x-1/2 -translate-y-1/2 rotate-45 bg-muted-foreground\" />\n      )}\n    </span>\n  )\n}\n\n/**\n * The number is the whole contract with the prose: it is `source.n`, never the\n * array index, so re-ordering or filtering this list can't renumber the answer.\n */\nfunction CitationNumber({ active, className, n }: { active: boolean; className?: string; n: number }) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full border px-1 font-mono text-[11px] leading-none tabular-nums\",\n        active ? \"border-primary bg-primary text-primary-foreground\" : \"bg-muted text-muted-foreground\",\n        className,\n      )}\n      data-citation-number={n}\n    >\n      {n}\n    </span>\n  )\n}\n\n/**\n * The reveal body. `inverted` exists because the tooltip surface is\n * `bg-foreground`: `text-muted-foreground` on it is nearly invisible, so the\n * secondary text drops to opacity instead of switching token.\n */\nfunction SourcePreview({\n  dateText,\n  inverted = false,\n  source,\n}: {\n  dateText: string | null\n  inverted?: boolean\n  source: AnswerSourcesItem\n}) {\n  const muted = inverted ? \"opacity-70\" : \"text-muted-foreground\"\n\n  return (\n    <div className=\"flex min-w-0 flex-col gap-1 text-left\">\n      <p className=\"flex min-w-0 flex-wrap items-center gap-x-1.5 text-[11px]\">\n        <span className={cn(\"font-mono tabular-nums\", muted)}>[{source.n}]</span>\n        <span className=\"min-w-0 wrap-anywhere font-medium\">{displayDomain(source.domain)}</span>\n        {dateText && <span className={muted}>· {dateText}</span>}\n      </p>\n      {/* The full title lives here — the card clamps it to two lines, and this is\n          where you find out what the third line said. */}\n      <p className=\"min-w-0 wrap-anywhere text-xs font-medium leading-snug\">{source.title}</p>\n      {source.snippet && (\n        <p className={cn(\"min-w-0 text-xs leading-snug text-pretty\", muted)}>{source.snippet}</p>\n      )}\n      <p className={cn(\"min-w-0 wrap-anywhere font-mono text-[10px]\", muted)}>{source.url}</p>\n      {source.dead && (\n        <p className=\"flex items-center gap-1 text-[11px] font-medium\">\n          <Link2Off aria-hidden=\"true\" className=\"size-3 shrink-0\" />\n          Link no longer reachable\n        </p>\n      )}\n    </div>\n  )\n}\n\n/* ---------------------------------------------------------------- component */\n\nexport interface AnswerSourcesProps extends Omit<React.HTMLAttributes<HTMLElement>, \"onSelect\"> {\n  /** Envelope state — whether the source list arrived at all. Not a property of any one source. */\n  status: AnswerSourcesStatus\n  /** The sources, in the order you want them read. Numbering comes from `n`, never from this order. */\n  items: readonly AnswerSourcesItem[]\n  /**\n   * `row` = the Perplexity-style horizontal strip of favicon cards that sits\n   * under an answer. `list` = the expandable vertical list with inline excerpts,\n   * for a side panel or a narrow column. Same data, same numbers, same states.\n   */\n  variant?: \"row\" | \"list\"\n  /** Sources drawn before the overflow control. Clamped to >= 1. Default 4. */\n  maxVisible?: number\n  /** Start expanded (all sources visible). Default false. */\n  defaultExpanded?: boolean\n  /**\n   * Highlight the source with this citation number and scroll it into view —\n   * wire it to the inline `[n]` marker your reader is hovering. If that source\n   * is currently collapsed away, the list expands itself first; otherwise the\n   * scroll would target an element that is not in the document.\n   */\n  activeN?: number\n  /** Reveal the excerpt on hover/focus. Default true. Set false for a dense strip. */\n  preview?: boolean\n  /** Preview open delay in ms. Default 250. */\n  delayDuration?: number\n  /** Anchor target for live sources. Default `_blank` (with `rel=\"noreferrer\"`). */\n  target?: React.HTMLAttributeAnchorTarget\n  /**\n   * Intercept a plain left click — open the source in a reader panel instead of\n   * a new tab. Modified clicks (⌘/Ctrl/Shift/Alt, middle click) are left to the\n   * browser, so \"open in a new tab\" never stops working. Also the only way a\n   * dead source becomes clickable (send it to your archive lookup).\n   */\n  onSelect?: (source: AnswerSourcesItem, event: React.MouseEvent<HTMLElement>) => void\n  /** Renders \"Try again\" in the `status=\"error\"` branch; omit it to hide the affordance. */\n  onRetry?: () => void\n  /** Override the date wording. Return `null` to print no date for that source. */\n  formatDate?: (iso: string) => string | null\n  /** Header text. Pass `null` to drop the header and keep only the cards. Default \"Sources\". */\n  heading?: React.ReactNode\n  /** Replaces the default `status=\"empty\"` body. */\n  emptyState?: React.ReactNode\n  /** Message shown in the `status=\"error\"` branch. */\n  errorMessage?: string\n  /** Accessible name for the region. Default \"Sources\". */\n  label?: string\n  /** Skeleton cards drawn while loading. Clamped to >= 1. Default 4. */\n  loadingCount?: number\n}\n\n/**\n * The sources behind one AI answer: numbered favicon cards that line up with the\n * inline `[n]` markers in the prose, as a horizontal strip or an expandable\n * list, with the retrieved excerpt one hover (or one Tab) away.\n */\nexport const AnswerSources = React.forwardRef<HTMLElement, AnswerSourcesProps>(\n  (\n    {\n      status,\n      items,\n      variant = \"row\",\n      maxVisible = 4,\n      defaultExpanded = false,\n      activeN,\n      preview = true,\n      delayDuration = 250,\n      target = \"_blank\",\n      onSelect,\n      onRetry,\n      formatDate = defaultFormatDate,\n      heading = \"Sources\",\n      emptyState,\n      errorMessage = \"The answer is still shown above — its sources couldn't be loaded.\",\n      label = \"Sources\",\n      loadingCount = 4,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const uid = React.useId()\n    const listId = `${uid}-list`\n    const rootRef = React.useRef<HTMLElement | null>(null)\n    const reduced = usePrefersReducedMotion()\n\n    const limit = Math.max(1, Math.floor(Number.isFinite(maxVisible) ? maxVisible : 4))\n    const skeletons = Math.max(1, Math.floor(Number.isFinite(loadingCount) ? loadingCount : 4))\n\n    /** Identity of the CURRENT source set — a new answer must not inherit the previous one's disclosure. */\n    const idToken = items.map(source => source.id).join(\"|\")\n    /** Position of the highlighted source, and whether the collapsed view hides it. */\n    const activeIndex = activeN === undefined ? -1 : items.findIndex(source => source.n === activeN)\n    const activeHidden = activeIndex >= limit\n\n    const [expanded, setExpanded] = React.useState(defaultExpanded)\n    const [prevToken, setPrevToken] = React.useState(idToken)\n    const [prevActive, setPrevActive] = React.useState(activeN)\n\n    // Adjust-state-during-render, never an effect: expanding in an effect would\n    // paint one frame with the target still collapsed, and the scroll below\n    // would fire against a node that is not in the document yet.\n    if (prevToken !== idToken) {\n      setPrevToken(idToken)\n      setPrevActive(activeN)\n      setExpanded(defaultExpanded || activeHidden)\n    } else if (prevActive !== activeN) {\n      setPrevActive(activeN)\n      if (activeHidden) setExpanded(true)\n    }\n\n    const overflow = Math.max(0, items.length - limit)\n    const canToggle = overflow > 0\n    const shown = expanded || !canToggle ? items : items.slice(0, limit)\n\n    // The effect does one thing: talk to the DOM. Expansion already happened\n    // above, so by the time this runs the target is mounted.\n    React.useEffect(() => {\n      if (activeN === undefined || status !== \"ready\") return\n      const node = rootRef.current?.querySelector<HTMLElement>(`[data-source-n=\"${activeN}\"]`)\n      if (!node) return\n      node.scrollIntoView({\n        behavior: reduced ? \"auto\" : \"smooth\",\n        // `nearest` on both axes: the strip scrolls, the page does not jump.\n        block: \"nearest\",\n        inline: \"nearest\",\n      })\n    }, [activeN, expanded, reduced, status])\n\n    const setRefs = (node: HTMLElement | null) => {\n      rootRef.current = node\n      if (typeof ref === \"function\") ref(node)\n      else if (ref) ref.current = node\n    }\n\n    const isRow = variant === \"row\"\n    const strip = isRow && !expanded\n\n    const rootClass = cn(\"flex w-full min-w-0 flex-col gap-2 text-sm\", className)\n    const trackClass = cn(\n      \"flex list-none\",\n      strip\n        ? // The strip scrolls horizontally; every card is focusable, so keyboard\n          // users reach the far end by tabbing and the browser scrolls for them.\n          \"gap-2 overflow-x-auto pb-1\"\n        : isRow\n          ? \"grid gap-2 [grid-template-columns:repeat(auto-fit,minmax(min(11rem,100%),1fr))]\"\n          : \"flex-col gap-2\",\n    )\n    // One knob, two places: the strip's fixed card width and the expanded grid's\n    // minimum column are the same \"natural card width\" — change them together.\n    const itemClass = strip ? \"w-44 shrink-0\" : \"min-w-0\"\n    const cardClass =\n      \"flex h-full w-full min-w-0 cursor-pointer rounded-lg border bg-card p-3 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\"\n\n    const header =\n      heading === null || heading === false ? null : (\n        <div className=\"flex min-w-0 items-center justify-between gap-3\">\n          <p className=\"flex min-w-0 items-center gap-1.5 text-xs font-medium text-muted-foreground\">\n            <span className=\"truncate\">{heading}</span>\n            {status === \"ready\" && items.length > 0 && (\n              <span className=\"tabular-nums\" data-source-count={items.length}>\n                · {items.length}\n              </span>\n            )}\n          </p>\n          {status === \"ready\" && canToggle && (\n            <button\n              aria-controls={listId}\n              aria-expanded={expanded}\n              className=\"ml-auto inline-flex shrink-0 cursor-pointer items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\"\n              data-toggle=\"\"\n              onClick={() => setExpanded(value => !value)}\n              type=\"button\"\n            >\n              {expanded ? \"Show less\" : `Show all ${items.length}`}\n              <ChevronDown\n                aria-hidden=\"true\"\n                className={cn(\n                  \"size-3 transition-transform duration-150 motion-reduce:transition-none\",\n                  expanded && \"rotate-180\",\n                )}\n              />\n            </button>\n          )}\n        </div>\n      )\n\n    /* ------------------------------------------------------------ envelopes */\n\n    if (status === \"loading\") {\n      return (\n        <section aria-busy=\"true\" aria-label={label} className={rootClass} ref={setRefs} {...props}>\n          {header}\n          <span className=\"sr-only\" role=\"status\">\n            Loading sources\n          </span>\n          <div aria-hidden=\"true\" className={trackClass}>\n            {Array.from({ length: skeletons }, (_, index) => (\n              // Same two-level structure as a real card (track item → card), so\n              // the ghosts occupy exactly the box the sources will land in.\n              <div className={itemClass} key={index}>\n                <div className={cn(cardClass, \"cursor-default flex-col gap-2\")}>\n                  <div className=\"flex items-center gap-2\">\n                    <div className=\"size-6 shrink-0 animate-pulse rounded-md bg-muted motion-reduce:animate-none\" />\n                    <div className=\"h-2.5 w-16 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                  </div>\n                  <div className=\"h-2.5 w-full animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                  <div className=\"h-2.5 w-2/3 animate-pulse rounded bg-muted motion-reduce:animate-none\" />\n                </div>\n              </div>\n            ))}\n          </div>\n        </section>\n      )\n    }\n\n    if (status === \"error\") {\n      return (\n        <section aria-label={label} className={rootClass} ref={setRefs} {...props}>\n          {header}\n          <div\n            className=\"flex flex-col items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3\"\n            role=\"alert\"\n          >\n            <p className=\"flex items-center gap-2 text-xs font-medium\">\n              <AlertCircle aria-hidden=\"true\" className=\"size-4 shrink-0 text-destructive\" />\n              Couldn&apos;t load the sources\n            </p>\n            <p className=\"min-w-0 wrap-anywhere text-xs text-muted-foreground\">{errorMessage}</p>\n            {onRetry && (\n              <button\n                className=\"inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none\"\n                data-retry=\"\"\n                onClick={onRetry}\n                type=\"button\"\n              >\n                <RefreshCcw aria-hidden=\"true\" className=\"size-3.5\" />\n                Try again\n              </button>\n            )}\n          </div>\n        </section>\n      )\n    }\n\n    if (status === \"empty\" || items.length === 0) {\n      return (\n        <section aria-label={label} className={rootClass} ref={setRefs} {...props}>\n          {header}\n          {emptyState ?? (\n            <p className=\"flex items-center gap-2 rounded-lg border border-dashed p-3 text-xs text-muted-foreground\">\n              <SearchX aria-hidden=\"true\" className=\"size-4 shrink-0\" />\n              This answer wasn&apos;t grounded in any source.\n            </p>\n          )}\n        </section>\n      )\n    }\n\n    /* ---------------------------------------------------------------- ready */\n\n    const handleSelect = (source: AnswerSourcesItem) => (event: React.MouseEvent<HTMLElement>) => {\n      if (!onSelect) return\n      // Let the browser own every modified click: ⌘/Ctrl/Shift/Alt and middle\n      // click must keep meaning \"open it over there\", not \"run my handler\".\n      if (\n        event.defaultPrevented ||\n        event.metaKey ||\n        event.ctrlKey ||\n        event.shiftKey ||\n        event.altKey ||\n        event.button !== 0\n      ) {\n        return\n      }\n      event.preventDefault()\n      onSelect(source, event)\n    }\n\n    return (\n      <section aria-label={label} className={rootClass} ref={setRefs} {...props}>\n        {header}\n        <TooltipProvider delayDuration={delayDuration}>\n          <ol className={trackClass} id={listId}>\n            {shown.map(source => {\n              const dead = source.dead === true\n              const active = activeN !== undefined && source.n === activeN\n              const dateText = source.publishedAt ? formatDate(source.publishedAt) : null\n              const host = displayDomain(source.domain)\n\n              const body = isRow ? (\n                <span className=\"flex min-w-0 flex-1 flex-col gap-1.5\">\n                  <span className=\"flex min-w-0 items-center gap-2\">\n                    <SourceFavicon dead={dead} source={source} />\n                    <span className=\"min-w-0 flex-1 truncate text-[11px] text-muted-foreground\">{host}</span>\n                    <CitationNumber active={active} n={source.n} />\n                  </span>\n                  <span\n                    className={cn(\n                      \"line-clamp-2 min-w-0 text-xs font-medium leading-snug\",\n                      dead && \"line-through decoration-1\",\n                    )}\n                  >\n                    {source.title}\n                  </span>\n                  {(dateText || dead) && (\n                    <span className=\"flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground\">\n                      {dateText}\n                      {dead && (\n                        <span className=\"inline-flex items-center gap-1\">\n                          <Link2Off aria-hidden=\"true\" className=\"size-3 shrink-0\" />\n                          unreachable\n                        </span>\n                      )}\n                    </span>\n                  )}\n                </span>\n              ) : (\n                <>\n                  <CitationNumber active={active} className=\"mt-0.5\" n={source.n} />\n                  <SourceFavicon className=\"mt-0.5\" dead={dead} source={source} />\n                  <span className=\"flex min-w-0 flex-1 flex-col gap-1\">\n                    <span\n                      className={cn(\n                        \"min-w-0 wrap-anywhere text-sm font-medium leading-snug\",\n                        dead && \"line-through decoration-1\",\n                      )}\n                    >\n                      {source.title}\n                    </span>\n                    <span className=\"flex min-w-0 flex-wrap items-center gap-x-1.5 text-[11px] text-muted-foreground\">\n                      <span className=\"min-w-0 truncate\">{host}</span>\n                      {dateText && <span>· {dateText}</span>}\n                      {dead && (\n                        <span className=\"inline-flex items-center gap-1\">\n                          ·\n                          <Link2Off aria-hidden=\"true\" className=\"size-3 shrink-0\" />\n                          unreachable\n                        </span>\n                      )}\n                    </span>\n                    {source.snippet && (\n                      <span className=\"line-clamp-2 min-w-0 text-xs leading-snug text-muted-foreground\">\n                        {source.snippet}\n                      </span>\n                    )}\n                  </span>\n                  {!dead && (\n                    <ExternalLink\n                      aria-hidden=\"true\"\n                      className=\"mt-0.5 size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none\"\n                    />\n                  )}\n                </>\n              )\n\n              const shared = {\n                className: cn(\n                  \"group\",\n                  cardClass,\n                  isRow ? \"flex-col\" : \"items-start gap-3\",\n                  active && \"border-primary/60 ring-2 ring-ring\",\n                  dead && !onSelect && \"cursor-default\",\n                ),\n                \"data-active\": active || undefined,\n                \"data-dead\": dead || undefined,\n                \"data-source-n\": source.n,\n              }\n\n              const control = dead ? (\n                // A dead source is NOT a link: an <a href> that lands on a 404 is\n                // a lie the reader pays for. It stays focusable so the excerpt is\n                // still reachable by keyboard, and says why it is inert.\n                <button\n                  {...shared}\n                  aria-disabled={onSelect ? undefined : true}\n                  onClick={handleSelect(source)}\n                  type=\"button\"\n                >\n                  {body}\n                  <span className=\"sr-only\">— link no longer reachable</span>\n                </button>\n              ) : (\n                <a\n                  {...shared}\n                  href={source.url}\n                  onClick={handleSelect(source)}\n                  rel={target === \"_blank\" ? \"noreferrer\" : undefined}\n                  target={target}\n                >\n                  {body}\n                </a>\n              )\n\n              return (\n                <li className={itemClass} key={source.id}>\n                  {preview ? (\n                    <Tooltip>\n                      <TooltipTrigger asChild>{control}</TooltipTrigger>\n                      {/* Read-only content only — a tooltip you cannot put a\n                          pointer inside must never hold something clickable. */}\n                      <TooltipContent className=\"block max-w-sm p-3\" collisionPadding={8} sideOffset={6}>\n                        <SourcePreview dateText={dateText} inverted source={source} />\n                      </TooltipContent>\n                    </Tooltip>\n                  ) : (\n                    control\n                  )}\n                </li>\n              )\n            })}\n\n            {strip && canToggle && (\n              <li className={itemClass}>\n                <button\n                  aria-controls={listId}\n                  aria-expanded={expanded}\n                  className={cn(cardClass, \"flex-col items-center justify-center gap-0.5 text-center\")}\n                  data-more=\"\"\n                  onClick={() => setExpanded(true)}\n                  type=\"button\"\n                >\n                  <span className=\"text-sm font-medium tabular-nums\">+{overflow}</span>\n                  <span className=\"text-[11px] text-muted-foreground\">\n                    more source{overflow === 1 ? \"\" : \"s\"}\n                  </span>\n                </button>\n              </li>\n            )}\n          </ol>\n        </TooltipProvider>\n      </section>\n    )\n  },\n)\n\nAnswerSources.displayName = \"AnswerSources\"\n\nexport default AnswerSources\n",
      "type": "registry:ui"
    },
    {
      "path": "src/registry/ui/answer-sources.contract.ts",
      "content": "import { z } from \"zod\"\n\n/**\n * The sources an AI answer was grounded in — the row of favicon cards that sits\n * under (or above) the generated text and lets a reader check the claim.\n *\n * The one thing this contract is strict about is `n`: the citation NUMBER is\n * authored upstream, by whatever produced the answer, and it must be the same\n * number the inline `[n]` markers in the prose carry. It is deliberately NOT the\n * array index — a source cited three times keeps one number, a source that was\n * retrieved but never cited can still be listed, and re-ordering this array for\n * display must never renumber the answer.\n */\nexport const answerSourcesItemSchema = z.object({\n  /** Stable identity, used as the React key and handed back to `onSelect`. */\n  id: z.string(),\n  /**\n   * Citation number, matching the inline `[n]` marker in the answer text.\n   * Authored by the producer of the answer, never derived from the array index.\n   */\n  n: z.number().int().min(1),\n  /** Page title as retrieved. Clamped in the card, shown in full in the preview. */\n  title: z.string(),\n  /**\n   * Absolute URL the card navigates to. Kept even for a `dead` source: the card\n   * stops linking to it, but the transcript still says where the claim came from.\n   */\n  url: z.string(),\n  /**\n   * Host as you want it read — `theverge.com`, `docs.stripe.com`. A leading\n   * `www.` is stripped at render time; nothing else is rewritten, because the\n   * subdomain is often the whole point (`arxiv.org` vs `blog.arxiv.org`).\n   */\n  domain: z.string(),\n  /**\n   * The retrieved passage the answer actually leaned on — one or two sentences,\n   * not the whole page. Revealed on hover/focus in the row variant and inline in\n   * the list variant. Absent means \"no excerpt\", not \"empty excerpt\".\n   */\n  snippet: z.string().optional(),\n  /**\n   * Publication date, ISO 8601 — a plain date (`2026-05-04`) or a full instant.\n   * It is formatted in a fixed locale and in UTC so the server and the browser\n   * print the same string; an unparseable value is dropped rather than rendered\n   * as \"Invalid Date\".\n   */\n  publishedAt: z.union([z.iso.date(), z.iso.datetime({ offset: true })]).optional(),\n  /**\n   * Favicon URL, supplied by your retrieval layer (or a favicon proxy you\n   * control). Omit it and the card draws a monogram from the domain instead —\n   * the component never guesses a third-party favicon endpoint on your behalf,\n   * because that would hand every source your users read to someone else's\n   * server.\n   */\n  faviconUrl: z.string().optional(),\n  /**\n   * The link no longer resolves (404, retracted, paywalled, robots-blocked).\n   * A dead source is still shown — the answer WAS grounded in it — but it is\n   * rendered as a struck-through, non-navigating card instead of a link that\n   * drops the reader on an error page.\n   */\n  dead: z.boolean().optional(),\n})\nexport type AnswerSourcesItem = z.infer<typeof answerSourcesItemSchema>\n\n/**\n * The envelope's own render state, independent of any single source: whether the\n * source list has arrived at all. `empty` is a real answer with no grounding\n * (the model answered from its parameters); `error` is the retrieval sidecar\n * failing while the answer itself may have rendered fine.\n */\nexport const answerSourcesStatusSchema = z.enum([\"loading\", \"empty\", \"error\", \"ready\"])\nexport type AnswerSourcesStatus = z.infer<typeof answerSourcesStatusSchema>\n\n/** The envelope a data layer / mock factory hands over; the demo spreads it into the props. */\nexport const answerSourcesSchema = z.object({\n  status: answerSourcesStatusSchema,\n  items: z.array(answerSourcesItemSchema),\n})\nexport type AnswerSourcesData = z.infer<typeof answerSourcesSchema>\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}