{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "answer-02",
  "title": "Grounded answer",
  "description": "Marks that tint their sentence and raise their source, whose passage unfolds with the shared words marked, under a line that counts how much of the answer is backed.",
  "dependencies": [],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/animated-size.json",
    "https://ui.ndekocode.dev/r/brand-logos.json",
    "https://ui.ndekocode.dev/r/brand-tile.json",
    "https://ui.ndekocode.dev/r/hatch.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/utils.json",
    "https://ui.ndekocode.dev/r/words.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/answer/answer-02.tsx",
      "content": "'use client';\n\nimport { useId, useState } from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport { hatchOf } from '@/registry/ndeko/lib/hatch';\nimport {\n  FileTextIcon,\n  MailIcon,\n  MessageSquareTextIcon,\n  type Icon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { countOf, leadingSpaceOf, nounOf } from '@/registry/ndeko/lib/words';\nimport { AnimatedSize } from '@/registry/ndeko/ui/animated-size';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\n\nimport { LabTile } from './_parts/lab-tile';\n\nexport type GroundingSourceKind = 'pdf' | 'email' | 'notes';\n\nexport type GroundingSource = {\n  id: string;\n  kind: GroundingSourceKind;\n  title: string;\n  /** Where in the source, in a few words: \"PDF, clause 4\". */\n  meta: string;\n};\n\nexport type GroundedSentence = {\n  id: string;\n  text: string;\n  /** Left out, the sentence is a claim nothing backs. */\n  citation?: {\n    sourceId: string;\n    /** The passage of the source the sentence rests on. */\n    passage: string;\n  };\n};\n\ntype PassagePiece = {\n  text: string;\n  isShared: boolean;\n};\n\nconst KIND_ICON: Record<GroundingSourceKind, Icon> = {\n  pdf: FileTextIcon,\n  email: MailIcon,\n  notes: MessageSquareTextIcon,\n};\n\nconst STOP_WORDS = new Set([\n  'a',\n  'also',\n  'an',\n  'and',\n  'are',\n  'as',\n  'at',\n  'be',\n  'by',\n  'each',\n  'for',\n  'from',\n  'in',\n  'is',\n  'it',\n  'its',\n  'of',\n  'on',\n  'or',\n  'that',\n  'the',\n  'this',\n  'to',\n  'up',\n  'was',\n  'will',\n  'with',\n  'your',\n]);\n\nconst EDGE_PUNCTUATION = /^[^\\p{L}\\p{N}]+|[^\\p{L}\\p{N}]+$/gu;\n\nconst SPACE_RUN = /(\\s+)/;\n\nfunction normalWordOf(word: string) {\n  const lower = word.toLowerCase();\n  return lower.replace(EDGE_PUNCTUATION, '');\n}\n\nfunction isKeyWord(normal: string) {\n  const isStopWord = STOP_WORDS.has(normal);\n  const isTooShort = normal.length < 2;\n  const isNoise = isStopWord || isTooShort;\n  return !isNoise;\n}\n\nfunction keyWordsOf(text: string) {\n  const keys = new Set<string>();\n  const words = text.split(/\\s+/);\n  for (const word of words) {\n    const normal = normalWordOf(word);\n    if (isKeyWord(normal)) {\n      keys.add(normal);\n    }\n  }\n  return keys;\n}\n\nexport function passagePiecesOf(passage: string, sentence: string) {\n  const keys = keyWordsOf(sentence);\n  const pieces: PassagePiece[] = [];\n  const parts = passage.split(SPACE_RUN);\n  for (const part of parts) {\n    if (part === '') {\n      continue;\n    }\n    const normal = normalWordOf(part);\n    const isShared = keys.has(normal);\n    pieces.push({ text: part, isShared });\n  }\n  return pieces;\n}\n\nfunction sharedCountOf(pieces: PassagePiece[]) {\n  const shared = pieces.filter((piece) => piece.isShared);\n  return shared.length;\n}\n\nfunction isBacked(sentence: GroundedSentence) {\n  return sentence.citation !== undefined;\n}\n\nfunction sourceNumberOf(sources: GroundingSource[], sourceId: string) {\n  const index = sources.findIndex((source) => source.id === sourceId);\n  return index + 1;\n}\n\nfunction backingWordsOf(total: number) {\n  const noun = nounOf(total, 'sentence');\n  return `of ${total} ${noun} backed by a source`;\n}\n\nfunction tintOf(isLit: boolean) {\n  if (isLit) {\n    return 'bg-primary/12';\n  }\n  return 'bg-transparent';\n}\n\nfunction markToneOf(isLit: boolean) {\n  if (isLit) {\n    return 'text-primary-ink';\n  }\n  return 'text-muted-foreground';\n}\n\nfunction overlayOf(isLit: boolean) {\n  if (isLit) {\n    return 'opacity-100';\n  }\n  return 'opacity-0';\n}\n\nfunction segmentToneOf(isLit: boolean, hasActive: boolean) {\n  if (isLit) {\n    return 'opacity-100 scale-y-150';\n  }\n  if (hasActive) {\n    return 'opacity-45';\n  }\n  return 'opacity-100';\n}\n\nfunction segmentStyleOf(sentence: GroundedSentence) {\n  if (isBacked(sentence)) {\n    return undefined;\n  }\n  const style: React.CSSProperties = {\n    backgroundImage: hatchOf('var(--muted-foreground)', 55),\n  };\n  return style;\n}\n\nfunction segmentFillOf(sentence: GroundedSentence) {\n  if (isBacked(sentence)) {\n    return 'bg-primary';\n  }\n  return 'bg-foreground/[0.06]';\n}\n\ntype BackingLineProps = {\n  sentences: GroundedSentence[];\n  activeSentenceId: string | null;\n};\n\nfunction BackingLine({ sentences, activeSentenceId }: BackingLineProps) {\n  const backedCount = sentences.filter(isBacked).length;\n  const words = backingWordsOf(sentences.length);\n  const hasActive = activeSentenceId !== null;\n\n  return (\n    <div className='flex flex-col gap-2'>\n      <p className='text-muted-foreground text-xs'>\n        <MotionNumber value={backedCount} className='text-foreground' /> {words}\n      </p>\n      <span aria-hidden='true' className='flex h-2 items-center gap-1'>\n        {sentences.map((sentence) => {\n          const isLit = sentence.id === activeSentenceId;\n          return (\n            <span\n              key={sentence.id}\n              style={segmentStyleOf(sentence)}\n              className={cn(\n                'h-1.5 flex-1 rounded-full transition-[opacity,scale] duration-200 ease-(--ease-out-strong)',\n                segmentFillOf(sentence),\n                segmentToneOf(isLit, hasActive)\n              )}\n            />\n          );\n        })}\n      </span>\n    </div>\n  );\n}\n\ntype GroundingMarkProps = {\n  number: number;\n  source: GroundingSource;\n  isLit: boolean;\n  cardId: string;\n  onEnter: () => void;\n  onLeave: () => void;\n  onToggle: () => void;\n};\n\nfunction GroundingMark({\n  number,\n  source,\n  isLit,\n  cardId,\n  onEnter,\n  onLeave,\n  onToggle,\n}: GroundingMarkProps) {\n  return (\n    <button\n      type='button'\n      aria-label={`Source ${number}: ${source.title}`}\n      aria-expanded={isLit}\n      aria-controls={cardId}\n      onPointerEnter={onEnter}\n      onPointerLeave={onLeave}\n      onFocus={onEnter}\n      onBlur={onLeave}\n      onClick={onToggle}\n      className={cn(\n        'keycap-3d focus-visible:ring-ring/25 relative ms-1 inline-flex h-4 min-w-4 cursor-pointer items-center justify-center rounded-[4px] px-1 align-[0.12em] text-[10px] leading-none font-medium tabular-nums transition-colors duration-150 outline-none focus-visible:ring-[3px]',\n        markToneOf(isLit)\n      )}\n    >\n      <span\n        aria-hidden='true'\n        className={cn(\n          'bg-primary/15 pointer-events-none absolute inset-0 rounded-[inherit] transition-opacity duration-150',\n          overlayOf(isLit)\n        )}\n      />\n      <span className='relative'>{number}</span>\n    </button>\n  );\n}\n\ntype SourceCardProps = {\n  source: GroundingSource;\n  number: number;\n  cardId: string;\n  activeSentence: GroundedSentence | null;\n};\n\nfunction SourceCard({\n  source,\n  number,\n  cardId,\n  activeSentence,\n}: SourceCardProps) {\n  const KindIcon = KIND_ICON[source.kind];\n  const shown = shownPassageOf(source, activeSentence);\n  const isRaised = shown !== null;\n\n  return (\n    <li\n      id={cardId}\n      className={cn(\n        'rounded-xl p-3 transition-[background-color,box-shadow,translate] duration-200 ease-(--ease-out-strong)',\n        sourceSurfaceOf(isRaised)\n      )}\n    >\n      <AnimatedSize>\n        <div className='flex flex-col gap-2'>\n          <div className='flex items-center gap-2'>\n            <span className='keycap-3d text-muted-foreground inline-flex size-6 shrink-0 items-center justify-center rounded-md'>\n              <KindIcon className='size-3.5' />\n            </span>\n            <span className='flex min-w-0 flex-1 flex-col'>\n              <span className='truncate text-[13px] font-medium'>\n                {source.title}\n              </span>\n              <span className='text-muted-foreground truncate text-xs'>\n                {source.meta}\n              </span>\n            </span>\n            <span className='keycap-3d text-muted-foreground inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-[4px] px-1 text-[10px] tabular-nums'>\n              {number}\n            </span>\n          </div>\n          {shown && (\n            <Passage passage={shown.passage} sentence={shown.sentence} />\n          )}\n        </div>\n      </AnimatedSize>\n    </li>\n  );\n}\n\nfunction shownPassageOf(\n  source: GroundingSource,\n  sentence: GroundedSentence | null\n) {\n  const citation = sentence?.citation;\n  if (!citation) {\n    return null;\n  }\n  if (citation.sourceId !== source.id) {\n    return null;\n  }\n  return { passage: citation.passage, sentence: sentence.text };\n}\n\nfunction sourceSurfaceOf(isRaised: boolean) {\n  if (isRaised) {\n    return 'surface-raised -translate-y-0.5';\n  }\n  return 'bg-foreground/[0.03] dark:bg-foreground/[0.05] translate-y-0';\n}\n\nfunction Passage({ passage, sentence }: { passage: string; sentence: string }) {\n  const pieces = passagePiecesOf(passage, sentence);\n  const sharedCount = sharedCountOf(pieces);\n  const sharedLine = `${countOf(sharedCount, 'word')} shared with the sentence`;\n\n  return (\n    <div className='flex flex-col gap-1.5 transition-opacity duration-200 starting:opacity-0'>\n      <blockquote className='border-foreground/10 text-foreground/85 border-s-2 ps-3 text-[13px] leading-relaxed'>\n        {pieces.map((piece, index) => {\n          if (piece.isShared) {\n            return (\n              <mark\n                key={index}\n                className='bg-primary/15 text-foreground rounded-[3px] px-px'\n              >\n                {piece.text}\n              </mark>\n            );\n          }\n          return <span key={index}>{piece.text}</span>;\n        })}\n      </blockquote>\n      <p className='text-muted-foreground text-xs'>{sharedLine}</p>\n    </div>\n  );\n}\n\nfunction sentenceOf(sentences: GroundedSentence[], id: string | null) {\n  if (id === null) {\n    return null;\n  }\n  const match = sentences.find((sentence) => sentence.id === id);\n  return match ?? null;\n}\n\nfunction announcementOf(\n  sentence: GroundedSentence | null,\n  sources: GroundingSource[]\n) {\n  const citation = sentence?.citation;\n  if (!citation) {\n    return '';\n  }\n  const source = sources.find(\n    (candidate) => candidate.id === citation.sourceId\n  );\n  if (!source) {\n    return '';\n  }\n  return `${source.title}: ${citation.passage}`;\n}\n\nexport type GroundedAnswerProps = {\n  sentences: GroundedSentence[];\n  sources: GroundingSource[];\n  className?: string;\n};\n\nexport function GroundedAnswer({\n  sentences,\n  sources,\n  className,\n}: GroundedAnswerProps) {\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n  const [pinnedId, setPinnedId] = useState<string | null>(null);\n  const baseId = useId();\n  const activeId = hoveredId ?? pinnedId;\n  const activeSentence = sentenceOf(sentences, activeId);\n  const announcement = announcementOf(activeSentence, sources);\n\n  function cardIdOf(sourceId: string) {\n    return `${baseId}-source-${sourceId}`;\n  }\n\n  function enter(sentenceId: string) {\n    setHoveredId(sentenceId);\n  }\n\n  function leave(sentenceId: string) {\n    setHoveredId((current) => {\n      if (current === sentenceId) {\n        return null;\n      }\n      return current;\n    });\n  }\n\n  function togglePin(sentenceId: string) {\n    setPinnedId((current) => {\n      if (current === sentenceId) {\n        return null;\n      }\n      return sentenceId;\n    });\n  }\n\n  function unpinOnEscape(event: React.KeyboardEvent<HTMLDivElement>) {\n    const isEscape = event.key === 'Escape';\n    const hasPin = pinnedId !== null;\n    if (isEscape && hasPin) {\n      event.preventDefault();\n      setPinnedId(null);\n    }\n  }\n\n  return (\n    <div onKeyDown={unpinOnEscape} className={cn('@container', className)}>\n      <div className='grid grid-cols-1 gap-4 @xl:grid-cols-[minmax(0,1fr)_15rem]'>\n        <div className='flex min-w-0 flex-col gap-3'>\n          <BackingLine sentences={sentences} activeSentenceId={activeId} />\n          <p className='text-sm leading-7'>\n            {sentences.map((sentence, index) => {\n              const citation = sentence.citation;\n              const isLit = sentence.id === activeId;\n              const leadingSpace = leadingSpaceOf(index);\n              if (!citation) {\n                return (\n                  <span key={sentence.id}>\n                    {leadingSpace}\n                    <span className='decoration-muted-foreground/70 underline decoration-dotted decoration-1 underline-offset-4'>\n                      {sentence.text}\n                    </span>\n                    <span className='sr-only'> (no source backs this)</span>\n                  </span>\n                );\n              }\n              const number = sourceNumberOf(sources, citation.sourceId);\n              const source = sources[number - 1];\n              return (\n                <span key={sentence.id}>\n                  {leadingSpace}\n                  <span\n                    className={cn(\n                      'rounded-[4px] box-decoration-clone px-0.5 transition-colors duration-200 ease-(--ease-out-strong)',\n                      tintOf(isLit)\n                    )}\n                  >\n                    {sentence.text}\n                  </span>\n                  {source && (\n                    <GroundingMark\n                      number={number}\n                      source={source}\n                      isLit={isLit}\n                      cardId={cardIdOf(source.id)}\n                      onEnter={() => enter(sentence.id)}\n                      onLeave={() => leave(sentence.id)}\n                      onToggle={() => togglePin(sentence.id)}\n                    />\n                  )}\n                </span>\n              );\n            })}\n          </p>\n          <p className='text-muted-foreground text-xs'>\n            Point at or focus a mark to see its passage. A click keeps it open,\n            Escape lets it go.\n          </p>\n        </div>\n        <ul aria-label='Sources' className='flex flex-col gap-2 self-start'>\n          {sources.map((source, index) => (\n            <SourceCard\n              key={source.id}\n              source={source}\n              number={index + 1}\n              cardId={cardIdOf(source.id)}\n              activeSentence={activeSentence}\n            />\n          ))}\n        </ul>\n      </div>\n      <LiveRegion message={announcement} />\n    </div>\n  );\n}\n\nconst SOURCES: GroundingSource[] = [\n  {\n    id: 'contract',\n    kind: 'pdf',\n    title: 'Q3 supply contract',\n    meta: 'PDF, clause 4',\n  },\n  {\n    id: 'email',\n    kind: 'email',\n    title: 'Email from Dana Reyes',\n    meta: 'Email, 14 Oct',\n  },\n  {\n    id: 'notes',\n    kind: 'notes',\n    title: 'Account review call',\n    meta: 'Call notes, 20 Oct',\n  },\n];\n\nconst SENTENCES: GroundedSentence[] = [\n  {\n    id: 'fee',\n    text: 'The Q3 contract moves bulk orders to a flat pallet fee of 18.40 EUR.',\n    citation: {\n      sourceId: 'contract',\n      passage:\n        '4.2 From 1 October, bulk orders of 40 pallets or more are billed a flat pallet fee of 18.40 EUR.',\n    },\n  },\n  {\n    id: 'tiers',\n    text: 'Orders under 40 pallets keep the tiered rate from Annex B.',\n    citation: {\n      sourceId: 'contract',\n      passage:\n        '4.3 Orders under 40 pallets keep the tiered rate set out in Annex B.',\n    },\n  },\n  {\n    id: 'terms',\n    text: 'Payment terms move from 30 to 45 days, starting with the October invoices.',\n    citation: {\n      sourceId: 'email',\n      passage:\n        'We can move your payment terms from 30 to 45 days, starting with the October invoices.',\n    },\n  },\n  {\n    id: 'returns',\n    text: 'Brightmoor also covers returns damaged in transit, up to 2% of each order.',\n    citation: {\n      sourceId: 'notes',\n      passage:\n        'Dana confirmed Brightmoor will cover returns damaged in transit, up to 2% of each order value.',\n    },\n  },\n  {\n    id: 'cheapest',\n    text: 'That makes Brightmoor the cheapest supplier in the region.',\n  },\n];\n\nconst QUESTION = 'What changed in the Brightmoor contract for Q3?';\n\nexport default function Answer02() {\n  return (\n    <div className='bg-card hairline-ring ring-border flex w-full max-w-[44rem] flex-col gap-4 rounded-xl p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)]'>\n      <div className='flex justify-end'>\n        <p className='bg-foreground/[0.05] dark:bg-foreground/[0.07] max-w-[85%] rounded-xl px-3.5 py-2 text-sm leading-relaxed text-balance'>\n          {QUESTION}\n        </p>\n      </div>\n      <div className='flex items-start gap-2.5'>\n        <LabTile lab='anthropic' className='mt-0.5' />\n        <GroundedAnswer\n          sentences={SENTENCES}\n          sources={SOURCES}\n          className='min-w-0 flex-1'\n        />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/answer/answer-02.tsx"
    },
    {
      "path": "registry/ndeko/components/answer/_parts/lab-tile.tsx",
      "content": "import { BrandTile, type BrandTileSize } from '@/registry/ndeko/ui/brand-tile';\n\nimport { labOf, type LabId } from './labs';\n\ntype LabTileProps = {\n  lab: LabId;\n  size?: BrandTileSize;\n  className?: string;\n};\n\nexport function LabTile({ lab, size = 'md', className }: LabTileProps) {\n  const entry = labOf(lab);\n\n  return <BrandTile brand={entry.brand} size={size} className={className} />;\n}\n",
      "type": "registry:component",
      "target": "components/answer/_parts/lab-tile.tsx"
    },
    {
      "path": "registry/ndeko/components/answer/_parts/labs.ts",
      "content": "import type { BrandSlug } from '@/registry/ndeko/lib/brand-logos';\n\nexport type LabId = 'anthropic' | 'openai' | 'google';\n\nexport type Lab = {\n  id: LabId;\n  name: string;\n  brand: BrandSlug;\n};\n\nexport const LABS: Lab[] = [\n  { id: 'anthropic', name: 'Anthropic', brand: 'claude' },\n  { id: 'openai', name: 'OpenAI', brand: 'openai' },\n  { id: 'google', name: 'Google', brand: 'gemini' },\n];\n\nconst FALLBACK_LAB = LABS[0];\n\nexport function labOf(id: LabId) {\n  const lab = LABS.find((candidate) => candidate.id === id);\n  return lab ?? FALLBACK_LAB;\n}\n",
      "type": "registry:component",
      "target": "components/answer/_parts/labs.ts"
    }
  ],
  "categories": [
    "components",
    "answer"
  ],
  "type": "registry:component"
}