{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "citation-coverage",
  "title": "Citation Coverage",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "An AI answer shown claim by claim: a coverage meter, claims with no source underlined and marked in words, numbered source markers, selecting a claim to light up its sources and quote, and a switch to see only the gaps.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/citation-coverage.tsx",
      "content": "// Ballmac UI: Citation Coverage. https://ui.ballmac.com/components/citation-coverage\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleAlert, ExternalLink, Link2 } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype CoverageSource = {\n  /** The number shown on the claims that use it. */\n  id: number\n  title: string\n  /** Where it lives. Shown as a link when given. */\n  url?: string\n  /** A passage from it, shown when a claim that uses it is selected. */\n  quote?: string\n}\n\ntype CoverageClaim = {\n  id: string\n  /** One sentence or statement from the answer. */\n  text: string\n  /** The ids of the sources behind it. Empty means nothing backs it. */\n  sources: number[]\n}\n\ntype CitationCoverageProps = Omit<React.ComponentProps<\"div\">, \"onSelect\"> & {\n  /** The answer, split into claims in reading order. */\n  claims: CoverageClaim[]\n  /** Everything the answer could cite. */\n  sources: CoverageSource[]\n  /** The selected claim id. Controlled. */\n  selected?: string | null\n  /** The selected claim id for uncontrolled use. */\n  defaultSelected?: string | null\n  /** Called when a claim is selected, or null when it is cleared. */\n  onSelectedChange?: (id: string | null) => void\n}\n\n/**\n * An answer shown claim by claim, so a reader can see which statements are backed by a source and which are not. Unsupported claims are underlined and\n * marked in words; selecting a claim lights up its sources and shows the passage. A switch narrows the answer to the unsupported claims.\n */\nfunction CitationCoverage({ claims, sources, selected: selectedProp, defaultSelected = null, onSelectedChange, className, ...props }: CitationCoverageProps) {\n  const msg = useMessages()\n  const uid = React.useId()\n  const [inner, setInner] = React.useState<string | null>(defaultSelected)\n  const selected = selectedProp !== undefined ? selectedProp : inner\n  const [onlyGaps, setOnlyGaps] = React.useState(false)\n  const supported = claims.filter((c) => c.sources.length > 0).length\n  const percent = claims.length ? Math.round((supported / claims.length) * 100) : 0\n  const chosen = claims.find((c) => c.id === selected)\n  const lit = new Set(chosen?.sources ?? [])\n  const shown = onlyGaps ? claims.filter((c) => c.sources.length === 0) : claims\n\n  function select(id: string) {\n    const next = id === selected ? null : id\n    if (selectedProp === undefined) setInner(next)\n    onSelectedChange?.(next)\n  }\n\n  return (\n    <div data-slot=\"citation-coverage\" className={cn(\"grid w-full min-w-0 gap-5 rounded-xl border bg-card p-4 text-card-foreground sm:p-5 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]\", className)} {...props}>\n      <div className=\"grid content-start gap-4\">\n        <div className=\"grid gap-2\">\n          <div className=\"flex items-baseline justify-between gap-3\">\n            <p className=\"text-sm font-medium text-foreground\">{msg(\"citation-coverage.title\", \"Source coverage\")}</p>\n            <p className=\"text-xs text-muted-foreground tabular-nums\">{msg(\"citation-coverage.summary\", \"{supported} of {total} claims have a source\", { supported, total: claims.length })}</p>\n          </div>\n          <div role=\"meter\" aria-label={msg(\"citation-coverage.title\", \"Source coverage\")} aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent} aria-valuetext={`${percent}%`} className=\"h-2 overflow-hidden rounded-full bg-muted\">\n            <div className=\"h-full rounded-full bg-chart-2 transition-[width] duration-500 motion-reduce:transition-none\" style={{ width: `${percent}%` }} />\n          </div>\n          <label className=\"mt-1 flex w-fit cursor-pointer items-center gap-2 text-[13px]\">\n            <input type=\"checkbox\" checked={onlyGaps} onChange={(e) => setOnlyGaps(e.target.checked)} className=\"size-4 accent-foreground\" />\n            {msg(\"citation-coverage.onlyGaps\", \"Show only claims without a source\")}\n          </label>\n        </div>\n\n        <p className=\"text-[15px] leading-8\" aria-live=\"polite\">\n          {shown.length === 0 ? (\n            <span className=\"text-muted-foreground\">{msg(\"citation-coverage.none\", \"Every claim has a source.\")}</span>\n          ) : (\n            shown.map((c) => {\n              const gap = c.sources.length === 0\n              const active = c.id === selected\n              return (\n                <React.Fragment key={c.id}>\n                  <button\n                    type=\"button\"\n                    aria-pressed={active}\n                    aria-describedby={gap ? `${uid}-gap` : undefined}\n                    onClick={() => select(c.id)}\n                    className={cn(\n                      \"rounded-sm px-0.5 text-start outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none\",\n                      gap ? \"underline decoration-destructive decoration-wavy decoration-1 underline-offset-4 hover:bg-destructive/10\" : \"hover:bg-accent\",\n                      active && \"bg-accent\"\n                    )}\n                  >\n                    {c.text}\n                    {gap && \" \"}\n                    {gap && <span className=\"sr-only\">({msg(\"citation-coverage.noSource\", \"no source\")})</span>}\n                  </button>\n                  {c.sources.map((s) => (\n                    <sup key={s} className={cn(\"mx-px inline-flex min-w-4 items-center justify-center rounded px-1 font-mono text-[10px] font-medium tabular-nums\", lit.has(s) ? \"bg-foreground text-background\" : \"bg-muted text-muted-foreground\")}>\n                      <span className=\"sr-only\">{msg(\"citation-coverage.source\", \"source\")} </span>\n                      {s}\n                    </sup>\n                  ))}{\" \"}\n                </React.Fragment>\n              )\n            })\n          )}\n        </p>\n        <p id={`${uid}-gap`} className=\"hidden\">{msg(\"citation-coverage.gapHelp\", \"This claim has no source.\")}</p>\n        {claims.some((c) => c.sources.length === 0) && (\n          <p className=\"flex items-center gap-1.5 text-xs text-muted-foreground\">\n            <CircleAlert aria-hidden=\"true\" className=\"size-3.5 text-destructive\" />\n            {msg(\"citation-coverage.legend\", \"A wavy underline means no source backs the claim.\")}\n          </p>\n        )}\n      </div>\n\n      <div className=\"grid content-start gap-2\">\n        <p className=\"text-sm font-medium text-foreground\">{msg(\"citation-coverage.sources\", \"Sources\")}</p>\n        <ol className=\"grid gap-2\">\n          {sources.map((s) => {\n            const on = lit.has(s.id)\n            return (\n              <li key={s.id} data-lit={on || undefined} className={cn(\"rounded-lg border p-3 text-[13px] transition-colors motion-reduce:transition-none\", on && \"border-foreground bg-accent/50\")}>\n                <p className=\"flex items-start gap-2\">\n                  <span className=\"mt-px flex size-5 shrink-0 items-center justify-center rounded bg-muted font-mono text-[11px] tabular-nums\">{s.id}</span>\n                  <span className=\"min-w-0\">\n                    <span className=\"block font-medium text-foreground\">{s.title}</span>\n                    {s.url && (\n                      <a href={s.url} className=\"mt-0.5 inline-flex max-w-full items-center gap-1 truncate text-xs text-muted-foreground underline-offset-4 outline-none hover:text-foreground hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50\" dir=\"ltr\">\n                        <ExternalLink aria-hidden=\"true\" className=\"size-3 shrink-0\" />\n                        <span className=\"truncate\">{s.url.replace(/^https?:\\/\\//, \"\")}</span>\n                      </a>\n                    )}\n                  </span>\n                </p>\n                {on && s.quote && (\n                  <blockquote className=\"mt-2 flex gap-2 border-s-2 ps-3 text-xs leading-relaxed text-muted-foreground\">\n                    <Link2 aria-hidden=\"true\" className=\"mt-0.5 size-3 shrink-0\" />\n                    {s.quote}\n                  </blockquote>\n                )}\n              </li>\n            )\n          })}\n        </ol>\n      </div>\n    </div>\n  )\n}\n\nexport { CitationCoverage, type CitationCoverageProps, type CoverageClaim, type CoverageSource }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/citation-coverage.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "ai",
      "citations",
      "sources",
      "rag",
      "grounding",
      "trust",
      "verification"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "claims is [{ id, text, sources: number[] }] in reading order (empty sources means unsupported); sources is [{ id, title, url?, quote? }]. selected / defaultSelected / onSelectedChange pick a claim. Computes the coverage percentage itself.",
      "whenToUse": [
        "Showing people how much of an AI answer is grounded in sources",
        "A review screen where unsupported claims need checking"
      ],
      "whenNotToUse": [
        "Plain inline citations in a chat message; use citation and sources-list",
        "A list of documents only; use sources-list"
      ],
      "composesWith": [
        "citation",
        "sources-list",
        "ai-message",
        "streaming-text"
      ],
      "a11y": [
        {
          "keys": "Tab, Enter / Space",
          "action": "Each claim is a toggle button; selecting it shows its sources and quote"
        },
        {
          "keys": "Screen readers",
          "action": "An unsupported claim reads '(no source)'; source markers read 'source 2'; the summary and the filtered answer are polite live regions"
        },
        {
          "keys": "Colour",
          "action": "Gaps are marked by a wavy underline and words, never colour alone"
        }
      ],
      "customization": [
        "claims and sources",
        "Controlled selection",
        "The coverage meter and the gaps filter are built in"
      ]
    },
    "statesNone": "A presentational review: the claims and sources are passed in, so it has nothing of its own to load or fail.",
    "examples": [
      "citation-coverage-demo",
      "citation-coverage-full"
    ],
    "url": "https://ui.ballmac.com/components/citation-coverage"
  },
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}