{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "answer-03",
  "title": "Message actions",
  "description": "The quiet bar under a reply: Copy that turns into a check, Regenerate with versions that crossfade, thumbs with a reason picker, Read aloud with a live equalizer and a menu.",
  "dependencies": [
    "motion"
  ],
  "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/button.json",
    "https://ui.ndekocode.dev/r/chat-message.json",
    "https://ui.ndekocode.dev/r/chat-motion.json",
    "https://ui.ndekocode.dev/r/dropdown-menu.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/level-bars.json",
    "https://ui.ndekocode.dev/r/live-region.json",
    "https://ui.ndekocode.dev/r/morph-button.json",
    "https://ui.ndekocode.dev/r/motion-number.json",
    "https://ui.ndekocode.dev/r/motion-presets.json",
    "https://ui.ndekocode.dev/r/motion-word-roll.json",
    "https://ui.ndekocode.dev/r/popover.json",
    "https://ui.ndekocode.dev/r/textarea.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/toggle.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/use-timeouts.json",
    "https://ui.ndekocode.dev/r/utils.json",
    "https://ui.ndekocode.dev/r/words.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/answer/answer-03.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion } from 'motion/react';\nimport { useId, useState } from 'react';\n\nimport { MotionNumber } from '@/components/core/motion-number';\nimport { WordRoll } from '@/components/core/word-roll';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { useTimeouts } from '@/registry/ndeko/hooks/use-timeouts';\nimport {\n  ChevronLeftIcon,\n  ChevronRightIcon,\n  CopyIcon,\n  EllipsisIcon,\n  RefreshCwIcon,\n  RotateCcwIcon,\n  Share2Icon,\n  ThumbsDownIcon,\n  ThumbsUpIcon,\n  TriangleAlertIcon,\n  Volume2Icon,\n} from '@/registry/ndeko/lib/icons';\nimport { rowPresenceOf } from '@/registry/ndeko/lib/motion-presets';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { countOf, leadingSpaceOf, wordsOf } from '@/registry/ndeko/lib/words';\nimport { AnimatedSize } from '@/registry/ndeko/ui/animated-size';\nimport { Button } from '@/registry/ndeko/ui/button';\nimport {\n  MessageAction,\n  MessageActions,\n} from '@/registry/ndeko/ui/chat-message';\nimport { Swap } from '@/registry/ndeko/ui/chat-motion';\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from '@/registry/ndeko/ui/dropdown-menu';\nimport { LevelBars } from '@/registry/ndeko/ui/level-bars';\nimport { LiveRegion } from '@/registry/ndeko/ui/live-region';\nimport { MorphButton, type MorphState } from '@/registry/ndeko/ui/morph-button';\nimport {\n  Popover,\n  PopoverContent,\n  PopoverTitle,\n  PopoverTrigger,\n} from '@/registry/ndeko/ui/popover';\nimport { Textarea } from '@/registry/ndeko/ui/textarea';\nimport { Toggle } from '@/registry/ndeko/ui/toggle';\n\nimport { CopyAction } from './_parts/copy-action';\nimport { LabTile } from './_parts/lab-tile';\n\nexport type Feedback = 'up' | 'down';\n\nexport type ReadState = 'idle' | 'reading';\n\nconst ACTION =\n  'text-muted-foreground aria-pressed:bg-primary/10 aria-pressed:text-primary-ink';\n\nexport const FEEDBACK_REASONS = [\n  'Not accurate',\n  'Out of date',\n  'Too long',\n  'Missed the question',\n  'Hard to follow',\n] as const;\n\nexport type FeedbackReason = (typeof FEEDBACK_REASONS)[number];\n\nconst SEND_LABELS: Record<MorphState, string> = {\n  idle: 'Send',\n  busy: 'Sending',\n  done: 'Thanks',\n};\n\nconst SENDING_MS = 600;\n\nconst THANKS_MS = 900;\n\nfunction readLabelOf(state: ReadState) {\n  if (state === 'reading') {\n    return 'Stop reading';\n  }\n  return 'Read aloud';\n}\n\nfunction withReason(\n  reasons: FeedbackReason[],\n  reason: FeedbackReason,\n  isOn: boolean\n) {\n  const others = reasons.filter((candidate) => candidate !== reason);\n  if (isOn) {\n    return [...others, reason];\n  }\n  return others;\n}\n\ntype ReasonPanelProps = {\n  onSend: (reasons: FeedbackReason[], note: string) => void;\n  onDone: () => void;\n};\n\nfunction ReasonPanel({ onSend, onDone }: ReasonPanelProps) {\n  const [reasons, setReasons] = useState<FeedbackReason[]>([]);\n  const [note, setNote] = useState('');\n  const [sendState, setSendState] = useState<MorphState>('idle');\n  const { schedule } = useTimeouts();\n  const noteId = useId();\n  const hasReason = reasons.length > 0;\n  const hasNote = note.trim().length > 0;\n  const canSend = hasReason || hasNote;\n\n  function send() {\n    if (!canSend) {\n      return;\n    }\n    setSendState('busy');\n    schedule(() => {\n      setSendState('done');\n      onSend(reasons, note.trim());\n    }, SENDING_MS);\n    const closeAt = SENDING_MS + THANKS_MS;\n    schedule(onDone, closeAt);\n  }\n\n  return (\n    <div className='flex flex-col gap-3'>\n      <PopoverTitle>What went wrong?</PopoverTitle>\n      <div role='group' aria-label='Reasons' className='flex flex-wrap gap-1.5'>\n        {FEEDBACK_REASONS.map((reason) => (\n          <Toggle\n            key={reason}\n            variant='outline'\n            size='sm'\n            pressed={reasons.includes(reason)}\n            onPressedChange={(isOn) =>\n              setReasons((current) => withReason(current, reason, isOn))\n            }\n            className='rounded-full text-xs'\n          >\n            {reason}\n          </Toggle>\n        ))}\n      </div>\n      <div className='flex flex-col gap-1.5'>\n        <label htmlFor={noteId} className='text-xs font-medium'>\n          Note{' '}\n          <span className='text-muted-foreground font-normal'>(optional)</span>\n        </label>\n        <Textarea\n          id={noteId}\n          value={note}\n          onChange={(event) => setNote(event.target.value)}\n          placeholder='The fee starts in November, not October'\n          className='min-h-14'\n        />\n      </div>\n      <div className='flex items-center justify-between gap-3'>\n        <p className='text-muted-foreground text-xs'>\n          {!canSend && 'Pick a reason or write a note'}\n        </p>\n        <MorphButton\n          type='button'\n          size='sm'\n          state={sendState}\n          labels={SEND_LABELS}\n          disabled={!canSend}\n          onClick={send}\n        />\n      </div>\n    </div>\n  );\n}\n\ntype VersionSwitchProps = {\n  index: number;\n  count: number;\n  onPrevious: () => void;\n  onNext: () => void;\n};\n\nfunction VersionSwitch({\n  index,\n  count,\n  onPrevious,\n  onNext,\n}: VersionSwitchProps) {\n  const isFirst = index === 0;\n  const isLast = index === count - 1;\n  const position = index + 1;\n\n  return (\n    <span className='text-muted-foreground flex items-center text-xs'>\n      <MessageAction tooltip='Previous version'>\n        <Button\n          type='button'\n          variant='ghost'\n          size='icon-sm'\n          aria-label='Previous version'\n          disabled={isFirst}\n          onClick={onPrevious}\n          className='text-muted-foreground size-6'\n        >\n          <ChevronLeftIcon className='size-3.5' />\n        </Button>\n      </MessageAction>\n      <span className='inline-flex min-w-9 justify-center tabular-nums'>\n        <MotionNumber value={position} />\n        <span aria-hidden='true' className='px-0.5'>\n          /\n        </span>\n        <span className='sr-only'> of </span>\n        <MotionNumber value={count} />\n      </span>\n      <MessageAction tooltip='Next version'>\n        <Button\n          type='button'\n          variant='ghost'\n          size='icon-sm'\n          aria-label='Next version'\n          disabled={isLast}\n          onClick={onNext}\n          className='text-muted-foreground size-6'\n        >\n          <ChevronRightIcon className='size-3.5' />\n        </Button>\n      </MessageAction>\n    </span>\n  );\n}\n\nexport type MessageActionBarProps = {\n  copyText: string;\n  versionIndex: number;\n  versionCount: number;\n  isRegenerating: boolean;\n  /** Left out, Regenerate stays available. */\n  canRegenerate?: boolean;\n  onRegenerate: () => void;\n  onPrevious: () => void;\n  onNext: () => void;\n  feedback: Feedback | null;\n  onFeedback: (feedback: Feedback | null) => void;\n  onSendReasons: (reasons: FeedbackReason[], note: string) => void;\n  readState: ReadState;\n  onToggleRead: () => void;\n  onShare: () => void;\n  onCopyMarkdown: () => void;\n  onReport: () => void;\n  onCopied?: () => void;\n};\n\nexport function MessageActionBar({\n  copyText,\n  versionIndex,\n  versionCount,\n  isRegenerating,\n  canRegenerate = true,\n  onRegenerate,\n  onPrevious,\n  onNext,\n  feedback,\n  onFeedback,\n  onSendReasons,\n  readState,\n  onToggleRead,\n  onShare,\n  onCopyMarkdown,\n  onReport,\n  onCopied,\n}: MessageActionBarProps) {\n  const [isReasonOpen, setIsReasonOpen] = useState(false);\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const isUp = feedback === 'up';\n  const isDown = feedback === 'down';\n  const isReading = readState === 'reading';\n  const hasVersions = versionCount > 1;\n  const isRegenerateOff = !canRegenerate || isRegenerating;\n  const readLabel = readLabelOf(readState);\n  const versionMotion = versionMotionOf(shouldReduceMotion);\n\n  function pressUp() {\n    setIsReasonOpen(false);\n    if (isUp) {\n      onFeedback(null);\n      return;\n    }\n    onFeedback('up');\n  }\n\n  function changeReasonOpen(nextOpen: boolean) {\n    if (!nextOpen) {\n      setIsReasonOpen(false);\n      return;\n    }\n    if (isDown) {\n      onFeedback(null);\n      return;\n    }\n    onFeedback('down');\n    setIsReasonOpen(true);\n  }\n\n  return (\n    <MessageActions className='-ms-1.5 flex-wrap'>\n      <MessageAction tooltip='Copy'>\n        <CopyAction\n          text={copyText}\n          label='Copy the answer'\n          onCopied={onCopied}\n        />\n      </MessageAction>\n      <MessageAction tooltip={regenerateTooltipOf(canRegenerate)}>\n        <Button\n          type='button'\n          variant='ghost'\n          size='icon-sm'\n          aria-label='Regenerate the answer'\n          disabled={isRegenerateOff}\n          focusableWhenDisabled\n          onClick={onRegenerate}\n          className={ACTION}\n        >\n          <RefreshCwIcon\n            className={cn('size-3.5', isRegenerating && 'animate-spin')}\n          />\n        </Button>\n      </MessageAction>\n      <AnimatePresence initial={false}>\n        {hasVersions && (\n          <motion.span\n            key='versions'\n            className='inline-flex overflow-hidden'\n            {...versionMotion}\n          >\n            <VersionSwitch\n              index={versionIndex}\n              count={versionCount}\n              onPrevious={onPrevious}\n              onNext={onNext}\n            />\n          </motion.span>\n        )}\n      </AnimatePresence>\n      <MessageAction tooltip='Good answer'>\n        <Button\n          type='button'\n          variant='ghost'\n          size='icon-sm'\n          aria-label='Good answer'\n          aria-pressed={isUp}\n          onClick={pressUp}\n          className={ACTION}\n        >\n          <Swap swapKey={String(isUp)}>\n            <ThumbsUpIcon className='size-3.5' />\n          </Swap>\n        </Button>\n      </MessageAction>\n      <Popover open={isReasonOpen} onOpenChange={changeReasonOpen}>\n        <MessageAction tooltip='Bad answer'>\n          <PopoverTrigger\n            render={\n              <Button\n                type='button'\n                variant='ghost'\n                size='icon-sm'\n                aria-label='Bad answer'\n                aria-pressed={isDown}\n                className={ACTION}\n              />\n            }\n          >\n            <Swap swapKey={String(isDown)}>\n              <ThumbsDownIcon className='size-3.5' />\n            </Swap>\n          </PopoverTrigger>\n        </MessageAction>\n        <PopoverContent\n          side='bottom'\n          align='start'\n          className='w-80 max-w-[calc(100vw-2rem)]'\n        >\n          <ReasonPanel\n            onSend={onSendReasons}\n            onDone={() => setIsReasonOpen(false)}\n          />\n        </PopoverContent>\n      </Popover>\n      <MessageAction tooltip={readLabel}>\n        <Button\n          type='button'\n          variant='ghost'\n          size='sm'\n          aria-label={readLabel}\n          onClick={onToggleRead}\n          className={cn(\n            'text-muted-foreground h-7 min-w-7 px-1.5',\n            isReading && 'bg-primary/10 text-primary-ink'\n          )}\n        >\n          <AnimatedSize\n            as='span'\n            axis='width'\n            contentClassName='flex items-center justify-center'\n          >\n            <Swap swapKey={readState}>\n              {isReading && (\n                <span className='flex items-center gap-1.5'>\n                  <LevelBars\n                    isActive\n                    count={4}\n                    seed={1.7}\n                    className='h-3.5 gap-[2px]'\n                    barClassName='w-[2px]'\n                  />\n                  <span className='text-xs'>Stop</span>\n                </span>\n              )}\n              {!isReading && <Volume2Icon className='size-3.5' />}\n            </Swap>\n          </AnimatedSize>\n        </Button>\n      </MessageAction>\n      <DropdownMenu>\n        <MessageAction tooltip='More'>\n          <DropdownMenuTrigger\n            render={\n              <Button\n                type='button'\n                variant='ghost'\n                size='icon-sm'\n                aria-label='More actions'\n                className={ACTION}\n              />\n            }\n          >\n            <EllipsisIcon className='size-3.5' />\n          </DropdownMenuTrigger>\n        </MessageAction>\n        <DropdownMenuContent align='end' className='min-w-48'>\n          <DropdownMenuItem onClick={onShare}>\n            <Share2Icon />\n            Share\n          </DropdownMenuItem>\n          <DropdownMenuItem onClick={onCopyMarkdown}>\n            <CopyIcon />\n            Copy as Markdown\n          </DropdownMenuItem>\n          <DropdownMenuSeparator />\n          <DropdownMenuItem variant='destructive' onClick={onReport}>\n            <TriangleAlertIcon />\n            Report\n          </DropdownMenuItem>\n        </DropdownMenuContent>\n      </DropdownMenu>\n    </MessageActions>\n  );\n}\n\nfunction regenerateTooltipOf(canRegenerate: boolean) {\n  if (canRegenerate) {\n    return 'Regenerate';\n  }\n  return 'This demo keeps four versions';\n}\n\nconst VERSION_ENTER = {\n  initial: { opacity: 0, width: 0 },\n  animate: { opacity: 1, width: 'auto' },\n  exit: { opacity: 0, width: 0 },\n  transition: { type: 'spring', bounce: 0, duration: 0.3 },\n} as const;\n\nconst VERSION_FADE = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0 },\n  transition: { duration: 0.15 },\n} as const;\n\nfunction versionMotionOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return VERSION_FADE;\n  }\n  return VERSION_ENTER;\n}\n\ntype Version = {\n  id: string;\n  sentences: string[];\n};\n\nconst VERSION_TEXTS: string[][] = [\n  [\n    'The flat fee of 18.40 EUR per pallet starts on 1 October and only covers orders of 40 pallets or more.',\n    'Smaller orders keep the tiered rate, which starts at 15.90 EUR.',\n    'If most of your orders stay under 40 pallets, your price does not change until the contract renews.',\n  ],\n  [\n    'From 1 October, Brightmoor bills 18.40 EUR per pallet on orders of 40 pallets and up.',\n    'Below that, the tiers stay, from 15.90 EUR.',\n    'A quick check: if your usual order is under 40 pallets, nothing changes for you.',\n  ],\n  [\n    'In short, 40 pallets or more now costs a flat 18.40 EUR each, starting 1 October.',\n    'Under 40, the old tiers from 15.90 EUR still apply.',\n    'Most small buyers will not see a difference on their invoices.',\n  ],\n  [\n    'Nothing changes for small orders.',\n    'The new flat fee of 18.40 EUR per pallet only applies from 40 pallets, from 1 October.',\n    'Orders under that keep the tiered rate starting at 15.90 EUR.',\n  ],\n];\n\nconst MAX_VERSIONS = VERSION_TEXTS.length;\n\nconst REGENERATE_MS = 1100;\n\nconst WORD_READ_MS = 330;\n\nconst SHARE_LINK = 'https://chat.lumen.example/s/7fq2-pallet-fee';\n\nconst QUESTION = 'Does the new pallet fee change anything for small orders?';\n\nfunction versionOf(index: number): Version {\n  const sentences = VERSION_TEXTS[index] ?? VERSION_TEXTS[0];\n  const number = index + 1;\n  return { id: `v${number}`, sentences };\n}\n\nfunction versionLineOf(index: number, count: number) {\n  const number = index + 1;\n  return `Version ${number} of ${count}`;\n}\n\nfunction textOf(version: Version) {\n  return version.sentences.join(' ');\n}\n\nfunction markdownOf(version: Version) {\n  const body = textOf(version);\n  return `**Claude**\\n\\n${body}`;\n}\n\nfunction readTimesOf(sentences: string[]) {\n  const times: number[] = [];\n  let elapsed = 0;\n  for (const sentence of sentences) {\n    times.push(elapsed);\n    const wordCount = wordsOf(sentence).length;\n    elapsed += wordCount * WORD_READ_MS;\n  }\n  return { starts: times, total: elapsed };\n}\n\nfunction sentenceToneOf(isRead: boolean) {\n  if (isRead) {\n    return 'bg-primary/10';\n  }\n  return 'bg-transparent';\n}\n\nfunction bodyToneOf(isRegenerating: boolean) {\n  if (isRegenerating) {\n    return 'opacity-45';\n  }\n  return 'opacity-100';\n}\n\nconst BODY_SWAP = {\n  initial: { opacity: 0, filter: 'blur(2px)' },\n  animate: { opacity: 1, filter: 'blur(0px)' },\n  exit: { opacity: 0, filter: 'blur(2px)' },\n  transition: { duration: 0.2, ease: [0.23, 1, 0.32, 1] },\n} as const;\n\nconst BODY_FADE = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0 },\n  transition: { duration: 0.15 },\n} as const;\n\nfunction bodyMotionOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return BODY_FADE;\n  }\n  return BODY_SWAP;\n}\n\nasync function copyToClipboard(text: string) {\n  try {\n    await navigator.clipboard.writeText(text);\n  } catch {\n    return false;\n  }\n  return true;\n}\n\nfunction feedbackLineOf(feedback: Feedback | null) {\n  if (feedback === 'up') {\n    return 'Marked as a good answer';\n  }\n  if (feedback === 'down') {\n    return 'Marked as a bad answer';\n  }\n  return 'Feedback removed';\n}\n\nexport default function Answer03() {\n  const [versions, setVersions] = useState<Version[]>(() => [versionOf(0)]);\n  const [versionIndex, setVersionIndex] = useState(0);\n  const [isRegenerating, setIsRegenerating] = useState(false);\n  const [feedback, setFeedback] = useState<Feedback | null>(null);\n  const [readState, setReadState] = useState<ReadState>('idle');\n  const [readIndex, setReadIndex] = useState<number | null>(null);\n  const [isReported, setIsReported] = useState(false);\n  const [notice, setNotice] = useState('');\n  const regenerateTimers = useTimeouts();\n  const readTimers = useTimeouts();\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const bodyMotion = bodyMotionOf(shouldReduceMotion);\n  const rowPresence = rowPresenceOf(shouldReduceMotion);\n  const version = versions[versionIndex];\n  const canRegenerate = versions.length < MAX_VERSIONS;\n  const versionWords = versionLineOf(versionIndex, versions.length);\n\n  function stopReading() {\n    readTimers.clearAll();\n    setReadState('idle');\n    setReadIndex(null);\n  }\n\n  function startReading() {\n    const { starts, total } = readTimesOf(version.sentences);\n    setReadState('reading');\n    setReadIndex(0);\n    setNotice('Reading the answer aloud');\n    starts.forEach((start, index) => {\n      readTimers.schedule(() => setReadIndex(index), start);\n    });\n    readTimers.schedule(() => {\n      setReadState('idle');\n      setReadIndex(null);\n      setNotice('Finished reading');\n    }, total);\n  }\n\n  function toggleRead() {\n    if (readState === 'reading') {\n      stopReading();\n      setNotice('Stopped reading');\n      return;\n    }\n    startReading();\n  }\n\n  function regenerate() {\n    const isFull = !canRegenerate;\n    if (isFull || isRegenerating) {\n      return;\n    }\n    stopReading();\n    setIsRegenerating(true);\n    setNotice('Writing a new version');\n    const nextIndex = versions.length;\n    const nextCount = nextIndex + 1;\n    regenerateTimers.schedule(() => {\n      setVersions((current) => [...current, versionOf(nextIndex)]);\n      setVersionIndex(nextIndex);\n      setIsRegenerating(false);\n      setNotice(versionLineOf(nextIndex, nextCount));\n    }, REGENERATE_MS);\n  }\n\n  function showVersion(index: number) {\n    stopReading();\n    setVersionIndex(index);\n    setNotice(versionLineOf(index, versions.length));\n  }\n\n  function showPrevious() {\n    const previous = versionIndex - 1;\n    showVersion(previous);\n  }\n\n  function showNext() {\n    const next = versionIndex + 1;\n    showVersion(next);\n  }\n\n  function changeFeedback(next: Feedback | null) {\n    setFeedback(next);\n    setNotice(feedbackLineOf(next));\n  }\n\n  function sendReasons(reasons: string[], note: string) {\n    const hasNote = note !== '';\n    if (hasNote) {\n      setNotice('Thanks, your note was sent');\n      return;\n    }\n    const sent = countOf(reasons.length, 'reason');\n    setNotice(`Thanks, ${sent} sent`);\n  }\n\n  async function share() {\n    const isCopied = await copyToClipboard(SHARE_LINK);\n    if (isCopied) {\n      setNotice('Share link copied');\n    }\n  }\n\n  async function copyMarkdown() {\n    const isCopied = await copyToClipboard(markdownOf(version));\n    if (isCopied) {\n      setNotice('Copied as Markdown');\n    }\n  }\n\n  function report() {\n    setIsReported(true);\n    setNotice('Answer reported');\n  }\n\n  function undoReport() {\n    setIsReported(false);\n    setNotice('Report withdrawn');\n  }\n\n  return (\n    <div className='bg-card hairline-ring ring-border flex w-full max-w-[32rem] 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        <div className='flex min-w-0 flex-1 flex-col gap-2'>\n          <p className='text-muted-foreground flex items-center gap-1.5 text-xs'>\n            <span className='text-foreground font-medium'>Claude</span>\n            <span aria-hidden='true'>·</span>\n            <WordRoll text={versionWords} />\n          </p>\n          <AnimatedSize>\n            <div className='relative'>\n              <AnimatePresence initial={false} mode='popLayout'>\n                <motion.p\n                  key={version.id}\n                  className={cn(\n                    'text-sm leading-relaxed transition-opacity duration-200',\n                    bodyToneOf(isRegenerating)\n                  )}\n                  {...bodyMotion}\n                >\n                  {version.sentences.map((sentence, index) => {\n                    const isRead = readIndex === index;\n                    return (\n                      <span key={index}>\n                        {leadingSpaceOf(index)}\n                        <span\n                          className={cn(\n                            'rounded-[4px] box-decoration-clone transition-colors duration-200',\n                            sentenceToneOf(isRead)\n                          )}\n                        >\n                          {sentence}\n                        </span>\n                      </span>\n                    );\n                  })}\n                </motion.p>\n              </AnimatePresence>\n            </div>\n          </AnimatedSize>\n          <MessageActionBar\n            copyText={textOf(version)}\n            versionIndex={versionIndex}\n            versionCount={versions.length}\n            isRegenerating={isRegenerating}\n            canRegenerate={canRegenerate}\n            onRegenerate={regenerate}\n            onPrevious={showPrevious}\n            onNext={showNext}\n            feedback={feedback}\n            onFeedback={changeFeedback}\n            onSendReasons={sendReasons}\n            readState={readState}\n            onToggleRead={toggleRead}\n            onShare={share}\n            onCopyMarkdown={copyMarkdown}\n            onReport={report}\n            onCopied={() => setNotice('Answer copied')}\n          />\n          <AnimatePresence initial={false}>\n            {isReported && (\n              <motion.div\n                key='reported'\n                className='overflow-hidden'\n                {...rowPresence}\n              >\n                <div className='bg-destructive/10 text-destructive-ink flex items-center gap-2 rounded-lg py-1.5 ps-2.5 pe-1.5 text-xs'>\n                  <TriangleAlertIcon className='size-3.5 shrink-0' />\n                  <span className='min-w-0 flex-1'>\n                    Reported. Someone will review this answer.\n                  </span>\n                  <Button\n                    type='button'\n                    variant='ghost'\n                    size='sm'\n                    onClick={undoReport}\n                    className='h-6 px-2'\n                  >\n                    <RotateCcwIcon className='size-3.5' />\n                    Undo\n                  </Button>\n                </div>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n      </div>\n      <LiveRegion message={notice} />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/answer/answer-03.tsx"
    },
    {
      "path": "registry/ndeko/components/answer/_parts/copy-action.tsx",
      "content": "'use client';\n\nimport { useState } from 'react';\n\nimport { useTimeouts } from '@/registry/ndeko/hooks/use-timeouts';\nimport { CheckIcon, CopyIcon } from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button, type ButtonProps } from '@/registry/ndeko/ui/button';\nimport { Swap } from '@/registry/ndeko/ui/chat-motion';\n\nconst COPIED_FOR_MS = 1500;\n\nfunction accessibleLabelOf(isCopied: boolean, label: string) {\n  if (isCopied) {\n    return 'Copied';\n  }\n  return label;\n}\n\ntype CopyActionProps = Omit<ButtonProps, 'children'> & {\n  text: string;\n  /** What the button copies, as an action: \"Copy the answer\". */\n  label: string;\n  onCopied?: () => void;\n};\n\n/** A copy icon button that passes trigger props through, so a tooltip row can wrap it. */\nexport function CopyAction({\n  text,\n  label,\n  onCopied,\n  onClick,\n  className,\n  ...props\n}: CopyActionProps) {\n  const [isCopied, setIsCopied] = useState(false);\n  const { schedule } = useTimeouts();\n  const accessibleLabel = accessibleLabelOf(isCopied, label);\n  const glyphKey = String(isCopied);\n\n  async function copy() {\n    try {\n      await navigator.clipboard.writeText(text);\n    } catch {\n      return;\n    }\n    setIsCopied(true);\n    onCopied?.();\n    schedule(() => setIsCopied(false), COPIED_FOR_MS);\n  }\n\n  const handleClick: ButtonProps['onClick'] = (event) => {\n    onClick?.(event);\n    void copy();\n  };\n\n  return (\n    <Button\n      {...props}\n      type='button'\n      variant='ghost'\n      size='icon-sm'\n      aria-label={accessibleLabel}\n      onClick={handleClick}\n      className={cn('text-muted-foreground', className)}\n    >\n      <Swap swapKey={glyphKey}>\n        {isCopied && <CheckIcon className='size-3.5' />}\n        {!isCopied && <CopyIcon className='size-3.5' />}\n      </Swap>\n    </Button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/answer/_parts/copy-action.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"
}