{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "answer-07",
  "title": "Retrieved passages",
  "description": "The passages an answer was built from, with similarity bars and the shared words marked, each on a switch: leaving one out writes the answer again word by word, and the count of passages used rolls.",
  "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/demonstration.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/live-duration.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/replay-button.json",
    "https://ui.ndekocode.dev/r/streamed-text.json",
    "https://ui.ndekocode.dev/r/switch.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-kept-height.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/use-session.json",
    "https://ui.ndekocode.dev/r/utils.json",
    "https://ui.ndekocode.dev/r/words.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/answer/answer-07.tsx",
      "content": "'use client';\n\nimport { Fragment } from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport { useKeptHeight } from '@/registry/ndeko/hooks/use-kept-height';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  useSession,\n  type SessionStep,\n} from '@/registry/ndeko/hooks/use-session';\nimport { spokenUnless } from '@/registry/ndeko/lib/demonstration';\nimport {\n  FileTextIcon,\n  MailIcon,\n  MessageSquareTextIcon,\n  type Icon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { leadingSpaceOf, nounOf, wordsOf } from '@/registry/ndeko/lib/words';\nimport { AnimatedSize } from '@/registry/ndeko/ui/animated-size';\nimport { ClockGate } from '@/registry/ndeko/ui/live-duration';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\nimport { ReplayButton } from '@/registry/ndeko/ui/replay-button';\nimport {\n  StreamCaret,\n  inkClassOf,\n  shownCountOf,\n  useRevealedCount,\n} from '@/registry/ndeko/ui/streamed-text';\nimport { Switch } from '@/registry/ndeko/ui/switch';\n\nimport { LabTile } from './_parts/lab-tile';\nimport { keyWordsOf, passagePiecesOf } from './_parts/passage-words';\n\nexport type PassageKind = 'pdf' | 'email' | 'notes';\n\nexport type RetrievedPassage = {\n  id: string;\n  kind: PassageKind;\n  source: string;\n  /** Where in the source, in a few words: \"PDF, clause 4.2\". */\n  meta: string;\n  text: string;\n  /** How close the passage is to the question, from 0 to 1. */\n  score: number;\n  isUsed: boolean;\n};\n\nexport type AnswerSentence = {\n  id: string;\n  text: string;\n  /** The passage the sentence rests on. Without one, the sentence marks a gap. */\n  passageId?: string;\n};\n\ntype AnswerWord = {\n  text: string;\n  /** The number of the passage, on the last word of the sentence that rests on it. */\n  mark?: number;\n};\n\nconst KIND_ICON: Record<PassageKind, Icon> = {\n  pdf: FileTextIcon,\n  email: MailIcon,\n  notes: MessageSquareTextIcon,\n};\n\nconst WORD_MS = 28;\n\nfunction scoreStyleOf(score: number): React.CSSProperties {\n  const share = Math.min(1, Math.max(0, score));\n  return { transform: `scaleX(${share})` };\n}\n\nfunction contentToneOf(isUsed: boolean) {\n  if (isUsed) {\n    return 'opacity-100';\n  }\n  return 'opacity-55';\n}\n\nfunction rowSurfaceOf(isPressed: boolean) {\n  if (isPressed) {\n    return 'bg-foreground/[0.07]';\n  }\n  return 'bg-foreground/[0.03] dark:bg-foreground/[0.05]';\n}\n\nfunction markOf(sentence: AnswerSentence, passages: RetrievedPassage[]) {\n  if (!sentence.passageId) {\n    return undefined;\n  }\n  const index = passages.findIndex(\n    (passage) => passage.id === sentence.passageId\n  );\n  if (index < 0) {\n    return undefined;\n  }\n  return index + 1;\n}\n\nfunction wordOf(text: string, isLast: boolean, mark: number | undefined) {\n  const word: AnswerWord = { text };\n  if (isLast && mark !== undefined) {\n    word.mark = mark;\n  }\n  return word;\n}\n\nfunction answerWordsOf(\n  sentences: AnswerSentence[],\n  passages: RetrievedPassage[]\n) {\n  const words: AnswerWord[] = [];\n  for (const sentence of sentences) {\n    const parts = wordsOf(sentence.text);\n    const mark = markOf(sentence, passages);\n    const lastIndex = parts.length - 1;\n    for (let index = 0; index < parts.length; index += 1) {\n      const isLast = index === lastIndex;\n      words.push(wordOf(parts[index], isLast, mark));\n    }\n  }\n  return words;\n}\n\nfunction PassageMark({ number }: { number: number }) {\n  return (\n    <>\n      <span\n        aria-hidden='true'\n        className='keycap-3d text-muted-foreground ms-1 inline-flex h-4 min-w-4 items-center justify-center rounded-[4px] px-1 align-[0.12em] text-[10px] leading-none font-medium tabular-nums'\n      >\n        {number}\n      </span>\n      <span className='sr-only'> (passage {number})</span>\n    </>\n  );\n}\n\ntype AnswerBodyProps = {\n  words: AnswerWord[];\n  isActive: boolean;\n};\n\n/** Mounted again for each new answer, so its words arrive from the first one every time. */\nfunction AnswerBody({ words, isActive }: AnswerBodyProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const count = useRevealedCount(words.length, isActive, WORD_MS);\n  const hasMoreWords = count < words.length;\n  const isStreaming = hasMoreWords && !shouldReduceMotion;\n  const shown = shownCountOf(isStreaming, count, words.length);\n  const lastShown = shown - 1;\n\n  return (\n    <p className='text-sm leading-relaxed'>\n      {words.map((word, index) => {\n        const isShown = index < shown;\n        const hasCaret = isStreaming && index === lastShown;\n        return (\n          <Fragment key={index}>\n            {leadingSpaceOf(index)}\n            <span className={inkClassOf(isStreaming, isShown)}>\n              {word.text}\n              {hasCaret && <StreamCaret />}\n              {word.mark !== undefined && <PassageMark number={word.mark} />}\n            </span>\n          </Fragment>\n        );\n      })}\n    </p>\n  );\n}\n\ntype PassageRowProps = {\n  passage: RetrievedPassage;\n  number: number;\n  keys: Set<string>;\n  isPressed: boolean;\n  onChange: (passageId: string, isUsed: boolean) => void;\n};\n\nfunction PassageRow({\n  passage,\n  number,\n  keys,\n  isPressed,\n  onChange,\n}: PassageRowProps) {\n  const KindGlyph = KIND_ICON[passage.kind];\n  const pieces = passagePiecesOf(passage.text, keys);\n  const toneClass = contentToneOf(passage.isUsed);\n\n  return (\n    <li\n      className={cn(\n        'flex items-start gap-3 rounded-lg p-3 transition-colors duration-200',\n        rowSurfaceOf(isPressed)\n      )}\n    >\n      <span\n        className={cn(\n          'keycap-3d text-muted-foreground mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-md transition-opacity duration-200',\n          toneClass\n        )}\n      >\n        <KindGlyph className='size-3.5' />\n      </span>\n      <div\n        className={cn(\n          'flex min-w-0 flex-1 flex-col gap-1 transition-opacity duration-200',\n          toneClass\n        )}\n      >\n        <div className='flex flex-wrap items-center gap-x-2 gap-y-0.5'>\n          <span className='text-muted-foreground text-xs tabular-nums'>\n            {number}\n          </span>\n          <span className='min-w-0 truncate text-[13px] font-medium'>\n            {passage.source}\n          </span>\n          <span className='text-muted-foreground min-w-0 truncate text-xs'>\n            {passage.meta}\n          </span>\n          <span className='ms-auto flex shrink-0 items-center gap-1.5'>\n            <span\n              aria-hidden='true'\n              className='bg-foreground/10 h-1 w-12 overflow-hidden rounded-full'\n            >\n              <span\n                style={scoreStyleOf(passage.score)}\n                className='bg-primary block h-full w-full origin-left rounded-full'\n              />\n            </span>\n            <span className='text-muted-foreground text-xs tabular-nums'>\n              <span className='sr-only'>Similarity </span>\n              {passage.score.toFixed(2)}\n            </span>\n          </span>\n        </div>\n        <p className='line-clamp-3 text-[13px] leading-relaxed'>\n          {pieces.map((piece, index) => {\n            if (piece.isMatch) {\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        </p>\n      </div>\n      <Switch\n        checked={passage.isUsed}\n        aria-label={`Use ${passage.source}, ${passage.meta}`}\n        onCheckedChange={(isUsed) => onChange(passage.id, isUsed)}\n        className='mt-0.5'\n      />\n    </li>\n  );\n}\n\nexport type PassageAnswerProps = {\n  question: string;\n  passages: RetrievedPassage[];\n  /** What the answer says with the passages that are on. It is written again whenever it changes. */\n  answer: AnswerSentence[];\n  onPassageChange: (passageId: string, isUsed: boolean) => void;\n  /** False off screen: the answer holds where it is. */\n  isActive?: boolean;\n  /** The passage whose row a demonstration holds down. */\n  pressedPassageId?: string | null;\n  className?: string;\n};\n\nexport function PassageAnswer({\n  question,\n  passages,\n  answer,\n  onPassageChange,\n  isActive = true,\n  pressedPassageId = null,\n  className,\n}: PassageAnswerProps) {\n  const keys = keyWordsOf(question);\n  const usedCount = passages.filter((passage) => passage.isUsed).length;\n  const words = answerWordsOf(answer, passages);\n  const answerKey = answer.map((sentence) => sentence.id).join(' ');\n\n  return (\n    <div className={cn('flex flex-col gap-4', className)}>\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      <section aria-label='Retrieved passages' className='flex flex-col gap-2'>\n        <h3 className='text-muted-foreground flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 px-1 text-xs'>\n          <span>Passages the answer is built from</span>\n          <span className='tabular-nums'>\n            <MotionNumber value={usedCount} /> of {passages.length} used\n          </span>\n        </h3>\n        <ul className='flex flex-col gap-1.5'>\n          {passages.map((passage, index) => (\n            <PassageRow\n              key={passage.id}\n              passage={passage}\n              number={index + 1}\n              keys={keys}\n              isPressed={pressedPassageId === passage.id}\n              onChange={onPassageChange}\n            />\n          ))}\n        </ul>\n      </section>\n      <div className='flex items-start gap-2.5'>\n        <LabTile lab='anthropic' className='mt-0.5' />\n        <div className='flex min-w-0 flex-1 flex-col gap-1.5'>\n          <p className='text-muted-foreground text-xs'>\n            Written from <MotionNumber value={usedCount} />{' '}\n            {nounOf(usedCount, 'passage')}\n          </p>\n          <AnimatedSize>\n            <AnswerBody key={answerKey} words={words} isActive={isActive} />\n          </AnimatedSize>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nconst QUESTION =\n  'Can Brightmoor change the pallet fee, and how much notice do we get?';\n\nconst PASSAGES: RetrievedPassage[] = [\n  {\n    id: 'fee',\n    kind: 'pdf',\n    source: 'Q3 supply contract',\n    meta: 'PDF, clause 4.2',\n    text: 'Brightmoor may change the flat pallet fee once per quarter. A new fee applies from the first day of the next quarter.',\n    score: 0.91,\n    isUsed: true,\n  },\n  {\n    id: 'notice',\n    kind: 'pdf',\n    source: 'Q3 supply contract',\n    meta: 'PDF, clause 9.1',\n    text: 'Any change to a fee needs 60 days written notice to the buyer. Notice sent by email counts as written notice.',\n    score: 0.86,\n    isUsed: true,\n  },\n  {\n    id: 'call',\n    kind: 'notes',\n    source: 'Account review call',\n    meta: 'Call notes, 20 Oct',\n    text: 'Dana said Brightmoor will not raise the pallet fee before the end of next year, but did not want to put it in writing.',\n    score: 0.78,\n    isUsed: true,\n  },\n  {\n    id: 'email',\n    kind: 'email',\n    source: 'Email from Dana Reyes',\n    meta: 'Email, 14 Oct',\n    text: 'We can move your payment terms from 30 to 45 days. The pallet fee is not part of that change.',\n    score: 0.64,\n    isUsed: true,\n  },\n  {\n    id: 'handbook',\n    kind: 'pdf',\n    source: 'Supplier handbook',\n    meta: 'PDF, section 3',\n    text: 'Fee changes announced by phone are not binding until confirmed in writing.',\n    score: 0.52,\n    isUsed: true,\n  },\n];\n\ntype SentenceSpec = {\n  id: string;\n  text: string;\n  passageId: string;\n  /** Said instead when the passage is left out. Without one, the sentence goes. */\n  gap?: AnswerSentence;\n};\n\nconst SENTENCES: SentenceSpec[] = [\n  {\n    id: 'fee',\n    passageId: 'fee',\n    text: 'Yes, Brightmoor can change the flat pallet fee once per quarter, from the first day of the next quarter.',\n  },\n  {\n    id: 'notice',\n    passageId: 'notice',\n    text: 'It has to give you 60 days of written notice, and an email counts.',\n    gap: {\n      id: 'notice-gap',\n      text: 'The passages left on do not say how much notice you get.',\n    },\n  },\n  {\n    id: 'call',\n    passageId: 'call',\n    text: 'On a call in October, Dana said the fee would hold until the end of next year, but nothing is in writing.',\n  },\n  {\n    id: 'email',\n    passageId: 'email',\n    text: 'Her October email changes the payment terms only, not the fee.',\n  },\n  {\n    id: 'handbook',\n    passageId: 'handbook',\n    text: 'A promise made by phone is not binding until it is confirmed in writing.',\n  },\n];\n\ntype PassageState = {\n  used: Record<string, boolean>;\n};\n\ntype PassageAction =\n  { type: 'use'; passageId: string; isUsed: boolean } | { type: 'hold' };\n\ntype PassageStep = SessionStep<PassageState, PassageAction>;\n\nfunction allUsed(): Record<string, boolean> {\n  const used: Record<string, boolean> = {};\n  for (const passage of PASSAGES) {\n    used[passage.id] = true;\n  }\n  return used;\n}\n\nconst INITIAL: PassageState = { used: allUsed() };\n\nconst RESTING: PassageState = { used: { ...allUsed(), notice: false } };\n\nfunction reducer(state: PassageState, action: PassageAction): PassageState {\n  switch (action.type) {\n    case 'use':\n      return {\n        used: { ...state.used, [action.passageId]: action.isUsed },\n      };\n    case 'hold':\n      return state;\n  }\n}\n\nfunction passagesOf(state: PassageState) {\n  return PASSAGES.map((passage) => ({\n    ...passage,\n    isUsed: state.used[passage.id],\n  }));\n}\n\nfunction sentenceOf(spec: SentenceSpec, isUsed: boolean) {\n  if (isUsed) {\n    return { id: spec.id, text: spec.text, passageId: spec.passageId };\n  }\n  return spec.gap;\n}\n\nfunction answerOf(state: PassageState) {\n  const sentences: AnswerSentence[] = [];\n  for (const spec of SENTENCES) {\n    const sentence = sentenceOf(spec, state.used[spec.passageId]);\n    if (sentence) {\n      sentences.push(sentence);\n    }\n  }\n  return sentences;\n}\n\nconst SCRIPT: PassageStep[] = [\n  {\n    after: 4800,\n    by: 'demo',\n    press: 'notice',\n    action: { type: 'use', passageId: 'notice', isUsed: false },\n  },\n  {\n    after: 4200,\n    by: 'demo',\n    press: 'notice',\n    action: { type: 'use', passageId: 'notice', isUsed: true },\n  },\n  { after: 4200, action: { type: 'hold' } },\n];\n\nconst SESSION = {\n  reducer,\n  initial: INITIAL,\n  resting: RESTING,\n  script: SCRIPT,\n  loopAfter: 3000,\n};\n\nfunction announcementOf(passages: RetrievedPassage[]) {\n  const usedCount = passages.filter((passage) => passage.isUsed).length;\n  return `The answer rests on ${usedCount} of ${passages.length} passages`;\n}\n\nexport default function Answer07() {\n  const {\n    ref,\n    isInView,\n    isAutoplaying,\n    state,\n    act,\n    pressed,\n    isFinished,\n    restart,\n  } = useSession(SESSION);\n  const passages = passagesOf(state);\n  const answer = answerOf(state);\n  const canReplay = isFinished && !isAutoplaying;\n  const announcement = announcementOf(passages);\n\n  function changePassage(passageId: string, isUsed: boolean) {\n    act({ type: 'use', passageId, isUsed });\n  }\n\n  useKeptHeight(ref);\n\n  return (\n    <div\n      ref={ref}\n      className='flex min-h-[46rem] w-full max-w-[44rem] flex-col gap-2'\n    >\n      <ClockGate isActive={isInView}>\n        <PassageAnswer\n          question={QUESTION}\n          passages={passages}\n          answer={answer}\n          onPassageChange={changePassage}\n          isActive={isInView}\n          pressedPassageId={pressed}\n          className='bg-card hairline-ring ring-border rounded-xl p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)]'\n        />\n      </ClockGate>\n      <div className='flex h-7 items-center justify-end'>\n        <ReplayButton isShown={canReplay} onReplay={restart} />\n      </div>\n      <LiveRegion message={spokenUnless(isAutoplaying, announcement)} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/answer/answer-07.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"
    },
    {
      "path": "registry/ndeko/components/answer/_parts/passage-words.ts",
      "content": "export type PassagePiece = {\n  text: string;\n  isMatch: boolean;\n};\n\nconst STOP_WORDS = new Set([\n  'a',\n  'an',\n  'and',\n  'are',\n  'as',\n  'at',\n  'be',\n  'by',\n  'can',\n  'do',\n  'for',\n  'from',\n  'get',\n  'how',\n  'in',\n  'is',\n  'it',\n  'its',\n  'much',\n  'of',\n  'on',\n  'or',\n  'that',\n  'the',\n  'to',\n  'was',\n  'we',\n  'what',\n  'when',\n  'will',\n  'with',\n  'you',\n  'your',\n]);\n\nconst EDGE_PUNCTUATION = /^[^\\p{L}\\p{N}]+|[^\\p{L}\\p{N}]+$/gu;\n\nconst SPACE_RUN = /(\\s+)/;\n\nconst MIN_WORD_LENGTH = 2;\n\nconst PLURAL_FROM_LENGTH = 4;\n\n/** A plural matches its singular: fees finds fee. */\nfunction stemOf(word: string) {\n  const isPlural = word.endsWith('s') && word.length >= PLURAL_FROM_LENGTH;\n  if (isPlural) {\n    return word.slice(0, -1);\n  }\n  return word;\n}\n\nfunction normalWordOf(word: string) {\n  const lower = word.toLowerCase();\n  const bare = lower.replace(EDGE_PUNCTUATION, '');\n  return stemOf(bare);\n}\n\nfunction isKeyWord(normal: string) {\n  const isStopWord = STOP_WORDS.has(normal);\n  const isTooShort = normal.length < MIN_WORD_LENGTH;\n  return !isStopWord && !isTooShort;\n}\n\nexport function keyWordsOf(question: string) {\n  const keys = new Set<string>();\n  for (const word of question.split(/\\s+/)) {\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, keys: Set<string>) {\n  const pieces: PassagePiece[] = [];\n  for (const part of passage.split(SPACE_RUN)) {\n    if (part === '') {\n      continue;\n    }\n    const isMatch = keys.has(normalWordOf(part));\n    pieces.push({ text: part, isMatch });\n  }\n  return pieces;\n}\n",
      "type": "registry:component",
      "target": "components/answer/_parts/passage-words.ts"
    }
  ],
  "categories": [
    "components",
    "answer"
  ],
  "type": "registry:component"
}