{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "comment-thread",
  "title": "Comment Thread",
  "author": "ilinxa",
  "description": "Recursive comment thread with composer, optimistic add, like, and delete, inline expansion past max depth, and realtime subscription hooks.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@ilinxa/expandable-text",
    "@ilinxa/engagement-bar",
    "avatar",
    "button",
    "dropdown-menu",
    "textarea"
  ],
  "files": [
    {
      "path": "src/registry/components/data/comment-thread/comment-thread.tsx",
      "content": "\"use client\";\n\nimport {\n  memo,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  DEFAULT_COMMENT_THREAD_LABELS,\n  type Comment,\n  type CommentThreadHandle,\n  type CommentThreadProps,\n  type CommentThreadLabels,\n} from \"./types\";\nimport { useCommentState } from \"./hooks/use-comment-state\";\nimport { CommentNode } from \"./parts/comment-node\";\nimport {\n  CommentComposer,\n  type CommentComposerHandle,\n} from \"./parts/comment-composer\";\nimport { CommentEmptyState } from \"./parts/comment-empty-state\";\nimport { defaultRelativeTime } from \"./lib/format-time\";\n\ninterface CommentThreadInnerProps extends CommentThreadProps {\n  ref?: React.Ref<CommentThreadHandle>;\n}\n\nfunction genTempId(): string {\n  if (typeof crypto !== \"undefined\" && \"randomUUID\" in crypto) {\n    return `temp-${crypto.randomUUID()}`;\n  }\n  return `temp-${Date.now()}-${Math.random().toString(36).slice(2)}`;\n}\n\nfunction CommentThreadInner(props: CommentThreadInnerProps) {\n  const {\n    comments: initialComments,\n    variant = \"default\",\n    currentUser,\n    maxDepth = 2,\n    indentPx = 24,\n    bodyMaxLines: bodyMaxLinesProp,\n    composerMinRows = 1,\n    composerMaxRows = 6,\n    submitOnEnter = true,\n    pageSize = 10,\n    subscribe,\n    onSubscribeDelta,\n    onAddComment,\n    onLikeComment,\n    onDeleteComment,\n    onReportComment,\n    onLoadMore,\n    commentActions,\n    renderNode,\n    renderViewReplies,\n    renderComposer,\n    composerEmptyState,\n    emptyState,\n    labels: labelsProp,\n    className,\n    composerClassName,\n    nodeClassName,\n    ref,\n  } = props;\n\n  const bodyMaxLines = bodyMaxLinesProp ?? (variant === \"compact\" ? 2 : 4);\n\n  const labels = useMemo<\n    Required<Omit<CommentThreadLabels, \"formatRelativeTime\">>\n  >(\n    () => ({ ...DEFAULT_COMMENT_THREAD_LABELS, ...labelsProp }),\n    [labelsProp],\n  );\n\n  const format = useMemo(\n    () => labelsProp?.formatRelativeTime ?? defaultRelativeTime,\n    [labelsProp?.formatRelativeTime],\n  );\n\n  const { comments, dispatch } = useCommentState({\n    initialComments,\n    subscribe,\n    onSubscribeDelta,\n  });\n\n  const [replyParentId, setReplyParentId] = useState<string | null>(null);\n  const replyTriggerRef = useRef<HTMLElement | null>(null);\n  const [currentPage, setCurrentPage] = useState(1);\n  const [isLoadingMore, setIsLoadingMore] = useState(false);\n  const [hasMore, setHasMore] = useState(initialComments.length === pageSize);\n  const composerRef = useRef<CommentComposerHandle | null>(null);\n\n  // Stable refs for the imperative handle.\n  const commentsRef = useRef<Comment[]>(comments);\n  useEffect(() => {\n    commentsRef.current = comments;\n  });\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      focusComposer: () => composerRef.current?.focus(),\n      openReply: (parentId: string) => setReplyParentId(parentId),\n      getCurrentComments: () => commentsRef.current,\n      reset: (next: Comment[]) => dispatch({ kind: \"reset\", next }),\n      dispatch,\n    }),\n    // dispatch is stable; setReplyParentId is stable; refs handle the rest.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [],\n  );\n\n  // ─── Handlers ─────────────────────────────────────────────────────────────\n\n  const handleLike = useCallback(\n    (commentId: string, nextLiked: boolean) => {\n      dispatch({ kind: \"like-toggle\", commentId, nextLiked });\n      onLikeComment?.(commentId, nextLiked);\n    },\n    [dispatch, onLikeComment],\n  );\n\n  const handleDelete = useCallback(\n    (commentId: string) => {\n      dispatch({ kind: \"remove\", commentId });\n      onDeleteComment?.(commentId);\n    },\n    [dispatch, onDeleteComment],\n  );\n\n  const handleReport = useCallback(\n    (commentId: string) => {\n      onReportComment?.(commentId);\n    },\n    [onReportComment],\n  );\n\n  const submitTopLevel = useCallback(\n    async (content: string) => {\n      if (!currentUser) return;\n      const tempId = genTempId();\n      const tempComment: Comment = {\n        id: tempId,\n        author: {\n          id: currentUser.id,\n          name: currentUser.name,\n          avatar: currentUser.avatar,\n        },\n        content,\n        createdAt: new Date(),\n        likes: 0,\n        isLiked: false,\n        replies: [],\n      };\n      dispatch({ kind: \"add\", comment: tempComment });\n      const result = await onAddComment?.(content);\n      if (result && typeof result === \"object\") {\n        dispatch({ kind: \"swap-temp\", tempId, real: result });\n      }\n    },\n    [currentUser, dispatch, onAddComment],\n  );\n\n  const submitReply = useCallback(\n    async (content: string, parentId: string) => {\n      if (!currentUser) return;\n      const tempId = genTempId();\n      const tempComment: Comment = {\n        id: tempId,\n        author: {\n          id: currentUser.id,\n          name: currentUser.name,\n          avatar: currentUser.avatar,\n        },\n        content,\n        createdAt: new Date(),\n        likes: 0,\n        isLiked: false,\n        replies: [],\n      };\n      dispatch({ kind: \"add\", comment: tempComment, parentId });\n      const result = await onAddComment?.(content, parentId);\n      if (result && typeof result === \"object\") {\n        dispatch({ kind: \"swap-temp\", tempId, real: result });\n      }\n      // Close inline composer + restore focus to the trigger (a11y).\n      setReplyParentId(null);\n      const trigger = replyTriggerRef.current;\n      if (trigger) {\n        requestAnimationFrame(() => trigger.focus());\n      }\n    },\n    [currentUser, dispatch, onAddComment],\n  );\n\n  const cancelReply = useCallback(() => {\n    setReplyParentId(null);\n    const trigger = replyTriggerRef.current;\n    if (trigger) {\n      requestAnimationFrame(() => trigger.focus());\n    }\n  }, []);\n\n  const openReply = useCallback(\n    (parentId: string, triggerEl: HTMLElement | null) => {\n      replyTriggerRef.current = triggerEl;\n      setReplyParentId(parentId);\n    },\n    [],\n  );\n\n  const handleLoadMore = useCallback(async () => {\n    if (!onLoadMore || isLoadingMore) return;\n    setIsLoadingMore(true);\n    try {\n      const nextPage = currentPage + 1;\n      const result = await onLoadMore(nextPage);\n      dispatch({ kind: \"append-page\", comments: result });\n      setCurrentPage(nextPage);\n      setHasMore(result.length === pageSize);\n    } finally {\n      setIsLoadingMore(false);\n    }\n  }, [onLoadMore, isLoadingMore, currentPage, pageSize, dispatch]);\n\n  // ─── Render ───────────────────────────────────────────────────────────────\n\n  const showEmpty = comments.length === 0 && !subscribe;\n\n  return (\n    <div className={cn(\"flex flex-col gap-3\", className)}>\n      {showEmpty ? (\n        emptyState ?? <CommentEmptyState message={labels.emptyState} />\n      ) : (\n        <ul className=\"flex flex-col gap-3\">\n          {comments.map((c) => (\n            <li key={c.id}>\n              <CommentNode\n                comment={c}\n                depth={0}\n                maxDepth={maxDepth}\n                indentPx={indentPx}\n                variant={variant}\n                bodyMaxLines={bodyMaxLines}\n                currentUser={currentUser}\n                labels={labels}\n                format={format}\n                isReplyOpen={replyParentId === c.id}\n                onOpenReply={openReply}\n                onCancelReply={cancelReply}\n                onSubmitReply={submitReply}\n                onLike={handleLike}\n                onDelete={handleDelete}\n                onReport={handleReport}\n                onReportPresent={!!onReportComment}\n                commentActions={commentActions}\n                renderNode={renderNode}\n                renderViewReplies={renderViewReplies}\n                composerMinRows={composerMinRows}\n                composerMaxRows={composerMaxRows}\n                submitOnEnter={submitOnEnter}\n                composerClassName={composerClassName}\n                nodeClassName={nodeClassName}\n              />\n            </li>\n          ))}\n        </ul>\n      )}\n\n      {hasMore && onLoadMore ? (\n        <button\n          type=\"button\"\n          onClick={() => {\n            void handleLoadMore();\n          }}\n          disabled={isLoadingMore}\n          className=\"self-center rounded-md px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50\"\n        >\n          {isLoadingMore ? `${labels.loadMore}…` : labels.loadMore}\n        </button>\n      ) : null}\n\n      {currentUser ? (\n        renderComposer ? (\n          renderComposer(\n            { value: \"\", isReply: false, isSubmitting: false },\n            {\n              setValue: () => {},\n              submit: async () => {},\n              cancel: () => {},\n            },\n          )\n        ) : (\n          <CommentComposer\n            ref={composerRef}\n            currentUser={currentUser}\n            placeholder={labels.composerPlaceholder}\n            onSubmit={submitTopLevel}\n            submitOnEnter={submitOnEnter}\n            minRows={composerMinRows}\n            maxRows={composerMaxRows}\n            className={composerClassName}\n            labels={labels}\n          />\n        )\n      ) : (\n        composerEmptyState\n      )}\n    </div>\n  );\n}\n\nconst CommentThread = memo(CommentThreadInner);\nCommentThread.displayName = \"CommentThread\";\n\nexport { CommentThread };\n",
      "type": "registry:component",
      "target": "components/comment-thread/comment-thread.tsx"
    },
    {
      "path": "src/registry/components/data/comment-thread/index.ts",
      "content": "export { CommentThread } from \"./comment-thread\";\n\nexport {\n  CommentComposer,\n  type CommentComposerProps,\n  type CommentComposerHandle,\n} from \"./parts/comment-composer\";\n\nexport {\n  commentReducer,\n  useCommentState,\n  type UseCommentStateOptions,\n  type UseCommentStateResult,\n} from \"./hooks/use-comment-state\";\n\nexport {\n  useAutosizeTextarea,\n  type UseAutosizeTextareaOptions,\n} from \"./hooks/use-autosize-textarea\";\n\nexport { defaultRelativeTime, toDate } from \"./lib/format-time\";\n\nexport type {\n  Comment,\n  CommentThreadProps,\n  CommentThreadHandle,\n  CommentThreadVariant,\n  CommentThreadLabels,\n  CommentThreadCurrentUser,\n  CommentNodeHelpers,\n  CommentComposerState,\n  CommentComposerHelpers,\n  CommentMenuItem,\n  CommentDelta,\n  CommentLocalAction,\n  Subscribe,\n  Unsubscribe,\n} from \"./types\";\n\nexport { DEFAULT_COMMENT_THREAD_LABELS } from \"./types\";\n\n",
      "type": "registry:component",
      "target": "components/comment-thread/index.ts"
    },
    {
      "path": "src/registry/components/data/comment-thread/types.ts",
      "content": "import type { ReactNode } from \"react\";\n\nexport type CommentThreadVariant = \"default\" | \"compact\";\n\nexport interface Comment {\n  id: string;\n  author: {\n    id: string;\n    name: string;\n    username?: string;\n    avatar?: string;\n  };\n  content: string;\n  createdAt: Date | string | number;\n  likes: number;\n  isLiked?: boolean;\n  replies?: Comment[];\n  /** Server-known total. Used as label hint when `replies.length` undercounts. */\n  replyCount?: number;\n  /**\n   * Server-marked edited flag. When `true`, the row renders an \"(edited)\"\n   * suffix after the timestamp. Independent of the realtime `{ kind: \"edited\" }`\n   * delta — that delta will also flip this to `true` so first-paint and\n   * post-edit UI behave identically. Override the suffix copy via\n   * {@link CommentThreadLabels.editedSuffix}.\n   */\n  edited?: boolean;\n}\n\nexport type CommentDelta =\n  | { kind: \"added\"; comment: Comment; parentId?: string }\n  | { kind: \"edited\"; commentId: string; content: string }\n  | { kind: \"removed\"; commentId: string }\n  | { kind: \"liked\"; commentId: string; liked: boolean; count: number };\n\nexport type Unsubscribe = () => void;\nexport type Subscribe<T> = (handler: (delta: T) => void) => Unsubscribe;\n\nexport interface CommentMenuItem {\n  label: string;\n  onClick?: () => void;\n  icon?: ReactNode;\n  destructive?: boolean;\n  disabled?: boolean;\n  /**\n   * Render a divider line above this item. Used by host components that group\n   * items into visual sections (e.g. post-card's moderator section sits\n   * above viewer-destructive items). The default destructive-boundary divider\n   * still fires automatically — this flag is for explicit section breaks that\n   * aren't destructive boundaries.\n   */\n  separatorBefore?: boolean;\n}\n\nexport interface CommentThreadCurrentUser {\n  id: string;\n  name: string;\n  avatar?: string;\n}\n\nexport interface CommentNodeHelpers {\n  currentUser?: CommentThreadCurrentUser;\n  isOwn: boolean;\n  depth: number;\n  onLike: (nextLiked: boolean) => void;\n  onReply: () => void;\n  onDelete: () => void;\n  onReport: () => void;\n}\n\nexport interface CommentComposerState {\n  value: string;\n  isReply: boolean;\n  parentId?: string;\n  isSubmitting: boolean;\n}\n\nexport interface CommentComposerHelpers {\n  setValue: (next: string) => void;\n  submit: () => Promise<void>;\n  cancel: () => void;\n}\n\nexport interface CommentThreadLabels {\n  composerPlaceholder?: string;\n  composerSend?: string;\n  composerCancel?: string;\n  like?: string;\n  unlike?: string;\n  reply?: string;\n  delete?: string;\n  report?: string;\n  /** Function so consumers pluralize / localize without our help. */\n  viewReplies?: (count: number) => string;\n  loadMore?: string;\n  emptyState?: string;\n  signInPrompt?: string;\n  /** Suffix appended after the timestamp when `comment.edited === true`. Default `\"(edited)\"`. */\n  editedSuffix?: string;\n  /** Override the default English relative-time formatter. */\n  formatRelativeTime?: (date: Date, now: Date) => string;\n}\n\nexport const DEFAULT_COMMENT_THREAD_LABELS: Required<\n  Omit<CommentThreadLabels, \"formatRelativeTime\">\n> = {\n  composerPlaceholder: \"Write a comment…\",\n  composerSend: \"Send\",\n  composerCancel: \"Cancel\",\n  like: \"Like\",\n  unlike: \"Unlike\",\n  reply: \"Reply\",\n  delete: \"Delete\",\n  report: \"Report\",\n  viewReplies: (count) =>\n    `View ${count} ${count === 1 ? \"reply\" : \"replies\"}`,\n  loadMore: \"Load older comments\",\n  emptyState: \"No comments yet — be the first.\",\n  signInPrompt: \"Sign in to comment\",\n  editedSuffix: \"(edited)\",\n};\n\nexport type CommentLocalAction =\n  | { kind: \"add\"; comment: Comment; parentId?: string }\n  | { kind: \"swap-temp\"; tempId: string; real: Comment }\n  | { kind: \"remove\"; commentId: string }\n  | { kind: \"like-toggle\"; commentId: string; nextLiked: boolean }\n  | { kind: \"patch-content\"; commentId: string; content: string }\n  | { kind: \"subscribe-delta\"; delta: CommentDelta }\n  | { kind: \"append-page\"; comments: Comment[] }\n  | { kind: \"reset\"; next: Comment[] };\n\nexport interface CommentThreadProps {\n  /** Initial comments tree. Component owns it from mount; subsequent prop reference changes are IGNORED. Use the imperative handle's reset() to push updates. */\n  comments: Comment[];\n\n  /** Visual variant. Default \"default\". */\n  variant?: CommentThreadVariant;\n\n  /** Viewer identity. Drives composer avatar + isOwn check on default kebab. Absent → composer hidden, composerEmptyState rendered. */\n  currentUser?: CommentThreadCurrentUser;\n\n  /** Initial render depth cap. Past this, \"view N replies\" inline-expands. Default 2. */\n  maxDepth?: number;\n\n  /** Pixels of indent per depth level. Default 24. */\n  indentPx?: number;\n\n  /** Body line clamp via expandable-text. Default 4 (default variant) / 2 (compact). */\n  bodyMaxLines?: number;\n\n  /** Composer autosize bounds. Defaults: 1 / 6. */\n  composerMinRows?: number;\n  composerMaxRows?: number;\n\n  /** Default true — Enter submits, Shift+Enter newline. */\n  submitOnEnter?: boolean;\n\n  /** First page size (controls when \"Load older comments\" button appears). Default 10. */\n  pageSize?: number;\n\n  /** Realtime delta stream. Identity-stable required. */\n  subscribe?: Subscribe<CommentDelta>;\n  /** Fires for every delta the subscription emits. */\n  onSubscribeDelta?: (delta: CommentDelta) => void;\n\n  /** Fired after optimistic add. If returned a Comment, the temp comment is swapped for the real one. */\n  onAddComment?: (\n    content: string,\n    parentId?: string,\n  ) => Promise<Comment | void> | Comment | void;\n  /** Fired after optimistic like flip. */\n  onLikeComment?: (commentId: string, nextLiked: boolean) => void;\n  /** Fired after optimistic delete. */\n  onDeleteComment?: (commentId: string) => void;\n  /** Fired on Report kebab click. If omitted, Report item is hidden. */\n  onReportComment?: (commentId: string) => void;\n  /** Fetch next page of older top-level comments. Component appends results. */\n  onLoadMore?: (page: number) => Promise<Comment[]>;\n\n  /** Override the default kebab items. Return [] to hide kebab entirely. */\n  commentActions?: (\n    comment: Comment,\n    helpers: {\n      currentUser?: CommentThreadCurrentUser;\n      isOwn: boolean;\n      depth: number;\n    },\n  ) => CommentMenuItem[];\n\n  /** Full-takeover for the per-row render. Composer below each row (reply mode) is still owned by the thread. */\n  renderNode?: (\n    comment: Comment,\n    depth: number,\n    helpers: CommentNodeHelpers,\n  ) => ReactNode;\n\n  /** Override the inline-expand \"view N replies\" link. */\n  renderViewReplies?: (parentId: string, count: number) => ReactNode;\n\n  /** Override the bottom composer entirely. */\n  renderComposer?: (\n    state: CommentComposerState,\n    helpers: CommentComposerHelpers,\n  ) => ReactNode;\n\n  /** Rendered in place of the bottom composer when currentUser is absent. Pass null to suppress. */\n  composerEmptyState?: ReactNode;\n\n  /** Rendered when comments.length === 0 and no realtime is wired. */\n  emptyState?: ReactNode;\n\n  labels?: CommentThreadLabels;\n\n  className?: string;\n  composerClassName?: string;\n  nodeClassName?: string;\n}\n\nexport interface CommentThreadHandle {\n  /** Programmatically focus the bottom composer textarea. */\n  focusComposer: () => void;\n  /** Programmatically open the inline reply composer for a parent comment. */\n  openReply: (parentId: string) => void;\n  /** Read the current optimistic comments tree. */\n  getCurrentComments: () => Comment[];\n  /** Replace the entire tree (controlled-mode escape hatch). */\n  reset: (next: Comment[]) => void;\n  /** Drive the reducer directly (advanced controlled-mode escape hatch). */\n  dispatch: (action: CommentLocalAction) => void;\n}\n",
      "type": "registry:component",
      "target": "components/comment-thread/types.ts"
    },
    {
      "path": "src/registry/components/data/comment-thread/parts/comment-node.tsx",
      "content": "\"use client\";\n\nimport {\n  memo,\n  useId,\n  useMemo,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\n// F-S1 lock: same-category cross-procomp via relative + specific-file paths.\nimport { ExpandableText } from \"../../expandable-text/expandable-text\";\nimport { EngagementBar } from \"../../engagement-bar/engagement-bar\";\nimport { CommentKebab } from \"./comment-kebab\";\nimport { CommentComposer } from \"./comment-composer\";\nimport { ViewRepliesLink } from \"./view-replies-link\";\nimport { toDate } from \"../lib/format-time\";\nimport type {\n  Comment,\n  CommentMenuItem,\n  CommentNodeHelpers,\n  CommentThreadCurrentUser,\n  CommentThreadLabels,\n} from \"../types\";\n\nexport interface CommentNodeProps {\n  comment: Comment;\n  depth: number;\n  maxDepth: number;\n  indentPx: number;\n  variant: \"default\" | \"compact\";\n  bodyMaxLines: number;\n  currentUser?: CommentThreadCurrentUser;\n  labels: Required<Omit<CommentThreadLabels, \"formatRelativeTime\">>;\n  format: (date: Date, now: Date) => string;\n  isReplyOpen: boolean;\n  onOpenReply: (parentId: string, triggerEl: HTMLElement | null) => void;\n  onCancelReply: () => void;\n  onSubmitReply: (content: string, parentId: string) => Promise<void>;\n  onLike: (commentId: string, nextLiked: boolean) => void;\n  onDelete: (commentId: string) => void;\n  onReport: (commentId: string) => void;\n  onReportPresent: boolean;\n  commentActions?: (\n    comment: Comment,\n    helpers: {\n      currentUser?: CommentThreadCurrentUser;\n      isOwn: boolean;\n      depth: number;\n    },\n  ) => CommentMenuItem[];\n  renderNode?: (\n    comment: Comment,\n    depth: number,\n    helpers: CommentNodeHelpers,\n  ) => ReactNode;\n  renderViewReplies?: (parentId: string, count: number) => ReactNode;\n  composerMinRows: number;\n  composerMaxRows: number;\n  submitOnEnter: boolean;\n  composerClassName?: string;\n  nodeClassName?: string;\n  /** Whether this branch's \"view N replies\" link should be hidden (because the parent already expanded it). */\n  forceShowAllReplies?: boolean;\n}\n\nfunction initials(name: string): string {\n  return name\n    .trim()\n    .split(/\\s+/)\n    .map((part) => part[0]?.toUpperCase() ?? \"\")\n    .slice(0, 2)\n    .join(\"\") || \"?\";\n}\n\nfunction CommentNodeInner(props: CommentNodeProps) {\n  const {\n    comment,\n    depth,\n    maxDepth,\n    indentPx,\n    bodyMaxLines,\n    currentUser,\n    labels,\n    format,\n    isReplyOpen,\n    onOpenReply,\n    onCancelReply,\n    onSubmitReply,\n    onLike,\n    onDelete,\n    onReport,\n    onReportPresent,\n    commentActions,\n    renderNode,\n    renderViewReplies,\n    composerMinRows,\n    composerMaxRows,\n    submitOnEnter,\n    composerClassName,\n    nodeClassName,\n    forceShowAllReplies = false,\n  } = props;\n\n  const baseId = useId();\n  const authorId = `${baseId}-author`;\n  const repliesId = `${baseId}-replies`;\n\n  const [expandedToDepth, setExpandedToDepth] = useState(0);\n\n  const isOwn = !!currentUser && currentUser.id === comment.author.id;\n\n  // helpers passed to renderNode slot — onReply receives null trigger\n  // (slot owners do their own focus-restoration if they care).\n  const helpers: CommentNodeHelpers = useMemo(\n    () => ({\n      currentUser,\n      isOwn,\n      depth,\n      onLike: (nextLiked: boolean) => onLike(comment.id, nextLiked),\n      onReply: () => onOpenReply(comment.id, null),\n      onDelete: () => onDelete(comment.id),\n      onReport: () => onReport(comment.id),\n    }),\n    [\n      currentUser,\n      isOwn,\n      depth,\n      comment.id,\n      onLike,\n      onOpenReply,\n      onDelete,\n      onReport,\n    ],\n  );\n\n  // ─── Render takeover ──────────────────────────────────────────────────────\n  if (renderNode) {\n    return (\n      <>\n        {renderNode(comment, depth, helpers)}\n        {isReplyOpen && currentUser ? (\n          <div className=\"ml-10 mt-2\">\n            <CommentComposer\n              currentUser={currentUser}\n              placeholder={labels.composerPlaceholder}\n              onSubmit={(content) => onSubmitReply(content, comment.id)}\n              onCancel={onCancelReply}\n              submitOnEnter={submitOnEnter}\n              minRows={composerMinRows}\n              maxRows={composerMaxRows}\n              className={composerClassName}\n              labels={labels}\n              ariaLabel={`Reply to ${comment.author.name}`}\n              autoFocus\n            />\n          </div>\n        ) : null}\n      </>\n    );\n  }\n\n  // ─── Reply visibility logic ───────────────────────────────────────────────\n  const totalReplies = comment.replies?.length ?? 0;\n  const renderableDepth = forceShowAllReplies\n    ? Number.POSITIVE_INFINITY\n    : maxDepth + expandedToDepth;\n  const showReplies = totalReplies > 0 && depth + 1 <= renderableDepth;\n  const hiddenAtBoundary = !showReplies && totalReplies > 0;\n  const hiddenCountLabel = comment.replyCount ?? totalReplies;\n\n  // ─── Default render ───────────────────────────────────────────────────────\n  return (\n    <article\n      role=\"article\"\n      aria-labelledby={authorId}\n      style={depth > 0 ? { paddingLeft: depth * indentPx } : undefined}\n      className={cn(\"group flex items-start gap-2\", nodeClassName)}\n    >\n      <Avatar className=\"h-8 w-8 shrink-0\">\n        {comment.author.avatar ? (\n          <AvatarImage src={comment.author.avatar} alt=\"\" />\n        ) : null}\n        <AvatarFallback>{initials(comment.author.name)}</AvatarFallback>\n      </Avatar>\n\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"rounded-xl bg-muted/50 px-3 py-2\">\n          <div className=\"flex items-center gap-1\">\n            <span id={authorId} className=\"text-sm font-semibold\">\n              {comment.author.name}\n            </span>\n            {comment.author.username ? (\n              <span className=\"text-xs text-muted-foreground\">\n                @{comment.author.username}\n              </span>\n            ) : null}\n          </div>\n          <ExpandableText\n            content={comment.content}\n            maxLines={bodyMaxLines}\n            contentClassName=\"text-sm mt-0.5\"\n          />\n        </div>\n\n        <div className=\"mt-1 flex items-center gap-3 px-1\">\n          <span className=\"text-xs text-muted-foreground\">\n            {format(toDate(comment.createdAt), new Date())}\n            {comment.edited ? (\n              <>\n                {\" \"}\n                <span className=\"text-muted-foreground/80\">\n                  {labels.editedSuffix}\n                </span>\n              </>\n            ) : null}\n          </span>\n          <EngagementBar\n            variant=\"compact\"\n            actions={[\n              {\n                kind: \"like\",\n                count: comment.likes,\n                liked: comment.isLiked ?? false,\n                onToggle: helpers.onLike,\n              },\n            ]}\n          />\n          {currentUser ? (\n            <button\n              type=\"button\"\n              onClick={(e) => onOpenReply(comment.id, e.currentTarget)}\n              className=\"text-xs font-medium text-muted-foreground transition-colors hover:text-foreground\"\n            >\n              {labels.reply}\n            </button>\n          ) : null}\n        </div>\n\n        {/* Recursive replies */}\n        {showReplies && comment.replies ? (\n          <ul id={repliesId} className=\"mt-2 flex flex-col gap-3\">\n            {comment.replies.map((reply) => (\n              <li key={reply.id}>\n                <CommentNode\n                  {...props}\n                  comment={reply}\n                  depth={depth + 1}\n                  forceShowAllReplies={forceShowAllReplies}\n                />\n              </li>\n            ))}\n          </ul>\n        ) : null}\n\n        {/* Past maxDepth — view-N-replies link (default inline-expand) */}\n        {hiddenAtBoundary\n          ? renderViewReplies\n            ? renderViewReplies(comment.id, hiddenCountLabel)\n            : (\n                <ViewRepliesLink\n                  count={hiddenCountLabel}\n                  label={labels.viewReplies(hiddenCountLabel)}\n                  controlsId={repliesId}\n                  onExpand={() => setExpandedToDepth((d) => d + 1)}\n                />\n              )\n          : null}\n\n        {/* Inline reply composer */}\n        {isReplyOpen && currentUser ? (\n          <div className=\"mt-2\">\n            <CommentComposer\n              currentUser={currentUser}\n              placeholder={labels.composerPlaceholder}\n              onSubmit={(content) => onSubmitReply(content, comment.id)}\n              onCancel={onCancelReply}\n              submitOnEnter={submitOnEnter}\n              minRows={composerMinRows}\n              maxRows={composerMaxRows}\n              className={composerClassName}\n              labels={labels}\n              ariaLabel={`Reply to ${comment.author.name}`}\n              autoFocus\n            />\n          </div>\n        ) : null}\n      </div>\n\n      <CommentKebab\n        comment={comment}\n        currentUser={currentUser}\n        isOwn={isOwn}\n        depth={depth}\n        labels={labels}\n        onDelete={helpers.onDelete}\n        onReport={helpers.onReport}\n        onReportPresent={onReportPresent}\n        commentActions={commentActions}\n      />\n    </article>\n  );\n}\n\nexport const CommentNode = memo(CommentNodeInner);\nCommentNode.displayName = \"CommentNode\";\n",
      "type": "registry:component",
      "target": "components/comment-thread/parts/comment-node.tsx"
    },
    {
      "path": "src/registry/components/data/comment-thread/parts/comment-composer.tsx",
      "content": "\"use client\";\n\nimport {\n  memo,\n  useEffect,\n  useImperativeHandle,\n  useState,\n  type ReactNode,\n} from \"react\";\nimport { Send } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Button } from \"@/components/ui/button\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { useAutosizeTextarea } from \"../hooks/use-autosize-textarea\";\nimport type { CommentThreadCurrentUser, CommentThreadLabels } from \"../types\";\n\nexport interface CommentComposerProps {\n  currentUser?: CommentThreadCurrentUser;\n  /** Defaults to labels.composerPlaceholder. */\n  placeholder?: string;\n  /** Initial uncontrolled value. */\n  initialValue?: string;\n  /** Controlled value — pair with onChange. */\n  value?: string;\n  onChange?: (next: string) => void;\n  /** Required when used standalone. Fire-and-forget. */\n  onSubmit: (content: string) => Promise<void> | void;\n  /** Optional Cancel button — only renders when provided. */\n  onCancel?: () => void;\n  /** Force-disable the composer (sign-out / network down). */\n  disabled?: boolean;\n  /** External busy signal — overrides internal isSubmitting. */\n  isSubmitting?: boolean;\n  /** Default true. */\n  submitOnEnter?: boolean;\n  /** Defaults: 1 / 6. */\n  minRows?: number;\n  maxRows?: number;\n  /** Aria-label override on the textarea. */\n  ariaLabel?: string;\n  /** Override the avatar visual entirely (e.g. persona switcher). */\n  avatarSlot?: ReactNode;\n  /** Auto-focus on mount (use for inline reply composers). */\n  autoFocus?: boolean;\n  className?: string;\n  labels?: Pick<\n    CommentThreadLabels,\n    \"composerPlaceholder\" | \"composerSend\" | \"composerCancel\"\n  >;\n  ref?: React.Ref<CommentComposerHandle>;\n}\n\nexport interface CommentComposerHandle {\n  focus: () => void;\n  blur: () => void;\n  clear: () => void;\n}\n\nfunction initials(name: string): string {\n  return name\n    .trim()\n    .split(/\\s+/)\n    .map((part) => part[0]?.toUpperCase() ?? \"\")\n    .slice(0, 2)\n    .join(\"\") || \"?\";\n}\n\nfunction CommentComposerInner({\n  currentUser,\n  placeholder,\n  initialValue = \"\",\n  value: controlledValue,\n  onChange,\n  onSubmit,\n  onCancel,\n  disabled = false,\n  isSubmitting: controlledSubmitting,\n  submitOnEnter = true,\n  minRows = 1,\n  maxRows = 6,\n  ariaLabel,\n  avatarSlot,\n  autoFocus = false,\n  className,\n  labels,\n  ref,\n}: CommentComposerProps) {\n  const isValueControlled = controlledValue !== undefined;\n  const [internalValue, setInternalValue] = useState(initialValue);\n  const value = isValueControlled ? controlledValue : internalValue;\n\n  const isSubmittingControlled = controlledSubmitting !== undefined;\n  const [internalSubmitting, setInternalSubmitting] = useState(false);\n  const isSubmitting = isSubmittingControlled\n    ? controlledSubmitting\n    : internalSubmitting;\n\n  const textareaRef = useAutosizeTextarea(value, { minRows, maxRows });\n\n  // Auto-focus on mount for inline reply composers (effect runs once).\n  useEffect(() => {\n    if (autoFocus) textareaRef.current?.focus();\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      focus: () => textareaRef.current?.focus(),\n      blur: () => textareaRef.current?.blur(),\n      clear: () => {\n        if (!isValueControlled) setInternalValue(\"\");\n        onChange?.(\"\");\n      },\n    }),\n    // textareaRef identity is stable; setters are stable; onChange may change but\n    // that's OK — the imperative handle reads it via closure on call.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [isValueControlled, onChange],\n  );\n\n  const submit = async () => {\n    const trimmed = value.trim();\n    if (!trimmed || isSubmitting || disabled) return;\n    if (!isSubmittingControlled) setInternalSubmitting(true);\n    try {\n      await onSubmit(trimmed);\n      if (!isValueControlled) setInternalValue(\"\");\n      onChange?.(\"\");\n    } finally {\n      if (!isSubmittingControlled) setInternalSubmitting(false);\n    }\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {\n    if (submitOnEnter && e.key === \"Enter\" && !e.shiftKey) {\n      e.preventDefault();\n      void submit();\n      return;\n    }\n    if (e.key === \"Escape\" && onCancel) {\n      e.preventDefault();\n      onCancel();\n    }\n  };\n\n  const placeholderText =\n    placeholder ?? labels?.composerPlaceholder ?? \"Write a comment…\";\n  const sendLabel = labels?.composerSend ?? \"Send\";\n  const cancelLabel = labels?.composerCancel ?? \"Cancel\";\n\n  return (\n    <div className={cn(\"flex items-start gap-2\", className)}>\n      {avatarSlot ??\n        (currentUser ? (\n          <Avatar className=\"h-8 w-8 shrink-0\">\n            {currentUser.avatar ? (\n              <AvatarImage src={currentUser.avatar} alt=\"\" />\n            ) : null}\n            <AvatarFallback>{initials(currentUser.name)}</AvatarFallback>\n          </Avatar>\n        ) : null)}\n      <div className=\"relative flex-1\">\n        <Textarea\n          ref={textareaRef}\n          value={value}\n          onChange={(e) => {\n            if (!isValueControlled) setInternalValue(e.target.value);\n            onChange?.(e.target.value);\n          }}\n          onKeyDown={onKeyDown}\n          placeholder={placeholderText}\n          rows={minRows}\n          disabled={disabled || isSubmitting}\n          aria-label={ariaLabel ?? placeholderText}\n          aria-busy={isSubmitting}\n          className=\"resize-none border-0 bg-muted/50 pr-10\"\n        />\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          onClick={() => {\n            void submit();\n          }}\n          disabled={!value.trim() || isSubmitting || disabled}\n          aria-label={sendLabel}\n          className=\"absolute right-1 top-1.5 h-7 w-7\"\n        >\n          <Send\n            className={cn(\n              \"h-4 w-4\",\n              value.trim() && \"text-primary\",\n            )}\n          />\n        </Button>\n      </div>\n      {onCancel ? (\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={() => {\n            if (!isValueControlled) setInternalValue(\"\");\n            onChange?.(\"\");\n            onCancel();\n          }}\n          disabled={isSubmitting}\n        >\n          {cancelLabel}\n        </Button>\n      ) : null}\n    </div>\n  );\n}\n\nexport const CommentComposer = memo(CommentComposerInner);\nCommentComposer.displayName = \"CommentComposer\";\n",
      "type": "registry:component",
      "target": "components/comment-thread/parts/comment-composer.tsx"
    },
    {
      "path": "src/registry/components/data/comment-thread/parts/comment-kebab.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { MoreHorizontal } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport type {\n  Comment,\n  CommentMenuItem,\n  CommentThreadCurrentUser,\n  CommentThreadLabels,\n} from \"../types\";\n\nexport interface CommentKebabProps {\n  comment: Comment;\n  currentUser?: CommentThreadCurrentUser;\n  isOwn: boolean;\n  depth: number;\n  labels: Required<Omit<CommentThreadLabels, \"formatRelativeTime\">>;\n  /** Default kebab onClick handlers — used when commentActions is not provided. */\n  onDelete: () => void;\n  onReport: () => void;\n  /** True when host wired onReportComment — drives the default Report item visibility. */\n  onReportPresent: boolean;\n  /** Override the default kebab items entirely. Returning [] hides the kebab. */\n  commentActions?: (\n    comment: Comment,\n    helpers: {\n      currentUser?: CommentThreadCurrentUser;\n      isOwn: boolean;\n      depth: number;\n    },\n  ) => CommentMenuItem[];\n  className?: string;\n}\n\nfunction CommentKebabInner({\n  comment,\n  currentUser,\n  isOwn,\n  depth,\n  labels,\n  onDelete,\n  onReport,\n  onReportPresent,\n  commentActions,\n  className,\n}: CommentKebabProps) {\n  const items: CommentMenuItem[] = commentActions\n    ? commentActions(comment, { currentUser, isOwn, depth })\n    : [\n        ...(onReportPresent\n          ? [{ label: labels.report, onClick: onReport }]\n          : []),\n        ...(isOwn\n          ? [\n              {\n                label: labels.delete,\n                destructive: true,\n                onClick: onDelete,\n              },\n            ]\n          : []),\n      ];\n\n  if (items.length === 0) return null;\n\n  // F-cross-13: drop `asChild` + render the Radix/Base-UI primitive directly\n  // as a <button>. The shadcn CLI rewrites `asChild` to `render={<Button …>}`\n  // at install-time (Base UI idiom), which breaks consumers who installed the\n  // Radix primitive. Inline button styling via `buttonVariants(…)` instead.\n  const triggerClass = cn(\n    buttonVariants({ variant: \"ghost\", size: \"icon\" }),\n    \"h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100\",\n    className,\n  );\n  return (\n    <DropdownMenu>\n      <DropdownMenuTrigger\n        className={triggerClass}\n        aria-label={`Comment actions for ${comment.author.name}`}\n      >\n        <MoreHorizontal className=\"h-3 w-3\" />\n      </DropdownMenuTrigger>\n      <DropdownMenuContent align=\"end\">\n        {items.map((item, i) => (\n          <DropdownMenuItem\n            key={`${item.label}-${i}`}\n            onClick={item.onClick}\n            disabled={item.disabled}\n            className={cn(\n              item.destructive &&\n                \"text-destructive focus:text-destructive\",\n            )}\n          >\n            {item.icon ? <span className=\"mr-2\">{item.icon}</span> : null}\n            {item.label}\n          </DropdownMenuItem>\n        ))}\n      </DropdownMenuContent>\n    </DropdownMenu>\n  );\n}\n\nexport const CommentKebab = memo(CommentKebabInner);\nCommentKebab.displayName = \"CommentKebab\";\n",
      "type": "registry:component",
      "target": "components/comment-thread/parts/comment-kebab.tsx"
    },
    {
      "path": "src/registry/components/data/comment-thread/parts/view-replies-link.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface ViewRepliesLinkProps {\n  count: number;\n  label: string;\n  controlsId?: string;\n  onExpand: () => void;\n  className?: string;\n}\n\nexport function ViewRepliesLink({\n  count,\n  label,\n  controlsId,\n  onExpand,\n  className,\n}: ViewRepliesLinkProps) {\n  if (count <= 0) return null;\n  return (\n    <button\n      type=\"button\"\n      onClick={onExpand}\n      aria-controls={controlsId}\n      aria-expanded={false}\n      className={cn(\n        \"mt-2 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground\",\n        className,\n      )}\n    >\n      {label}\n    </button>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/comment-thread/parts/view-replies-link.tsx"
    },
    {
      "path": "src/registry/components/data/comment-thread/parts/comment-empty-state.tsx",
      "content": "import { cn } from \"@/lib/utils\";\n\nexport interface CommentEmptyStateProps {\n  message: string;\n  className?: string;\n}\n\nexport function CommentEmptyState({ message, className }: CommentEmptyStateProps) {\n  return (\n    <div\n      className={cn(\n        \"rounded-md border border-dashed border-muted-foreground/30 px-4 py-6 text-center text-sm text-muted-foreground\",\n        className,\n      )}\n    >\n      {message}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/comment-thread/parts/comment-empty-state.tsx"
    },
    {
      "path": "src/registry/components/data/comment-thread/hooks/use-comment-state.ts",
      "content": "\"use client\";\n\nimport { useEffect, useReducer, useRef } from \"react\";\nimport type {\n  Comment,\n  CommentDelta,\n  CommentLocalAction,\n  Subscribe,\n} from \"../types\";\n\n// ─── Pure tree-walk helpers ──────────────────────────────────────────────────\n\nfunction findAndPatch(\n  tree: Comment[],\n  predicate: (c: Comment) => boolean,\n  patch: (c: Comment) => Comment,\n): Comment[] {\n  let mutated = false;\n  const next = tree.map((c) => {\n    if (predicate(c)) {\n      const patched = patch(c);\n      if (patched !== c) mutated = true;\n      return patched;\n    }\n    if (c.replies && c.replies.length > 0) {\n      const replies = findAndPatch(c.replies, predicate, patch);\n      if (replies !== c.replies) {\n        mutated = true;\n        return { ...c, replies };\n      }\n    }\n    return c;\n  });\n  return mutated ? next : tree;\n}\n\nfunction pruneById(tree: Comment[], id: string): Comment[] {\n  let mutated = false;\n  const next: Comment[] = [];\n  for (const c of tree) {\n    if (c.id === id) {\n      mutated = true;\n      continue;\n    }\n    if (c.replies && c.replies.length > 0) {\n      const replies = pruneById(c.replies, id);\n      if (replies !== c.replies) {\n        mutated = true;\n        next.push({ ...c, replies });\n        continue;\n      }\n    }\n    next.push(c);\n  }\n  return mutated ? next : tree;\n}\n\nfunction insertReply(\n  tree: Comment[],\n  parentId: string,\n  comment: Comment,\n): Comment[] {\n  return findAndPatch(\n    tree,\n    (c) => c.id === parentId,\n    (c) => ({ ...c, replies: [...(c.replies ?? []), comment] }),\n  );\n}\n\n// ─── Reducer ─────────────────────────────────────────────────────────────────\n\n/** Pure reducer — exported for hosts driving their own state. */\nexport function commentReducer(\n  state: Comment[],\n  action: CommentLocalAction,\n): Comment[] {\n  switch (action.kind) {\n    case \"add\":\n      if (action.parentId) {\n        return insertReply(state, action.parentId, action.comment);\n      }\n      // Head insertion for top-level (newest first; pairs with load-older at bottom).\n      return [action.comment, ...state];\n\n    case \"swap-temp\":\n      return findAndPatch(\n        state,\n        (c) => c.id === action.tempId,\n        () => action.real,\n      );\n\n    case \"remove\":\n      return pruneById(state, action.commentId);\n\n    case \"like-toggle\":\n      return findAndPatch(\n        state,\n        (c) => c.id === action.commentId,\n        (c) => {\n          if (c.isLiked === action.nextLiked) return c; // idempotent\n          return {\n            ...c,\n            isLiked: action.nextLiked,\n            likes: action.nextLiked\n              ? c.likes + 1\n              : Math.max(0, c.likes - 1),\n          };\n        },\n      );\n\n    case \"patch-content\":\n      return findAndPatch(\n        state,\n        (c) => c.id === action.commentId,\n        (c) => ({ ...c, content: action.content }),\n      );\n\n    case \"subscribe-delta\": {\n      const d = action.delta;\n      switch (d.kind) {\n        case \"added\":\n          return d.parentId\n            ? insertReply(state, d.parentId, d.comment)\n            : [d.comment, ...state];\n        case \"edited\":\n          return findAndPatch(\n            state,\n            (c) => c.id === d.commentId,\n            (c) => ({ ...c, content: d.content, edited: true }),\n          );\n        case \"removed\":\n          return pruneById(state, d.commentId);\n        case \"liked\":\n          return findAndPatch(\n            state,\n            (c) => c.id === d.commentId,\n            (c) => ({ ...c, isLiked: d.liked, likes: d.count }),\n          );\n      }\n      return state;\n    }\n\n    case \"append-page\":\n      return [...state, ...action.comments];\n\n    case \"reset\":\n      return action.next;\n  }\n}\n\n// ─── Hook ────────────────────────────────────────────────────────────────────\n\nexport interface UseCommentStateOptions {\n  /** Captured ON MOUNT only — subsequent prop changes are ignored. */\n  initialComments: Comment[];\n  subscribe?: Subscribe<CommentDelta>;\n  onSubscribeDelta?: (delta: CommentDelta) => void;\n}\n\nexport interface UseCommentStateResult {\n  comments: Comment[];\n  dispatch: React.Dispatch<CommentLocalAction>;\n}\n\n/**\n * Wires:\n *  - useReducer over commentReducer\n *  - subscription effect that fires onSubscribeDelta and patches state\n *\n * onSubscribeDelta is mirrored to a ref so the subscription effect re-runs\n * ONLY on `subscribe` identity change — same shape as engagement-bar.\n */\nexport function useCommentState(\n  opts: UseCommentStateOptions,\n): UseCommentStateResult {\n  const [comments, dispatch] = useReducer(\n    commentReducer,\n    opts.initialComments,\n  );\n\n  const onSubscribeDeltaRef = useRef(opts.onSubscribeDelta);\n  useEffect(() => {\n    onSubscribeDeltaRef.current = opts.onSubscribeDelta;\n  });\n\n  const subscribe = opts.subscribe;\n  useEffect(() => {\n    if (!subscribe) return;\n    const unsub = subscribe((delta) => {\n      onSubscribeDeltaRef.current?.(delta);\n      dispatch({ kind: \"subscribe-delta\", delta });\n    });\n    return unsub;\n  }, [subscribe]);\n\n  return { comments, dispatch };\n}\n",
      "type": "registry:component",
      "target": "components/comment-thread/hooks/use-comment-state.ts"
    },
    {
      "path": "src/registry/components/data/comment-thread/hooks/use-autosize-textarea.ts",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useRef } from \"react\";\n\nexport interface UseAutosizeTextareaOptions {\n  minRows?: number;\n  maxRows?: number;\n}\n\n/**\n * Resizes a <textarea> to fit its content within [minRows, maxRows] line bounds.\n * Pure DOM mutation in useLayoutEffect — no React state, no rerender.\n *\n * Returns a ref to attach to the textarea element.\n */\nexport function useAutosizeTextarea(\n  value: string,\n  opts: UseAutosizeTextareaOptions = {},\n) {\n  const { minRows = 1, maxRows = 6 } = opts;\n  const ref = useRef<HTMLTextAreaElement | null>(null);\n\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    // Reset to \"auto\" so scrollHeight reflects natural content height.\n    el.style.height = \"auto\";\n    const computed = window.getComputedStyle(el);\n    const lineHeight = parseFloat(computed.lineHeight) || 20;\n    const paddingY =\n      parseFloat(computed.paddingTop) + parseFloat(computed.paddingBottom);\n    const min = lineHeight * minRows + paddingY;\n    const max = lineHeight * maxRows + paddingY;\n    const next = Math.min(Math.max(el.scrollHeight, min), max);\n    el.style.height = `${next}px`;\n    // Allow internal scroll only if natural height exceeds maxRows.\n    el.style.overflowY = el.scrollHeight > max ? \"auto\" : \"hidden\";\n  }, [value, minRows, maxRows]);\n\n  return ref;\n}\n",
      "type": "registry:component",
      "target": "components/comment-thread/hooks/use-autosize-textarea.ts"
    },
    {
      "path": "src/registry/components/data/comment-thread/lib/format-time.ts",
      "content": "/** Coerces Date | string | number into a Date — matches event-card / progress-timeline. */\nexport function toDate(value: Date | string | number): Date {\n  if (value instanceof Date) return value;\n  return new Date(value);\n}\n\nconst MONTHS = [\n  \"January\",\n  \"February\",\n  \"March\",\n  \"April\",\n  \"May\",\n  \"June\",\n  \"July\",\n  \"August\",\n  \"September\",\n  \"October\",\n  \"November\",\n  \"December\",\n];\n\n/**\n * Default English relative-time formatter for comments. Tighter granularity than\n * news-card's day-level formatter:\n *   < 1 min     → \"Just now\"\n *   < 60 min    → \"5m\"\n *   < 24 hours  → \"2h\"\n *   < 7 days    → \"3d\"\n *   < 5 weeks   → \"2w\"\n *   ≥ 5 weeks   → \"March 5\"  (or \"March 5, 2025\" if year differs)\n */\nexport function defaultRelativeTime(date: Date, now: Date = new Date()): string {\n  const diffMs = now.getTime() - date.getTime();\n  const diffSec = Math.floor(diffMs / 1000);\n  if (diffSec < 60) return \"Just now\";\n\n  const diffMin = Math.floor(diffSec / 60);\n  if (diffMin < 60) return `${diffMin}m`;\n\n  const diffHr = Math.floor(diffMin / 60);\n  if (diffHr < 24) return `${diffHr}h`;\n\n  const diffDay = Math.floor(diffHr / 24);\n  if (diffDay < 7) return `${diffDay}d`;\n\n  const diffWk = Math.floor(diffDay / 7);\n  if (diffWk < 5) return `${diffWk}w`;\n\n  const sameYear = date.getFullYear() === now.getFullYear();\n  return sameYear\n    ? `${MONTHS[date.getMonth()]} ${date.getDate()}`\n    : `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;\n}\n",
      "type": "registry:component",
      "target": "components/comment-thread/lib/format-time.ts"
    }
  ],
  "categories": [
    "data"
  ],
  "type": "registry:block"
}