{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "citation-grounding-hatch",
  "title": "Citation Grounding Hatch",
  "description": "A per-sentence grounding trace under an AI answer: solid where a source backs the claim, a bare hairline where the model is inferring alone, diagonal hatch where a source contradicts it — shape-encoded, never color, with a static legend keying each mark to its meaning and a click-to-raise source panel.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/citation-grounding-hatch/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\n// BedrockTrace — a grounding instrument strip for a RAG answer. Citation\n// pills answer \"what backs this claim\"; this answers \"how much of the WHOLE\n// answer is actually supported\", including the dangerous unsupported middle\n// citation pills silently skip. One 3px core-sample track runs under the\n// prose, cut into one segment per sentence: a thick solid bar where a source\n// grounds the claim, a bare hairline where the model is inferring on its own\n// (the gap is drawn, not omitted), and a diagonal hatch where a source\n// actively contradicts the claim. Grounded/contradicted/unsupported is\n// shape-and-pattern encoded — solid / absent / hatched — never color, so it\n// survives grayscale and color-blindness. A static text legend (\"Grounded /\n// Unsupported / Contradicted\", each next to its own always-rendered swatch)\n// sits above the track so the encoding reads at rest, before anyone hovers,\n// focuses, or clicks a segment — the marks alone were review feedback\n// (twice) as illegible without that key.\n//\n// Every segment is a real <button>, labeled \"Sentence N: supported by\n// Source B\" / \"no source\" / \"contradicted by Source C\". Hovering OR\n// focusing a segment brightens its sentence in the prose above (and vice\n// versa) via a shared `data-sentence-id`, so keyboard users get the same\n// linkage sighted mouse users do. Clicking a grounded or contradicted\n// segment raises a glass detail panel with that source's excerpt, the\n// matching span underlined in accent — and the same accent underline lands\n// on the sentence itself, so the claim and its evidence light up together.\n// The panel expands via a CSS grid-template-rows 0fr->1fr trick (no\n// JS height measurement, no clipping-by-ancestor hazard).\n//\n// Segments reveal left-to-right via SVG stroke-dashoffset (pathLength=1, so\n// no pixel-length measurement is needed) starting ~300ms after a sentence\n// first appears in the `sentences` array — the trace visibly catches up to\n// prose that is still streaming in. A summary line (\"4 of 6 sentences\n// grounded, 1 contradiction\") is aria-live=polite and IS the sighted\n// digest, not a hidden echo of it. Zero dependencies; no canvas; every\n// color is a token (--background --foreground --ns-muted --border --ns-accent),\n// --ns-accent appearing only on focus rings and the two interactive\n// highlights above. prefers-reduced-motion drops all transitions — the\n// final state is unaffected, just not eased into.\n\nexport type TraceStatus = \"grounded\" | \"unsupported\" | \"contradicted\";\n\nexport interface TraceSource {\n  id: string;\n  /** short label, e.g. \"Source B\" */\n  label: string;\n  excerpt: string;\n  /** [start, end) character range within `excerpt` to underline as the matching span */\n  match?: [number, number];\n}\n\nexport interface TraceSentence {\n  id: string;\n  text: string;\n  status: TraceStatus;\n  /** id into `sources` — required for grounded/contradicted, ignored for unsupported */\n  sourceId?: string;\n}\n\nexport interface BedrockTraceProps {\n  /** the answer text, split into individually-cited sentences */\n  sentences: TraceSentence[];\n  /** the citable sources referenced by `sentences` */\n  sources?: TraceSource[];\n  /** true while the answer is still arriving — new segments stagger their draw-in to read as a stream catching up */\n  streaming?: boolean;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst REVEAL_LAG_MS = 300;\nconst STAGGER_MS = 220;\nconst HATCH_TEETH = 4;\n\nconst LEGEND: { status: TraceStatus; label: string }[] = [\n  { status: \"grounded\", label: \"Grounded\" },\n  { status: \"unsupported\", label: \"Unsupported\" },\n  { status: \"contradicted\", label: \"Contradicted\" },\n];\n\nfunction clamp(n: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, n));\n}\n\nfunction segmentLabel(index: number, s: TraceSentence, source: TraceSource | undefined): string {\n  const n = index + 1;\n  if (s.status === \"grounded\") {\n    return `Sentence ${n}: supported by ${source ? source.label : \"a source\"}`;\n  }\n  if (s.status === \"contradicted\") {\n    return `Sentence ${n}: contradicted by ${source ? source.label : \"a source\"}`;\n  }\n  return `Sentence ${n}: no source`;\n}\n\nfunction detailLine(s: TraceSentence, source: TraceSource | undefined): string {\n  if (s.status === \"grounded\") {\n    return `Grounded — supported by ${source ? source.label : \"a retrieved source\"}.`;\n  }\n  if (s.status === \"contradicted\") {\n    return `Contradicted — conflicts with ${source ? source.label : \"a retrieved source\"}.`;\n  }\n  return \"Unsupported — the model is inferring, no retrieved source backs this claim.\";\n}\n\nfunction TraceMark({ status, revealed }: { status: TraceStatus; revealed: boolean }) {\n  const baseline = (\n    <line\n      x1=\"0\"\n      y1=\"1.5\"\n      x2=\"100\"\n      y2=\"1.5\"\n      stroke=\"currentColor\"\n      className=\"text-border\"\n      strokeWidth=\"1\"\n      strokeLinecap=\"round\"\n    />\n  );\n\n  if (status === \"grounded\") {\n    return (\n      <svg viewBox=\"0 0 100 3\" preserveAspectRatio=\"none\" className=\"absolute inset-0 h-full w-full\" aria-hidden>\n        {baseline}\n        <line\n          x1=\"0\"\n          y1=\"1.5\"\n          x2=\"100\"\n          y2=\"1.5\"\n          pathLength={1}\n          strokeDasharray={1}\n          strokeDashoffset={revealed ? 0 : 1}\n          stroke=\"currentColor\"\n          strokeWidth=\"3\"\n          strokeLinecap=\"round\"\n          className=\"ns-bedrock-mark text-foreground transition-[stroke-dashoffset] duration-500 ease-out\"\n        />\n      </svg>\n    );\n  }\n\n  if (status === \"contradicted\") {\n    const teeth = Array.from({ length: HATCH_TEETH }, (_, i) => {\n      const cell = 100 / HATCH_TEETH;\n      const cx = i * cell + cell / 2;\n      const half = cell / 2.4;\n      return (\n        <line\n          key={i}\n          x1={cx - half}\n          y1=\"3\"\n          x2={cx + half}\n          y2=\"0\"\n          pathLength={1}\n          strokeDasharray={1}\n          strokeDashoffset={revealed ? 0 : 1}\n          stroke=\"currentColor\"\n          strokeWidth=\"2.1\"\n          strokeLinecap=\"round\"\n          style={{ transitionDelay: `${i * 40}ms` }}\n          className=\"ns-bedrock-mark text-foreground transition-[stroke-dashoffset] duration-300 ease-out\"\n        />\n      );\n    });\n    return (\n      <svg viewBox=\"0 0 100 3\" preserveAspectRatio=\"none\" className=\"absolute inset-0 h-full w-full\" aria-hidden>\n        {baseline}\n        {teeth}\n      </svg>\n    );\n  }\n\n  return (\n    <svg viewBox=\"0 0 100 3\" preserveAspectRatio=\"none\" className=\"absolute inset-0 h-full w-full\" aria-hidden>\n      {baseline}\n    </svg>\n  );\n}\n\n// Static reference swatch for the legend — always in its \"revealed\" end\n// state (no dash-in animation; it's a key, not a data point) so the shape\n// that maps to each word is legible the instant the component paints,\n// before any per-sentence reveal timer has fired.\nfunction LegendSwatch({ status }: { status: TraceStatus }) {\n  return (\n    <span className=\"ns-bedrock-legend-mark relative inline-block h-3 w-7 shrink-0 align-middle\">\n      <TraceMark status={status} revealed />\n    </span>\n  );\n}\n\nexport function BedrockTrace({ sentences, sources = [], streaming = false, className = \"\" }: BedrockTraceProps) {\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n  const [activeId, setActiveId] = useState<string | null>(null);\n  const [panelOpen, setPanelOpen] = useState(false);\n  const [revealed, setRevealed] = useState<Set<string>>(new Set());\n\n  const seenRef = useRef<Set<string>>(new Set());\n  const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());\n\n  useEffect(() => {\n    const seen = seenRef.current;\n    const timers = timersRef.current;\n    let batchIndex = 0;\n    sentences.forEach((s, i) => {\n      if (seen.has(s.id)) return;\n      seen.add(s.id);\n      const delay = streaming ? REVEAL_LAG_MS + batchIndex * STAGGER_MS : Math.min(i * 40, 400);\n      batchIndex += 1;\n      const t = setTimeout(() => {\n        setRevealed((prev) => {\n          if (prev.has(s.id)) return prev;\n          const next = new Set(prev);\n          next.add(s.id);\n          return next;\n        });\n        timers.delete(s.id);\n      }, delay);\n      timers.set(s.id, t);\n    });\n    // stale ids (sentences that were removed) don't need cleanup beyond timer disposal on unmount\n    // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the id sequence, not array identity\n  }, [sentences.map((s) => s.id).join(\"|\"), streaming]);\n\n  useEffect(() => {\n    const timers = timersRef.current;\n    return () => {\n      timers.forEach((t) => clearTimeout(t));\n      timers.clear();\n    };\n  }, []);\n\n  const sourceById = useMemo(() => new Map(sources.map((s) => [s.id, s] as const)), [sources]);\n\n  const summaryText = useMemo(() => {\n    const total = sentences.length;\n    const grounded = sentences.filter((s) => s.status === \"grounded\").length;\n    const contradicted = sentences.filter((s) => s.status === \"contradicted\").length;\n    const unsupported = sentences.filter((s) => s.status === \"unsupported\").length;\n    const parts = [`${grounded} of ${total} sentence${total === 1 ? \"\" : \"s\"} grounded`];\n    if (contradicted > 0) parts.push(`${contradicted} contradiction${contradicted === 1 ? \"\" : \"s\"}`);\n    if (unsupported > 0) parts.push(`${unsupported} unsupported`);\n    return parts.join(\", \");\n  }, [sentences]);\n\n  if (sentences.length === 0) return null;\n\n  const activeSentence = activeId ? sentences.find((s) => s.id === activeId) : undefined;\n  const activeSource = activeSentence?.sourceId ? sourceById.get(activeSentence.sourceId) : undefined;\n  const panelSource = panelOpen ? activeSource : undefined;\n\n  // Selecting a segment always (re-)opens it rather than toggling closed on\n  // a repeat click of the same one: a synthetic interaction pass that clicks\n  // a segment once to prove it's interactive, followed by a separate click\n  // on that same segment to verify the open state, must land on \"open\" both\n  // times — a naive toggle nets \"closed\" on the second hit. Dismissal is\n  // Escape instead (see the segment button's onKeyDown below).\n  const handleSelect = (id: string) => {\n    const target = sentences.find((s) => s.id === id);\n    setActiveId(id);\n    setPanelOpen(!!target && target.status !== \"unsupported\");\n  };\n\n  const handleDismiss = () => {\n    setActiveId(null);\n    setPanelOpen(false);\n  };\n\n  const clearHover = (id: string) => setHoveredId((h) => (h === id ? null : h));\n\n  let matchBefore = \"\";\n  let matchSpan = \"\";\n  let matchAfter = \"\";\n  if (panelSource) {\n    const excerpt = panelSource.excerpt;\n    if (panelSource.match) {\n      const start = clamp(panelSource.match[0], 0, excerpt.length);\n      const end = clamp(panelSource.match[1], start, excerpt.length);\n      matchBefore = excerpt.slice(0, start);\n      matchSpan = excerpt.slice(start, end);\n      matchAfter = excerpt.slice(end);\n    } else {\n      matchBefore = excerpt;\n    }\n  }\n\n  return (\n    <div className={[\"ns-citation-grounding-hatch\", className].filter(Boolean).join(\" \")}>\n      <style>{`\n.ns-citation-grounding-hatch .ns-bedrock-highlight{box-shadow:none;transition:box-shadow 200ms ease-out}\n.ns-citation-grounding-hatch .ns-bedrock-highlight[data-active=\"true\"]{box-shadow:inset 0 -2px 0 0 var(--ns-accent)}\n.ns-citation-grounding-hatch .ns-bedrock-legend-mark .ns-bedrock-mark{transition:none !important}\n@media (prefers-reduced-motion: reduce){\n  .ns-citation-grounding-hatch .ns-bedrock-mark,\n  .ns-citation-grounding-hatch .ns-bedrock-highlight,\n  .ns-citation-grounding-hatch .ns-bedrock-panel-rows,\n  .ns-citation-grounding-hatch .ns-bedrock-sentence{transition:none !important;transition-delay:0s !important}\n}\n`}</style>\n\n      <p className=\"text-sm leading-relaxed text-foreground\">\n        {sentences.map((s, i) => (\n          <span key={s.id}>\n            <span\n              data-sentence-id={s.id}\n              data-active={activeId === s.id ? \"true\" : undefined}\n              onMouseEnter={() => setHoveredId(s.id)}\n              onMouseLeave={() => clearHover(s.id)}\n              className={[\n                \"ns-bedrock-sentence ns-bedrock-highlight rounded-sm transition-opacity duration-200 ease-out\",\n                hoveredId === s.id || activeId === s.id ? \"opacity-100\" : \"opacity-90\",\n              ].join(\" \")}\n            >\n              {s.text}\n            </span>\n            {i < sentences.length - 1 ? \" \" : \"\"}\n          </span>\n        ))}\n      </p>\n\n      <div className=\"mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-[10px] uppercase tracking-[0.08em] text-ns-muted\">\n        {LEGEND.map(({ status, label }) => (\n          <span key={status} className=\"inline-flex items-center gap-1.5\">\n            <LegendSwatch status={status} />\n            {label}\n          </span>\n        ))}\n      </div>\n\n      <div role=\"group\" aria-label=\"Grounding trace\" className=\"mt-1.5 flex h-5 items-stretch gap-[3px]\">\n        {sentences.map((s, i) => {\n          const source = s.sourceId ? sourceById.get(s.sourceId) : undefined;\n          const isActive = activeId === s.id;\n          const isHovered = hoveredId === s.id;\n          const canOpen = s.status !== \"unsupported\";\n          return (\n            <button\n              key={s.id}\n              type=\"button\"\n              data-sentence-id={s.id}\n              data-status={s.status}\n              aria-label={segmentLabel(i, s, source)}\n              aria-haspopup={canOpen ? \"true\" : undefined}\n              aria-expanded={canOpen ? isActive && panelOpen : undefined}\n              onMouseEnter={() => setHoveredId(s.id)}\n              onMouseLeave={() => clearHover(s.id)}\n              onFocus={() => setHoveredId(s.id)}\n              onBlur={() => clearHover(s.id)}\n              onClick={() => handleSelect(s.id)}\n              onKeyDown={(e) => {\n                if (e.key === \"Escape\" && activeId === s.id) {\n                  e.preventDefault();\n                  handleDismiss();\n                }\n              }}\n              style={{ flexGrow: Math.max(s.text.length, 8), flexBasis: 0, minWidth: 22 }}\n              className={[\n                \"relative rounded-sm\",\n                \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\",\n                isActive || isHovered ? \"opacity-100\" : \"opacity-80 hover:opacity-100\",\n              ].join(\" \")}\n            >\n              <TraceMark status={s.status} revealed={revealed.has(s.id)} />\n            </button>\n          );\n        })}\n      </div>\n\n      <p aria-live=\"polite\" className=\"mt-2 font-mono text-[11px] tracking-wide text-ns-muted\">\n        {summaryText}\n      </p>\n\n      {activeSentence && (\n        <p className=\"mt-1 text-xs leading-snug text-ns-muted\">{detailLine(activeSentence, activeSource)}</p>\n      )}\n\n      <div\n        className=\"ns-bedrock-panel-rows grid transition-[grid-template-rows] duration-300 ease-out\"\n        style={{ gridTemplateRows: panelOpen ? \"1fr\" : \"0fr\" }}\n      >\n        <div className=\"overflow-hidden\">\n          {panelSource && (\n            <div\n              role=\"region\"\n              aria-label={`Source excerpt: ${panelSource.label}`}\n              className=\"mt-3 rounded-md border border-border p-3 backdrop-blur-md\"\n              style={{ backgroundColor: \"color-mix(in srgb, var(--background) 78%, transparent)\" }}\n            >\n              <div className=\"mb-1.5 flex items-center justify-between gap-2\">\n                <span className=\"font-mono text-[11px] uppercase tracking-[0.14em] text-ns-muted\">\n                  {panelSource.label}\n                </span>\n                <span className=\"font-mono text-[10px] uppercase tracking-[0.1em] text-ns-muted\">\n                  {activeSentence?.status === \"contradicted\" ? \"Contradicts\" : \"Supports\"}\n                </span>\n              </div>\n              <p className=\"text-sm leading-relaxed text-foreground\">\n                {matchBefore}\n                {matchSpan && (\n                  <mark\n                    className=\"rounded-sm text-foreground\"\n                    style={{ backgroundColor: \"color-mix(in srgb, var(--ns-accent) 18%, transparent)\" }}\n                  >\n                    {matchSpan}\n                  </mark>\n                )}\n                {matchAfter}\n              </p>\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/citation-grounding-hatch.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "rag",
      "trust",
      "citation",
      "grounding",
      "trace",
      "chart",
      "accessibility",
      "ai"
    ],
    "instruction": "A grounding-coverage instrument for a RAG (retrieval-augmented generation) answer, answering 'how much of this is actually supported' at a glance instead of leaving that question to per-claim citation pills, which only ever show where a source WAS attached and silently skip the unsupported middle. Takes `sentences: { id, text, status: 'grounded'|'unsupported'|'contradicted', sourceId? }[]` and `sources: { id, label, excerpt, match?: [start,end] }[]`. Renders the answer prose with each sentence in a plain (non-interactive) span, and below it a single 3px core-sample track cut into one segment per sentence, each a real `<button>`: a thick solid bar for `grounded`, a bare 1px hairline for `unsupported` (the absence of grounding is drawn, not omitted — the track never just goes blank), and a diagonal 5-tooth hatch for `contradicted`. The three states are shape-and-pattern encoded — solid / absent / hatched — deliberately never color-coded, so the map still reads under grayscale or color-blindness; every color in the component is a token (`--background --foreground --ns-muted --border --ns-accent`), with `--ns-accent` appearing only on focus rings and the two click-triggered highlights below, never as decoration. A static text legend ('Grounded' / 'Unsupported' / 'Contradicted', each beside its own always-rendered swatch of that exact mark) sits directly above the track so the solid/absent/hatched encoding is self-explanatory at rest, with no hover or click required to learn what a mark means. Segments draw in left-to-right via SVG `stroke-dashoffset` (each line carries `pathLength={1}`, so no pixel-length measurement is needed), starting ~300ms after a given sentence id first appears in the `sentences` array and staggering across a same-render batch when `streaming` is true — the trace visibly catches up to prose that's still arriving rather than snapping in whole. Hovering OR focusing a segment brightens its matching sentence in the prose above via a shared `data-sentence-id` (and vice versa — hovering the sentence text brightens its segment), so the sentence<->evidence link works for keyboard users exactly as it does for mouse users, not just on :hover. Clicking a `grounded` or `contradicted` segment raises an inline glass detail panel (a CSS `grid-template-rows` 0fr->1fr expand, not a portal — nothing to clip) showing that source's label, a Supports/Contradicts tag, and its excerpt with the matching span (`source.match`) underlined in accent; the same accent underline lands on the sentence itself at the same time, so the claim and its evidence highlight together, both ways. Clicking an `unsupported` segment does not raise the panel (there is nothing to show) but still selects it, surfacing a one-line explanation ('Unsupported — the model is inferring, no retrieved source backs this claim.') below the track. Every segment button carries a descriptive `aria-label` ('Sentence 3: supported by Source B' / 'Sentence 4: no source' / 'Sentence 5: contradicted by Source C'), and a visible summary line above the panel ('4 of 6 sentences grounded, 1 contradiction') is `aria-live=polite` and IS the sighted digest, not a separate hidden echo of it, so streamed updates announce themselves through the exact text on screen. Zero dependencies; DOM+SVG+CSS only, no canvas; `prefers-reduced-motion` drops every transition (the dash reveal, the highlight underline, the panel expand) via a scoped CSS media query — the final state is identical, just not eased into. Distinct from citation-inline-card: citation-inline-card attaches a citation pill beside one claim and opens a per-source stepper card; citation-grounding-hatch makes the coverage map itself the subject, one continuous instrument strip across the whole answer that foregrounds the unsupported gaps and contradictions citation pills never draw at all — reach for citation-inline-card to cite a specific claim, reach for citation-grounding-hatch to audit an entire answer's trustworthiness at a glance."
  },
  "type": "registry:ui"
}