{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "answer-04",
  "title": "Thinking state",
  "description": "Steps, sources and a timed tool call grow live while the reasoning streams under a fade, then the block folds into one line that says how long it thought.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/agent-run.json",
    "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/button.json",
    "https://ui.ndekocode.dev/r/call-status.json",
    "https://ui.ndekocode.dev/r/chat-motion.json",
    "https://ui.ndekocode.dev/r/company-mark.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/motion-word-roll.json",
    "https://ui.ndekocode.dev/r/replay-button.json",
    "https://ui.ndekocode.dev/r/streamed-text.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-agent-run.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/utils.json",
    "https://ui.ndekocode.dev/r/words.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/answer/answer-04.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion, useInView } from 'motion/react';\nimport { useId, useRef, useState } from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport { WordRoll } from '@/components/core/word-roll';\nimport {\n  useRunScript,\n  type RunScriptStep,\n} from '@/registry/ndeko/hooks/use-agent-run';\nimport { useKeptHeight } from '@/registry/ndeko/hooks/use-kept-height';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport {\n  elapsedOf,\n  emptyRun,\n  formatDuration,\n  type CallStatus,\n  type PlanStep,\n  type RunEvent,\n  type RunSnapshot,\n  type StepStatus,\n  type ToolCall,\n} from '@/registry/ndeko/lib/agent-run';\nimport {\n  BrainIcon,\n  ChevronDownIcon,\n  CircleDashedIcon,\n} from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { nounOf, wordsOf } from '@/registry/ndeko/lib/words';\nimport { AnimatedSize } from '@/registry/ndeko/ui/animated-size';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport { CallStatusMark } from '@/registry/ndeko/ui/call-status';\nimport { Fold, Swap } from '@/registry/ndeko/ui/chat-motion';\nimport { CompanyMark } from '@/registry/ndeko/ui/company-mark';\nimport { ClockGate, LiveDuration } from '@/registry/ndeko/ui/live-duration';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\nimport { ReplayButton } from '@/registry/ndeko/ui/replay-button';\nimport { StreamedWords } from '@/registry/ndeko/ui/streamed-text';\n\nimport { LabTile } from './_parts/lab-tile';\n\nexport type ThinkingSite = {\n  id: string;\n  site: string;\n};\n\nconst SEARCH_TOOL = 'web_search';\n\nconst STEP_AS_CALL: Record<Exclude<StepStatus, 'skipped'>, CallStatus> = {\n  upcoming: 'queued',\n  running: 'running',\n  done: 'done',\n  failed: 'failed',\n};\n\nfunction StepGlyph({ status }: { status: StepStatus }) {\n  if (status === 'skipped') {\n    return (\n      <span className='inline-flex size-4 shrink-0'>\n        <CircleDashedIcon className='text-muted-foreground size-4' />\n        <span className='sr-only'>Skipped</span>\n      </span>\n    );\n  }\n  return <CallStatusMark status={STEP_AS_CALL[status]} />;\n}\n\nfunction isThinkingStatus(run: RunSnapshot) {\n  const isIdle = run.status === 'idle';\n  const isRunning = run.status === 'running';\n  return isIdle || isRunning;\n}\n\nfunction siteCallsOf(run: RunSnapshot) {\n  return run.calls.filter((call) => call.tool === SEARCH_TOOL);\n}\n\nfunction toolCallOf(run: RunSnapshot) {\n  return run.calls.find((call) => call.tool !== SEARCH_TOOL);\n}\n\nfunction siteOf(sites: ThinkingSite[], call: ToolCall) {\n  return sites.find((site) => site.id === call.id);\n}\n\nfunction thoughtWordsOf(run: RunSnapshot) {\n  if (isThinkingStatus(run)) {\n    return 'Thinking';\n  }\n  const end = run.endedAt ?? run.startedAt ?? 0;\n  const elapsed = elapsedOf(run, end);\n  const duration = formatDuration(elapsed);\n  if (run.status === 'cancelled') {\n    return `Thought for ${duration}, answered early`;\n  }\n  return `Thought for ${duration}`;\n}\n\nfunction chevronTurnOf(isOpen: boolean) {\n  if (isOpen) {\n    return 'rotate-180';\n  }\n  return 'rotate-0';\n}\n\nfunction stepTitleOf(step: PlanStep) {\n  if (step.id !== 'search') {\n    return step.title;\n  }\n  if (step.status === 'running') {\n    return 'Searching';\n  }\n  return 'Searched';\n}\n\nfunction compareCallOf(step: PlanStep, call: ToolCall | undefined) {\n  if (step.id !== 'compare') {\n    return undefined;\n  }\n  return call;\n}\n\ntype SearchRowProps = {\n  calls: ToolCall[];\n  sites: ThinkingSite[];\n};\n\nfunction SearchRow({ calls, sites }: SearchRowProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const tileMotion = tileMotionOf(shouldReduceMotion);\n  const count = calls.length;\n  const noun = nounOf(count, 'source');\n\n  return (\n    <span className='flex min-w-0 flex-wrap items-center gap-1.5 ps-6'>\n      <ul aria-label='Sources found' className='flex items-center gap-1'>\n        <AnimatePresence initial={false}>\n          {calls.map((call) => {\n            const site = siteOf(sites, call);\n            if (!site) {\n              return null;\n            }\n            return (\n              <motion.li key={call.id} className='flex' {...tileMotion}>\n                <CompanyMark seed={site.site} size='sm' />\n                <span className='sr-only'>{site.site}</span>\n              </motion.li>\n            );\n          })}\n        </AnimatePresence>\n      </ul>\n      <span className='text-muted-foreground text-xs'>\n        <MotionNumber value={count} /> {noun}\n      </span>\n    </span>\n  );\n}\n\nconst TILE_ENTER = {\n  initial: { opacity: 0, scale: 0.6, filter: 'blur(2px)' },\n  animate: { opacity: 1, scale: 1, filter: 'blur(0px)' },\n  exit: { opacity: 0, scale: 0.6 },\n  transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] },\n} as const;\n\nconst TILE_FADE = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0 },\n  transition: { duration: 0.15 },\n} as const;\n\nfunction tileMotionOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return TILE_FADE;\n  }\n  return TILE_ENTER;\n}\n\nfunction ToolRow({ call }: { call: ToolCall }) {\n  const isRunning = call.status === 'running';\n\n  return (\n    <span className='flex min-w-0 items-baseline gap-2 ps-6 text-xs'>\n      <span className='text-muted-foreground shrink-0 font-mono'>\n        {call.tool}\n      </span>\n      <span className='text-foreground/80 min-w-0 truncate'>{call.label}</span>\n      <LiveDuration\n        item={call}\n        isRunning={isRunning}\n        className='text-muted-foreground ms-auto shrink-0'\n      />\n    </span>\n  );\n}\n\ntype ReasoningRowProps = {\n  words: string[];\n  shown: number;\n  isStreaming: boolean;\n};\n\n/** While it streams, the oldest lines fade under a mask so the newest one stays in view. */\nfunction ReasoningRow({ words, shown, isStreaming }: ReasoningRowProps) {\n  return (\n    <div className='ps-6'>\n      <div\n        className={cn(\n          'text-muted-foreground flex flex-col justify-end text-[13px] leading-relaxed',\n          reasoningFrameOf(isStreaming)\n        )}\n      >\n        <p>\n          <StreamedWords\n            words={words}\n            shown={shown}\n            isStreaming={isStreaming}\n            keepsPlace={false}\n          />\n        </p>\n      </div>\n    </div>\n  );\n}\n\nfunction reasoningFrameOf(isStreaming: boolean) {\n  if (isStreaming) {\n    return 'max-h-[4.6lh] overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_45%)]';\n  }\n  return '';\n}\n\ntype ThinkingStepRowProps = {\n  step: PlanStep;\n  run: RunSnapshot;\n  sites: ThinkingSite[];\n  reasoningWords: string[];\n};\n\nfunction ThinkingStepRow({\n  step,\n  run,\n  sites,\n  reasoningWords,\n}: ThinkingStepRowProps) {\n  const siteCalls = siteCallsOf(run);\n  const compareCall = compareCallOf(step, toolCallOf(run));\n  const isSearch = step.id === 'search';\n  const isReason = step.id === 'reason';\n  const hasSites = siteCalls.length > 0;\n  const showsSearch = isSearch && hasSites;\n  const shownReasoning = run.usage.outputTokens;\n  const hasReasoning = shownReasoning > 0;\n  const showsReasoning = isReason && hasReasoning;\n  const isStreaming = step.status === 'running';\n\n  return (\n    <li className='flex flex-col gap-1.5'>\n      <span className='flex items-center gap-2 text-[13px]'>\n        <Swap swapKey={step.status} className='size-4 shrink-0'>\n          <StepGlyph status={step.status} />\n        </Swap>\n        <span className='min-w-0 truncate'>{stepTitleOf(step)}</span>\n      </span>\n      <AnimatedSize>\n        {showsSearch && <SearchRow calls={siteCalls} sites={sites} />}\n        {compareCall && <ToolRow call={compareCall} />}\n        {showsReasoning && (\n          <ReasoningRow\n            words={reasoningWords}\n            shown={shownReasoning}\n            isStreaming={isStreaming}\n          />\n        )}\n      </AnimatedSize>\n    </li>\n  );\n}\n\nexport type ThinkingBlockProps = {\n  run: RunSnapshot;\n  sites: ThinkingSite[];\n  reasoning: string;\n  onAnswerNow?: () => void;\n  className?: string;\n};\n\nexport function ThinkingBlock({\n  run,\n  sites,\n  reasoning,\n  onAnswerNow,\n  className,\n}: ThinkingBlockProps) {\n  const isThinking = isThinkingStatus(run);\n  const [openChoice, setOpenChoice] = useState<boolean | null>(null);\n  const [wasThinking, setWasThinking] = useState(isThinking);\n  const panelId = useId();\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const buttonMotion = buttonMotionOf(shouldReduceMotion);\n  const reasoningWords = wordsOf(reasoning);\n  const headline = thoughtWordsOf(run);\n  const hasStarted = run.startedAt !== undefined;\n  const showsClock = isThinking && hasStarted;\n  const canAnswerNow = isThinking && onAnswerNow !== undefined;\n\n  if (wasThinking !== isThinking) {\n    setWasThinking(isThinking);\n    setOpenChoice(null);\n  }\n\n  const isOpen = openChoice ?? isThinking;\n\n  function toggle() {\n    setOpenChoice(!isOpen);\n  }\n\n  return (\n    <div\n      className={cn(\n        'bg-foreground/[0.03] dark:bg-foreground/[0.05] rounded-xl p-1.5',\n        className\n      )}\n    >\n      <div className='flex items-center gap-2'>\n        <button\n          type='button'\n          aria-expanded={isOpen}\n          aria-controls={panelId}\n          onClick={toggle}\n          className='hover:bg-foreground/[0.04] focus-visible:ring-ring/25 flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-lg px-2 text-start text-[13px] outline-none focus-visible:ring-[3px]'\n        >\n          <BrainIcon className='text-muted-foreground size-4 shrink-0' />\n          <span\n            className={cn(\n              'min-w-0 truncate font-medium',\n              isThinking && 'animate-pulse'\n            )}\n          >\n            <WordRoll text={headline} />\n          </span>\n          {showsClock && (\n            <LiveDuration\n              item={run}\n              isRunning={isThinking}\n              className='text-muted-foreground text-xs'\n            />\n          )}\n          <ChevronDownIcon\n            className={cn(\n              'text-muted-foreground ms-auto size-3.5 shrink-0 transition-transform duration-200 ease-(--ease-out-strong)',\n              chevronTurnOf(isOpen)\n            )}\n          />\n        </button>\n        <AnimatePresence initial={false}>\n          {canAnswerNow && (\n            <motion.span key='answer-now' className='flex' {...buttonMotion}>\n              <Button\n                type='button'\n                variant='outline'\n                size='sm'\n                onClick={onAnswerNow}\n              >\n                Answer now\n              </Button>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </div>\n      <Fold open={isOpen} id={panelId}>\n        <ol className='flex flex-col gap-2.5 px-2 pt-1.5 pb-2'>\n          {run.plan.map((step) => (\n            <ThinkingStepRow\n              key={step.id}\n              step={step}\n              run={run}\n              sites={sites}\n              reasoningWords={reasoningWords}\n            />\n          ))}\n        </ol>\n      </Fold>\n    </div>\n  );\n}\n\nconst BUTTON_ENTER = {\n  initial: { opacity: 0, scale: 0.95 },\n  animate: { opacity: 1, scale: 1 },\n  exit: { opacity: 0, scale: 0.95 },\n  transition: { duration: 0.15, ease: [0.23, 1, 0.32, 1] },\n} as const;\n\nconst BUTTON_FADE = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0 },\n  transition: { duration: 0.15 },\n} as const;\n\nfunction buttonMotionOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return BUTTON_FADE;\n  }\n  return BUTTON_ENTER;\n}\n\nconst QUESTION = 'Which supplier is cheapest for 50 pallets a month?';\n\nconst SITES: ThinkingSite[] = [\n  { id: 'brightmoor', site: 'brightmoor-supply.example' },\n  { id: 'harbourline', site: 'harbourline-freight.example' },\n  { id: 'northgate', site: 'northgate-freight.example' },\n  { id: 'palletwatch', site: 'palletwatch.example' },\n  { id: 'freightdesk', site: 'freightdesk.example' },\n];\n\nconst REASONING =\n  'Fifty pallets a month sits above the 40 pallet line, so Brightmoor bills its flat 18.40 EUR. Harbourline still uses tiers, and 50 pallets land in tier two at 15.60 EUR. Northgate quotes 17.10 EUR flat but adds a 60 EUR monthly fee, which comes to 1.20 EUR on each of the 50 pallets. Harbourline stays the cheapest, Northgate and Brightmoor end up close.';\n\nconst ANSWER =\n  'Harbourline is the cheapest for 50 pallets a month, at 780 EUR. Northgate comes to 915 EUR once its monthly fee is added, and Brightmoor to 920 EUR with its flat fee of 18.40 EUR per pallet.';\n\nconst PLAN: PlanStep[] = [\n  { id: 'question', title: 'Read the question', status: 'upcoming' },\n  { id: 'search', title: 'Search', status: 'upcoming' },\n  { id: 'compare', title: 'Compare the price lists', status: 'upcoming' },\n  { id: 'reason', title: 'Work out the cheapest', status: 'upcoming' },\n];\n\nconst THINKING_RUN = emptyRun({\n  id: 'thinking',\n  title: 'Thinking',\n  plan: PLAN,\n});\n\nconst ANSWER_RUN = emptyRun({ id: 'answer', title: 'Answer' });\n\nconst SITE_MS = 340;\n\nconst REASON_WORD_MS = 60;\n\nconst ANSWER_WORD_MS = 45;\n\nfunction stepEvent(id: string, status: StepStatus): RunEvent {\n  return { type: 'step.updated', id, patch: { status } };\n}\n\nfunction thinkingScriptOf(): RunScriptStep[] {\n  const steps: RunScriptStep[] = [\n    { after: 400, event: { type: 'run.started' } },\n    { after: 0, event: stepEvent('question', 'running') },\n    { after: 500, event: stepEvent('question', 'done') },\n    { after: 0, event: stepEvent('search', 'running') },\n  ];\n  for (const site of SITES) {\n    steps.push({\n      after: SITE_MS,\n      event: {\n        type: 'call.started',\n        call: {\n          id: site.id,\n          tool: SEARCH_TOOL,\n          label: site.site,\n          status: 'done',\n        },\n      },\n    });\n  }\n  steps.push({ after: 200, event: stepEvent('search', 'done') });\n  steps.push({ after: 0, event: stepEvent('compare', 'running') });\n  steps.push({\n    after: 0,\n    event: {\n      type: 'call.started',\n      call: {\n        id: 'compare',\n        tool: 'compare_prices',\n        label: 'Compared 3 price lists for 50 pallets',\n        status: 'running',\n        input: { pallets: 50, suppliers: 3 },\n      },\n    },\n  });\n  steps.push({\n    after: 1300,\n    event: { type: 'call.updated', id: 'compare', patch: { status: 'done' } },\n  });\n  steps.push({ after: 0, event: stepEvent('compare', 'done') });\n  steps.push({ after: 0, event: stepEvent('reason', 'running') });\n  const reasoningWords = wordsOf(REASONING);\n  for (let index = 0; index < reasoningWords.length; index += 1) {\n    steps.push({\n      after: REASON_WORD_MS,\n      event: { type: 'usage.added', outputTokens: 1 },\n    });\n  }\n  steps.push({ after: 0, event: stepEvent('reason', 'done') });\n  steps.push({ after: 0, event: { type: 'run.finished', status: 'done' } });\n  return steps;\n}\n\nfunction answerScriptOf(): RunScriptStep[] {\n  const steps: RunScriptStep[] = [\n    { after: 250, event: { type: 'run.started' } },\n  ];\n  const answerWords = wordsOf(ANSWER);\n  for (let index = 0; index < answerWords.length; index += 1) {\n    steps.push({\n      after: ANSWER_WORD_MS,\n      event: { type: 'usage.added', outputTokens: 1 },\n    });\n  }\n  steps.push({ after: 0, event: { type: 'run.finished', status: 'done' } });\n  return steps;\n}\n\nconst THINKING_SCRIPT = thinkingScriptOf();\n\nconst ANSWER_SCRIPT = answerScriptOf();\n\nconst ANSWER_WORDS = wordsOf(ANSWER);\n\nfunction hasThinkingEnded(run: RunSnapshot) {\n  const isDone = run.status === 'done';\n  const isCancelled = run.status === 'cancelled';\n  return isDone || isCancelled;\n}\n\nfunction announcementOf(thinking: RunSnapshot, answer: RunSnapshot) {\n  if (answer.status === 'done') {\n    return 'Answer ready';\n  }\n  if (answer.status === 'running') {\n    return `${thoughtWordsOf(thinking)}. Answering`;\n  }\n  const running = thinking.plan.find((step) => step.status === 'running');\n  if (running?.id === 'search') {\n    return 'Searching sources';\n  }\n  if (running?.id === 'reason') {\n    return 'Reasoning';\n  }\n  if (thinking.status === 'running') {\n    return 'Thinking';\n  }\n  return '';\n}\n\nexport default function Answer04() {\n  const [isCut, setIsCut] = useState(false);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const isInView = useInView(rootRef, { amount: 0.3 });\n  const isThinkingActive = isInView && !isCut;\n  const thinking = useRunScript(THINKING_SCRIPT, {\n    initial: THINKING_RUN,\n    isActive: isThinkingActive,\n  });\n  const hasEnded = hasThinkingEnded(thinking.snapshot);\n  const isAnswerActive = isInView && hasEnded;\n  const answer = useRunScript(ANSWER_SCRIPT, {\n    initial: ANSWER_RUN,\n    isActive: isAnswerActive,\n  });\n  const answerShown = answer.snapshot.usage.outputTokens;\n  const isAnswering = answer.snapshot.status === 'running';\n  const hasAnswer = answerShown > 0;\n  const canReplay = answer.isFinished;\n  const announcement = announcementOf(thinking.snapshot, answer.snapshot);\n\n  function answerNow() {\n    const snapshot = thinking.store.getSnapshot();\n    const unfinished = snapshot.plan.filter((step) => {\n      const isUpcoming = step.status === 'upcoming';\n      const isRunning = step.status === 'running';\n      return isUpcoming || isRunning;\n    });\n    for (const step of unfinished) {\n      thinking.store.dispatch(stepEvent(step.id, 'skipped'));\n    }\n    const runningCalls = snapshot.calls.filter(\n      (call) => call.status === 'running'\n    );\n    for (const call of runningCalls) {\n      thinking.store.dispatch({\n        type: 'call.updated',\n        id: call.id,\n        patch: { status: 'done' },\n      });\n    }\n    thinking.store.dispatch({ type: 'run.finished', status: 'cancelled' });\n    setIsCut(true);\n  }\n\n  function replay() {\n    setIsCut(false);\n    thinking.restart();\n    answer.restart();\n  }\n\n  useKeptHeight(rootRef);\n\n  return (\n    <div\n      ref={rootRef}\n      className='bg-card hairline-ring ring-border flex w-full max-w-[34rem] flex-col gap-4 rounded-xl p-4 shadow-[0_1px_2px_rgb(0_0_0/0.04)]'\n    >\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-1.5' />\n        <div className='flex min-w-0 flex-1 flex-col gap-3'>\n          <ClockGate isActive={isInView}>\n            <ThinkingBlock\n              run={thinking.snapshot}\n              sites={SITES}\n              reasoning={REASONING}\n              onAnswerNow={answerNow}\n            />\n          </ClockGate>\n          <AnimatedSize>\n            {hasAnswer && (\n              <p className='text-sm leading-relaxed'>\n                <StreamedWords\n                  words={ANSWER_WORDS}\n                  shown={answerShown}\n                  isStreaming={isAnswering}\n                  keepsPlace={false}\n                />\n              </p>\n            )}\n          </AnimatedSize>\n        </div>\n      </div>\n      <div className='-mt-2 flex h-7 items-center justify-end'>\n        <ReplayButton isShown={canReplay} onReplay={replay} label='Ask again' />\n      </div>\n      <LiveRegion message={announcement} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/answer/answer-04.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"
}