{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "post-card",
  "title": "Post Card",
  "author": "ilinxa",
  "description": "Social post composite in four layouts — text expansion, media carousel, engagement bar, and comment thread wired together.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@ilinxa/expandable-text",
    "@ilinxa/video-player",
    "@ilinxa/media-carousel",
    "@ilinxa/engagement-bar",
    "@ilinxa/comment-thread",
    "avatar",
    "button",
    "card",
    "dropdown-menu"
  ],
  "files": [
    {
      "path": "src/registry/components/data/post-card/post-card.tsx",
      "content": "\"use client\";\n\nimport {\n  memo,\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n// F-S1 lock: cross-procomp imports use RELATIVE paths to specific files.\nimport type { CommentThreadHandle } from \"../comment-thread/types\";\nimport { defaultRelativeTime, toDate } from \"../comment-thread/lib/format-time\";\nimport type {\n  EngagementBarHandle,\n  EngagementDelta,\n} from \"../engagement-bar/types\";\nimport {\n  DEFAULT_POST_CARD_LABELS,\n  type Post,\n  type PostCardProps,\n  type PostCardLabels,\n  type PostHandlers,\n  type PostLikeUser,\n} from \"./types\";\nimport {\n  defaultPostEngagementActions,\n  defaultPostKebabActions,\n} from \"./lib/defaults\";\nimport { FeedVariant } from \"./parts/feed-variant\";\nimport { CompactVariant } from \"./parts/compact-variant\";\nimport { ListVariant } from \"./parts/list-variant\";\nimport { DetailVariant } from \"./parts/detail-variant\";\nimport { LikersStrip } from \"../engagement-bar/parts/likers-strip\";\nimport { ShareMenu } from \"../engagement-bar/parts/share-menu\";\nimport { CommentThread } from \"../comment-thread/comment-thread\";\n\nfunction PostCardInner(props: PostCardProps) {\n  const {\n    variant,\n    post: initialPost,\n    currentUser,\n\n    commentThread,\n    commentPageSize = 10,\n\n    onLike,\n    onComment,\n    onShare,\n    onBookmark,\n\n    onAddComment,\n    onLikeComment,\n    onDeleteComment,\n    onReportComment,\n    onLoadMoreComments,\n    onCopyLink,\n    onReport,\n\n    // v0.2.0 mutation handlers — destructured here for use by the imperative handle\n    // (C3) AND the role-aware kebab wiring (C11). The imperative handle exposes only\n    // the 5 most-direct triggers (per dynamicity-primacy memory: programmatic escape\n    // hatches for the common actions). The kebab pulls from all 8 via `mutationHandlers`.\n    onEdit,\n    onDelete,\n    onPin,\n    onRevealSensitive,\n    onVotePoll,\n    onChangeVisibility,\n    onMarkSensitive,\n    onSeeAnalytics,\n    onBlockAuthor,\n    onMuteAuthor,\n    onTranslate,\n\n    // v0.2.0 role-aware kebab inputs (C11) — pass-through to defaultPostKebabActions.\n    // Triple-optional: when ALL three are undefined, the helper takes the legacy\n    // handler-driven path (zero v0.1 drift). When ANY is set, the resolver assembles\n    // role-aware items.\n    permissions,\n    canPerformAction,\n\n    // v0.2.0 header-level callbacks (threaded to PostHeader via variants).\n    onLocationClick,\n    onMentionClick,\n    // v0.2.0 content-body sibling — tag chips below content (C5).\n    onTagClick,\n\n    // v0.2.0 sensitive-media gate (C6 — feed + detail only per description §1.3).\n    disableSensitiveGate,\n    renderSensitiveGate,\n\n    // v0.2.0 link-preview card (C7 — feed + detail only per description §1.3).\n    onLinkPreviewClick,\n    disableLinkPreviewRender,\n    renderLinkPreview,\n\n    // v0.2.0 repost mini-card (C8 — feed + detail only per description §1.3).\n    onRepostOfClick,\n    disableRepostOfRender,\n    renderRepostOf,\n\n    // v0.2.0 poll widget (C9 — feed + detail only per description §1.3).\n    // `viewerMode` is also read by C11's kebab wiring (extended\n    // `defaultPostKebabActions`); destructured once here.\n    viewerMode,\n    disablePollRender,\n    renderPoll,\n\n    engagementSubscribe,\n    commentSubscribe,\n    onSubscribeEngagementDelta,\n    onSubscribeCommentDelta,\n\n    getHref,\n    linkComponent,\n    disableHeartBurst = false,\n\n    engagementMode = \"inline\",\n    likers: initialLikers,\n    onLoadMoreLikers,\n    shareSuggestions,\n    onShareSearch,\n    onShareTo,\n    inlineCommentsMaxHeight = \"24rem\",\n    defaultInlinePanel = \"none\",\n    openLikersOnLike = true,\n\n    renderHeader,\n    renderContent,\n    renderMedia,\n    engagementActions: engagementActionsSlot,\n    renderEngagementBar,\n    kebabActions,\n    moderatorActions,\n    commentActions,\n    renderCommentSection,\n\n    headingAs,\n    bodyMaxLines: bodyMaxLinesProp,\n\n    labels: labelsProp,\n    className,\n    headerClassName,\n    bodyClassName,\n    mediaClassName,\n    engagementClassName,\n    commentSectionClassName,\n\n    ref,\n  } = props;\n\n  // ─── Always-uncontrolled `post` — stateful from mount; updated via reset() handle ───\n  const [statefulPost, setStatefulPost] = useState<Post>(initialPost);\n\n  // ─── v0.2.0 local-mirror additions (per plan C3 / Q-P32 / Q-P33) ───\n  // Optimistic poll vote — host can reject by calling `ref.current.reset(originalPost)`\n  // which clears this alongside the rest of the mirror.\n  const [pollVote, setPollVote] = useState<{\n    optionId: string;\n    votedAt: Date;\n  } | null>(null);\n  // Sensitive-media gate state — flips to `true` when viewer taps \"Show\".\n  // Resets on `ref.current.reset(...)`.\n  const [sensitiveRevealed, setSensitiveRevealed] = useState(false);\n\n  // ─── Inline panel state (only meaningful when engagementMode === \"inline\") ───\n  // detail variant ignores inline mode (it always shows the thread).\n  const inlineModeActive = engagementMode === \"inline\" && variant !== \"detail\";\n  const [activePanel, setActivePanel] = useState<\n    \"none\" | \"likes\" | \"comments\" | \"share\"\n  >(inlineModeActive ? defaultInlinePanel : \"none\");\n  const [statefulLikers, setStatefulLikers] = useState<PostLikeUser[]>(\n    initialLikers ?? [],\n  );\n\n  const labels = useMemo<\n    Required<\n      Omit<PostCardLabels, \"formatRelativeTime\" | \"engagementLabels\" | \"commentLabels\">\n    >\n  >(\n    () => ({ ...DEFAULT_POST_CARD_LABELS, ...labelsProp }),\n    [labelsProp],\n  );\n\n  const format = labelsProp?.formatRelativeTime ?? defaultRelativeTime;\n  const formattedTime = format(toDate(statefulPost.createdAt), new Date());\n\n  // ─── Heart-burst + variant defaults ───\n  const resolvedHeadingAs: \"h2\" | \"h3\" | \"h4\" =\n    headingAs ?? (variant === \"detail\" ? \"h2\" : \"h3\");\n  const resolvedBodyMaxLines =\n    bodyMaxLinesProp ??\n    (variant === \"feed\"\n      ? 3\n      : variant === \"compact\" || variant === \"list\"\n        ? 2\n        : 0);\n\n  const heartBurstWired =\n    !disableHeartBurst &&\n    (statefulPost.media?.length ?? 0) > 0 &&\n    !!onLike &&\n    (variant === \"feed\" || variant === \"detail\");\n\n  const cardLinkable = !!getHref && variant !== \"detail\";\n  const linkHref = getHref ? getHref(statefulPost) : undefined;\n  const LinkComponent = linkComponent ?? \"a\";\n\n  // ─── Local mirror updates via realtime subscribe (R-Plan-1 + R-Plan-4) ───\n  const onSubscribeEngagementDeltaRef = useRef(onSubscribeEngagementDelta);\n  useEffect(() => {\n    onSubscribeEngagementDeltaRef.current = onSubscribeEngagementDelta;\n  });\n\n  useEffect(() => {\n    if (!engagementSubscribe) return;\n    const unsub = engagementSubscribe((delta: EngagementDelta) => {\n      onSubscribeEngagementDeltaRef.current?.(delta);\n      setStatefulPost((prev) => {\n        switch (delta.kind) {\n          case \"like-changed\":\n            return {\n              ...prev,\n              likes: delta.count,\n              isLiked: delta.liked ?? prev.isLiked,\n            };\n          case \"bookmark-changed\":\n            return { ...prev, isBookmarked: delta.bookmarked };\n          case \"comment-count-changed\":\n            return { ...prev, comments: delta.count };\n          case \"share-count-changed\":\n            return { ...prev, shares: delta.count };\n          case \"view-count-changed\":\n            return { ...prev, viewCount: delta.count };\n          default:\n            return prev;\n        }\n      });\n    });\n    return unsub;\n  }, [engagementSubscribe]);\n\n  // ─── Wrapped handlers (R-Plan-2) — dispatch local mirror first, then fire host's handler ───\n  const handleLike = useCallback(\n    (id: string, next: boolean) => {\n      setStatefulPost((prev) => ({\n        ...prev,\n        isLiked: next,\n        likes: next ? prev.likes + 1 : Math.max(0, prev.likes - 1),\n      }));\n      onLike?.(id, next);\n      // Inline mode: open likers panel on a fresh like (kasder UX).\n      if (inlineModeActive && next && openLikersOnLike) {\n        setActivePanel(\"likes\");\n      }\n    },\n    [onLike, inlineModeActive, openLikersOnLike],\n  );\n\n  const handleBookmark = useCallback(\n    (id: string, next: boolean) => {\n      setStatefulPost((prev) => ({ ...prev, isBookmarked: next }));\n      onBookmark?.(id, next);\n    },\n    [onBookmark],\n  );\n\n  // Under inline mode, comment-icon click toggles the inline comments panel\n  // instead of firing the host's onComment.\n  const handleComment = useCallback(\n    (id: string) => {\n      if (inlineModeActive) {\n        setActivePanel((prev) => (prev === \"comments\" ? \"none\" : \"comments\"));\n      } else {\n        onComment?.(id);\n      }\n    },\n    [inlineModeActive, onComment],\n  );\n\n  // Under inline mode + shareSuggestions provided, share toggles the share panel\n  // instead of firing the host's onShare.\n  const handleShare = useCallback(\n    (id: string) => {\n      if (inlineModeActive && shareSuggestions) {\n        setActivePanel((prev) => (prev === \"share\" ? \"none\" : \"share\"));\n      } else {\n        onShare?.(id);\n      }\n    },\n    [inlineModeActive, shareSuggestions, onShare],\n  );\n\n  const wrappedHandlers: PostHandlers = useMemo(\n    () => ({\n      onLike: handleLike,\n      onComment: handleComment,\n      onShare: handleShare,\n      onBookmark: handleBookmark,\n    }),\n    [handleLike, handleComment, handleShare, handleBookmark],\n  );\n\n  // ─── Default kebab handlers ───\n  const handleCopyLink = useCallback(\n    (id: string) => {\n      if (onCopyLink) {\n        onCopyLink(id);\n      } else if (getHref && typeof navigator !== \"undefined\" && navigator.clipboard) {\n        // Default: write the href to clipboard\n        const href = getHref(statefulPost);\n        if (href) {\n          void navigator.clipboard.writeText(\n            href.startsWith(\"http\")\n              ? href\n              : typeof window !== \"undefined\"\n                ? new URL(href, window.location.origin).toString()\n                : href,\n          );\n        }\n      }\n    },\n    [onCopyLink, getHref, statefulPost],\n  );\n\n  const handleReport = useCallback(\n    (id: string) => {\n      onReport?.(id);\n    },\n    [onReport],\n  );\n\n  // Inline-mode: count tap on the like action toggles the likers panel\n  // (heart icon stays bound to onToggle).\n  const handleLikeCountClick = useCallback(() => {\n    if (!inlineModeActive) return;\n    setActivePanel((prev) => (prev === \"likes\" ? \"none\" : \"likes\"));\n  }, [inlineModeActive]);\n\n  // ─── Engagement actions array (mirrored post + wrapped handlers) ───\n  const engagementActionsArr = useMemo(\n    () =>\n      engagementActionsSlot\n        ? engagementActionsSlot(statefulPost, wrappedHandlers, variant)\n        : defaultPostEngagementActions(\n            statefulPost,\n            wrappedHandlers,\n            variant,\n            inlineModeActive ? handleLikeCountClick : undefined,\n          ),\n    [\n      engagementActionsSlot,\n      statefulPost,\n      wrappedHandlers,\n      variant,\n      inlineModeActive,\n      handleLikeCountClick,\n    ],\n  );\n\n  // ─── Kebab items (C11 — role-aware via extended defaultPostKebabActions) ───\n  // The helper is dual-mode: when viewerMode + permissions + canPerformAction are\n  // ALL undefined, it takes the v0.1 legacy code path (zero drift). When ANY is set,\n  // it resolves the matrix + assembles role-aware items per plan §3 resolution order.\n  const kebabItems = useMemo(() => {\n    if (kebabActions) return kebabActions(statefulPost);\n    return defaultPostKebabActions(\n      statefulPost,\n      {\n        onBookmark: handleBookmark,\n        onShare,\n        onReport: onReport ? handleReport : undefined,\n        onCopyLink:\n          onCopyLink || getHref ? handleCopyLink : undefined,\n      },\n      labels,\n      viewerMode,\n      permissions,\n      canPerformAction,\n      {\n        onEdit,\n        onDelete,\n        onPin,\n        onChangeVisibility,\n        onMarkSensitive,\n        onSeeAnalytics,\n        onBlockAuthor,\n        onMuteAuthor,\n      },\n      onTranslate,\n      moderatorActions,\n    );\n  }, [\n    kebabActions,\n    statefulPost,\n    handleBookmark,\n    onShare,\n    onReport,\n    handleReport,\n    onCopyLink,\n    getHref,\n    handleCopyLink,\n    labels,\n    viewerMode,\n    permissions,\n    canPerformAction,\n    onEdit,\n    onDelete,\n    onPin,\n    onChangeVisibility,\n    onMarkSensitive,\n    onSeeAnalytics,\n    onBlockAuthor,\n    onMuteAuthor,\n    onTranslate,\n    moderatorActions,\n  ]);\n\n  // ─── State + refs ───\n  const [kebabOpen, setKebabOpen] = useState(false);\n  const [burstKey, setBurstKey] = useState(0);\n  const barRef = useRef<EngagementBarHandle | null>(null);\n  const threadRef = useRef<CommentThreadHandle | null>(null);\n  const statefulPostRef = useRef<Post>(statefulPost);\n  useEffect(() => {\n    statefulPostRef.current = statefulPost;\n  });\n\n  const onMediaDoubleTap = useCallback(() => {\n    if (!heartBurstWired) return;\n    barRef.current?.triggerLike();\n    setBurstKey((k) => k + 1);\n  }, [heartBurstWired]);\n\n  // C6 — sensitive-media reveal: single handler combining local-state flip\n  // + host's onRevealSensitive callback (analytics hook). Used by the gate\n  // button in feed + detail variants AND by the imperative handle's\n  // revealSensitive() (which does its own flip via the ref pattern in C3).\n  const onSensitiveReveal = useCallback(() => {\n    setSensitiveRevealed(true);\n    onRevealSensitive?.(statefulPostRef.current.id);\n  }, [onRevealSensitive]);\n\n  // C9 — poll vote dispatcher: optimistic flip + host callback. Used by the\n  // PollWidget in feed + detail variants. The imperative handle's votePoll()\n  // does the same flip via the ref pattern in C3 (handler-ref capture).\n  const onPollVote = useCallback(\n    (optionId: string) => {\n      setPollVote({ optionId, votedAt: new Date() });\n      onVotePoll?.(statefulPostRef.current.id, optionId);\n    },\n    [onVotePoll],\n  );\n  const isOwnerView = viewerMode === \"owner\";\n\n  // ─── Imperative handle (R-Plan-3 + v0.2.0 C3 additions) ───\n  // Handler refs — useImperativeHandle deps + closure capture pattern. v0.2.0\n  // mutation handlers (onEdit / onDelete / onPin / onRevealSensitive / onVotePoll)\n  // are accessed via refs so the handle identity doesn't churn on every handler\n  // identity change (host typically re-creates them per render).\n  const onEditRef = useRef(onEdit);\n  const onDeleteRef = useRef(onDelete);\n  const onPinRef = useRef(onPin);\n  const onRevealSensitiveRef = useRef(onRevealSensitive);\n  const onVotePollRef = useRef(onVotePoll);\n  useEffect(() => {\n    onEditRef.current = onEdit;\n    onDeleteRef.current = onDelete;\n    onPinRef.current = onPin;\n    onRevealSensitiveRef.current = onRevealSensitive;\n    onVotePollRef.current = onVotePoll;\n  });\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      openKebab: () => setKebabOpen(true),\n      triggerLike: () => {\n        barRef.current?.triggerLike();\n        if (heartBurstWired) setBurstKey((k) => k + 1);\n      },\n      getCurrentPost: () => statefulPostRef.current,\n      reset: (next: Post) => {\n        setStatefulPost(next);\n        // Clear v0.2.0 local-mirror state — caller's \"reset to known state\" intent\n        // covers the optimistic poll vote + the sensitive-gate reveal.\n        setPollVote(null);\n        setSensitiveRevealed(false);\n      },\n      getEngagementHandle: () => barRef.current,\n      getThreadHandle: () => threadRef.current,\n\n      // v0.2.0 triggers — fire the handler without consulting the permissions\n      // matrix. Matrix gates the UI; handle is the escape hatch. If the handler\n      // isn't wired, the method is a no-op.\n      triggerEdit: () => {\n        onEditRef.current?.(statefulPostRef.current.id);\n      },\n      triggerDelete: () => {\n        onDeleteRef.current?.(statefulPostRef.current.id);\n      },\n      triggerPin: () => {\n        const p = statefulPostRef.current;\n        onPinRef.current?.(p.id, !(p.isPinned ?? false));\n      },\n      revealSensitive: () => {\n        setSensitiveRevealed(true);\n        onRevealSensitiveRef.current?.(statefulPostRef.current.id);\n      },\n      votePoll: (optionId: string) => {\n        setPollVote({ optionId, votedAt: new Date() });\n        onVotePollRef.current?.(statefulPostRef.current.id, optionId);\n      },\n    }),\n    // heartBurstWired re-derives per render; capture via ref if needed.\n    // For v0.1 simplicity, identity churns when heartBurstWired changes — acceptable.\n    [heartBurstWired],\n  );\n\n  const authorId = useId();\n\n  const handleLoadMoreLikers = useCallback(async (): Promise<PostLikeUser[]> => {\n    if (!onLoadMoreLikers) return [];\n    const next = await onLoadMoreLikers();\n    setStatefulLikers((prev) => [...prev, ...next]);\n    return next;\n  }, [onLoadMoreLikers]);\n\n  // ─── Inline panel node (only built when inline mode + a panel is open) ───\n  const inlinePanelNode = useMemo(() => {\n    if (!inlineModeActive || activePanel === \"none\") return null;\n    if (activePanel === \"likes\") {\n      return (\n        <LikersStrip\n          totalCount={statefulPost.likes}\n          likers={statefulLikers}\n          heading={labels.likersHeading}\n          onLoadMore={onLoadMoreLikers ? handleLoadMoreLikers : undefined}\n          moreAriaLabelTemplate={labels.likersMoreLabel}\n          onClose={() => setActivePanel(\"none\")}\n          closeLabel={labels.hidePanelLabel}\n        />\n      );\n    }\n    if (activePanel === \"share\") {\n      return (\n        <ShareMenu\n          users={shareSuggestions ?? []}\n          onSearch={onShareSearch}\n          onShareTo={(user) => {\n            onShareTo?.(statefulPost.id, user);\n            setActivePanel(\"none\");\n          }}\n          heading={labels.shareHeading}\n          searchPlaceholder={labels.shareSearchPlaceholder}\n          emptyLabel={labels.shareEmptyLabel}\n          onClose={() => setActivePanel(\"none\")}\n          closeLabel={labels.hidePanelLabel}\n        />\n      );\n    }\n    // comments\n    return (\n      <div\n        style={{ maxHeight: inlineCommentsMaxHeight }}\n        className=\"overflow-y-auto\"\n      >\n        <CommentThread\n          ref={threadRef}\n          comments={commentThread ?? []}\n          currentUser={currentUser}\n          pageSize={commentPageSize}\n          subscribe={commentSubscribe}\n          onSubscribeDelta={onSubscribeCommentDelta}\n          onAddComment={onAddComment}\n          onLikeComment={onLikeComment}\n          onDeleteComment={onDeleteComment}\n          onReportComment={onReportComment}\n          onLoadMore={onLoadMoreComments}\n          commentActions={commentActions}\n          labels={labelsProp?.commentLabels}\n        />\n      </div>\n    );\n  }, [\n    inlineModeActive,\n    activePanel,\n    statefulPost.id,\n    statefulPost.likes,\n    statefulLikers,\n    labels,\n    onLoadMoreLikers,\n    handleLoadMoreLikers,\n    shareSuggestions,\n    onShareSearch,\n    onShareTo,\n    inlineCommentsMaxHeight,\n    commentThread,\n    currentUser,\n    commentPageSize,\n    commentSubscribe,\n    onSubscribeCommentDelta,\n    onAddComment,\n    onLikeComment,\n    onDeleteComment,\n    onReportComment,\n    onLoadMoreComments,\n    commentActions,\n    labelsProp?.commentLabels,\n  ]);\n\n  // ─── Common props bundle ───\n  const baseProps = {\n    post: statefulPost,\n    currentUser,\n    authorId,\n    formattedTime,\n    kebabOpen,\n    onKebabOpenChange: setKebabOpen,\n    kebabItems,\n    engagementActions: engagementActionsArr,\n    engagementBarRef: barRef,\n    engagementClassName,\n    engagementLabels: labelsProp?.engagementLabels,\n    engagementSubscribeNoop: undefined,\n    bodyMaxLines: resolvedBodyMaxLines,\n    bodyClassName,\n    headingAs: resolvedHeadingAs,\n    labels,\n    burstKey,\n    heartBurstWired,\n    onMediaDoubleTap: heartBurstWired ? onMediaDoubleTap : undefined,\n    cardLinkable,\n    linkHref,\n    inlinePanelNode,\n    LinkComponent,\n    renderHeader,\n    renderContent,\n    renderMedia,\n    renderEngagementBar,\n    onLocationClick,\n    onMentionClick,\n    onTagClick,\n    sensitiveRevealed,\n    onSensitiveReveal,\n    disableSensitiveGate,\n    renderSensitiveGate,\n    onLinkPreviewClick,\n    disableLinkPreviewRender,\n    renderLinkPreview,\n    onRepostOfClick,\n    disableRepostOfRender,\n    renderRepostOf,\n    getHref,\n    linkComponent,\n    cardLabels: labelsProp,\n    onPollVote,\n    pollOptimisticVote: pollVote,\n    isOwnerView,\n    disablePollRender,\n    renderPoll,\n    formatRelativeTime: format,\n    className,\n    headerClassName,\n    mediaClassName,\n  };\n\n  switch (variant) {\n    case \"feed\":\n      return <FeedVariant {...baseProps} />;\n    case \"compact\":\n      return <CompactVariant {...baseProps} />;\n    case \"list\":\n      return <ListVariant {...baseProps} />;\n    case \"detail\":\n      return (\n        <DetailVariant\n          {...baseProps}\n          commentThread={commentThread ?? []}\n          commentPageSize={commentPageSize}\n          commentSubscribe={commentSubscribe}\n          onSubscribeCommentDelta={onSubscribeCommentDelta}\n          onAddComment={onAddComment}\n          onLikeComment={onLikeComment}\n          onDeleteComment={onDeleteComment}\n          onReportComment={onReportComment}\n          onLoadMoreComments={onLoadMoreComments}\n          commentActions={commentActions}\n          renderCommentSection={renderCommentSection}\n          commentLabels={labelsProp?.commentLabels}\n          threadRef={threadRef}\n          commentSectionClassName={commentSectionClassName}\n          commentsHeading={labels.commentsHeading}\n        />\n      );\n  }\n}\n\nconst PostCard = memo(PostCardInner);\nPostCard.displayName = \"PostCard\";\n\nexport { PostCard };\n",
      "type": "registry:component",
      "target": "components/post-card/post-card.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/index.ts",
      "content": "export { PostCard } from \"./post-card\";\nexport { VerifiedBadge } from \"./parts/verified-badge\";\n// v0.2.0 sub-exports — host-side opt-in for inline mention highlighting via\n// renderContent slot (MentionText) + standalone tag chip row (TagChips).\n// The card auto-renders TagChips below content when post.tags is set; this\n// sub-export is for hosts wanting to render the chips elsewhere.\nexport { MentionText } from \"./parts/mention-text\";\nexport type { MentionTextProps } from \"./parts/mention-text\";\nexport { TagChips } from \"./parts/tag-chips\";\nexport type { TagChipsProps } from \"./parts/tag-chips\";\n// v0.2.0 sub-export — host-side opt-in for rendering a nested repost mini-card\n// outside the auto-rendered feed/detail slot (e.g. inside a custom layout).\nexport { RepostOfCard } from \"./parts/repost-of-card\";\nexport type { RepostOfCardProps } from \"./parts/repost-of-card\";\n// v0.2.0 sub-export — host-side opt-in for rendering an inline poll widget\n// outside the auto-rendered feed/detail slot (e.g. a standalone poll page).\nexport { PollWidget } from \"./parts/poll-widget\";\nexport type { PollWidgetProps } from \"./parts/poll-widget\";\n// LikersStrip + ShareMenu moved to engagement-bar in v0.2.0; re-exported here\n// as a soft-compat affordance so v0.1 consumers that imported them from this\n// barrel continue to work. New consumers should import directly from\n// @ilinxa/engagement-bar.\n// v0.3.1 F-S1 lock: relative + specific-file paths so shadcn 4.6.0's\n// path-rewriter doesn't mangle these cross-procomp re-exports.\nexport { LikersStrip } from \"../engagement-bar/parts/likers-strip\";\nexport { ShareMenu } from \"../engagement-bar/parts/share-menu\";\nexport {\n  defaultPostEngagementActions,\n  defaultPostKebabActions,\n} from \"./lib/defaults\";\n\nexport type {\n  Post,\n  PostAuthor,\n  PostHandlers,\n  PostCardProps,\n  PostCardHandle,\n  PostCardVariant,\n  PostCardLabels,\n  EngagementMode,\n  PostLikeUser,\n} from \"./types\";\n\n// The `Post` / `PostCardProps` sub-shapes. Every one of these is reachable\n// from an already-exported type — `Post.mentions`, `Post.poll`,\n// `Post.location`, `Post.replyTo`, `Post.linkPreview`, `Post.visibility`,\n// `PostCardProps.viewerMode` / `.permissions` / `.mutations` — but none was\n// importable from this barrel, so a consumer could hold a `Post` and still\n// not name the type of a field they were building\n// (validate:barrel-exports, 2026-08-17).\nexport type {\n  LinkPreview,\n  PostLocation,\n  PostMention,\n  PostMutationHandlers,\n  PostPermissionAction,\n  PostPermissions,\n  PostPoll,\n  PostPollOption,\n  PostReplyTo,\n  PostViewerMode,\n  PostVisibility,\n} from \"./types\";\n\nexport { DEFAULT_POST_CARD_LABELS } from \"./types\";\n\n// Re-exports for consumer convenience — single import.\n// v0.3.1 F-S1 lock: relative + specific-file paths.\n// v0.3.2: cross-category MediaItem re-export DROPPED — the shadcn rewriter\n// mangles cross-category /types imports. `PostMediaItem` is now defined in\n// `./types.ts` (structurally identical to media-carousel's MediaItem),\n// with a soft-compat `MediaItem` alias re-exported below.\nexport type { PostMediaItem, MediaItem } from \"./types\";\nexport type {\n  Comment,\n  CommentDelta,\n  CommentMenuItem,\n  CommentThreadCurrentUser,\n  Subscribe,\n  Unsubscribe,\n} from \"../comment-thread/types\";\nexport type {\n  EngagementAction,\n  EngagementDelta,\n  EngagementBarHandle,\n} from \"../engagement-bar/types\";\n\n",
      "type": "registry:component",
      "target": "components/post-card/index.ts"
    },
    {
      "path": "src/registry/components/data/post-card/types.ts",
      "content": "import type { ElementType, ReactNode } from \"react\";\n// F-S1 lock: cross-procomp imports use RELATIVE paths to specific files\n// (NOT barrels) so shadcn 4.6.0's path-rewriter doesn't mangle them.\n// See .claude/decisions/2026-05-28-post-card-v0.3.1-fcross-11-fs1-cleanup.md\n// Cross-category type imports are not safe under shadcn 4.6.0's rewriter\n// (both `@/registry/components/<other-cat>/<other-slug>` AND its `/types`\n// suffix get mangled — sometimes to the wrong slug, sometimes preserving an\n// invalid `<cat>/` prefix). For cross-category TYPES we inline a local\n// structurally-compatible copy. Keep in sync with media-carousel's\n// MediaItem export. Cross-category COMPONENT imports (MediaCarousel) DO\n// work via `@/registry/components/<cat>/<slug>/<slug>` — see variants.\n\n/**\n * Structurally identical to `@ilinxa/media-carousel`'s `MediaItem`.\n * Inlined to avoid the cross-category type-import rewriter bug. If\n * media-carousel's MediaItem changes shape, this must follow manually.\n */\nexport type PostMediaItem =\n  | {\n      id: string;\n      type: \"image\";\n      url: string;\n      alt?: string;\n    }\n  | {\n      id: string;\n      type: \"video\";\n      url: string;\n      alt?: string;\n      poster?: string;\n    };\n\n/**\n * @deprecated v0.3.2 — use `PostMediaItem`. Soft-compat alias for v0.2.x\n * consumers that imported `MediaItem` from this barrel. The cross-category\n * type re-export was broken at install-time even at v0.2.0; this alias\n * preserves the symbol name for source-code compatibility.\n */\nexport type MediaItem = PostMediaItem;\nimport type {\n  Comment,\n  CommentDelta,\n  CommentMenuItem,\n  CommentThreadCurrentUser,\n  CommentThreadLabels,\n  CommentThreadHandle,\n  Subscribe,\n} from \"../comment-thread/types\";\nimport type {\n  EngagementAction,\n  EngagementBarHandle,\n  EngagementBarLabels,\n  EngagementDelta,\n  EngagementLikerProfile,\n} from \"../engagement-bar/types\";\n\nexport type PostCardVariant = \"feed\" | \"compact\" | \"list\" | \"detail\";\n\n/**\n * Two-mode toggle for role-aware kebab + render affordances.\n *  - `\"owner\"`: shows Edit / Delete / Pin / Change-visibility / Mark-sensitive / See-analytics kebab items (when their handlers are wired).\n *  - `\"viewer\"`: shows Report / Block author / Mute author (when wired).\n *\n * **Opt-in semantics (per Q-D1 lock):** `viewerMode === undefined` → v0.1 legacy\n * mode (handler-driven kebab, no role-aware items). No auto-derivation from\n * `currentUser` — the host explicitly picks the mode to keep the library neutral\n * across identity / role models (different projects derive ownership differently).\n *\n * Moderator UX (take-down, feature, lock, etc.) is **NOT** a third `viewerMode`\n * value — moderation is an orthogonal capability that crosses owner / viewer\n * lines. Wire it via `permissions.canModerate: true` (or\n * `canPerformAction(\"moderate\", post) === true`) PLUS the\n * {@link PostCardProps.moderatorActions} slot which supplies the menu items.\n * The moderator section renders as its own group in the kebab with a divider\n * above it, sitting between common items and viewer-destructive items. Full\n * kebab takeover via {@link PostCardProps.kebabActions} still bypasses everything.\n */\nexport type PostViewerMode = \"owner\" | \"viewer\";\n\n/**\n * Facebook-style extensible visibility shape.\n *\n * Library renders default labels + icons for the 6 base values via\n * `DEFAULT_POST_CARD_LABELS`. Custom string values (e.g. `\"specific-friends\"`,\n * `\"list:close-friends\"`, `\"everyone-except-bob\"`) get a fallback \"Custom\" label.\n * Granular Facebook-style cases (\"Friends except…\", \"Specific friends only\")\n * are encoded by the host as their own string keys; library doesn't model the\n * granularity directly.\n */\nexport type PostVisibility =\n  | \"public\"\n  | \"followers\"\n  | \"friends\"\n  | \"circle\"\n  | \"only-me\"\n  | \"private\"\n  | (string & {});\n\n/** Discriminator for the universal `canPerformAction` predicate. */\nexport type PostPermissionAction =\n  | \"edit\"\n  | \"delete\"\n  | \"pin\"\n  | \"changeVisibility\"\n  | \"markSensitive\"\n  | \"seeAnalytics\"\n  | \"share\"\n  | \"bookmark\"\n  | \"report\"\n  | \"blockAuthor\"\n  | \"muteAuthor\"\n  | \"moderate\";\n\n/**\n * Per-action permissions matrix. Overrides the `viewerMode`-derived defaults.\n *\n * Resolution order (most → least specific):\n *  1. `canPerformAction(action, post)` returning `true` / `false` — wins everything\n *  2. `permissions[canX]` per-field — `false` denies, `true` allows\n *  3. `viewerMode`-derived defaults (`\"owner\"` → all owner-side `true`, viewer-side `false`; `\"viewer\"` → inverse)\n *  4. Library-baseline default (legacy mode — no `viewerMode` + no `permissions` + no `canPerformAction`): v0.1 handler-driven kebab behavior\n *\n * `canShare` / `canBookmark` are **host-policy gates** — viewer-side actions whose\n * visibility the host can deny (e.g. `visibility: \"private\"` posts may hide Share;\n * unauthenticated viewers may have `canBookmark: false`).\n */\nexport interface PostPermissions {\n  // owner-side\n  canEdit?: boolean;\n  canDelete?: boolean;\n  canPin?: boolean;\n  canChangeVisibility?: boolean;\n  canMarkSensitive?: boolean;\n  canSeeAnalytics?: boolean;\n  // host-policy gates\n  canShare?: boolean;\n  canBookmark?: boolean;\n  // viewer-side\n  canReport?: boolean;\n  canBlockAuthor?: boolean;\n  canMuteAuthor?: boolean;\n  /**\n   * Moderator capability — orthogonal to owner / viewer modes. When `true`\n   * AND {@link PostCardProps.moderatorActions} returns ≥1 item, the kebab\n   * renders a moderator section between common items and viewer-destructive\n   * items with a divider above it. Defaults to `false` in both viewer modes —\n   * moderators must be opted in explicitly by the host's permission resolver.\n   */\n  canModerate?: boolean;\n}\n\n/**\n * Owner / viewer mutation handlers. Separate from {@link PostHandlers} (which\n * stays \"engagement-action handlers only\" — `onLike` / `onComment` / `onShare`\n * / `onBookmark`). Flattened onto `PostCardProps` — consumers pass handlers\n * at the top level, not as a nested prop bag.\n *\n * `onChangeVisibility` per Q-P42 lock: library does NOT ship a visibility picker\n * UI. This callback fires when the kebab \"Change visibility\" item is tapped;\n * the host opens its own picker (banner / sheet / dialog / inline control) and\n * calls back to its API directly. Library only receives the trigger event.\n */\nexport interface PostMutationHandlers {\n  // owner-side\n  onEdit?: (postId: string) => void;\n  onDelete?: (postId: string) => void;\n  onPin?: (postId: string, nextPinned: boolean) => void;\n  onChangeVisibility?: (\n    postId: string,\n    currentVisibility: PostVisibility | undefined,\n  ) => void;\n  onMarkSensitive?: (\n    postId: string,\n    nextSensitive: boolean,\n    reason?: string,\n  ) => void;\n  onSeeAnalytics?: (postId: string) => void;\n  // viewer-side (onReport already exists on PostCardProps from v0.1 — preserved)\n  onBlockAuthor?: (authorId: string) => void;\n  onMuteAuthor?: (authorId: string) => void;\n}\n\n/**\n * Inline mention with range offset for highlighting in the content body.\n * Consumer-supplied; library doesn't parse mentions from raw text.\n */\nexport interface PostMention {\n  id: string;\n  name: string;\n  username?: string;\n  /** [start, end] character offsets in `post.content` (UTF-16 code units, end-exclusive). */\n  range: [number, number];\n}\n\n/** Geo / place chip data for the header sub-row. */\nexport interface PostLocation {\n  name: string;\n  lat?: number;\n  lng?: number;\n}\n\n/** Reply-to ancestor — drives the \"Replying to @x\" sub-header line. */\nexport interface PostReplyTo {\n  id: string;\n  author: PostAuthor;\n}\n\n/** Single poll option. */\nexport interface PostPollOption {\n  id: string;\n  label: string;\n  voteCount: number;\n}\n\n/**\n * Inline poll on the post body. Viewer sees vote buttons; owner sees live\n * results. Optimistic vote flow per Q-D5 lock — host calls `onVotePoll`,\n * library flips local-mirror immediately, host reverts via `ref.current.reset()`\n * on failure.\n */\nexport interface PostPoll {\n  options: PostPollOption[];\n  closesAt?: Date | string | number;\n  totalVotes: number;\n  /** Whether the current viewer has already voted (gates the vote buttons). */\n  hasVoted?: boolean;\n  /** The option id the viewer voted for (highlights it in the results view). */\n  viewerVoteOptionId?: string;\n  /** Whether the poll allows multi-select. Default false (single-choice). */\n  multiSelect?: boolean;\n}\n\n/**\n * OG-style link preview card. Host pre-fetches and supplies; library is\n * fetch-free per Q-D4 lock.\n */\nexport interface LinkPreview {\n  url: string;\n  title?: string;\n  description?: string;\n  /** Image URL — rendered as a hero image at the top of the preview card. */\n  image?: string;\n  siteName?: string;\n}\n\n/**\n * Behavior of the comment + like actions:\n *  - `\"inline\"` (default): like splits into heart (toggle) + count (open likers panel inline);\n *    comment toggles an inline `<CommentThread>` (with composer) below the engagement bar.\n *    Card owns the panel state. Matches the kasder PostEngagementPanel UX.\n *  - `\"navigate\"`: comment-icon click fires `onComment(postId)` (host navigates / scrolls);\n *    like is a single button (heart + count bundled, fires `onLike(postId, next)`). No inline panels.\n *\n * Default is `\"inline\"` so the canonical social UX works without opt-in. Pass `\"navigate\"` to\n * deactivate the inline panels for a host that wants page navigation instead.\n */\nexport type EngagementMode = \"navigate\" | \"inline\";\n\n/**\n * @deprecated Use `EngagementLikerProfile` from `@ilinxa/engagement-bar` instead.\n * Kept as a soft-compat type alias for v0.1.x consumers that imported this name\n * directly. Will be removed in a future major bump. Structurally identical to\n * `EngagementLikerProfile` (relaxed-fields UI display shape).\n */\nexport type PostLikeUser = EngagementLikerProfile;\n\nexport interface PostAuthor {\n  id: string;\n  name: string;\n  username?: string;\n  avatar?: string;\n  isVerified?: boolean;\n}\n\nexport interface Post {\n  id: string;\n  author: PostAuthor;\n  content: string;\n  media?: MediaItem[];\n  createdAt: Date | string | number;\n  likes: number;\n  isLiked?: boolean;\n  comments: number;\n  shares?: number;\n  viewCount?: number;\n  isBookmarked?: boolean;\n\n  // ─── v0.2.0 schema expansion (all optional; v0.1.x consumers' Post shape still satisfies) ───\n\n  /** Hashtag chips rendered below the content body. Clickable via `onTagClick`. */\n  tags?: string[];\n  /** Inline mention ranges for `@name` highlighting in the body (host-supplied; library doesn't parse). */\n  mentions?: PostMention[];\n  /** Place chip in the header sub-row beside the timestamp. */\n  location?: PostLocation;\n  /**\n   * Audience visibility. Renders a small icon badge next to the timestamp.\n   * Default semantics if absent: treated as `\"public\"` (no badge rendered).\n   */\n  visibility?: PostVisibility;\n  /** Timestamp of the last edit. Renders an \"(edited)\" suffix after the relative time. */\n  editedAt?: Date | string | number;\n  /** Pinned posts render a \"Pinned\" badge above the header. */\n  isPinned?: boolean;\n  /** Sensitive content gate over the media block — viewer must tap to reveal. */\n  isSensitive?: boolean;\n  /** Optional reason shown inside the sensitive-content gate. */\n  sensitiveReason?: string;\n  /** BCP-47 language tag. Drives `onTranslate` kebab item visibility (no auto-translate in v0.2.0). */\n  language?: string;\n  /** Reply-to ancestor — drives the \"Replying to @x\" sub-header line. */\n  replyTo?: PostReplyTo;\n  /** Quoted / reposted parent — renders as a nested compact mini-card under the content. */\n  repostOf?: Post;\n  /** OG-style link preview card rendered below content and above media. */\n  linkPreview?: LinkPreview;\n  /** Inline poll widget below content. */\n  poll?: PostPoll;\n}\n\n/** Engagement-action handlers only — comment handlers stay separate. */\nexport interface PostHandlers {\n  onLike?: (postId: string, nextLiked: boolean) => void;\n  onComment?: (postId: string) => void;\n  onShare?: (postId: string) => void;\n  onBookmark?: (postId: string, nextBookmarked: boolean) => void;\n}\n\nexport interface PostCardLabels {\n  verifiedBadgeLabel?: string;\n  bookmark?: string;\n  unbookmark?: string;\n  share?: string;\n  copyLink?: string;\n  report?: string;\n  commentsHeading?: string;\n  /** Inline likers panel heading. Default \"Likes\". */\n  likersHeading?: string;\n  /** Likers panel \"+N\" pill aria-label template; `{count}` is replaced. Default \"+{count} more\". */\n  likersMoreLabel?: string;\n  /** Inline panel \"Hide\" button label. Default \"Hide\". */\n  hidePanelLabel?: string;\n  /** Share panel heading. Default \"Share with…\". */\n  shareHeading?: string;\n  /** Share panel search placeholder. Default \"Search people…\". */\n  shareSearchPlaceholder?: string;\n  /** Share panel empty-state. Default \"No matches.\" */\n  shareEmptyLabel?: string;\n  formatRelativeTime?: (date: Date, now: Date) => string;\n  engagementLabels?: EngagementBarLabels;\n  commentLabels?: CommentThreadLabels;\n\n  // ─── v0.2.0 kebab labels (role-aware items) ───\n\n  /** Owner-side kebab item label. Default \"Edit\". */\n  edit?: string;\n  /** Owner-side kebab item label. Default \"Delete\". */\n  delete?: string;\n  /** Owner-side kebab item label when not pinned. Default \"Pin to top\". */\n  pin?: string;\n  /** Owner-side kebab item label when already pinned. Default \"Unpin\". */\n  unpin?: string;\n  /** Owner-side kebab item label. Default \"Change visibility\". */\n  changeVisibility?: string;\n  /** Owner-side kebab item label when not sensitive. Default \"Mark as sensitive\". */\n  markSensitive?: string;\n  /** Owner-side kebab item label when already sensitive. Default \"Remove sensitive mark\". */\n  unmarkSensitive?: string;\n  /** Owner-side kebab item label. Default \"See analytics\". */\n  seeAnalytics?: string;\n  /** Viewer-side kebab item label. Default \"Block author\". */\n  blockAuthor?: string;\n  /** Viewer-side kebab item label. Default \"Mute author\". */\n  muteAuthor?: string;\n  /** Translate kebab item label (visible when `post.language` is set and `onTranslate` handler is wired). Default \"Translate\". */\n  translate?: string;\n\n  // ─── v0.2.0 visibility-badge + edit-suffix labels ───\n\n  /** Visibility badge label / tooltip for `\"public\"`. Default \"Public\". */\n  visibilityPublic?: string;\n  /** Visibility badge label / tooltip for `\"followers\"`. Default \"Followers\". */\n  visibilityFollowers?: string;\n  /** Visibility badge label / tooltip for `\"friends\"`. Default \"Friends\". */\n  visibilityFriends?: string;\n  /** Visibility badge label / tooltip for `\"circle\"`. Default \"Circle\". */\n  visibilityCircle?: string;\n  /** Visibility badge label / tooltip for `\"only-me\"`. Default \"Only me\". */\n  visibilityOnlyMe?: string;\n  /** Visibility badge label / tooltip for `\"private\"`. Default \"Private\". */\n  visibilityPrivate?: string;\n  /** Fallback label for any custom visibility string not in the 6 base values. Default \"Custom\". */\n  visibilityCustom?: string;\n  /** Suffix appended after the relative timestamp when `editedAt` is set. Default \"(edited)\". */\n  editedSuffix?: string;\n  /** Pinned badge text. Default \"Pinned\". */\n  pinnedBadgeLabel?: string;\n  /** \"Replying to\" sub-header line label prefix. Default \"Replying to\". Renders as `{label} @{username}`. */\n  replyingTo?: string;\n\n  // ─── v0.2.0 sensitive-gate labels ───\n\n  /** Sensitive-media gate heading. Default \"Sensitive content\". */\n  sensitiveHeading?: string;\n  /** Sensitive-media gate reveal button. Default \"Show\". */\n  sensitiveRevealLabel?: string;\n\n  // ─── v0.2.0 poll labels ───\n\n  /** Poll widget heading. Default \"Poll\". */\n  pollHeading?: string;\n  /** Poll \"X votes\" pluralization template; `{count}` is replaced. Default \"{count} votes\". */\n  pollTotalVotesLabel?: string;\n  /** Poll countdown text when active; `{time}` is replaced with relative time. Default \"Closes {time}\". */\n  pollClosesAtLabel?: string;\n  /** Poll countdown text when closed. Default \"Poll closed\". */\n  pollClosedLabel?: string;\n}\n\nexport const DEFAULT_POST_CARD_LABELS: Required<\n  Omit<\n    PostCardLabels,\n    \"formatRelativeTime\" | \"engagementLabels\" | \"commentLabels\"\n  >\n> = {\n  verifiedBadgeLabel: \"Verified account\",\n  bookmark: \"Bookmark\",\n  unbookmark: \"Remove bookmark\",\n  share: \"Share\",\n  copyLink: \"Copy link\",\n  report: \"Report\",\n  commentsHeading: \"Comments\",\n  likersHeading: \"Likes\",\n  likersMoreLabel: \"+{count} more\",\n  hidePanelLabel: \"Hide\",\n  shareHeading: \"Share with…\",\n  shareSearchPlaceholder: \"Search people…\",\n  shareEmptyLabel: \"No matches.\",\n  // v0.2.0 additions\n  edit: \"Edit\",\n  delete: \"Delete\",\n  pin: \"Pin to top\",\n  unpin: \"Unpin\",\n  changeVisibility: \"Change visibility\",\n  markSensitive: \"Mark as sensitive\",\n  unmarkSensitive: \"Remove sensitive mark\",\n  seeAnalytics: \"See analytics\",\n  blockAuthor: \"Block author\",\n  muteAuthor: \"Mute author\",\n  translate: \"Translate\",\n  visibilityPublic: \"Public\",\n  visibilityFollowers: \"Followers\",\n  visibilityFriends: \"Friends\",\n  visibilityCircle: \"Circle\",\n  visibilityOnlyMe: \"Only me\",\n  visibilityPrivate: \"Private\",\n  visibilityCustom: \"Custom\",\n  editedSuffix: \"(edited)\",\n  pinnedBadgeLabel: \"Pinned\",\n  replyingTo: \"Replying to\",\n  sensitiveHeading: \"Sensitive content\",\n  sensitiveRevealLabel: \"Show\",\n  pollHeading: \"Poll\",\n  pollTotalVotesLabel: \"{count} votes\",\n  pollClosesAtLabel: \"Closes {time}\",\n  pollClosedLabel: \"Poll closed\",\n};\n\nexport interface PostCardProps extends PostHandlers, PostMutationHandlers {\n  variant: PostCardVariant;\n  post: Post;\n  currentUser?: CommentThreadCurrentUser;\n\n  // ─── v0.2.0 role-aware mode (opt-in per Q-D1 lock) ───\n\n  /**\n   * Two-mode toggle for role-aware kebab + render affordances. `undefined` =\n   * v0.1 legacy mode (handler-driven kebab, no role-aware items). No\n   * auto-derivation from `currentUser` — host explicitly picks.\n   */\n  viewerMode?: PostViewerMode;\n  /** Per-action permissions matrix. Overrides `viewerMode`-derived defaults. */\n  permissions?: PostPermissions;\n  /**\n   * Universal permission predicate — wins over `permissions` + `viewerMode`.\n   * Return `true` / `false` to force-allow / deny; return `undefined` to fall\n   * through to the matrix → mode defaults → legacy mode.\n   */\n  canPerformAction?: (\n    action: PostPermissionAction,\n    post: Post,\n  ) => boolean | undefined;\n\n  // ─── v0.2.0 schema-expansion render handlers ───\n\n  /** Fired when the viewer taps a poll option (optimistic flow). */\n  onVotePoll?: (postId: string, optionId: string) => void;\n  /** Fired when the viewer dismisses the sensitive-media gate. Analytics hook. */\n  onRevealSensitive?: (postId: string) => void;\n  /** Fired when the kebab \"Translate\" item is tapped (visible only if `post.language` is set). */\n  onTranslate?: (postId: string, sourceLanguage: string) => void;\n  /** Fired when a `@mention` span is tapped in the content body. Host opens its own profile preview / popover. */\n  onMentionClick?: (mentionId: string) => void;\n  /** Fired when a `#tag` chip is tapped below the content body. */\n  onTagClick?: (tag: string) => void;\n  /** Fired when the location chip in the header sub-row is tapped. */\n  onLocationClick?: (location: PostLocation) => void;\n  /** Fired when the OG link-preview card is tapped. Overrides the default `<a href>` navigation. */\n  onLinkPreviewClick?: (url: string) => void;\n  /** Fired when the nested repost mini-card is tapped. Overrides the default `getHref(repostOf)` navigation. */\n  onRepostOfClick?: (originalPost: Post) => void;\n\n  commentThread?: Comment[];\n  commentPageSize?: number;\n\n  onAddComment?: (\n    content: string,\n    parentId?: string,\n  ) => Promise<Comment | void> | Comment | void;\n  onLikeComment?: (commentId: string, nextLiked: boolean) => void;\n  onDeleteComment?: (commentId: string) => void;\n  onReportComment?: (commentId: string) => void;\n  onLoadMoreComments?: (page: number) => Promise<Comment[]>;\n  onCopyLink?: (postId: string) => void;\n  onReport?: (postId: string) => void;\n\n  engagementSubscribe?: Subscribe<EngagementDelta>;\n  commentSubscribe?: Subscribe<CommentDelta>;\n  onSubscribeEngagementDelta?: (delta: EngagementDelta) => void;\n  onSubscribeCommentDelta?: (delta: CommentDelta) => void;\n\n  /** Card overlay-link target + Copy-link kebab item. Ignored for overlay-link in detail variant. */\n  getHref?: (post: Post) => string;\n  /** Polymorphic root component for overlay-link. Default \"a\". */\n  linkComponent?: ElementType;\n  /** Opt-out for the canonical heart-burst flow. Default false. */\n  disableHeartBurst?: boolean;\n\n  // ─── Inline engagement panels (kasder UX) ───\n  /**\n   * `\"navigate\"` (default): comment click → `onComment(postId)`; no inline panels.\n   * `\"inline\"`: comment click toggles `<CommentThread>` panel; like opens inline likers strip.\n   */\n  engagementMode?: EngagementMode;\n  /** Pre-loaded likers (for inline likers panel). Only used when `engagementMode === \"inline\"`. */\n  likers?: PostLikeUser[];\n  /** Fetch more likers — host returns appended page; component appends to local state. */\n  onLoadMoreLikers?: () => Promise<PostLikeUser[]>;\n\n  /** Recent / suggested users for the share panel. When provided, share button opens\n   * an inline searchable user list (kasder UX). Otherwise share fires `onShare(postId)`. */\n  shareSuggestions?: PostLikeUser[];\n  /** Optional async search for the share panel. Receives the query, returns matching users. */\n  onShareSearch?: (query: string) => Promise<PostLikeUser[]>;\n  /** Fired when the user picks a recipient from the share panel. */\n  onShareTo?: (postId: string, recipient: PostLikeUser) => void;\n  /** Max-height of the inline comments panel before scroll. Default `\"24rem\"` (~384px). */\n  inlineCommentsMaxHeight?: string;\n  /** Open one of the inline panels by default on mount (only when `engagementMode === \"inline\"`). */\n  defaultInlinePanel?: \"none\" | \"likes\" | \"comments\";\n  /** Auto-open the likers panel on like (matches kasder). Default true under inline mode. */\n  openLikersOnLike?: boolean;\n\n  renderHeader?: (\n    post: Post,\n    helpers: { currentUser?: CommentThreadCurrentUser },\n  ) => ReactNode;\n  renderContent?: (post: Post) => ReactNode;\n  renderMedia?: (\n    media: MediaItem[],\n    helpers: { onDoubleTap?: () => void },\n  ) => ReactNode;\n  engagementActions?: (\n    post: Post,\n    handlers: PostHandlers,\n    variant: PostCardVariant,\n  ) => EngagementAction[];\n  renderEngagementBar?: (\n    post: Post,\n    defaults: { actions: EngagementAction[] },\n  ) => ReactNode;\n  kebabActions?: (post: Post) => CommentMenuItem[];\n  /**\n   * Supplier for the moderator section of the kebab. Items render only when\n   * `canModerate` resolves to `true` (via `permissions.canModerate` or\n   * `canPerformAction(\"moderate\", post)`). The library inserts a divider\n   * above the first returned item and positions the group between common\n   * items (Bookmark / Share / Copy link / Translate) and viewer-destructive\n   * items (Mute / Block / Report). Return `[]` to suppress.\n   *\n   * Bypassed entirely when {@link PostCardProps.kebabActions} is supplied\n   * (full takeover wins).\n   */\n  moderatorActions?: (post: Post) => CommentMenuItem[];\n  commentActions?: (\n    comment: Comment,\n    helpers: {\n      currentUser?: CommentThreadCurrentUser;\n      isOwn: boolean;\n      depth: number;\n    },\n  ) => CommentMenuItem[];\n  renderCommentSection?: (\n    post: Post,\n    handlers: {\n      onAddComment?: PostCardProps[\"onAddComment\"];\n      onLikeComment?: PostCardProps[\"onLikeComment\"];\n      onDeleteComment?: PostCardProps[\"onDeleteComment\"];\n      onReportComment?: PostCardProps[\"onReportComment\"];\n      onLoadMoreComments?: PostCardProps[\"onLoadMoreComments\"];\n    },\n  ) => ReactNode;\n\n  // ─── v0.2.0 render slots for schema-expansion features ───\n\n  /** Full takeover for the inline poll widget. Default renders `<PollWidget>` when `post.poll` is set. */\n  renderPoll?: (\n    post: Post,\n    helpers: { onVote?: (optionId: string) => void; isOwnerView: boolean },\n  ) => ReactNode;\n  /** Full takeover for the OG link-preview card. Default renders `<LinkPreviewCard>` when `post.linkPreview` is set. */\n  renderLinkPreview?: (post: Post) => ReactNode;\n  /** Full takeover for the nested repost mini-card. Default renders `<RepostOfCard>` when `post.repostOf` is set. */\n  renderRepostOf?: (\n    post: Post,\n    helpers: { onClick?: () => void },\n  ) => ReactNode;\n  /** Full takeover for the sensitive-media gate overlay. Default renders `<SensitiveGate>` when `post.isSensitive` is true. */\n  renderSensitiveGate?: (\n    post: Post,\n    helpers: { onReveal: () => void },\n  ) => ReactNode;\n\n  // ─── v0.2.0 opt-outs (disable default renders without taking over via slot) ───\n\n  /** When true, the default poll widget render is suppressed even if `post.poll` is set. */\n  disablePollRender?: boolean;\n  /** When true, the default link-preview card render is suppressed even if `post.linkPreview` is set. */\n  disableLinkPreviewRender?: boolean;\n  /** When true, the default repost mini-card render is suppressed even if `post.repostOf` is set. */\n  disableRepostOfRender?: boolean;\n  /** When true, the default sensitive-media gate is suppressed even if `post.isSensitive` is true. */\n  disableSensitiveGate?: boolean;\n\n  headingAs?: \"h2\" | \"h3\" | \"h4\";\n  bodyMaxLines?: number;\n\n  labels?: PostCardLabels;\n  className?: string;\n  headerClassName?: string;\n  bodyClassName?: string;\n  mediaClassName?: string;\n  engagementClassName?: string;\n  commentSectionClassName?: string;\n\n  ref?: React.Ref<PostCardHandle>;\n}\n\nexport interface PostCardHandle {\n  openKebab: () => void;\n  /** Card-level \"canonical Instagram tap\" — flips like via bar AND bumps burst counter (when wired). */\n  triggerLike: () => void;\n  /** Read the current local mirror state (post + engagement deltas applied). */\n  getCurrentPost: () => Post;\n  /** Replace the post (re-derives local engagement mirror; clears pollVote + sensitiveRevealed). */\n  reset: (next: Post) => void;\n  getEngagementHandle: () => EngagementBarHandle | null;\n  getThreadHandle: () => CommentThreadHandle | null;\n\n  // ─── v0.2.0 imperative triggers ───\n  // The handle is an escape hatch — these methods fire the handler without\n  // consulting the permissions matrix. The matrix gates UI affordances (which\n  // kebab items render), not programmatic triggers. If the handler isn't wired,\n  // the method is a no-op. Consumers using these triggers are presumed to know\n  // what they're doing (e.g. moderator dashboards with custom UI bypassing the\n  // default kebab).\n\n  /** Fires `onEdit(post.id)` if wired. No-op otherwise. */\n  triggerEdit: () => void;\n  /** Fires `onDelete(post.id)` if wired. No-op otherwise. */\n  triggerDelete: () => void;\n  /** Fires `onPin(post.id, !post.isPinned)` if wired. No-op otherwise. */\n  triggerPin: () => void;\n  /** Sets local `sensitiveRevealed=true` (clears the gate) + fires `onRevealSensitive` if wired. */\n  revealSensitive: () => void;\n  /**\n   * Optimistic poll vote — sets local `pollVote = { optionId, votedAt }` + fires\n   * `onVotePoll(post.id, optionId)` if wired. Host can reject by calling `reset(originalPost)`,\n   * which clears the local pollVote alongside the rest of the mirror.\n   */\n  votePoll: (optionId: string) => void;\n}\n",
      "type": "registry:component",
      "target": "components/post-card/types.ts"
    },
    {
      "path": "src/registry/components/data/post-card/parts/feed-variant.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { cn } from \"@/lib/utils\";\n// F-S1 lock: cross-procomp imports via relative + specific-file paths.\nimport { ExpandableText } from \"../../expandable-text/expandable-text\";\n// Cross-category: absolute-with-suffix (relative would resolve to non-existent\n// `src/media/...` in consumer's flat tree).\nimport { MediaCarousel } from \"@/registry/components/media/media-carousel/media-carousel\";\nimport { EngagementBar } from \"../../engagement-bar/engagement-bar\";\nimport { EngagementHeartBurst } from \"../../engagement-bar/parts/engagement-heart-burst\";\nimport { PostHeader } from \"./post-header\";\nimport { TagChips } from \"./tag-chips\";\nimport { SensitiveGate } from \"./sensitive-gate\";\nimport { LinkPreviewCard } from \"./link-preview-card\";\nimport { RepostOfCard } from \"./repost-of-card\";\nimport { PollWidget } from \"./poll-widget\";\nimport type { VariantInnerProps } from \"./variant-shared\";\n\nfunction FeedVariantInner(props: VariantInnerProps) {\n  const {\n    post,\n    currentUser,\n    authorId,\n    formattedTime,\n    kebabOpen,\n    onKebabOpenChange,\n    kebabItems,\n    engagementActions,\n    engagementBarRef,\n    engagementClassName,\n    engagementLabels,\n    engagementSubscribeNoop,\n    bodyMaxLines,\n    bodyClassName,\n    headingAs,\n    labels,\n    burstKey,\n    heartBurstWired,\n    onMediaDoubleTap,\n    cardLinkable,\n    linkHref,\n    LinkComponent,\n    renderHeader,\n    renderContent,\n    renderMedia,\n    renderEngagementBar,\n    onLocationClick,\n    onMentionClick,\n    onTagClick,\n    sensitiveRevealed,\n    onSensitiveReveal,\n    disableSensitiveGate,\n    renderSensitiveGate,\n    onLinkPreviewClick,\n    disableLinkPreviewRender,\n    renderLinkPreview,\n    onRepostOfClick,\n    disableRepostOfRender,\n    renderRepostOf,\n    getHref,\n    linkComponent,\n    cardLabels,\n    onPollVote,\n    pollOptimisticVote,\n    isOwnerView,\n    disablePollRender,\n    renderPoll,\n    formatRelativeTime,\n    inlinePanelNode,\n    className,\n    headerClassName,\n    mediaClassName,\n  } = props;\n\n  const showSensitiveGate =\n    !disableSensitiveGate && post.isSensitive === true && !sensitiveRevealed;\n  const showLinkPreview =\n    !disableLinkPreviewRender && post.linkPreview !== undefined;\n  const showRepostOf =\n    !disableRepostOfRender && post.repostOf !== undefined;\n  const showPoll = !disablePollRender && post.poll !== undefined;\n  const pollHasVoted =\n    pollOptimisticVote !== null || (post.poll?.hasVoted ?? false);\n  const pollOptimisticVoteOptionId =\n    pollOptimisticVote?.optionId ?? post.poll?.viewerVoteOptionId;\n  const pollOptimisticIncrement = pollOptimisticVote !== null;\n\n  const handleRepostClick = onRepostOfClick\n    ? () => post.repostOf && onRepostOfClick(post.repostOf)\n    : undefined;\n\n  const overlayLink =\n    cardLinkable && linkHref ? (\n      <LinkComponent\n        href={linkHref}\n        aria-label={post.content.slice(0, 80)}\n        className=\"absolute inset-0 z-0\"\n      />\n    ) : null;\n\n  const headerNode = renderHeader ? (\n    renderHeader(post, { currentUser })\n  ) : (\n    <PostHeader\n      post={post}\n      kebabOpen={kebabOpen}\n      onKebabOpenChange={onKebabOpenChange}\n      kebabItems={kebabItems}\n      headingAs={headingAs}\n      formattedTime={formattedTime}\n      authorId={authorId}\n      labels={labels}\n      onLocationClick={onLocationClick}\n      onMentionClick={onMentionClick}\n      className={headerClassName}\n    />\n  );\n\n  const contentNode = renderContent ? (\n    renderContent(post)\n  ) : (\n    <ExpandableText\n      content={post.content}\n      maxLines={bodyMaxLines}\n      contentClassName={cn(\"text-[15px] sm:text-sm\", bodyClassName)}\n    />\n  );\n\n  const mediaNode =\n    post.media && post.media.length > 0\n      ? renderMedia\n        ? renderMedia(post.media, { onDoubleTap: onMediaDoubleTap })\n        : (\n            <MediaCarousel\n              items={post.media}\n              variant=\"gallery\"\n              onDoubleTap={onMediaDoubleTap}\n            />\n          )\n      : null;\n\n  const engagementNode = renderEngagementBar ? (\n    renderEngagementBar(post, { actions: engagementActions })\n  ) : (\n    <EngagementBar\n      ref={engagementBarRef}\n      actions={engagementActions}\n      onSubscribeDelta={engagementSubscribeNoop}\n      labels={engagementLabels}\n    />\n  );\n\n  return (\n    <article\n      role=\"article\"\n      aria-labelledby={authorId}\n      className={cn(\n        \"relative overflow-hidden rounded-xl border border-border bg-card\",\n        className,\n      )}\n    >\n      {overlayLink}\n      <div className=\"relative z-10 flex flex-col gap-3 p-3 sm:p-4\">\n        {headerNode}\n        {contentNode}\n        {post.tags && post.tags.length > 0 ? (\n          <TagChips tags={post.tags} onTagClick={onTagClick} />\n        ) : null}\n        {showPoll && post.poll\n          ? renderPoll\n            ? renderPoll(post, { onVote: onPollVote, isOwnerView })\n            : (\n              <PollWidget\n                poll={post.poll}\n                hasVoted={pollHasVoted}\n                isOwnerView={isOwnerView}\n                optimisticVoteOptionId={pollOptimisticVoteOptionId}\n                optimisticIncrement={pollOptimisticIncrement}\n                onVote={onPollVote}\n                labels={{\n                  pollHeading: labels.pollHeading,\n                  pollTotalVotesLabel: labels.pollTotalVotesLabel,\n                  pollClosesAtLabel: labels.pollClosesAtLabel,\n                  pollClosedLabel: labels.pollClosedLabel,\n                }}\n                formatRelativeTime={formatRelativeTime}\n              />\n            )\n          : null}\n        {showLinkPreview && post.linkPreview\n          ? renderLinkPreview\n            ? renderLinkPreview(post)\n            : (\n              <LinkPreviewCard\n                preview={post.linkPreview}\n                onClick={onLinkPreviewClick}\n              />\n            )\n          : null}\n        {showRepostOf && post.repostOf\n          ? renderRepostOf\n            ? renderRepostOf(post, { onClick: handleRepostClick })\n            : (\n              <RepostOfCard\n                originalPost={post.repostOf}\n                onClick={onRepostOfClick}\n                getHref={getHref}\n                linkComponent={linkComponent}\n                labels={cardLabels}\n              />\n            )\n          : null}\n      </div>\n      {mediaNode ? (\n        <div className={cn(\"relative\", mediaClassName)}>\n          {mediaNode}\n          {heartBurstWired ? (\n            <div className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center\">\n              <EngagementHeartBurst trigger={burstKey} />\n            </div>\n          ) : null}\n          {showSensitiveGate\n            ? renderSensitiveGate\n              ? renderSensitiveGate(post, { onReveal: onSensitiveReveal })\n              : (\n                <SensitiveGate\n                  heading={labels.sensitiveHeading}\n                  reason={post.sensitiveReason}\n                  revealLabel={labels.sensitiveRevealLabel}\n                  onReveal={onSensitiveReveal}\n                />\n              )\n            : null}\n        </div>\n      ) : null}\n      <div className={cn(\"relative z-10 px-3 pb-3 pt-3 sm:px-4\", engagementClassName)}>\n        {engagementNode}\n      </div>\n      {inlinePanelNode ? (\n        <div className=\"relative z-10 border-t border-border/60 px-3 py-3 sm:px-4\">\n          {inlinePanelNode}\n        </div>\n      ) : null}\n    </article>\n  );\n}\n\nexport const FeedVariant = memo(FeedVariantInner);\nFeedVariant.displayName = \"PostCard.FeedVariant\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/feed-variant.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/compact-variant.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { cn } from \"@/lib/utils\";\n// F-S1 lock: cross-procomp imports via relative + specific-file paths.\nimport { ExpandableText } from \"../../expandable-text/expandable-text\";\nimport { EngagementBar } from \"../../engagement-bar/engagement-bar\";\nimport { PostHeader } from \"./post-header\";\nimport { TagChips } from \"./tag-chips\";\nimport type { VariantInnerProps } from \"./variant-shared\";\n\nfunction CompactVariantInner(props: VariantInnerProps) {\n  const {\n    post,\n    currentUser,\n    authorId,\n    formattedTime,\n    kebabOpen,\n    onKebabOpenChange,\n    kebabItems,\n    engagementActions,\n    engagementBarRef,\n    engagementClassName,\n    engagementLabels,\n    engagementSubscribeNoop,\n    bodyMaxLines,\n    bodyClassName,\n    headingAs,\n    labels,\n    cardLinkable,\n    linkHref,\n    LinkComponent,\n    renderHeader,\n    renderContent,\n    renderMedia,\n    renderEngagementBar,\n    onLocationClick,\n    onMentionClick,\n    onTagClick,\n    inlinePanelNode,\n    className,\n    headerClassName,\n    mediaClassName,\n  } = props;\n\n  const overlayLink =\n    cardLinkable && linkHref ? (\n      <LinkComponent\n        href={linkHref}\n        aria-label={post.content.slice(0, 60)}\n        className=\"absolute inset-0 z-0\"\n      />\n    ) : null;\n\n  const firstMedia =\n    post.media && post.media.length > 0 && post.media[0].type === \"image\"\n      ? post.media[0]\n      : null;\n\n  const headerNode = renderHeader ? (\n    renderHeader(post, { currentUser })\n  ) : (\n    <PostHeader\n      post={post}\n      kebabOpen={kebabOpen}\n      onKebabOpenChange={onKebabOpenChange}\n      kebabItems={kebabItems}\n      headingAs={headingAs}\n      formattedTime={formattedTime}\n      authorId={authorId}\n      labels={labels}\n      onLocationClick={onLocationClick}\n      onMentionClick={onMentionClick}\n      compact\n      className={headerClassName}\n    />\n  );\n\n  const contentNode = renderContent ? (\n    renderContent(post)\n  ) : (\n    <ExpandableText\n      content={post.content}\n      maxLines={bodyMaxLines}\n      contentClassName={cn(\"text-xs md:text-sm\", bodyClassName)}\n    />\n  );\n\n  return (\n    <article\n      role=\"article\"\n      aria-labelledby={authorId}\n      className={cn(\n        \"relative overflow-hidden rounded-lg border border-border bg-card p-2.5 sm:p-3\",\n        className,\n      )}\n    >\n      {overlayLink}\n      <div className=\"relative z-10 flex flex-col gap-2\">\n        {headerNode}\n        <div className=\"flex items-start gap-3\">\n          <div className=\"min-w-0 flex-1\">{contentNode}</div>\n          {firstMedia ? (\n            renderMedia ? (\n              renderMedia(post.media ?? [], {})\n            ) : (\n              <div\n                className={cn(\n                  \"relative h-16 w-16 shrink-0 overflow-hidden rounded-md\",\n                  mediaClassName,\n                )}\n              >\n                <img\n                  src={firstMedia.url}\n                  alt={firstMedia.alt ?? \"\"}\n                  className=\"h-full w-full object-cover\"\n                />\n              </div>\n            )\n          ) : null}\n        </div>\n        {post.tags && post.tags.length > 0 ? (\n          <TagChips tags={post.tags} onTagClick={onTagClick} />\n        ) : null}\n        <div className={cn(\"relative z-10 mt-1\", engagementClassName)}>\n          {renderEngagementBar ? (\n            renderEngagementBar(post, { actions: engagementActions })\n          ) : (\n            <EngagementBar\n              ref={engagementBarRef}\n              variant=\"compact\"\n              actions={engagementActions}\n              onSubscribeDelta={engagementSubscribeNoop}\n              labels={engagementLabels}\n            />\n          )}\n        </div>\n        {inlinePanelNode ? (\n          <div className=\"relative z-10 mt-2 border-t border-border/60 pt-2\">\n            {inlinePanelNode}\n          </div>\n        ) : null}\n      </div>\n    </article>\n  );\n}\n\nexport const CompactVariant = memo(CompactVariantInner);\nCompactVariant.displayName = \"PostCard.CompactVariant\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/compact-variant.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/list-variant.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { cn } from \"@/lib/utils\";\n// F-S1 lock: cross-procomp imports via relative + specific-file paths.\nimport { ExpandableText } from \"../../expandable-text/expandable-text\";\nimport { EngagementBar } from \"../../engagement-bar/engagement-bar\";\nimport { PostHeader } from \"./post-header\";\nimport { TagChips } from \"./tag-chips\";\nimport type { VariantInnerProps } from \"./variant-shared\";\n\nfunction ListVariantInner(props: VariantInnerProps) {\n  const {\n    post,\n    currentUser,\n    authorId,\n    formattedTime,\n    kebabOpen,\n    onKebabOpenChange,\n    kebabItems,\n    engagementActions,\n    engagementBarRef,\n    engagementClassName,\n    engagementLabels,\n    engagementSubscribeNoop,\n    bodyMaxLines,\n    bodyClassName,\n    headingAs,\n    labels,\n    cardLinkable,\n    linkHref,\n    LinkComponent,\n    renderHeader,\n    renderContent,\n    renderMedia,\n    renderEngagementBar,\n    onLocationClick,\n    onMentionClick,\n    onTagClick,\n    className,\n    headerClassName,\n    mediaClassName,\n  } = props;\n\n  const firstMedia =\n    post.media && post.media.length > 0 && post.media[0].type === \"image\"\n      ? post.media[0]\n      : null;\n\n  const overlayLink =\n    cardLinkable && linkHref ? (\n      <LinkComponent\n        href={linkHref}\n        aria-label={post.content.slice(0, 80)}\n        className=\"absolute inset-0 z-0\"\n      />\n    ) : null;\n\n  const headerNode = renderHeader ? (\n    renderHeader(post, { currentUser })\n  ) : (\n    <PostHeader\n      post={post}\n      kebabOpen={kebabOpen}\n      onKebabOpenChange={onKebabOpenChange}\n      kebabItems={kebabItems}\n      headingAs={headingAs}\n      formattedTime={formattedTime}\n      authorId={authorId}\n      labels={labels}\n      onLocationClick={onLocationClick}\n      onMentionClick={onMentionClick}\n      compact\n      className={headerClassName}\n    />\n  );\n\n  const contentNode = renderContent ? (\n    renderContent(post)\n  ) : (\n    <ExpandableText\n      content={post.content}\n      maxLines={bodyMaxLines}\n      contentClassName={cn(\"text-xs md:text-sm\", bodyClassName)}\n    />\n  );\n\n  // Default for list: counts-only meta row, no interactive bar.\n  const defaultMeta = (\n    <span className=\"text-xs text-muted-foreground\">\n      {post.likes} likes · {post.comments} comments\n      {post.shares !== undefined ? ` · ${post.shares} shares` : \"\"}\n    </span>\n  );\n\n  return (\n    <article\n      role=\"article\"\n      aria-labelledby={authorId}\n      className={cn(\n        \"relative flex items-stretch gap-3 overflow-hidden rounded-lg border border-border bg-card\",\n        firstMedia ? \"p-0\" : \"p-2.5 sm:p-3\",\n        className,\n      )}\n    >\n      {overlayLink}\n      {firstMedia ? (\n        renderMedia ? (\n          renderMedia(post.media ?? [], {})\n        ) : (\n          <div\n            className={cn(\n              \"relative w-16 shrink-0 self-stretch overflow-hidden sm:w-20 md:w-24 lg:w-28\",\n              mediaClassName,\n            )}\n          >\n            <img\n              src={firstMedia.url}\n              alt={firstMedia.alt ?? \"\"}\n              className=\"absolute inset-0 h-full w-full object-cover\"\n            />\n          </div>\n        )\n      ) : null}\n      <div\n        className={cn(\n          \"relative z-10 flex min-w-0 flex-1 flex-col gap-2\",\n          firstMedia ? \"p-2.5 sm:p-3\" : \"\",\n        )}\n      >\n        {headerNode}\n        {contentNode}\n        {post.tags && post.tags.length > 0 ? (\n          <TagChips tags={post.tags} onTagClick={onTagClick} />\n        ) : null}\n        <div className={cn(\"relative z-10 mt-auto\", engagementClassName)}>\n          {renderEngagementBar ? (\n            renderEngagementBar(post, { actions: engagementActions })\n          ) : engagementActions.length > 0 ? (\n            <EngagementBar\n              ref={engagementBarRef}\n              variant=\"compact\"\n              actions={engagementActions}\n              onSubscribeDelta={engagementSubscribeNoop}\n              labels={engagementLabels}\n            />\n          ) : (\n            defaultMeta\n          )}\n        </div>\n      </div>\n    </article>\n  );\n}\n\nexport const ListVariant = memo(ListVariantInner);\nListVariant.displayName = \"PostCard.ListVariant\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/list-variant.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/detail-variant.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { cn } from \"@/lib/utils\";\n// F-S1 lock: cross-procomp imports via relative + specific-file paths.\nimport { ExpandableText } from \"../../expandable-text/expandable-text\";\n// Cross-category: absolute-with-suffix (relative would resolve to non-existent\n// `src/media/...` in consumer's flat tree).\nimport { MediaCarousel } from \"@/registry/components/media/media-carousel/media-carousel\";\nimport { EngagementBar } from \"../../engagement-bar/engagement-bar\";\nimport { EngagementHeartBurst } from \"../../engagement-bar/parts/engagement-heart-burst\";\nimport { CommentThread } from \"../../comment-thread/comment-thread\";\nimport { PostHeader } from \"./post-header\";\nimport { TagChips } from \"./tag-chips\";\nimport { SensitiveGate } from \"./sensitive-gate\";\nimport { LinkPreviewCard } from \"./link-preview-card\";\nimport { RepostOfCard } from \"./repost-of-card\";\nimport { PollWidget } from \"./poll-widget\";\nimport type { DetailVariantInnerProps } from \"./variant-shared\";\n\nfunction DetailVariantInner(props: DetailVariantInnerProps) {\n  const {\n    post,\n    currentUser,\n    authorId,\n    formattedTime,\n    kebabOpen,\n    onKebabOpenChange,\n    kebabItems,\n    engagementActions,\n    engagementBarRef,\n    engagementClassName,\n    engagementLabels,\n    engagementSubscribeNoop,\n    bodyMaxLines,\n    bodyClassName,\n    headingAs,\n    labels,\n    burstKey,\n    heartBurstWired,\n    onMediaDoubleTap,\n    renderHeader,\n    renderContent,\n    renderMedia,\n    renderEngagementBar,\n    renderCommentSection,\n    onLocationClick,\n    onMentionClick,\n    onTagClick,\n    sensitiveRevealed,\n    onSensitiveReveal,\n    disableSensitiveGate,\n    renderSensitiveGate,\n    onLinkPreviewClick,\n    disableLinkPreviewRender,\n    renderLinkPreview,\n    onRepostOfClick,\n    disableRepostOfRender,\n    renderRepostOf,\n    getHref,\n    linkComponent,\n    cardLabels,\n    onPollVote,\n    pollOptimisticVote,\n    isOwnerView,\n    disablePollRender,\n    renderPoll,\n    formatRelativeTime,\n    commentThread,\n    commentPageSize,\n    commentSubscribe,\n    onSubscribeCommentDelta,\n    onAddComment,\n    onLikeComment,\n    onDeleteComment,\n    onReportComment,\n    onLoadMoreComments,\n    commentActions,\n    commentLabels,\n    threadRef,\n    commentSectionClassName,\n    commentsHeading,\n    className,\n    headerClassName,\n    mediaClassName,\n  } = props;\n\n  const headerNode = renderHeader ? (\n    renderHeader(post, { currentUser })\n  ) : (\n    <PostHeader\n      post={post}\n      kebabOpen={kebabOpen}\n      onKebabOpenChange={onKebabOpenChange}\n      kebabItems={kebabItems}\n      headingAs={headingAs}\n      formattedTime={formattedTime}\n      authorId={authorId}\n      labels={labels}\n      onLocationClick={onLocationClick}\n      onMentionClick={onMentionClick}\n      className={headerClassName}\n    />\n  );\n\n  const contentNode = renderContent ? (\n    renderContent(post)\n  ) : (\n    <ExpandableText\n      content={post.content}\n      // bodyMaxLines may be 0 in detail (no clamp); ExpandableText handles 0 as no clamp\n      maxLines={bodyMaxLines || 999}\n      contentClassName={cn(\n        \"text-[15px] leading-relaxed sm:text-sm lg:text-base\",\n        bodyClassName,\n      )}\n    />\n  );\n\n  const mediaNode =\n    post.media && post.media.length > 0\n      ? renderMedia\n        ? renderMedia(post.media, { onDoubleTap: onMediaDoubleTap })\n        : (\n            <MediaCarousel\n              items={post.media}\n              variant=\"gallery\"\n              onDoubleTap={onMediaDoubleTap}\n            />\n          )\n      : null;\n\n  const engagementNode = renderEngagementBar ? (\n    renderEngagementBar(post, { actions: engagementActions })\n  ) : (\n    <EngagementBar\n      ref={engagementBarRef}\n      actions={engagementActions}\n      onSubscribeDelta={engagementSubscribeNoop}\n      labels={engagementLabels}\n    />\n  );\n\n  const commentSectionNode = renderCommentSection ? (\n    renderCommentSection(post, {\n      onAddComment,\n      onLikeComment,\n      onDeleteComment,\n      onReportComment,\n      onLoadMoreComments,\n    })\n  ) : (\n    <CommentThread\n      ref={threadRef}\n      comments={commentThread}\n      currentUser={currentUser}\n      pageSize={commentPageSize}\n      subscribe={commentSubscribe}\n      onSubscribeDelta={onSubscribeCommentDelta}\n      onAddComment={onAddComment}\n      onLikeComment={onLikeComment}\n      onDeleteComment={onDeleteComment}\n      onReportComment={onReportComment}\n      onLoadMore={onLoadMoreComments}\n      commentActions={commentActions}\n      labels={commentLabels}\n    />\n  );\n\n  return (\n    <article\n      role=\"article\"\n      aria-labelledby={authorId}\n      className={cn(\n        \"relative overflow-hidden rounded-xl border border-border bg-card\",\n        className,\n      )}\n    >\n      <div className=\"flex flex-col gap-3 p-3 sm:p-4 md:p-5 lg:p-6\">\n        {headerNode}\n        {contentNode}\n        {post.tags && post.tags.length > 0 ? (\n          <TagChips tags={post.tags} onTagClick={onTagClick} />\n        ) : null}\n        {!disablePollRender && post.poll\n          ? renderPoll\n            ? renderPoll(post, { onVote: onPollVote, isOwnerView })\n            : (\n              <PollWidget\n                poll={post.poll}\n                hasVoted={\n                  pollOptimisticVote !== null || (post.poll.hasVoted ?? false)\n                }\n                isOwnerView={isOwnerView}\n                optimisticVoteOptionId={\n                  pollOptimisticVote?.optionId ?? post.poll.viewerVoteOptionId\n                }\n                optimisticIncrement={pollOptimisticVote !== null}\n                onVote={onPollVote}\n                labels={{\n                  pollHeading: labels.pollHeading,\n                  pollTotalVotesLabel: labels.pollTotalVotesLabel,\n                  pollClosesAtLabel: labels.pollClosesAtLabel,\n                  pollClosedLabel: labels.pollClosedLabel,\n                }}\n                formatRelativeTime={formatRelativeTime}\n              />\n            )\n          : null}\n        {!disableLinkPreviewRender && post.linkPreview\n          ? renderLinkPreview\n            ? renderLinkPreview(post)\n            : (\n              <LinkPreviewCard\n                preview={post.linkPreview}\n                onClick={onLinkPreviewClick}\n              />\n            )\n          : null}\n        {!disableRepostOfRender && post.repostOf\n          ? renderRepostOf\n            ? renderRepostOf(post, {\n                onClick: onRepostOfClick\n                  ? () => post.repostOf && onRepostOfClick(post.repostOf)\n                  : undefined,\n              })\n            : (\n              <RepostOfCard\n                originalPost={post.repostOf}\n                onClick={onRepostOfClick}\n                getHref={getHref}\n                linkComponent={linkComponent}\n                labels={cardLabels}\n              />\n            )\n          : null}\n      </div>\n      {mediaNode ? (\n        <div className={cn(\"relative\", mediaClassName)}>\n          {mediaNode}\n          {heartBurstWired ? (\n            <div className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center\">\n              <EngagementHeartBurst trigger={burstKey} />\n            </div>\n          ) : null}\n          {!disableSensitiveGate && post.isSensitive && !sensitiveRevealed\n            ? renderSensitiveGate\n              ? renderSensitiveGate(post, { onReveal: onSensitiveReveal })\n              : (\n                <SensitiveGate\n                  heading={labels.sensitiveHeading}\n                  reason={post.sensitiveReason}\n                  revealLabel={labels.sensitiveRevealLabel}\n                  onReveal={onSensitiveReveal}\n                />\n              )\n            : null}\n        </div>\n      ) : null}\n      <div\n        className={cn(\n          \"px-3 pb-3 pt-3 sm:px-4 md:px-5 lg:px-6\",\n          engagementClassName,\n        )}\n      >\n        {engagementNode}\n      </div>\n      <div\n        className={cn(\n          \"border-t border-border/60 px-3 py-3 sm:px-4 sm:py-4 md:px-5 md:py-5 lg:px-6 lg:py-6\",\n          commentSectionClassName,\n        )}\n      >\n        <h3 className=\"mb-3 text-sm font-semibold\">{commentsHeading}</h3>\n        {commentSectionNode}\n      </div>\n    </article>\n  );\n}\n\nexport const DetailVariant = memo(DetailVariantInner);\nDetailVariant.displayName = \"PostCard.DetailVariant\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/detail-variant.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/post-header.tsx",
      "content": "\"use client\";\n\nimport { Fragment, memo } from \"react\";\nimport { MapPin, MoreHorizontal } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport {\n  DropdownMenu,\n  DropdownMenuContent,\n  DropdownMenuItem,\n  DropdownMenuSeparator,\n  DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\n// F-S1 lock: cross-procomp import via relative + specific-file path.\nimport type { CommentMenuItem } from \"../../comment-thread/types\";\nimport { VerifiedBadge } from \"./verified-badge\";\nimport { PinnedBadge } from \"./pinned-badge\";\nimport { VisibilityBadge } from \"./visibility-badge\";\nimport { EditedSuffix } from \"./edited-suffix\";\nimport type { Post, PostCardLabels, PostLocation } from \"../types\";\n\nexport interface PostHeaderProps {\n  post: Post;\n  kebabOpen: boolean;\n  onKebabOpenChange: (next: boolean) => void;\n  kebabItems: CommentMenuItem[];\n  headingAs: \"h2\" | \"h3\" | \"h4\";\n  formattedTime: string;\n  authorId: string;\n  labels: Required<\n    Pick<\n      PostCardLabels,\n      | \"verifiedBadgeLabel\"\n      | \"pinnedBadgeLabel\"\n      | \"editedSuffix\"\n      | \"replyingTo\"\n      | \"visibilityPublic\"\n      | \"visibilityFollowers\"\n      | \"visibilityFriends\"\n      | \"visibilityCircle\"\n      | \"visibilityOnlyMe\"\n      | \"visibilityPrivate\"\n      | \"visibilityCustom\"\n    >\n  >;\n  /** Fired when the location chip is tapped. */\n  onLocationClick?: (location: PostLocation) => void;\n  /** Fired when the mention in the \"Replying to\" sub-line is tapped. */\n  onMentionClick?: (mentionId: string) => void;\n  compact?: boolean;\n  className?: string;\n}\n\nfunction initials(name: string): string {\n  return (\n    name\n      .trim()\n      .split(/\\s+/)\n      .map((part) => part[0]?.toUpperCase() ?? \"\")\n      .slice(0, 2)\n      .join(\"\") || \"?\"\n  );\n}\n\nfunction PostHeaderInner({\n  post,\n  kebabOpen,\n  onKebabOpenChange,\n  kebabItems,\n  headingAs: Heading,\n  formattedTime,\n  authorId,\n  labels,\n  onLocationClick,\n  onMentionClick,\n  compact = false,\n  className,\n}: PostHeaderProps) {\n  // §2.1-B step rules: non-compact `h-9 w-9 → h-10 w-10` at sm; compact\n  // `h-7 w-7 → h-8 w-8` at sm. Avatar is decorative (not a touch target).\n  const avatarSize = compact\n    ? \"h-7 w-7 sm:h-8 sm:w-8\"\n    : \"h-9 w-9 sm:h-10 sm:w-10\";\n  const nameSize = compact ? \"text-sm\" : \"text-sm\";\n\n  // Per description §1.3 render-surface table:\n  //   - replyTo: feed / detail only — hidden on compact / list\n  //   - location: feed / detail / compact / list — all render; compact/list truncate\n  // (compact=true is passed by both compact AND list variants — they share the trimmed header.)\n  const showReplyTo = !compact && post.replyTo !== undefined;\n  const showLocation = post.location !== undefined;\n\n  return (\n    <div className={cn(\"flex flex-col gap-1\", className)}>\n      {post.isPinned ? (\n        <PinnedBadge label={labels.pinnedBadgeLabel} />\n      ) : null}\n\n      {showReplyTo && post.replyTo ? (\n        <div\n          className=\"flex min-w-0 items-center gap-1 text-xs text-muted-foreground\"\n          // F-11 closure: truncation lives on the username span only; the\n          // \"Replying to\" label stays full-width since it's short + localized.\n        >\n          <span className=\"shrink-0\">{labels.replyingTo}</span>\n          <button\n            type=\"button\"\n            onClick={\n              onMentionClick && post.replyTo\n                ? () => onMentionClick(post.replyTo!.author.id)\n                : undefined\n            }\n            className={cn(\n              \"min-w-0 truncate font-medium text-foreground\",\n              onMentionClick\n                ? \"hover:underline focus:underline focus:outline-none\"\n                : \"cursor-default\",\n            )}\n            disabled={!onMentionClick}\n          >\n            @{post.replyTo.author.username ?? post.replyTo.author.name}\n          </button>\n        </div>\n      ) : null}\n\n      <div className=\"flex items-center justify-between gap-2\">\n        {/* Identity column — truncation discipline per F-11 closure. */}\n        <div className=\"flex min-w-0 items-center gap-3\">\n          <Avatar className={cn(avatarSize, \"shrink-0\")}>\n            {post.author.avatar ? (\n              <AvatarImage src={post.author.avatar} alt=\"\" />\n            ) : null}\n            <AvatarFallback>{initials(post.author.name)}</AvatarFallback>\n          </Avatar>\n          <div className=\"flex min-w-0 flex-col\">\n            {/* Name + verified badge row. min-w-0 on name span; verified stays shrink-0. */}\n            <div className=\"flex min-w-0 items-center gap-1\">\n              <Heading\n                id={authorId}\n                className={cn(\n                  \"min-w-0 truncate font-semibold\",\n                  nameSize,\n                )}\n              >\n                {post.author.name}\n              </Heading>\n              {post.author.isVerified ? (\n                <span className=\"shrink-0\">\n                  <VerifiedBadge ariaLabel={labels.verifiedBadgeLabel} />\n                </span>\n              ) : null}\n            </div>\n\n            {/* Sub-line — username · time + new v0.2.0 zone (edited / visibility / location). */}\n            <div className=\"flex min-w-0 items-center gap-1 text-xs text-muted-foreground\">\n              <span className=\"min-w-0 truncate\">\n                {post.author.username ? `@${post.author.username} · ` : \"\"}\n                {formattedTime}\n              </span>\n              {post.editedAt ? (\n                <EditedSuffix\n                  label={labels.editedSuffix}\n                  editedAt={post.editedAt}\n                />\n              ) : null}\n              {post.visibility ? (\n                <VisibilityBadge\n                  visibility={post.visibility}\n                  labels={{\n                    visibilityPublic: labels.visibilityPublic,\n                    visibilityFollowers: labels.visibilityFollowers,\n                    visibilityFriends: labels.visibilityFriends,\n                    visibilityCircle: labels.visibilityCircle,\n                    visibilityOnlyMe: labels.visibilityOnlyMe,\n                    visibilityPrivate: labels.visibilityPrivate,\n                    visibilityCustom: labels.visibilityCustom,\n                  }}\n                />\n              ) : null}\n              {showLocation && post.location ? (\n                <button\n                  type=\"button\"\n                  onClick={\n                    onLocationClick && post.location\n                      ? () => onLocationClick(post.location!)\n                      : undefined\n                  }\n                  disabled={!onLocationClick}\n                  className={cn(\n                    \"inline-flex min-w-0 shrink items-center gap-0.5 truncate\",\n                    onLocationClick\n                      ? \"hover:text-foreground focus:text-foreground focus:outline-none\"\n                      : \"cursor-default\",\n                  )}\n                >\n                  <MapPin className=\"h-3 w-3 shrink-0\" aria-hidden=\"true\" />\n                  <span className=\"truncate\">{post.location.name}</span>\n                </button>\n              ) : null}\n            </div>\n          </div>\n        </div>\n\n        {/* Kebab — shrink-0 so it never gets pushed off-screen.\n            F-cross-13: drop `asChild` (shadcn CLI rewrites it to `render={…}`\n            at install-time — Base UI idiom — which breaks consumers who\n            installed the Radix dropdown-menu primitive). Render the trigger\n            as the button directly with `buttonVariants(…)` for styling. */}\n        {kebabItems.length > 0 ? (\n          <DropdownMenu open={kebabOpen} onOpenChange={onKebabOpenChange}>\n            <DropdownMenuTrigger\n              className={cn(\n                buttonVariants({ variant: \"ghost\", size: \"icon\" }),\n                \"relative z-10 h-11 w-11 shrink-0\",\n              )}\n              aria-label=\"Post actions\"\n            >\n              <MoreHorizontal className=\"h-5 w-5\" />\n            </DropdownMenuTrigger>\n            <DropdownMenuContent align=\"end\" className=\"z-20\">\n              {kebabItems.map((item, i) => {\n                // v0.3.0: honor explicit `separatorBefore` flag (used by the\n                // moderator section in `defaultPostKebabActions`) AND the\n                // implicit destructive-boundary divider. Both can fire but\n                // only one separator renders (the explicit flag short-circuits).\n                const showSeparatorBefore =\n                  i > 0 &&\n                  (item.separatorBefore ||\n                    (item.destructive && !kebabItems[i - 1]?.destructive));\n                return (\n                  <Fragment key={`${item.label}-${i}`}>\n                    {showSeparatorBefore ? <DropdownMenuSeparator /> : null}\n                    <DropdownMenuItem\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 ? (\n                        <span className=\"mr-2\">{item.icon}</span>\n                      ) : null}\n                      {item.label}\n                    </DropdownMenuItem>\n                  </Fragment>\n                );\n              })}\n            </DropdownMenuContent>\n          </DropdownMenu>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\nexport const PostHeader = memo(PostHeaderInner);\nPostHeader.displayName = \"PostHeader\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/post-header.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/verified-badge.tsx",
      "content": "import { cn } from \"@/lib/utils\";\n\nexport interface VerifiedBadgeProps {\n  className?: string;\n  ariaLabel?: string;\n}\n\nexport function VerifiedBadge({\n  className,\n  ariaLabel = \"Verified account\",\n}: VerifiedBadgeProps) {\n  return (\n    <svg\n      role=\"img\"\n      aria-label={ariaLabel}\n      viewBox=\"0 0 24 24\"\n      className={cn(\"h-4 w-4 fill-current text-primary\", className)}\n    >\n      <path d=\"M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z\" />\n    </svg>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/post-card/parts/verified-badge.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/pinned-badge.tsx",
      "content": "import { Pin } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PinnedBadgeProps {\n  /** Localized label text. Default in DEFAULT_POST_CARD_LABELS is \"Pinned\". */\n  label: string;\n  className?: string;\n}\n\n/**\n * Sealed RSC-compatible pinned-state pill rendered above the post header\n * when `post.isPinned` is true. Decorative chip; no interactivity.\n */\nexport function PinnedBadge({ label, className }: PinnedBadgeProps) {\n  return (\n    <div\n      className={cn(\n        \"inline-flex w-fit items-center gap-1 self-start rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground\",\n        className,\n      )}\n      role=\"status\"\n      aria-label={label}\n    >\n      <Pin className=\"h-3 w-3\" aria-hidden=\"true\" />\n      <span>{label}</span>\n    </div>\n  );\n}\n\nPinnedBadge.displayName = \"PinnedBadge\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/pinned-badge.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/visibility-badge.tsx",
      "content": "import {\n  Globe,\n  Lock,\n  MoreHorizontal,\n  User,\n  UserCheck,\n  Users,\n  Users2,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { PostVisibility } from \"../types\";\n\nexport interface VisibilityBadgeLabels {\n  visibilityPublic: string;\n  visibilityFollowers: string;\n  visibilityFriends: string;\n  visibilityCircle: string;\n  visibilityOnlyMe: string;\n  visibilityPrivate: string;\n  visibilityCustom: string;\n}\n\nexport interface VisibilityBadgeProps {\n  visibility: PostVisibility;\n  labels: VisibilityBadgeLabels;\n  className?: string;\n}\n\nconst ICON_BY_VISIBILITY = {\n  public: Globe,\n  followers: Users,\n  friends: UserCheck,\n  circle: Users2,\n  \"only-me\": User,\n  private: Lock,\n} as const;\n\nconst LABEL_KEY_BY_VISIBILITY = {\n  public: \"visibilityPublic\",\n  followers: \"visibilityFollowers\",\n  friends: \"visibilityFriends\",\n  circle: \"visibilityCircle\",\n  \"only-me\": \"visibilityOnlyMe\",\n  private: \"visibilityPrivate\",\n} as const satisfies Record<\n  keyof typeof ICON_BY_VISIBILITY,\n  keyof VisibilityBadgeLabels\n>;\n\n/**\n * Sealed RSC-compatible visibility icon rendered next to the post timestamp.\n *\n * Renders one of 6 lucide icons for the known base values; falls back to\n * `MoreHorizontal` + the `visibilityCustom` label for any custom string value.\n * Pure render; no interactivity (the \"Change visibility\" affordance lives in\n * the kebab and is host-driven per Q-P42).\n */\nexport function VisibilityBadge({\n  visibility,\n  labels,\n  className,\n}: VisibilityBadgeProps) {\n  const knownIcon =\n    ICON_BY_VISIBILITY[visibility as keyof typeof ICON_BY_VISIBILITY];\n  const Icon = knownIcon ?? MoreHorizontal;\n  const labelKey =\n    LABEL_KEY_BY_VISIBILITY[visibility as keyof typeof LABEL_KEY_BY_VISIBILITY];\n  const label = labelKey ? labels[labelKey] : labels.visibilityCustom;\n  return (\n    <span\n      className={cn(\n        \"inline-flex shrink-0 items-center text-muted-foreground\",\n        className,\n      )}\n      role=\"img\"\n      aria-label={label}\n      title={label}\n    >\n      <Icon className=\"h-3 w-3\" aria-hidden=\"true\" />\n    </span>\n  );\n}\n\nVisibilityBadge.displayName = \"VisibilityBadge\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/visibility-badge.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/edited-suffix.tsx",
      "content": "import { cn } from \"@/lib/utils\";\n\nexport interface EditedSuffixProps {\n  /** Visible label text (e.g. \"(edited)\"). */\n  label: string;\n  /** Exact edit timestamp — rendered as the `title` attribute for a hover tooltip. */\n  editedAt: Date | string | number;\n  className?: string;\n}\n\nfunction toExactDateString(input: Date | string | number): string {\n  const d = input instanceof Date ? input : new Date(input);\n  return d.toLocaleString();\n}\n\n/**\n * Sealed RSC-compatible \"(edited)\" suffix rendered inline with the post\n * timestamp when `post.editedAt` is set. The `title` attribute exposes the\n * exact edit timestamp on hover (browser-native tooltip; no JS).\n */\nexport function EditedSuffix({ label, editedAt, className }: EditedSuffixProps) {\n  return (\n    <span\n      className={cn(\"shrink-0 text-muted-foreground\", className)}\n      title={toExactDateString(editedAt)}\n    >\n      {label}\n    </span>\n  );\n}\n\nEditedSuffix.displayName = \"EditedSuffix\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/edited-suffix.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/mention-text.tsx",
      "content": "\"use client\";\n\nimport { Fragment, type ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { PostMention } from \"../types\";\n\nexport interface MentionTextProps {\n  /** Plain text body (typically `post.content`). */\n  content: string;\n  /** Mention ranges in UTF-16 code units (end-exclusive). */\n  mentions?: PostMention[];\n  /** Click handler for highlighted mention spans. When omitted, mentions render as disabled buttons. */\n  onMentionClick?: (mentionId: string) => void;\n  /** Class for the outer wrapping `<span>`. */\n  className?: string;\n  /** Class applied to each highlighted mention span. Defaults paired with text-primary + hover underline. */\n  mentionClassName?: string;\n}\n\n/**\n * Sealed RSC-compatible content renderer that highlights `@mention` spans\n * inside a plain text body. Sub-exported from `post-card/index.ts` per\n * Q-EB pattern for host-side opt-in via the `renderContent` slot:\n *\n * ```tsx\n * <PostCard\n *   post={post}\n *   renderContent={(p) => (\n *     <MentionText\n *       content={p.content}\n *       mentions={p.mentions}\n *       onMentionClick={(id) => router.push(`/users/${id}`)}\n *     />\n *   )}\n * />\n * ```\n *\n * Why opt-in (per F-Plan-7 closure): the default `renderContent` is\n * `<ExpandableText content={post.content}>`, which takes `content: string`\n * and can't accept the JSX-span tree this component emits. Hosts wanting\n * inline highlighting trade away the \"read more\" clamping; this is a\n * deliberate v0.2.0 design choice. v0.3+ may bridge by extending\n * ExpandableText to accept ReactNode children.\n *\n * Invalid ranges (negative start, end beyond content length, start ≥ end)\n * are filtered out silently — the corresponding text renders as plain.\n */\nexport function MentionText({\n  content,\n  mentions,\n  onMentionClick,\n  className,\n  mentionClassName,\n}: MentionTextProps) {\n  if (!mentions || mentions.length === 0) {\n    return <span className={className}>{content}</span>;\n  }\n  const sorted = mentions\n    .filter(\n      (m) =>\n        m.range[0] >= 0 &&\n        m.range[1] <= content.length &&\n        m.range[0] < m.range[1],\n    )\n    .slice()\n    .sort((a, b) => a.range[0] - b.range[0]);\n\n  if (sorted.length === 0) {\n    return <span className={className}>{content}</span>;\n  }\n\n  const parts: ReactNode[] = [];\n  let pos = 0;\n  for (let i = 0; i < sorted.length; i++) {\n    const mention = sorted[i];\n    const [start, end] = mention.range;\n    if (start < pos) {\n      // Overlapping ranges — skip the overlapping mention to avoid garbled output.\n      continue;\n    }\n    if (start > pos) {\n      parts.push(\n        <Fragment key={`text-${i}`}>{content.slice(pos, start)}</Fragment>,\n      );\n    }\n    parts.push(\n      <button\n        key={`mention-${mention.id}-${i}`}\n        type=\"button\"\n        onClick={\n          onMentionClick ? () => onMentionClick(mention.id) : undefined\n        }\n        disabled={!onMentionClick}\n        className={cn(\n          \"font-medium text-primary\",\n          onMentionClick &&\n            \"hover:underline focus:underline focus:outline-none\",\n          !onMentionClick && \"cursor-default\",\n          mentionClassName,\n        )}\n      >\n        {content.slice(start, end)}\n      </button>,\n    );\n    pos = end;\n  }\n  if (pos < content.length) {\n    parts.push(<Fragment key=\"text-tail\">{content.slice(pos)}</Fragment>);\n  }\n  return <span className={className}>{parts}</span>;\n}\n\nMentionText.displayName = \"MentionText\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/mention-text.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/tag-chips.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface TagChipsProps {\n  /** Hashtag list (rendered as `#{tag}` buttons). */\n  tags: string[];\n  /** Click handler for each chip. When omitted, chips render as disabled buttons. */\n  onTagClick?: (tag: string) => void;\n  /** Class for the outer flex wrapper. */\n  className?: string;\n  /** Class for individual chip buttons. */\n  chipClassName?: string;\n}\n\n/**\n * Sealed RSC-compatible tag-chip row rendered as a sibling to the post content\n * body (NOT inside `<ExpandableText>`'s clamping — auto-rendered by the 4\n * variants when `post.tags?.length > 0` per F-Plan-7 closure).\n *\n * Sub-exported from `post-card/index.ts` for hosts wanting to render the\n * chips elsewhere (e.g. above the header, beside the location chip).\n */\nexport function TagChips({\n  tags,\n  onTagClick,\n  className,\n  chipClassName,\n}: TagChipsProps) {\n  if (tags.length === 0) return null;\n  return (\n    <div\n      className={cn(\n        \"relative z-10 flex flex-wrap items-center gap-1.5\",\n        className,\n      )}\n    >\n      {tags.map((tag) => (\n        <button\n          key={tag}\n          type=\"button\"\n          onClick={onTagClick ? () => onTagClick(tag) : undefined}\n          disabled={!onTagClick}\n          className={cn(\n            // Q-P40 / WCAG 2.5.5 — chips must be ≥44×44 since they're a primary\n            // touch target. h-11 (44px) is the floor; visual chip-density is\n            // sacrificed for compliance per the v0.2.0 plan lock.\n            \"inline-flex h-11 items-center rounded-full bg-muted px-3 text-xs font-medium text-muted-foreground\",\n            onTagClick &&\n              \"transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n            !onTagClick && \"cursor-default\",\n            chipClassName,\n          )}\n        >\n          #{tag}\n        </button>\n      ))}\n    </div>\n  );\n}\n\nTagChips.displayName = \"TagChips\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/tag-chips.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/sensitive-gate.tsx",
      "content": "\"use client\";\n\nimport { ShieldAlert } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\n\nexport interface SensitiveGateProps {\n  /** Heading text. Default in DEFAULT_POST_CARD_LABELS is \"Sensitive content\". */\n  heading: string;\n  /** Optional reason text rendered below the heading (e.g. \"Spoiler — finale\"). */\n  reason?: string;\n  /** Reveal button label. Default \"Show\". */\n  revealLabel: string;\n  /**\n   * Fired when the viewer taps the Reveal button. The parent variant is\n   * responsible for flipping the local `sensitiveRevealed` state and firing\n   * the host's `onRevealSensitive` callback (post-card.tsx wraps both\n   * into a single `onSensitiveReveal` baseProp).\n   */\n  onReveal: () => void;\n  className?: string;\n}\n\n/**\n * Absolute-positioned overlay shown above the media carousel when\n * `post.isSensitive` is true AND the viewer hasn't revealed yet AND\n * `disableSensitiveGate` is not set.\n *\n * Per Q-D6 / Q-P33 lock: per-post gate (NOT per-MediaItem). One gate covers\n * the entire carousel; one tap dismisses it for all items.\n *\n * Per Q-P41 lock: respects `prefers-reduced-motion` via\n * `motion-reduce:transition-none`. With motion enabled, the gate has a\n * subtle 200ms backdrop transition on mount; with motion reduced, the gate\n * appears + disappears without animation.\n *\n * Keyboard: `<Button>` element supports Enter / Space natively. No focus\n * trap needed — once revealed, the gate unmounts and focus naturally moves\n * to the next interactive element below.\n */\nexport function SensitiveGate({\n  heading,\n  reason,\n  revealLabel,\n  onReveal,\n  className,\n}: SensitiveGateProps) {\n  return (\n    <div\n      className={cn(\n        \"absolute inset-0 z-30 flex flex-col items-center justify-center gap-2 bg-background/95 p-6 text-center backdrop-blur-sm\",\n        \"transition-opacity duration-200 motion-reduce:transition-none\",\n        className,\n      )}\n      role=\"alert\"\n      aria-live=\"polite\"\n    >\n      <ShieldAlert\n        className=\"h-8 w-8 text-muted-foreground\"\n        aria-hidden=\"true\"\n      />\n      <div className=\"text-sm font-semibold\">{heading}</div>\n      {reason ? (\n        <div className=\"max-w-prose text-xs text-muted-foreground\">\n          {reason}\n        </div>\n      ) : null}\n      <Button\n        type=\"button\"\n        variant=\"outline\"\n        onClick={onReveal}\n        className=\"mt-2 h-11 px-6\"\n      >\n        {revealLabel}\n      </Button>\n    </div>\n  );\n}\n\nSensitiveGate.displayName = \"SensitiveGate\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/sensitive-gate.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/link-preview-card.tsx",
      "content": "\"use client\";\n\nimport { ExternalLink } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type { LinkPreview } from \"../types\";\n\nexport interface LinkPreviewCardProps {\n  preview: LinkPreview;\n  /**\n   * Click handler — overrides the default `<a href={url} target=\"_blank\">`\n   * navigation. When provided, the card renders as a `<button>` and fires\n   * the callback with the URL; when omitted, the card renders as a plain\n   * anchor and opens in a new tab.\n   */\n  onClick?: (url: string) => void;\n  className?: string;\n}\n\n/** Safe hostname extraction — falls back to the raw URL string if parsing throws. */\nfunction getHostname(url: string): string {\n  try {\n    return new URL(url).hostname;\n  } catch {\n    return url;\n  }\n}\n\n/**\n * Sealed client component rendering an OG-style link preview card below the\n * post content body and above the media block.\n *\n * Per Q-D4 lock: host pre-fetches `post.linkPreview` data; library is fully\n * fetch-free. v0.3+ may add an optional `linkPreviewFetcher` prop for\n * library-side resolution.\n *\n * Renders nothing if `preview.url` is the only populated field — bare URL\n * with no title / description / image / siteName looks empty; consumers\n * supplying just a URL should suppress via `disableLinkPreviewRender`.\n */\nexport function LinkPreviewCard({\n  preview,\n  onClick,\n  className,\n}: LinkPreviewCardProps) {\n  const interactive = !!onClick;\n\n  // Render-skip guard: bare URL without metadata produces an empty card.\n  // The library defaults to skipping it; consumers wanting a bare URL chip\n  // can render their own via the renderLinkPreview slot.\n  if (\n    !preview.title &&\n    !preview.description &&\n    !preview.image &&\n    !preview.siteName\n  ) {\n    return null;\n  }\n\n  const body = (\n    <>\n      {preview.image ? (\n        <div className=\"relative aspect-video w-full overflow-hidden bg-muted\">\n          {/* eslint-disable-next-line @next/next/no-img-element */}\n          <img\n            src={preview.image}\n            alt=\"\"\n            className=\"h-full w-full object-cover\"\n            loading=\"lazy\"\n          />\n        </div>\n      ) : null}\n      <div className=\"flex flex-col gap-1 p-3\">\n        {preview.siteName ? (\n          <div className=\"text-[10px] font-medium uppercase tracking-wide text-muted-foreground\">\n            {preview.siteName}\n          </div>\n        ) : null}\n        {preview.title ? (\n          <div className=\"line-clamp-2 text-sm font-semibold text-foreground\">\n            {preview.title}\n          </div>\n        ) : null}\n        {preview.description ? (\n          <div className=\"line-clamp-2 text-xs text-muted-foreground\">\n            {preview.description}\n          </div>\n        ) : null}\n        <div className=\"flex items-center gap-1 text-[10px] text-muted-foreground\">\n          <ExternalLink className=\"h-3 w-3 shrink-0\" aria-hidden=\"true\" />\n          <span className=\"truncate\">{getHostname(preview.url)}</span>\n        </div>\n      </div>\n    </>\n  );\n\n  const baseClasses = cn(\n    \"block overflow-hidden rounded-lg border border-border bg-card transition-colors\",\n    interactive\n      ? \"w-full text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n      : \"hover:bg-muted/30\",\n    className,\n  );\n\n  if (interactive) {\n    return (\n      <button\n        type=\"button\"\n        onClick={() => onClick(preview.url)}\n        className={baseClasses}\n      >\n        {body}\n      </button>\n    );\n  }\n\n  return (\n    <a\n      href={preview.url}\n      target=\"_blank\"\n      rel=\"noopener noreferrer\"\n      className={baseClasses}\n    >\n      {body}\n    </a>\n  );\n}\n\nLinkPreviewCard.displayName = \"LinkPreviewCard\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/link-preview-card.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/repost-of-card.tsx",
      "content": "\"use client\";\n\nimport { useMemo, type ElementType } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { PostCard } from \"../post-card\";\nimport type { Post, PostCardProps } from \"../types\";\n\nexport interface RepostOfCardProps {\n  /** The original post being reposted (`statefulPost.repostOf`). */\n  originalPost: Post;\n  /**\n   * Click handler — overrides default `getHref`-based navigation. When\n   * provided, the foreground click target renders as a `<button>` and fires\n   * with the original post. When omitted and `getHref` is set, renders as\n   * an `<a>`. When neither is set, the card is non-interactive.\n   */\n  onClick?: (originalPost: Post) => void;\n  /** Used to derive the default href when `onClick` is omitted. */\n  getHref?: PostCardProps[\"getHref\"];\n  /** Polymorphic root for the anchor case. Default `\"a\"`. */\n  linkComponent?: PostCardProps[\"linkComponent\"];\n  /** Forwarded to the nested compact `<PostCard>` for header label strings. */\n  labels?: PostCardProps[\"labels\"];\n  className?: string;\n}\n\n/**\n * Sealed client component rendering a nested counts-only mini-card for a\n * reposted post. Nests `<PostCard variant=\"compact\" engagementMode=\"navigate\"\n * viewerMode=\"viewer\" engagementActions={() => []}>` per plan Q-P30 — the\n * empty-array slot suppresses the engagement bar entirely (engagement-bar\n * short-circuits when actions.length === 0), leaving only the compact header\n * and content body. Result: counts-only meta row inside the nested compact card.\n *\n * Recursion-strip: `originalPost.repostOf` is forced to `undefined` before being\n * passed down, so a repost-of-a-repost renders only one level deep.\n *\n * Polymorphic root + interaction model:\n *   - `onClick` provided → foreground `<button>` captures click + keyboard.\n *   - `getHref` provided → foreground `<LinkComponent href>` captures click +\n *     middle-click + right-click-open-in-new-tab semantics.\n *   - Neither → non-interactive.\n * The nested mini-card is wrapped in `pointer-events-none` so any descendant\n * interactivity (kebab if labels populated, mention/tag chips, etc.) cannot\n * conflict with the outer click target. The foreground click target sits at\n * `z-10` above the nested card.\n */\nexport function RepostOfCard({\n  originalPost,\n  onClick,\n  getHref,\n  linkComponent,\n  labels,\n  className,\n}: RepostOfCardProps) {\n  // Recursion-strip: prevent infinite nesting of repost mini-cards.\n  const strippedOriginal = useMemo<Post>(\n    () => ({ ...originalPost, repostOf: undefined }),\n    [originalPost],\n  );\n\n  const overlayClasses =\n    \"absolute inset-0 z-10 cursor-pointer rounded-lg transition-colors hover:bg-foreground/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\";\n\n  const ariaLabel = `Open ${originalPost.author.name}'s post`;\n\n  return (\n    <div\n      className={cn(\n        \"relative overflow-hidden rounded-lg border border-border bg-muted/30\",\n        className,\n      )}\n    >\n      <div className=\"pointer-events-none\">\n        <PostCard\n          variant=\"compact\"\n          engagementMode=\"navigate\"\n          viewerMode=\"viewer\"\n          engagementActions={() => []}\n          post={strippedOriginal}\n          labels={labels}\n        />\n      </div>\n      {onClick ? (\n        <button\n          type=\"button\"\n          onClick={() => onClick(originalPost)}\n          className={overlayClasses}\n          aria-label={ariaLabel}\n        />\n      ) : getHref ? (\n        (() => {\n          const LinkComponent: ElementType = linkComponent ?? \"a\";\n          return (\n            <LinkComponent\n              href={getHref(originalPost)}\n              className={overlayClasses}\n              aria-label={ariaLabel}\n            />\n          );\n        })()\n      ) : null}\n    </div>\n  );\n}\n\nRepostOfCard.displayName = \"RepostOfCard\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/repost-of-card.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/poll-widget.tsx",
      "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { PostPoll, PostPollOption } from \"../types\";\n\nexport interface PollWidgetProps {\n  poll: PostPoll;\n  /**\n   * Whether the viewer has already cast a vote. Resolved from local-mirror\n   * (optimistic, takes precedence) OR `poll.hasVoted` (server-resolved).\n   * When true, the results view renders instead of vote buttons.\n   */\n  hasVoted: boolean;\n  /**\n   * Owner-side view — always shows results, never the vote buttons (owners\n   * don't vote on their own polls).\n   */\n  isOwnerView: boolean;\n  /**\n   * The option id the viewer voted for (highlights in results view). When\n   * sourced from the local mirror, the optimistic increment is applied to\n   * that option's display count.\n   */\n  optimisticVoteOptionId?: string;\n  /**\n   * Whether the optimistic-vote increment should bump the displayed counts.\n   * `true` when `optimisticVoteOptionId` came from the in-card local mirror\n   * (the host hasn't reconciled yet). `false` when sourced from\n   * `poll.viewerVoteOptionId` (server-resolved; counts already reflect it).\n   */\n  optimisticIncrement: boolean;\n  /** Fires when the viewer taps a vote button. */\n  onVote?: (optionId: string) => void;\n  labels: {\n    pollHeading: string;\n    pollTotalVotesLabel: string;\n    pollClosesAtLabel: string;\n    pollClosedLabel: string;\n  };\n  /** Relative-time formatter for the closesAt countdown. */\n  formatRelativeTime?: (date: Date, now: Date) => string;\n  className?: string;\n}\n\nfunction toDate(value: Date | string | number): Date {\n  if (value instanceof Date) return value;\n  return new Date(value);\n}\n\nfunction clampPct(n: number): number {\n  if (!Number.isFinite(n) || n <= 0) return 0;\n  if (n >= 100) return 100;\n  return n;\n}\n\ninterface ResolvedOption extends PostPollOption {\n  pct: number;\n  isViewerChoice: boolean;\n}\n\n/**\n * Sealed client component rendering the inline poll widget. Two views:\n *   - **Vote view** (viewer + not voted + not closed) — list of vote buttons,\n *     min h-11 per WCAG 2.5.5 (Q-P40).\n *   - **Results view** (owner OR voted OR closed) — bar chart with width\n *     transition (Q-P17 / Q-P41 motion-reduce safe). Viewer's option is\n *     highlighted via accent ring.\n *\n * Optimistic vote per Q-D5 / Q-P32 — when `optimisticVoteOptionId` comes from\n * the local mirror (`optimisticIncrement === true`), the picked option's\n * displayed count + the displayed total both get +1 immediately. Host can\n * reject by calling `ref.current.reset(originalPost)`, which clears the\n * local mirror upstream.\n *\n * Multi-select polls are not yet fully supported in v0.2.0 — single-vote\n * behavior renders regardless of `poll.multiSelect`. Tracked as a v0.2.x /\n * v0.3 follow-up.\n */\nexport function PollWidget({\n  poll,\n  hasVoted,\n  isOwnerView,\n  optimisticVoteOptionId,\n  optimisticIncrement,\n  onVote,\n  labels,\n  formatRelativeTime,\n  className,\n}: PollWidgetProps) {\n  const now = new Date();\n  const closesDate = poll.closesAt ? toDate(poll.closesAt) : undefined;\n  const isClosed = !!(closesDate && closesDate.getTime() <= now.getTime());\n  const showResults = isOwnerView || hasVoted || isClosed;\n\n  const { resolvedOptions, displayedTotal } = useMemo(() => {\n    const bumpedTotal =\n      poll.totalVotes + (optimisticVoteOptionId && optimisticIncrement ? 1 : 0);\n    const safeTotal = bumpedTotal > 0 ? bumpedTotal : 1;\n    const resolved: ResolvedOption[] = poll.options.map((opt) => {\n      const isViewerChoice = opt.id === optimisticVoteOptionId;\n      const bumpedCount =\n        isViewerChoice && optimisticIncrement\n          ? opt.voteCount + 1\n          : opt.voteCount;\n      return {\n        ...opt,\n        voteCount: bumpedCount,\n        isViewerChoice,\n        pct: clampPct((bumpedCount / safeTotal) * 100),\n      };\n    });\n    return { resolvedOptions: resolved, displayedTotal: bumpedTotal };\n  }, [poll, optimisticVoteOptionId, optimisticIncrement]);\n\n  // Closes-at line. Computed inline (not memoized) so the relative-time string\n  // refreshes whenever the parent re-renders. Memoization would freeze `now`\n  // on first render — same hydration / refresh convention as `formattedTime`\n  // in post-card.tsx.\n  let closesAtLine: string | undefined;\n  if (isClosed) {\n    closesAtLine = labels.pollClosedLabel;\n  } else if (closesDate && formatRelativeTime) {\n    closesAtLine = labels.pollClosesAtLabel.replace(\n      \"{time}\",\n      formatRelativeTime(closesDate, now),\n    );\n  }\n\n  const totalVotesLine = labels.pollTotalVotesLabel.replace(\n    \"{count}\",\n    String(displayedTotal),\n  );\n\n  return (\n    <div\n      className={cn(\n        \"flex flex-col gap-2 rounded-lg border border-border bg-card p-3\",\n        className,\n      )}\n      role=\"group\"\n      aria-label={labels.pollHeading}\n    >\n      <div className=\"flex items-center justify-between gap-2 text-xs text-muted-foreground\">\n        <span className=\"font-semibold text-foreground\">{labels.pollHeading}</span>\n        <span className=\"flex items-center gap-2\">\n          <span>{totalVotesLine}</span>\n          {closesAtLine ? <span aria-hidden=\"true\">·</span> : null}\n          {closesAtLine ? <span>{closesAtLine}</span> : null}\n        </span>\n      </div>\n\n      {showResults ? (\n        <ul className=\"flex flex-col gap-2\">\n          {resolvedOptions.map((opt) => (\n            <li key={opt.id} className=\"relative\">\n              <div\n                className={cn(\n                  \"relative flex items-center justify-between gap-2 overflow-hidden rounded-md border border-border bg-muted/30 px-3 py-2 text-sm\",\n                  opt.isViewerChoice && \"ring-1 ring-primary\",\n                )}\n              >\n                <div\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"absolute inset-y-0 left-0 transition-[width] duration-300 ease-out motion-reduce:transition-none\",\n                    opt.isViewerChoice ? \"bg-primary/20\" : \"bg-foreground/[0.06]\",\n                  )}\n                  style={{ width: `${opt.pct}%` }}\n                />\n                <span className=\"relative min-w-0 truncate font-medium\">\n                  {opt.label}\n                </span>\n                <span className=\"relative shrink-0 tabular-nums text-xs text-muted-foreground\">\n                  {Math.round(opt.pct)}%\n                </span>\n              </div>\n            </li>\n          ))}\n        </ul>\n      ) : (\n        <ul className=\"flex flex-col gap-2\">\n          {resolvedOptions.map((opt) => (\n            <li key={opt.id}>\n              <button\n                type=\"button\"\n                onClick={onVote ? () => onVote(opt.id) : undefined}\n                disabled={!onVote}\n                className={cn(\n                  \"flex h-11 w-full items-center justify-start rounded-md border border-border bg-background px-3 text-sm font-medium\",\n                  \"transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                  \"disabled:cursor-not-allowed disabled:opacity-60\",\n                )}\n              >\n                <span className=\"min-w-0 truncate\">{opt.label}</span>\n              </button>\n            </li>\n          ))}\n        </ul>\n      )}\n    </div>\n  );\n}\n\nPollWidget.displayName = \"PollWidget\";\n",
      "type": "registry:component",
      "target": "components/post-card/parts/poll-widget.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/parts/variant-shared.ts",
      "content": "import type { ElementType } from \"react\";\n// F-S1 lock: cross-procomp imports use RELATIVE paths to specific files.\nimport type {\n  Comment,\n  CommentDelta,\n  CommentMenuItem,\n  CommentThreadCurrentUser,\n  CommentThreadLabels,\n  CommentThreadHandle,\n  Subscribe,\n} from \"../../comment-thread/types\";\nimport type {\n  EngagementAction,\n  EngagementBarHandle,\n  EngagementBarLabels,\n  EngagementDelta,\n} from \"../../engagement-bar/types\";\nimport type {\n  Post,\n  PostCardLabels,\n  PostCardProps,\n} from \"../types\";\n\n/**\n * Shared internal prop bundle passed to per-variant parts. The root dispatcher\n * computes everything here once; each variant consumes what it needs.\n */\nexport interface VariantInnerProps {\n  post: Post;\n  currentUser?: CommentThreadCurrentUser;\n  authorId: string;\n  formattedTime: string;\n\n  kebabOpen: boolean;\n  onKebabOpenChange: (next: boolean) => void;\n  kebabItems: CommentMenuItem[];\n\n  engagementActions: EngagementAction[];\n  engagementBarRef?: React.Ref<EngagementBarHandle>;\n  engagementClassName?: string;\n  engagementLabels?: EngagementBarLabels;\n  /** No-op forwarded to the bar's onSubscribeDelta — the root subscribes to the realtime channel itself. */\n  engagementSubscribeNoop?: (delta: EngagementDelta) => void;\n\n  bodyMaxLines: number;\n  bodyClassName?: string;\n\n  headingAs: \"h2\" | \"h3\" | \"h4\";\n\n  labels: Required<\n    Omit<\n      PostCardLabels,\n      \"formatRelativeTime\" | \"engagementLabels\" | \"commentLabels\"\n    >\n  >;\n\n  burstKey: number;\n  heartBurstWired: boolean;\n  onMediaDoubleTap?: () => void;\n\n  cardLinkable: boolean;\n  linkHref?: string;\n  LinkComponent: ElementType;\n\n  renderHeader?: PostCardProps[\"renderHeader\"];\n  renderContent?: PostCardProps[\"renderContent\"];\n  renderMedia?: PostCardProps[\"renderMedia\"];\n  renderEngagementBar?: PostCardProps[\"renderEngagementBar\"];\n\n  // v0.2.0 header-level callbacks threaded to PostHeader (location chip,\n  // replyTo \"Replying to @x\" mention click).\n  onLocationClick?: PostCardProps[\"onLocationClick\"];\n  onMentionClick?: PostCardProps[\"onMentionClick\"];\n  /** Threaded to the auto-rendered <TagChips> sibling below content (C5). */\n  onTagClick?: PostCardProps[\"onTagClick\"];\n\n  // v0.2.0 sensitive-media gate (C6) — feed + detail variants only per description §1.3.\n  /** Current reveal state; gate renders when post.isSensitive && !sensitiveRevealed. */\n  sensitiveRevealed: boolean;\n  /** Single handler: flips local sensitiveRevealed=true + fires host's onRevealSensitive. */\n  onSensitiveReveal: () => void;\n  /** When true, gate is suppressed entirely even if post.isSensitive (moderator surfaces). */\n  disableSensitiveGate?: PostCardProps[\"disableSensitiveGate\"];\n  /** Full takeover for the sensitive-media gate overlay. Receives `onReveal` helper. */\n  renderSensitiveGate?: PostCardProps[\"renderSensitiveGate\"];\n\n  // v0.2.0 link-preview card (C7) — feed + detail variants only per description §1.3.\n  /** Click handler — overrides default <a href target=_blank> navigation. */\n  onLinkPreviewClick?: PostCardProps[\"onLinkPreviewClick\"];\n  /** When true, suppresses the default link-preview render even if post.linkPreview is set. */\n  disableLinkPreviewRender?: PostCardProps[\"disableLinkPreviewRender\"];\n  /** Full takeover for the link-preview card. */\n  renderLinkPreview?: PostCardProps[\"renderLinkPreview\"];\n\n  // v0.2.0 poll widget (C9) — feed + detail variants only per description §1.3.\n  /** Per-card vote dispatcher — wraps setPollVote(local-mirror) + onVotePoll(host). */\n  onPollVote?: (optionId: string) => void;\n  /** Local-mirror optimistic vote (from useState in post-card.tsx). */\n  pollOptimisticVote: { optionId: string; votedAt: Date } | null;\n  /** Resolved owner view flag — true when viewerMode === \"owner\". */\n  isOwnerView: boolean;\n  /** When true, suppresses the default poll-widget render even if post.poll is set. */\n  disablePollRender?: PostCardProps[\"disablePollRender\"];\n  /** Full takeover for the poll widget. */\n  renderPoll?: PostCardProps[\"renderPoll\"];\n  /** Relative-time formatter for the closesAt countdown line. */\n  formatRelativeTime?: (date: Date, now: Date) => string;\n\n  // v0.2.0 repost mini-card (C8) — feed + detail variants only per description §1.3.\n  /** Click handler — overrides default getHref-based navigation on the nested repost card. */\n  onRepostOfClick?: PostCardProps[\"onRepostOfClick\"];\n  /** When true, suppresses the default repost mini-card render even if post.repostOf is set. */\n  disableRepostOfRender?: PostCardProps[\"disableRepostOfRender\"];\n  /** Full takeover for the repost mini-card. */\n  renderRepostOf?: PostCardProps[\"renderRepostOf\"];\n  /** Forwarded to the nested compact `<PostCard>` for default href derivation. */\n  getHref?: PostCardProps[\"getHref\"];\n  /** Forwarded to the nested compact `<PostCard>` for polymorphic anchor root. */\n  linkComponent?: PostCardProps[\"linkComponent\"];\n  /** Forwarded to the nested compact `<PostCard>` for header label strings. */\n  cardLabels?: PostCardProps[\"labels\"];\n\n  /** Inline likes/comments panel rendered below the engagement bar (feed + compact only). */\n  inlinePanelNode?: import(\"react\").ReactNode;\n\n  className?: string;\n  headerClassName?: string;\n  mediaClassName?: string;\n}\n\n/** Detail variant adds comment-thread props on top. */\nexport interface DetailVariantInnerProps extends VariantInnerProps {\n  commentThread: Comment[];\n  commentPageSize: number;\n  commentSubscribe?: Subscribe<CommentDelta>;\n  onSubscribeCommentDelta?: (delta: CommentDelta) => void;\n  onAddComment?: PostCardProps[\"onAddComment\"];\n  onLikeComment?: PostCardProps[\"onLikeComment\"];\n  onDeleteComment?: PostCardProps[\"onDeleteComment\"];\n  onReportComment?: PostCardProps[\"onReportComment\"];\n  onLoadMoreComments?: PostCardProps[\"onLoadMoreComments\"];\n  commentActions?: PostCardProps[\"commentActions\"];\n  renderCommentSection?: PostCardProps[\"renderCommentSection\"];\n  commentLabels?: CommentThreadLabels;\n  threadRef?: React.Ref<CommentThreadHandle>;\n  commentSectionClassName?: string;\n  commentsHeading: string;\n}\n",
      "type": "registry:component",
      "target": "components/post-card/parts/variant-shared.ts"
    },
    {
      "path": "src/registry/components/data/post-card/lib/defaults.tsx",
      "content": "import {\n  BarChart3,\n  Bookmark,\n  Copy,\n  EyeOff,\n  Flag,\n  Globe,\n  Languages,\n  Pencil,\n  Pin,\n  PinOff,\n  Share2,\n  ShieldAlert,\n  Trash2,\n  UserX,\n  VolumeX,\n} from \"lucide-react\";\n// F-S1 lock: cross-procomp imports via relative + specific-file paths.\nimport type { EngagementAction } from \"../../engagement-bar/types\";\nimport type { CommentMenuItem } from \"../../comment-thread/types\";\nimport type {\n  Post,\n  PostCardLabels,\n  PostCardVariant,\n  PostHandlers,\n  PostMutationHandlers,\n  PostPermissionAction,\n  PostPermissions,\n  PostViewerMode,\n} from \"../types\";\nimport {\n  canPerformActionInternal,\n  resolvePostPermissions,\n} from \"./permissions\";\n\nexport function defaultPostEngagementActions(\n  post: Post,\n  handlers: PostHandlers,\n  variant: PostCardVariant,\n  /**\n   * Optional separate handler for the like-count tap target. When provided, the\n   * heart icon stays on `onToggle` and the count text becomes its own button\n   * firing `onCountClick` (kasder UX — heart toggles like, count opens likers panel).\n   */\n  onLikeCountClick?: () => void,\n): EngagementAction[] {\n  const like: EngagementAction = {\n    kind: \"like\",\n    count: post.likes,\n    liked: post.isLiked ?? false,\n    onToggle: handlers.onLike\n      ? (next) => handlers.onLike!(post.id, next)\n      : undefined,\n    onCountClick: onLikeCountClick,\n  };\n\n  const comment: EngagementAction = {\n    kind: \"comment\",\n    count: post.comments,\n    onClick: handlers.onComment\n      ? () => handlers.onComment!(post.id)\n      : undefined,\n  };\n\n  if (variant === \"compact\") {\n    return [like, comment];\n  }\n\n  if (variant === \"list\") {\n    // List variant default: counts only — interactive bar opt-in via slot.\n    return [];\n  }\n\n  // feed + detail\n  const actions: EngagementAction[] = [\n    like,\n    comment,\n    {\n      kind: \"share\",\n      count: post.shares,\n      onClick: handlers.onShare ? () => handlers.onShare!(post.id) : undefined,\n    },\n    {\n      kind: \"bookmark\",\n      bookmarked: post.isBookmarked ?? false,\n      onToggle: handlers.onBookmark\n        ? (next) => handlers.onBookmark!(post.id, next)\n        : undefined,\n    },\n  ];\n  if (variant === \"detail\" && post.viewCount !== undefined) {\n    actions.push({ kind: \"view-count\", count: post.viewCount });\n  }\n  return actions;\n}\n\n/**\n * Default kebab item builder. Dual-mode helper:\n *\n *   - **Legacy mode (v0.1 backwards-compat):** when `viewerMode` / `permissions` /\n *     `canPerformAction` are ALL `undefined`, the helper takes the exact v0.1\n *     handler-driven code path. Items are built from \"which handler is wired.\"\n *     Zero behavior drift for v0.1 consumers.\n *\n *   - **Role-aware mode (v0.2.0):** when ANY of the three role-aware args is set,\n *     the helper calls `resolvePostPermissions` + `canPerformActionInternal` to\n *     produce role-appropriate items per the resolution order in plan §3.\n *     Owner mode shows Edit / Delete / Pin / Change-visibility / Mark-sensitive /\n *     See-analytics; viewer mode shows Report / Block author / Mute author;\n *     common actions (Bookmark / Share / Copy link / Translate) gated by the\n *     resolved matrix.\n *\n * Backwards-compat: trailing args (`viewerMode`, `permissions`, `canPerformAction`,\n * `mutationHandlers`, `onTranslate`) are all optional. Old v0.1 call sites\n * compile unchanged.\n */\nexport function defaultPostKebabActions(\n  post: Post,\n  handlers: {\n    onBookmark?: PostHandlers[\"onBookmark\"];\n    onShare?: PostHandlers[\"onShare\"];\n    onReport?: (postId: string) => void;\n    onCopyLink?: (postId: string) => void;\n  },\n  labels: Required<\n    Pick<\n      PostCardLabels,\n      \"bookmark\" | \"unbookmark\" | \"share\" | \"copyLink\" | \"report\"\n    >\n  > &\n    Partial<\n      Pick<\n        PostCardLabels,\n        | \"edit\"\n        | \"delete\"\n        | \"pin\"\n        | \"unpin\"\n        | \"changeVisibility\"\n        | \"markSensitive\"\n        | \"unmarkSensitive\"\n        | \"seeAnalytics\"\n        | \"blockAuthor\"\n        | \"muteAuthor\"\n        | \"translate\"\n      >\n    >,\n  viewerMode?: PostViewerMode,\n  permissions?: PostPermissions,\n  canPerformAction?: (\n    action: PostPermissionAction,\n    post: Post,\n  ) => boolean | undefined,\n  mutationHandlers?: Partial<PostMutationHandlers>,\n  onTranslate?: (postId: string, sourceLanguage: string) => void,\n  /**\n   * v0.3.0 — supplier for the moderator section. Items render between common\n   * items and viewer-destructive items, with `separatorBefore: true` injected\n   * on the first item so the host kebab renders a divider above the group.\n   * Gated by `canDo(\"moderate\")` — host opts the viewer in via\n   * `permissions.canModerate` or `canPerformAction(\"moderate\", post)`.\n   */\n  moderatorActions?: (post: Post) => CommentMenuItem[],\n): CommentMenuItem[] {\n  const roleAwareMode =\n    viewerMode !== undefined ||\n    permissions !== undefined ||\n    canPerformAction !== undefined;\n\n  // ─── Legacy mode (v0.1 path) ─── unchanged behavior; resolver NOT called\n  if (!roleAwareMode) {\n    const items: CommentMenuItem[] = [];\n    if (handlers.onBookmark) {\n      items.push({\n        label: post.isBookmarked ? labels.unbookmark : labels.bookmark,\n        icon: <Bookmark className=\"h-4 w-4\" />,\n        onClick: () =>\n          handlers.onBookmark!(post.id, !(post.isBookmarked ?? false)),\n      });\n    }\n    if (handlers.onShare) {\n      items.push({\n        label: labels.share,\n        icon: <Share2 className=\"h-4 w-4\" />,\n        onClick: () => handlers.onShare!(post.id),\n      });\n    }\n    if (handlers.onCopyLink) {\n      items.push({\n        label: labels.copyLink,\n        icon: <Copy className=\"h-4 w-4\" />,\n        onClick: () => handlers.onCopyLink!(post.id),\n      });\n    }\n    if (handlers.onReport) {\n      items.push({\n        label: labels.report,\n        icon: <Flag className=\"h-4 w-4\" />,\n        destructive: true,\n        onClick: () => handlers.onReport!(post.id),\n      });\n    }\n    return items;\n  }\n\n  // ─── Role-aware mode (v0.2.0) ─── resolve matrix + assemble items per resolution order\n  const matrix = resolvePostPermissions(viewerMode, permissions);\n  const canDo = (action: PostPermissionAction) =>\n    canPerformActionInternal(action, post, matrix, canPerformAction);\n  const items: CommentMenuItem[] = [];\n\n  // Owner-side items — only shown when matrix allows AND handler is wired\n  if (canDo(\"edit\") && mutationHandlers?.onEdit) {\n    items.push({\n      label: labels.edit ?? \"Edit\",\n      icon: <Pencil className=\"h-4 w-4\" />,\n      onClick: () => mutationHandlers.onEdit!(post.id),\n    });\n  }\n  if (canDo(\"pin\") && mutationHandlers?.onPin) {\n    items.push({\n      label: post.isPinned ? (labels.unpin ?? \"Unpin\") : (labels.pin ?? \"Pin to top\"),\n      icon: post.isPinned ? <PinOff className=\"h-4 w-4\" /> : <Pin className=\"h-4 w-4\" />,\n      onClick: () => mutationHandlers.onPin!(post.id, !(post.isPinned ?? false)),\n    });\n  }\n  if (canDo(\"changeVisibility\") && mutationHandlers?.onChangeVisibility) {\n    items.push({\n      label: labels.changeVisibility ?? \"Change visibility\",\n      icon: <Globe className=\"h-4 w-4\" />,\n      onClick: () => mutationHandlers.onChangeVisibility!(post.id, post.visibility),\n    });\n  }\n  if (canDo(\"markSensitive\") && mutationHandlers?.onMarkSensitive) {\n    items.push({\n      label: post.isSensitive\n        ? (labels.unmarkSensitive ?? \"Remove sensitive mark\")\n        : (labels.markSensitive ?? \"Mark as sensitive\"),\n      icon: post.isSensitive ? <EyeOff className=\"h-4 w-4\" /> : <ShieldAlert className=\"h-4 w-4\" />,\n      onClick: () =>\n        mutationHandlers.onMarkSensitive!(post.id, !(post.isSensitive ?? false)),\n    });\n  }\n  if (canDo(\"seeAnalytics\") && mutationHandlers?.onSeeAnalytics) {\n    items.push({\n      label: labels.seeAnalytics ?? \"See analytics\",\n      icon: <BarChart3 className=\"h-4 w-4\" />,\n      onClick: () => mutationHandlers.onSeeAnalytics!(post.id),\n    });\n  }\n\n  // Common items (host-policy gated) — Bookmark / Share / Copy link\n  if (canDo(\"bookmark\") && handlers.onBookmark) {\n    items.push({\n      label: post.isBookmarked ? labels.unbookmark : labels.bookmark,\n      icon: <Bookmark className=\"h-4 w-4\" />,\n      onClick: () =>\n        handlers.onBookmark!(post.id, !(post.isBookmarked ?? false)),\n    });\n  }\n  if (canDo(\"share\") && handlers.onShare) {\n    items.push({\n      label: labels.share,\n      icon: <Share2 className=\"h-4 w-4\" />,\n      onClick: () => handlers.onShare!(post.id),\n    });\n  }\n  if (handlers.onCopyLink) {\n    items.push({\n      label: labels.copyLink,\n      icon: <Copy className=\"h-4 w-4\" />,\n      onClick: () => handlers.onCopyLink!(post.id),\n    });\n  }\n\n  // Translate (gated by post.language presence + handler) — not a canX matrix action\n  if (post.language && onTranslate) {\n    items.push({\n      label: labels.translate ?? \"Translate\",\n      icon: <Languages className=\"h-4 w-4\" />,\n      onClick: () => onTranslate(post.id, post.language!),\n    });\n  }\n\n  // ─── v0.3.0 moderator section ─── ILX-3\n  // Rendered between common items and viewer-destructive items. Gated by\n  // `canDo(\"moderate\")` AND a non-empty `moderatorActions(post)` return.\n  // The first moderator item carries `separatorBefore: true` so the host\n  // kebab renders a divider above the whole group.\n  if (canDo(\"moderate\") && moderatorActions) {\n    const modItems = moderatorActions(post);\n    if (modItems.length > 0) {\n      items.push({ ...modItems[0], separatorBefore: true });\n      for (let i = 1; i < modItems.length; i++) {\n        items.push(modItems[i]);\n      }\n    }\n  }\n\n  // Viewer-side items (destructive at the bottom)\n  if (canDo(\"muteAuthor\") && mutationHandlers?.onMuteAuthor) {\n    items.push({\n      label: labels.muteAuthor ?? \"Mute author\",\n      icon: <VolumeX className=\"h-4 w-4\" />,\n      onClick: () => mutationHandlers.onMuteAuthor!(post.author.id),\n    });\n  }\n  if (canDo(\"blockAuthor\") && mutationHandlers?.onBlockAuthor) {\n    items.push({\n      label: labels.blockAuthor ?? \"Block author\",\n      icon: <UserX className=\"h-4 w-4\" />,\n      destructive: true,\n      onClick: () => mutationHandlers.onBlockAuthor!(post.author.id),\n    });\n  }\n  if (canDo(\"report\") && handlers.onReport) {\n    items.push({\n      label: labels.report,\n      icon: <Flag className=\"h-4 w-4\" />,\n      destructive: true,\n      onClick: () => handlers.onReport!(post.id),\n    });\n  }\n\n  // Owner-side destructive (last) — Delete\n  if (canDo(\"delete\") && mutationHandlers?.onDelete) {\n    items.push({\n      label: labels.delete ?? \"Delete\",\n      icon: <Trash2 className=\"h-4 w-4\" />,\n      destructive: true,\n      onClick: () => mutationHandlers.onDelete!(post.id),\n    });\n  }\n\n  return items;\n}\n",
      "type": "registry:component",
      "target": "components/post-card/lib/defaults.tsx"
    },
    {
      "path": "src/registry/components/data/post-card/lib/permissions.ts",
      "content": "/**\n * Pure permission resolver for post-card v0.2.0.\n *\n * Resolution order (most → least specific) per plan §3:\n *   1. `canPerformAction(action, post)` returning `true | false` — wins everything for that action\n *   2. `permissions[canX]` per-field — `false` denies, `true` allows\n *   3. `viewerMode`-derived defaults from `PERMISSION_DEFAULTS_BY_MODE`\n *   4. Library-baseline default (legacy mode — caller must short-circuit before\n *      calling this resolver; see `defaultPostKebabActions` dual-mode branch)\n *\n * Truth table:\n * | viewerMode  | permissions  | resolved matrix                                    |\n * |-------------|--------------|----------------------------------------------------|\n * | `undefined` | `undefined`  | (resolver NOT called — legacy v0.1 path)           |\n * | `undefined` | `{ ... }`    | viewer baseline + permissions override per-field   |\n * | `\"owner\"`   | `undefined`  | owner defaults (all owner-side true)               |\n * | `\"owner\"`   | `{ ... }`    | owner defaults + permissions override per-field    |\n * | `\"viewer\"`  | `undefined`  | viewer defaults (viewer-side true, owner-side false) |\n * | `\"viewer\"`  | `{ ... }`    | viewer defaults + permissions override per-field   |\n */\n\nimport type {\n  Post,\n  PostPermissionAction,\n  PostPermissions,\n  PostViewerMode,\n} from \"../types\";\n\n/**\n * Library-baseline permission matrix by viewer mode. `Required<PostPermissions>`\n * ensures every field is resolved (no `undefined` leaks downstream).\n *\n * `canShare` + `canBookmark` default to `true` in both modes — host-policy gates\n * (the host can deny by setting `permissions={{ canShare: false }}` for visibility-restricted posts).\n */\nexport const PERMISSION_DEFAULTS_BY_MODE: Record<\n  PostViewerMode,\n  Required<PostPermissions>\n> = {\n  owner: {\n    canEdit: true,\n    canDelete: true,\n    canPin: true,\n    canChangeVisibility: true,\n    canMarkSensitive: true,\n    canSeeAnalytics: true,\n    canShare: true,\n    canBookmark: true,\n    canReport: false,\n    canBlockAuthor: false,\n    canMuteAuthor: false,\n    // moderation is orthogonal to viewerMode — never auto-derived; host opts in\n    // explicitly via permissions.canModerate or canPerformAction(\"moderate\", …).\n    canModerate: false,\n  },\n  viewer: {\n    canEdit: false,\n    canDelete: false,\n    canPin: false,\n    canChangeVisibility: false,\n    canMarkSensitive: false,\n    canSeeAnalytics: false,\n    canShare: true,\n    canBookmark: true,\n    canReport: true,\n    canBlockAuthor: true,\n    canMuteAuthor: true,\n    canModerate: false,\n  },\n};\n\n/**\n * Map a `PostPermissionAction` string to its `PostPermissions` matrix key.\n *\n * `\"edit\"` → `\"canEdit\"`, `\"changeVisibility\"` → `\"canChangeVisibility\"`, etc.\n * Pure, no side effects. Used by `canPerformActionInternal` to look up the\n * matrix value for a given action.\n */\nfunction actionToPermissionKey(\n  action: PostPermissionAction,\n): keyof PostPermissions {\n  return `can${action.charAt(0).toUpperCase()}${action.slice(1)}` as keyof PostPermissions;\n}\n\n/**\n * Pure resolver — returns the fully-resolved permission matrix for the\n * rendering pass. Does NOT consult `canPerformAction` (that's evaluated\n * per-action by `canPerformActionInternal`).\n *\n * Caller's contract: only invoke when role-aware mode is active (at least one\n * of `viewerMode` / `permissions` / `canPerformAction` is set). Legacy mode\n * short-circuits in the calling helper before this is reached.\n */\nexport function resolvePostPermissions(\n  viewerMode: PostViewerMode | undefined,\n  permissions: PostPermissions | undefined,\n): Required<PostPermissions> {\n  const base = viewerMode\n    ? PERMISSION_DEFAULTS_BY_MODE[viewerMode]\n    : PERMISSION_DEFAULTS_BY_MODE.viewer; // viewer baseline when viewerMode undefined but permissions set\n  if (!permissions) return { ...base };\n  // Skip undefined fields during merge: `permissions={{ canEdit: undefined }}`\n  // should fall through to the mode default, not override it with undefined.\n  // Required<PostPermissions> contract: every returned field is a definite boolean.\n  const result: Required<PostPermissions> = { ...base };\n  for (const key of Object.keys(permissions) as (keyof PostPermissions)[]) {\n    const value = permissions[key];\n    if (value !== undefined) {\n      result[key] = value;\n    }\n  }\n  return result;\n}\n\n/**\n * Per-action resolution. Returns `true` to allow the action / `false` to deny.\n *\n * Resolution per plan §3:\n *   1. `canPerformAction(action, post)` returning `true | false` wins\n *   2. Matrix value from `resolvedMatrix` (already includes mode defaults + permissions overrides)\n *\n * `canPerformAction` returning `undefined` falls through to step 2.\n */\nexport function canPerformActionInternal(\n  action: PostPermissionAction,\n  post: Post,\n  resolvedMatrix: Required<PostPermissions>,\n  canPerformAction:\n    | ((action: PostPermissionAction, post: Post) => boolean | undefined)\n    | undefined,\n): boolean {\n  if (canPerformAction) {\n    const result = canPerformAction(action, post);\n    if (result !== undefined) return result;\n  }\n  return resolvedMatrix[actionToPermissionKey(action)] !== false;\n}\n",
      "type": "registry:component",
      "target": "components/post-card/lib/permissions.ts"
    }
  ],
  "categories": [
    "data"
  ],
  "type": "registry:block"
}