{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "comment-02",
  "title": "Mention suggestions",
  "description": "A comment field where @ lists the team, arrows and Enter pick a name and each mention is tinted in the text",
  "dependencies": [
    "date-fns",
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/avatar.json",
    "https://ui.ndekocode.dev/r/button.json",
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/recipes.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/comment/comment-02.tsx",
      "content": "'use client';\n\nimport { AnimatePresence, motion, type MotionProps } from 'motion/react';\nimport { useId, useRef, useState } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { AtSignIcon } from '@/registry/ndeko/lib/icons';\nimport { MENU_ITEM } from '@/registry/ndeko/lib/recipes';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { Button } from '@/registry/ndeko/ui/button';\n\nimport {\n  draftCommentOf,\n  MENTION_TRIGGER,\n  segmentsOf,\n  type CommentItem,\n} from './_parts/comment-model';\nimport { CommentBlock, PersonAvatar } from './_parts/comment-parts';\nimport { ENTER, EXIT, heightPresenceOf } from './_parts/motion-parts';\nimport { CURRENT_USER_ID, PEOPLE, type Person } from './_parts/people';\n\ntype MentionQuery = {\n  start: number;\n  text: string;\n};\n\ntype TextEdit = {\n  value: string;\n  caret: number;\n};\n\nconst MAX_QUERY_LENGTH = 24;\n\nconst MAX_SUGGESTIONS = 5;\n\nconst FIRST_COMMENTS: CommentItem[] = [\n  {\n    id: 'm1',\n    authorId: 'leo',\n    at: '2026-11-05T10:02:00',\n    body: 'The column picker is in review. @Tomás Rivera has the dates part.',\n  },\n];\n\n/** The field and the layer of marks under it share one box and one text setting, or the marks drift off the words. */\nconst FIELD_BOX =\n  'col-start-1 row-start-1 max-h-40 min-h-16 w-full px-3 pt-2.5 pb-1';\n\nconst FIELD_TEXT =\n  'text-base leading-5 whitespace-pre-wrap [overflow-wrap:anywhere] md:text-[13px]';\n\nconst MENTION_MARK =\n  'rounded-[3px] bg-primary/15 text-transparent dark:bg-primary/25';\n\n/** Keeps a last empty line drawn in the layer when the text ends with a line break. */\nconst MIRROR_TAIL = '​';\n\nconst POPUP_MOTION: MotionProps = {\n  initial: { opacity: 0, scale: 0.95 },\n  animate: { opacity: 1, scale: 1 },\n  exit: { opacity: 0, scale: 0.95, transition: { ...EXIT, duration: 0.1 } },\n  transition: { ...ENTER, duration: 0.15 },\n};\n\nconst POPUP_FADE: MotionProps = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0, transition: { ...EXIT, duration: 0.1 } },\n  transition: { ...ENTER, duration: 0.15 },\n};\n\nfunction popupMotionOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return POPUP_FADE;\n  }\n  return POPUP_MOTION;\n}\n\nfunction startsWord(text: string, index: number) {\n  if (index === 0) {\n    return true;\n  }\n  const previous = text.charAt(index - 1);\n  return /\\s/.test(previous);\n}\n\nfunction mentionQueryBefore(text: string, caret: number) {\n  const before = text.slice(0, caret);\n  const start = before.lastIndexOf(MENTION_TRIGGER);\n  if (start === -1) {\n    return null;\n  }\n  if (!startsWord(text, start)) {\n    return null;\n  }\n  const query = before.slice(start + MENTION_TRIGGER.length);\n  const isShortEnough = query.length <= MAX_QUERY_LENGTH;\n  const isOneLine = !query.includes('\\n');\n  const spaceCount = query.split(' ').length - 1;\n  const isTwoWordsAtMost = spaceCount <= 1;\n  const isQuery = isShortEnough && isOneLine && isTwoWordsAtMost;\n  if (!isQuery) {\n    return null;\n  }\n  const mention: MentionQuery = { start, text: query };\n  return mention;\n}\n\nfunction matchesQuery(person: Person, query: string) {\n  const lowered = query.toLowerCase();\n  const name = person.name.toLowerCase();\n  if (name.startsWith(lowered)) {\n    return true;\n  }\n  const words = name.split(' ');\n  return words.some((word) => word.startsWith(lowered));\n}\n\nfunction suggestionsOf(query: MentionQuery | null) {\n  if (!query) {\n    return [];\n  }\n  const others = PEOPLE.filter((person) => person.id !== CURRENT_USER_ID);\n  const matching = others.filter((person) => matchesQuery(person, query.text));\n  return matching.slice(0, MAX_SUGGESTIONS);\n}\n\nfunction wrappedIndex(index: number, count: number) {\n  return (index + count) % count;\n}\n\nfunction triggerFor(text: string, caret: number) {\n  if (startsWord(text, caret)) {\n    return MENTION_TRIGGER;\n  }\n  return ` ${MENTION_TRIGGER}`;\n}\n\nfunction insertedAt(text: string, start: number, end: number, insert: string) {\n  const value = text.slice(0, start) + insert + text.slice(end);\n  const edit: TextEdit = { value, caret: start + insert.length };\n  return edit;\n}\n\nfunction MentionLayer({\n  text,\n  layerRef,\n}: {\n  text: string;\n  layerRef: React.RefObject<HTMLDivElement | null>;\n}) {\n  const segments = segmentsOf(text);\n  return (\n    <div\n      ref={layerRef}\n      aria-hidden='true'\n      className={cn(\n        FIELD_BOX,\n        FIELD_TEXT,\n        'pointer-events-none overflow-hidden text-transparent'\n      )}\n    >\n      {segments.map((segment, index) => {\n        const key = `${index}-${segment.text}`;\n        if (segment.isMention) {\n          return (\n            <mark key={key} className={MENTION_MARK}>\n              {segment.text}\n            </mark>\n          );\n        }\n        return <span key={key}>{segment.text}</span>;\n      })}\n      {MIRROR_TAIL}\n    </div>\n  );\n}\n\nfunction SuggestionList({\n  listId,\n  people,\n  activeIndex,\n  onPick,\n  onHighlight,\n}: {\n  listId: string;\n  people: Person[];\n  activeIndex: number;\n  onPick: (person: Person) => void;\n  onHighlight: (index: number) => void;\n}) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  return (\n    <motion.div\n      id={listId}\n      role='listbox'\n      aria-label='People to mention'\n      {...popupMotionOf(shouldReduceMotion)}\n      className='surface-raised absolute bottom-full left-0 z-20 mb-2 w-60 origin-bottom-left rounded-xl p-1'\n    >\n      {people.map((person, index) => {\n        const isActive = index === activeIndex;\n        return (\n          <div\n            key={person.id}\n            id={`${listId}-${index}`}\n            role='option'\n            tabIndex={-1}\n            aria-selected={isActive}\n            data-highlighted={isActive || undefined}\n            onMouseDown={(event) => {\n              event.preventDefault();\n              onPick(person);\n            }}\n            onMouseEnter={() => onHighlight(index)}\n            className={cn(MENU_ITEM, 'cursor-pointer')}\n          >\n            <PersonAvatar person={person} className='size-5 text-[9px]' />\n            <span className='min-w-0 flex-1 truncate'>{person.name}</span>\n          </div>\n        );\n      })}\n    </motion.div>\n  );\n}\n\nfunction PostedComment({ comment }: { comment: CommentItem }) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  return (\n    <motion.li\n      {...heightPresenceOf(shouldReduceMotion)}\n      className='overflow-hidden'\n    >\n      <CommentBlock comment={comment} className='pb-4' />\n    </motion.li>\n  );\n}\n\nexport default function Comment02() {\n  const listId = useId();\n  const fieldRef = useRef<HTMLTextAreaElement>(null);\n  const layerRef = useRef<HTMLDivElement>(null);\n  const [comments, setComments] = useState(FIRST_COMMENTS);\n  const [body, setBody] = useState('');\n  const [query, setQuery] = useState<MentionQuery | null>(null);\n  const [activeIndex, setActiveIndex] = useState(0);\n  const suggestions = suggestionsOf(query);\n  const showsSuggestions = suggestions.length > 0;\n  const highlighted = Math.min(activeIndex, suggestions.length - 1);\n  const trimmed = body.trim();\n  const canSend = trimmed.length > 0;\n  let activeOptionId: string | undefined;\n  if (showsSuggestions) {\n    activeOptionId = `${listId}-${highlighted}`;\n  }\n\n  function readQuery(text: string, caret: number) {\n    setQuery(mentionQueryBefore(text, caret));\n    setActiveIndex(0);\n  }\n\n  function apply(edit: TextEdit) {\n    setBody(edit.value);\n    readQuery(edit.value, edit.caret);\n    requestAnimationFrame(() => {\n      const field = fieldRef.current;\n      field?.focus();\n      field?.setSelectionRange(edit.caret, edit.caret);\n    });\n  }\n\n  function caretOf() {\n    const field = fieldRef.current;\n    if (!field) {\n      return body.length;\n    }\n    return field.selectionStart;\n  }\n\n  function pick(person: Person) {\n    if (!query) {\n      return;\n    }\n    const mention = `${MENTION_TRIGGER}${person.name} `;\n    apply(insertedAt(body, query.start, caretOf(), mention));\n  }\n\n  function startMention() {\n    const caret = caretOf();\n    apply(insertedAt(body, caret, caret, triggerFor(body, caret)));\n  }\n\n  function send() {\n    if (!canSend) {\n      return;\n    }\n    setComments((current) => [...current, draftCommentOf(trimmed)]);\n    setBody('');\n    setQuery(null);\n  }\n\n  function handleSuggestionKey(\n    event: React.KeyboardEvent<HTMLTextAreaElement>\n  ) {\n    if (event.key === 'ArrowDown') {\n      event.preventDefault();\n      setActiveIndex(wrappedIndex(highlighted + 1, suggestions.length));\n      return true;\n    }\n    if (event.key === 'ArrowUp') {\n      event.preventDefault();\n      setActiveIndex(wrappedIndex(highlighted - 1, suggestions.length));\n      return true;\n    }\n    const isPickKey = event.key === 'Enter' || event.key === 'Tab';\n    if (isPickKey) {\n      event.preventDefault();\n      pick(suggestions[highlighted]);\n      return true;\n    }\n    if (event.key === 'Escape') {\n      event.preventDefault();\n      event.stopPropagation();\n      setQuery(null);\n      return true;\n    }\n    return false;\n  }\n\n  function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {\n    if (showsSuggestions) {\n      const isHandled = handleSuggestionKey(event);\n      if (isHandled) {\n        return;\n      }\n    }\n    const isCommand = event.metaKey || event.ctrlKey;\n    const isSendKey = isCommand && event.key === 'Enter';\n    if (isSendKey) {\n      event.preventDefault();\n      send();\n    }\n  }\n\n  function handleChange(event: React.ChangeEvent<HTMLTextAreaElement>) {\n    const field = event.target;\n    setBody(field.value);\n    readQuery(field.value, field.selectionStart);\n  }\n\n  function handleSelect(event: React.SyntheticEvent<HTMLTextAreaElement>) {\n    const field = event.currentTarget;\n    setQuery(mentionQueryBefore(field.value, field.selectionStart));\n  }\n\n  function syncLayer(event: React.UIEvent<HTMLTextAreaElement>) {\n    const layer = layerRef.current;\n    if (layer) {\n      layer.scrollTop = event.currentTarget.scrollTop;\n    }\n  }\n\n  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {\n    event.preventDefault();\n    send();\n  }\n\n  return (\n    <section aria-label='Comments' className='w-full max-w-md'>\n      <ul>\n        <AnimatePresence initial={false}>\n          {comments.map((comment) => (\n            <PostedComment key={comment.id} comment={comment} />\n          ))}\n        </AnimatePresence>\n      </ul>\n      <form\n        onSubmit={handleSubmit}\n        className='bg-card hairline-ring ring-foreground/12 focus-within:ring-ring/25 dark:bg-foreground/[0.04] dark:ring-foreground/15 relative rounded-lg shadow-[0_1px_2px_rgb(0_0_0/0.05)] transition-shadow duration-150 focus-within:ring-[3px]'\n      >\n        <div className='grid'>\n          <MentionLayer text={body} layerRef={layerRef} />\n          <textarea\n            ref={fieldRef}\n            value={body}\n            onChange={handleChange}\n            onSelect={handleSelect}\n            onKeyDown={handleKeyDown}\n            onScroll={syncLayer}\n            onBlur={() => setQuery(null)}\n            rows={2}\n            aria-label='Comment, type @ to mention someone'\n            aria-autocomplete='list'\n            aria-controls={listId}\n            aria-activedescendant={activeOptionId}\n            placeholder='Comment or type @ to mention someone'\n            className={cn(\n              FIELD_BOX,\n              FIELD_TEXT,\n              'placeholder:text-muted-foreground/75 relative resize-none bg-transparent outline-none'\n            )}\n          />\n        </div>\n        <div className='flex items-center justify-between px-1.5 pb-1.5'>\n          <Button\n            type='button'\n            variant='ghost'\n            size='icon-sm'\n            aria-label='Mention someone'\n            onClick={startMention}\n            className='text-muted-foreground'\n          >\n            <AtSignIcon />\n          </Button>\n          <Button type='submit' size='sm' disabled={!canSend}>\n            Comment\n          </Button>\n        </div>\n        <AnimatePresence>\n          {showsSuggestions && (\n            <SuggestionList\n              key='suggestions'\n              listId={listId}\n              people={suggestions}\n              activeIndex={highlighted}\n              onPick={pick}\n              onHighlight={setActiveIndex}\n            />\n          )}\n        </AnimatePresence>\n      </form>\n    </section>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/comment/comment-02.tsx"
    },
    {
      "path": "registry/ndeko/components/comment/_parts/comment-model.ts",
      "content": "import { CURRENT_USER_ID, PEOPLE } from './people';\nimport { NOW } from './time';\n\nexport type CommentItem = {\n  id: string;\n  authorId: string;\n  at: string;\n  body: string;\n  editedAt?: string;\n};\n\nexport type Segment = {\n  text: string;\n  isMention: boolean;\n};\n\nexport const MENTION_TRIGGER = '@';\n\nexport const MENTION_NAMES = PEOPLE.map((person) => person.name);\n\nfunction mentionTokensOf(names: string[]) {\n  const tokens = names.map((name) => `${MENTION_TRIGGER}${name}`);\n  return tokens.sort((first, second) => second.length - first.length);\n}\n\nfunction pushPlain(segments: Segment[], text: string) {\n  if (text.length === 0) {\n    return;\n  }\n  segments.push({ text, isMention: false });\n}\n\n/** Splits a comment into plain text and mentions of known people, written \"@First Last\". */\nexport function segmentsOf(text: string, names: string[] = MENTION_NAMES) {\n  const tokens = mentionTokensOf(names);\n  const segments: Segment[] = [];\n  let plain = '';\n  let index = 0;\n  while (index < text.length) {\n    const token = tokens.find((candidate) => text.startsWith(candidate, index));\n    if (token) {\n      pushPlain(segments, plain);\n      segments.push({ text: token, isMention: true });\n      plain = '';\n      index += token.length;\n      continue;\n    }\n    plain += text[index];\n    index += 1;\n  }\n  pushPlain(segments, plain);\n  return segments;\n}\n\nlet draftCount = 0;\n\n/** A comment the signed-in person just wrote, stamped with the previews' fixed now. */\nexport function draftCommentOf(body: string): CommentItem {\n  draftCount += 1;\n  return {\n    id: `draft-${draftCount}`,\n    authorId: CURRENT_USER_ID,\n    at: NOW,\n    body,\n  };\n}\n\nexport function commentWordOf(count: number) {\n  if (count === 1) {\n    return 'comment';\n  }\n  return 'comments';\n}\n\nexport function replyWordOf(count: number) {\n  if (count === 1) {\n    return 'reply';\n  }\n  return 'replies';\n}\n",
      "type": "registry:component",
      "target": "components/comment/_parts/comment-model.ts"
    },
    {
      "path": "registry/ndeko/components/comment/_parts/comment-parts.tsx",
      "content": "import { cn } from '@/registry/ndeko/lib/utils';\nimport { Avatar } from '@/registry/ndeko/ui/avatar';\n\nimport { segmentsOf, type CommentItem } from './comment-model';\nimport { actorNameOf, personOf, type Person } from './people';\nimport { relativeTimeOf } from './time';\n\nexport const MENTION_CHIP =\n  'rounded-[4px] bg-primary/10 px-0.5 font-medium text-primary-ink dark:bg-primary/20';\n\nexport const TEXT_ACTION =\n  'text-muted-foreground hover:text-foreground focus-visible:ring-ring/25 inline-flex h-6 cursor-pointer items-center gap-1 rounded-md text-xs font-medium transition-colors outline-none focus-visible:ring-[3px]';\n\nexport function PersonAvatar({\n  person,\n  className,\n}: {\n  person: Person;\n  className?: string;\n}) {\n  return (\n    <Avatar\n      className={cn('size-7 text-[10px]', className)}\n      seed={person.name}\n    />\n  );\n}\n\n/** A comment's words, with each mention of a known person drawn as a tinted chip. */\nexport function CommentText({\n  body,\n  className,\n}: {\n  body: string;\n  className?: string;\n}) {\n  const segments = segmentsOf(body);\n  return (\n    <p\n      className={cn(\n        'text-[13px] leading-5 [overflow-wrap:anywhere] whitespace-pre-wrap',\n        className\n      )}\n    >\n      {segments.map((segment, index) => {\n        const key = `${index}-${segment.text}`;\n        if (segment.isMention) {\n          return (\n            <span key={key} className={MENTION_CHIP}>\n              {segment.text}\n            </span>\n          );\n        }\n        return <span key={key}>{segment.text}</span>;\n      })}\n    </p>\n  );\n}\n\nexport function CommentMeta({\n  comment,\n  children,\n}: {\n  comment: CommentItem;\n  children?: React.ReactNode;\n}) {\n  return (\n    <p className='flex min-w-0 items-baseline gap-1.5 text-[13px]'>\n      <span className='truncate font-medium'>\n        {actorNameOf(comment.authorId)}\n      </span>\n      <span aria-hidden='true' className='text-muted-foreground'>\n        ·\n      </span>\n      <time\n        dateTime={comment.at}\n        className='text-muted-foreground shrink-0 text-xs tabular-nums'\n      >\n        {relativeTimeOf(comment.at)}\n      </time>\n      {children}\n    </p>\n  );\n}\n\ntype CommentBlockProps = {\n  comment: CommentItem;\n  avatarClassName?: string;\n  meta?: React.ReactNode;\n  actions?: React.ReactNode;\n  children?: React.ReactNode;\n  className?: string;\n};\n\n/** Avatar, author and time, then the words aligned under the name. */\nexport function CommentBlock({\n  comment,\n  avatarClassName,\n  meta,\n  actions,\n  children,\n  className,\n}: CommentBlockProps) {\n  return (\n    <div className={cn('flex gap-3', className)}>\n      <PersonAvatar\n        person={personOf(comment.authorId)}\n        className={avatarClassName}\n      />\n      <div className='min-w-0 flex-1'>\n        <div className='flex min-h-7 items-center justify-between gap-2'>\n          <CommentMeta comment={comment}>{meta}</CommentMeta>\n          {actions}\n        </div>\n        {children}\n        {!children && <CommentText body={comment.body} />}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/comment/_parts/comment-parts.tsx"
    },
    {
      "path": "registry/ndeko/components/comment/_parts/motion-parts.tsx",
      "content": "'use client';\n\nimport {\n  AnimatePresence,\n  motion,\n  type MotionProps,\n  type Transition,\n  type Variants,\n} from 'motion/react';\nimport { useState } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nexport const EASE_OUT_STRONG: [number, number, number, number] = [\n  0.23, 1, 0.32, 1,\n];\n\nexport const ENTER: Transition = { duration: 0.2, ease: EASE_OUT_STRONG };\n\nexport const EXIT: Transition = { duration: 0.15, ease: EASE_OUT_STRONG };\n\nexport type PresenceProps = Pick<\n  MotionProps,\n  'initial' | 'animate' | 'exit' | 'transition'\n>;\n\nconst HEIGHT_PRESENCE: PresenceProps = {\n  initial: { height: 0, opacity: 0 },\n  animate: { height: 'auto', opacity: 1 },\n  exit: { height: 0, opacity: 0, transition: EXIT },\n  transition: ENTER,\n};\n\nconst FADE_PRESENCE: PresenceProps = {\n  initial: { opacity: 0 },\n  animate: { opacity: 1 },\n  exit: { opacity: 0, transition: EXIT },\n  transition: ENTER,\n};\n\n/** Rows that arrive or leave a list: their height opens and closes so the rows after them slide. */\nexport function heightPresenceOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_PRESENCE;\n  }\n  return HEIGHT_PRESENCE;\n}\n\nconst SWAP_TRANSITION: Transition = { duration: 0.15, ease: EASE_OUT_STRONG };\n\nconst SWAP_VARIANTS: Variants = {\n  hidden: { opacity: 0, scale: 0.6, filter: 'blur(2px)' },\n  shown: { opacity: 1, scale: 1, filter: 'blur(0px)' },\n};\n\nconst FADE_VARIANTS: Variants = {\n  hidden: { opacity: 0 },\n  shown: { opacity: 1 },\n};\n\nfunction swapVariantsOf(shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return FADE_VARIANTS;\n  }\n  return SWAP_VARIANTS;\n}\n\ntype SwapProps = {\n  swapKey: string;\n  children: React.ReactNode;\n  className?: string;\n};\n\n/** An icon or a short label that becomes another one in place. */\nexport function Swap({ swapKey, children, className }: SwapProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n\n  return (\n    <span className={cn('relative inline-flex', className)}>\n      <AnimatePresence mode='popLayout' initial={false}>\n        <motion.span\n          key={swapKey}\n          variants={swapVariantsOf(shouldReduceMotion)}\n          initial='hidden'\n          animate='shown'\n          exit='hidden'\n          transition={SWAP_TRANSITION}\n          className='inline-flex'\n        >\n          {children}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nconst COUNT_SLIDE = 8;\n\nconst COUNT_TRANSITION: Transition = { duration: 0.18, ease: EASE_OUT_STRONG };\n\nconst COUNT_VARIANTS: Variants = {\n  enter: (offset: number) => ({ y: offset, opacity: 0 }),\n  center: { y: 0, opacity: 1 },\n  leave: (offset: number) => ({ y: -offset, opacity: 0 }),\n};\n\nfunction directionOf(previous: number, next: number) {\n  if (next < previous) {\n    return -1;\n  }\n  return 1;\n}\n\nfunction offsetOf(direction: number, shouldReduceMotion: boolean | null) {\n  if (shouldReduceMotion) {\n    return 0;\n  }\n  return direction * COUNT_SLIDE;\n}\n\ntype RollingCountProps = {\n  value: number;\n  /** Writes the value, such as a duration; the digits still roll on the number. */\n  format?: (value: number) => string;\n  className?: string;\n};\n\nfunction plainNumber(value: number) {\n  return String(value);\n}\n\n/** A figure the person just changed: the new digits slide in from the side of the change. */\nexport function RollingCount({\n  value,\n  format = plainNumber,\n  className,\n}: RollingCountProps) {\n  const shouldReduceMotion = usePrefersReducedMotion();\n  const [previous, setPrevious] = useState(value);\n  const [direction, setDirection] = useState(1);\n\n  if (previous !== value) {\n    setDirection(directionOf(previous, value));\n    setPrevious(value);\n  }\n\n  const offset = offsetOf(direction, shouldReduceMotion);\n\n  return (\n    <span\n      className={cn(\n        'relative inline-flex overflow-hidden tabular-nums',\n        className\n      )}\n    >\n      <AnimatePresence mode='popLayout' initial={false} custom={offset}>\n        <motion.span\n          key={value}\n          custom={offset}\n          variants={COUNT_VARIANTS}\n          initial='enter'\n          animate='center'\n          exit='leave'\n          transition={COUNT_TRANSITION}\n          className='inline-block'\n        >\n          {format(value)}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/comment/_parts/motion-parts.tsx"
    },
    {
      "path": "registry/ndeko/components/comment/_parts/people.ts",
      "content": "export type Person = {\n  id: string;\n  name: string;\n  firstName: string;\n};\n\nexport const CURRENT_USER_ID = 'amara';\n\nexport const PEOPLE: Person[] = [\n  {\n    id: 'amara',\n    name: 'Amara Okafor',\n    firstName: 'Amara',\n  },\n  {\n    id: 'leo',\n    name: 'Léo Martin',\n    firstName: 'Léo',\n  },\n  {\n    id: 'sana',\n    name: 'Sana Iqbal',\n    firstName: 'Sana',\n  },\n  {\n    id: 'tomas',\n    name: 'Tomás Rivera',\n    firstName: 'Tomás',\n  },\n  {\n    id: 'mei',\n    name: 'Mei Lin',\n    firstName: 'Mei',\n  },\n  {\n    id: 'jonah',\n    name: 'Jonah Brandt',\n    firstName: 'Jonah',\n  },\n];\n\nexport function personOf(id: string) {\n  const found = PEOPLE.find((person) => person.id === id);\n  if (found) {\n    return found;\n  }\n  return PEOPLE[0];\n}\n\nexport function isCurrentUser(id: string) {\n  return id === CURRENT_USER_ID;\n}\n\nexport function actorNameOf(id: string) {\n  if (isCurrentUser(id)) {\n    return 'You';\n  }\n  return personOf(id).name;\n}\n\nexport function currentUser() {\n  return personOf(CURRENT_USER_ID);\n}\n",
      "type": "registry:component",
      "target": "components/comment/_parts/people.ts"
    },
    {
      "path": "registry/ndeko/components/comment/_parts/time.ts",
      "content": "import {\n  differenceInCalendarDays,\n  differenceInMinutes,\n  format,\n  parseISO,\n} from 'date-fns';\n\n/** The previews read every time against one fixed moment, so the server and the browser print the same labels. */\nexport const NOW = '2026-11-05T11:00:00';\n\nconst MINUTES_PER_HOUR = 60;\n\nfunction daysBeforeNow(iso: string) {\n  return differenceInCalendarDays(parseISO(NOW), parseISO(iso));\n}\n\nexport function relativeTimeOf(iso: string) {\n  const minutes = differenceInMinutes(parseISO(NOW), parseISO(iso));\n  if (minutes < 1) {\n    return 'Just now';\n  }\n  if (minutes < MINUTES_PER_HOUR) {\n    return `${minutes}m ago`;\n  }\n  const days = daysBeforeNow(iso);\n  if (days === 0) {\n    const hours = Math.floor(minutes / MINUTES_PER_HOUR);\n    return `${hours}h ago`;\n  }\n  if (days === 1) {\n    return 'Yesterday';\n  }\n  return format(parseISO(iso), 'MMM d');\n}\n\nexport function exactTimeOf(iso: string) {\n  return format(parseISO(iso), \"EEE, MMM d 'at' HH:mm\");\n}\n\nexport function clockOf(iso: string) {\n  return format(parseISO(iso), 'HH:mm');\n}\n\nexport function dayKeyOf(iso: string) {\n  return iso.slice(0, 10);\n}\n\nexport function dayLabelOf(iso: string) {\n  const days = daysBeforeNow(iso);\n  if (days === 0) {\n    return 'Today';\n  }\n  if (days === 1) {\n    return 'Yesterday';\n  }\n  return format(parseISO(iso), 'EEEE, MMM d');\n}\n\nexport function shortDateOf(iso: string) {\n  return format(parseISO(iso), 'MMM d');\n}\n\nexport function durationLabelOf(minutes: number) {\n  const hours = Math.floor(minutes / MINUTES_PER_HOUR);\n  const rest = minutes % MINUTES_PER_HOUR;\n  if (hours === 0) {\n    return `${rest}m`;\n  }\n  if (rest === 0) {\n    return `${hours}h`;\n  }\n  return `${hours}h ${rest}m`;\n}\n\nexport function countLabel(count: number, singular: string, plural: string) {\n  if (count === 1) {\n    return `${count} ${singular}`;\n  }\n  return `${count} ${plural}`;\n}\n",
      "type": "registry:component",
      "target": "components/comment/_parts/time.ts"
    }
  ],
  "categories": [
    "components",
    "comment"
  ],
  "type": "registry:component"
}