{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "story-viewer",
  "title": "Story Viewer",
  "author": "ilinxa",
  "description": "Full-screen story viewer — segmented progress, 3D cube transitions, finger-following swipe, tap zones, and an engagement overlay.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "@ilinxa/video-player",
    "@ilinxa/engagement-bar",
    "@ilinxa/comment-thread",
    "dialog",
    "avatar",
    "button"
  ],
  "files": [
    {
      "path": "src/registry/components/media/story-viewer/story-viewer.tsx",
      "content": "\"use client\";\n\nimport {\n  memo,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  DEFAULT_STORY_VIEWER_LABELS,\n  type Story,\n  type StoryItem,\n  type StoryViewerHandle,\n  type StoryViewerLabels,\n  type StoryViewerProps,\n} from \"./types\";\nimport { useStoryViewerState } from \"./hooks/use-story-viewer-state\";\nimport { useStoryProgress } from \"./hooks/use-story-progress\";\nimport { useStoryKeyboardNav } from \"./hooks/use-story-keyboard-nav\";\nimport { useStoryEngagementState } from \"./hooks/use-story-engagement-state\";\nimport { useLongPressPause } from \"./hooks/use-long-press-pause\";\nimport {\n  useCubeTransition,\n  type CubeDirection,\n} from \"./hooks/use-cube-transition\";\nimport { ViewerShell } from \"./parts/viewer-shell\";\nimport { ProgressBars } from \"./parts/progress-bars\";\nimport { ViewerHeader } from \"./parts/viewer-header\";\nimport { TapZones } from \"./parts/tap-zones\";\nimport { NavArrows } from \"./parts/nav-arrows\";\nimport { ItemView } from \"./parts/item-view\";\n// v0.3.7: Heart icon is rendered inline in the DM bar row.\nimport { Heart } from \"lucide-react\";\nimport { EngagementOverlay } from \"./parts/engagement-overlay\";\nimport { ReplyComposer } from \"./parts/reply-composer\";\nimport { OwnerOverlay } from \"./parts/owner-overlay\";\nimport { KebabPanel } from \"./parts/kebab-panel\";\nimport { LinkCta } from \"./parts/link-cta\";\nimport { CommentsPanel } from \"./parts/comments-panel\";\nimport { SharePanel } from \"./parts/share-panel\";\nimport { StoryCubeFace } from \"./parts/story-cube-face\";\nimport { defaultStoryKebabActions } from \"./lib/kebab\";\nimport { buildStoryEngagementActionsWithMatrix } from \"./lib/engagement-actions\";\nimport type { CommentComposerHandle } from \"@/registry/components/data/comment-thread/parts/comment-composer\";\n\ninterface StoryViewerInnerProps extends StoryViewerProps {\n  ref?: React.Ref<StoryViewerHandle>;\n}\n\nfunction StoryViewerInner(props: StoryViewerInnerProps) {\n  const {\n    stories: initialStories,\n    initialStoryIndex,\n    isOpen,\n    onClose,\n    ref,\n    subscribe,\n    onSubscribeDelta,\n    onStoryViewed,\n    onItemViewed,\n    onCursorChange,\n    onAutoCloseAtEnd,\n    renderItem,\n    defaultItemDuration = 5,\n    labels: labelsProp,\n    className,\n    contentClassName,\n    // v0.2.0 — engagement layer inputs (all optional; gated on viewerMode + !disableEngagement)\n    viewerMode,\n    permissions,\n    canPerformAction,\n    engagementSubscribe,\n    onSubscribeEngagementDelta,\n    onLikeStory,\n    onReactStory,\n    onShareStory,\n    reactionKinds,\n    reactors,\n    onLoadReactors,\n    disableEngagement = false,\n    renderEngagementOverlay,\n    // v0.2.0 — reply composer inputs (C4)\n    currentUser,\n    onAddReply,\n    composerEmptyState,\n    disableReplyComposer = false,\n    renderReplyComposer,\n    // v0.2.0 — owner overlay inputs (C5)\n    onLoadViewers,\n    disableOwnerOverlay = false,\n    renderOwnerOverlay,\n    // v0.2.0 — kebab inputs (C6)\n    kebabActions,\n    moderatorActions,\n    // v0.2.0 — render slots for the 4 default parts (C7)\n    renderHeader,\n    renderProgress,\n    renderNavArrows,\n    renderTapZones,\n    // v0.2.0 — remaining disable opt-outs (C8); 3 already pulled above\n    disableTapZones = false,\n    disableKeyboardNav = false,\n    disableNavArrows = false,\n    disableAutoClose = false,\n    disableProgressBars = false,\n    // v0.2.0 — long-press pause + link CTA (C9)\n    longPressThresholdMs,\n    linkComponent,\n    onLinkClick,\n    // v0.2.2 — author tap-target\n    onAuthorClick,\n    authorComponent,\n    // v0.3.0 — comments panel\n    renderCommentsPanel,\n    disableComments = false,\n    // v0.3.1 — share panel\n    renderSharePanel,\n    disableSharePanel = false,\n    // v0.4.0 — Instagram-canonical 3D cube transition between stories\n    disableStoryTransition = false,\n    storyTransitionDurationMs = 400,\n    // v0.2.0 — kebab item handlers (mirrors post-card v0.2.0; flattened\n    // on Props rather than a discrete StoryMutationHandlers interface). All\n    // optional; gated by the permissions matrix in defaultStoryKebabActions.\n    onSaveToHighlights,\n    onDeleteStory,\n    onShareToFeed,\n    onReport,\n    onBlockAuthor,\n    onMuteAuthor,\n    onCopyLink,\n    isSavedToHighlights,\n  } = props;\n\n  // v0.2.0 labels shape: required for all keys EXCEPT the nested forwards\n  // (engagementLabels + commentLabels) which stay optional — hosts opt-in.\n  const labels = useMemo<\n    Required<Omit<StoryViewerLabels, \"engagementLabels\" | \"commentLabels\">> &\n      Pick<StoryViewerLabels, \"engagementLabels\" | \"commentLabels\">\n  >(\n    () => ({ ...DEFAULT_STORY_VIEWER_LABELS, ...labelsProp }),\n    [labelsProp],\n  );\n\n  const {\n    stories,\n    cursor,\n    isPaused,\n    isMuted,\n    setPaused,\n    setMuted,\n    goToPrevItem,\n    goToNextItem,\n    goToPrevStory,\n    goToNextStory,\n    goToStoryIndex,\n    goToItemIndex,\n    reset,\n    dispatch,\n  } = useStoryViewerState({\n    initialStories,\n    initialStoryIndex,\n    isOpen,\n    subscribe,\n    onSubscribeDelta,\n    onStoryViewed,\n    onItemViewed,\n    onCursorChange,\n    onAutoCloseAtEnd,\n    onClose,\n    disableAutoClose,\n  });\n\n  const currentStory = stories[cursor.storyIndex];\n  const currentItem = currentStory?.items[cursor.itemIndex];\n\n  // Imperative handle.\n  const storiesRef = useRef(stories);\n  const cursorRef = useRef(cursor);\n  /*\n   * `reactors` + `onLoadReactors` describe a reactions-preview surface in the\n   * engagement overlay that was never built: no part reads either prop, so a\n   * consumer supplying profiles (or a fetcher) sees nothing and gets no signal.\n   * Building the reactor list is a feature, not a wiring fix — so until it\n   * exists, the props announce themselves rather than swallowing the data.\n   */\n  useEffect(() => {\n    if (process.env.NODE_ENV === \"production\") return;\n    if (reactors === undefined && onLoadReactors === undefined) return;\n    console.warn(\n      \"story-viewer: `reactors` / `onLoadReactors` are not implemented — the engagement overlay has no reactions-preview surface yet, so neither prop has any effect. Render reactors yourself via `renderEngagementOverlay`.\",\n    );\n  }, [reactors, onLoadReactors]);\n\n  useEffect(() => {\n    storiesRef.current = stories;\n    cursorRef.current = cursor;\n  });\n  // Forward-ref-stable handlers for the v0.2.0 trigger* handle methods.\n  // The methods need to read the CURRENT story/item/engagement state at call\n  // time (not the closure snapshot from when the handle was first created),\n  // so we mirror them through refs to keep the handle's identity stable.\n  const triggerLikeRef = useRef<() => void>(() => {});\n  const triggerReactionRef = useRef<(kind?: string) => void>(() => {});\n  const triggerShareRef = useRef<() => void>(() => {});\n  // ─── v0.2.0 reply composer ref (for focus + triggerReply handle method) ──\n  // Declared BEFORE the imperative handle: the handle's triggerReply /\n  // openKebab closures reference composerRef + setKebabOpen, and declaring\n  // them after the handle trips the React Compiler\n  // \"cannot access variable before it is declared\" check.\n  const composerRef = useRef<CommentComposerHandle | null>(null);\n  // ─── v0.2.0 kebab state (C6) — shared between engagement-overlay placement + header fallback ──\n  const [kebabOpen, setKebabOpen] = useState(false);\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      // v0.1 (preserved)\n      goToStory: goToStoryIndex,\n      goToItem: goToItemIndex,\n      setPaused: (paused: boolean) => setPaused(paused),\n      getCursor: () => ({\n        storyIndex: cursorRef.current.storyIndex,\n        itemIndex: cursorRef.current.itemIndex,\n      }),\n      getCurrentStories: () => storiesRef.current,\n      reset,\n      dispatch,\n      // v0.2.0 additions — wired through refs so the handle stays stable\n      // while the underlying handlers re-resolve on every render against\n      // the latest cursor + engagement state.\n      setMuted: (muted: boolean) => setMuted(muted),\n      triggerLike: () => triggerLikeRef.current(),\n      triggerReaction: (kind?: string) => triggerReactionRef.current(kind),\n      triggerReply: (content?: string) => {\n        composerRef.current?.focus();\n        if (content) {\n          // CommentComposerHandle exposes focus; setValue not in v0.2.1.\n          // Future enhancement: extend Handle with setValue method.\n        }\n      },\n      triggerShare: () => triggerShareRef.current(),\n      openKebab: () => setKebabOpen(true),\n    }),\n    [goToStoryIndex, goToItemIndex, setPaused, setMuted, reset, dispatch],\n  );\n\n  // Per-item video metadata duration cache.\n  const [videoMetadataMs, setVideoMetadataMs] = useState<Record<string, number>>({});\n  const handleVideoMetadata = useCallback(\n    (duration: number) => {\n      if (!currentItem) return;\n      if (!Number.isFinite(duration) || duration <= 0) return;\n      setVideoMetadataMs((prev) => {\n        if (prev[currentItem.id] != null) return prev;\n        return { ...prev, [currentItem.id]: duration * 1000 };\n      });\n    },\n    [currentItem],\n  );\n\n  // Resolve item duration (R-Plan-4): explicit → video metadata → default.\n  const itemDurationMs = useMemo(() => {\n    if (!currentItem) return defaultItemDuration * 1000;\n    if (currentItem.duration != null) return currentItem.duration * 1000;\n    if (currentItem.type === \"video\") {\n      const meta = videoMetadataMs[currentItem.id];\n      if (meta != null) return meta;\n    }\n    return defaultItemDuration * 1000;\n  }, [currentItem, videoMetadataMs, defaultItemDuration]);\n\n  const itemKey = currentStory && currentItem\n    ? `${currentStory.id}:${currentItem.id}`\n    : \"\";\n\n  const progress = useStoryProgress({\n    isOpen: isOpen && !!currentItem,\n    isPaused,\n    itemKey,\n    itemDurationMs,\n    onComplete: goToNextItem,\n  });\n\n  // ─── v0.4.0–v0.4.1 cube transition + swipe ──────────────────────────────\n  // Detect story-level cursor changes during render and trigger the AUTO\n  // cube animation in the SAME commit as the cursor change. The previous\n  // values live in React state (`trackedCursor`) — NOT refs — so the\n  // mid-render setState pattern handles the sync without tripping the\n  // React Compiler \"no refs during render\" lint.\n  //\n  // `pendingDragCommit` suppresses the auto trigger when the cursor change\n  // was caused by a drag commit (we already played the cube animation\n  // during the drag — no need to play it again).\n  // Destructure the cube-hook container to locals up front. `setRotatorRef`\n  // closes over the hook's internal rotator ref, so once\n  // `ref={cube.setRotatorRef}` appears in JSX the React Compiler's aliasing\n  // analysis treats the whole `cube` container as a ref and flags every\n  // later `cube.state` read as \"ref access during render\" (known\n  // false-positive class — same destructure-to-locals fix as blackboard /\n  // reply-composer). Locals keep the analysis precise; identities unchanged.\n  const {\n    state: cubeState,\n    setRotatorRef: cubeSetRotatorRef,\n    startAuto: cubeStartAuto,\n    beginDrag: cubeBeginDrag,\n    setDragAngle: cubeSetDragAngle,\n    releaseDrag: cubeReleaseDrag,\n    forceIdle: cubeForceIdle,\n  } = useCubeTransition(storyTransitionDurationMs);\n  const [trackedCursor, setTrackedCursor] = useState({\n    storyIndex: cursor.storyIndex,\n    itemIndex: cursor.itemIndex,\n    isOpen,\n  });\n  const [pendingDragCommit, setPendingDragCommit] = useState(false);\n  if (trackedCursor.isOpen !== isOpen) {\n    setTrackedCursor({\n      storyIndex: cursor.storyIndex,\n      itemIndex: cursor.itemIndex,\n      isOpen,\n    });\n  } else if (pendingDragCommit && cursor.storyIndex !== trackedCursor.storyIndex) {\n    // Cursor change came from a drag commit — suppress the auto animation.\n    setPendingDragCommit(false);\n    setTrackedCursor({\n      storyIndex: cursor.storyIndex,\n      itemIndex: cursor.itemIndex,\n      isOpen,\n    });\n  } else if (\n    isOpen &&\n    !disableStoryTransition &&\n    cubeState.phase === \"idle\" &&\n    cursor.storyIndex !== trackedCursor.storyIndex &&\n    stories.length > 0\n  ) {\n    const direction =\n      cursor.storyIndex > trackedCursor.storyIndex ? \"next\" : \"prev\";\n    const leavingStory = stories[trackedCursor.storyIndex];\n    const leavingItem = leavingStory?.items[trackedCursor.itemIndex];\n    if (leavingStory && leavingItem) {\n      cubeStartAuto({\n        direction,\n        leaving: {\n          storyId: leavingStory.id,\n          itemId: leavingItem.id,\n          itemIndex: trackedCursor.itemIndex,\n          progress: 100,\n        },\n      });\n    }\n    setTrackedCursor({\n      storyIndex: cursor.storyIndex,\n      itemIndex: cursor.itemIndex,\n      isOpen,\n    });\n  } else if (\n    trackedCursor.storyIndex !== cursor.storyIndex ||\n    trackedCursor.itemIndex !== cursor.itemIndex\n  ) {\n    setTrackedCursor({\n      storyIndex: cursor.storyIndex,\n      itemIndex: cursor.itemIndex,\n      isOpen,\n    });\n  }\n  // Force idle on viewer close to clear any in-flight cube state.\n  // Dep only on the memoized `cubeForceIdle` (stable identity) — depending\n  // on the whole hook container triggers an infinite loop because the hook\n  // returns a fresh container object on every render.\n  useEffect(() => {\n    if (!isOpen) cubeForceIdle();\n  }, [isOpen, cubeForceIdle]);\n\n  // Derived: cube mount + per-face transforms. The cube structure\n  // (perspective + container + rotator) is rendered whenever phase !== idle.\n  // The CSS transition on the rotator is enabled in auto/releasing modes\n  // (animated angle change) and DISABLED in dragging mode (pointer drives\n  // it directly — any transition would lag the finger).\n  const cubeMounted = cubeState.phase !== \"idle\";\n  const cubeUsesTransition =\n    cubeState.phase === \"auto\" || cubeState.phase === \"releasing\";\n  // Hoist narrowed snapshots out of the discriminated union for JSX use.\n  let autoLeavingStory: Story | null = null;\n  let autoLeavingItem: StoryItem | null = null;\n  let autoLeavingItemIndex = 0;\n  let autoLeavingProgress = 0;\n  let autoDirection: CubeDirection | null = null;\n  let dragPrev: { story: Story; item: StoryItem; itemIndex: number; progress: number } | null = null;\n  let dragNext: { story: Story; item: StoryItem; itemIndex: number; progress: number } | null = null;\n  if (cubeState.phase === \"auto\") {\n    const cs = cubeState;\n    autoDirection = cs.direction;\n    autoLeavingItemIndex = cs.leaving.itemIndex;\n    autoLeavingProgress = cs.leaving.progress;\n    autoLeavingStory = stories.find((s) => s.id === cs.leaving.storyId) ?? null;\n    autoLeavingItem =\n      autoLeavingStory?.items.find((i) => i.id === cs.leaving.itemId) ?? null;\n  } else if (\n    cubeState.phase === \"dragging\" ||\n    cubeState.phase === \"releasing\"\n  ) {\n    const cs = cubeState;\n    if (cs.prev) {\n      const ps = stories.find((s) => s.id === cs.prev!.storyId);\n      const pi = ps?.items.find((i) => i.id === cs.prev!.itemId);\n      if (ps && pi) {\n        dragPrev = {\n          story: ps,\n          item: pi,\n          itemIndex: cs.prev.itemIndex,\n          progress: cs.prev.progress,\n        };\n      }\n    }\n    if (cs.next) {\n      const ns = stories.find((s) => s.id === cs.next!.storyId);\n      const ni = ns?.items.find((i) => i.id === cs.next!.itemId);\n      if (ns && ni) {\n        dragNext = {\n          story: ns,\n          item: ni,\n          itemIndex: cs.next.itemIndex,\n          progress: cs.next.progress,\n        };\n      }\n    }\n  }\n\n  // ─── v0.4.1 swipe gesture ──────────────────────────────────────────────\n  // Pointer-driven Instagram-style swipe between stories. Engages when\n  // horizontal drag exceeds 10px AND > vertical drag. While engaged,\n  // `--story-cube-angle` is driven directly through the cube hook.\n  // On release: distance > 30% width OR velocity > 0.5 px/ms → commit.\n  // Else: snap back to 0.\n  const swipeRef = useRef<{\n    pointerId: number;\n    startX: number;\n    startY: number;\n    startTime: number;\n    active: boolean;\n    width: number;\n    suppressClick: boolean;\n  } | null>(null);\n  const swipeJustEndedRef = useRef(false);\n  // The gesture handlers themselves are declared LATER in the component,\n  // after the longPress hook is initialized (they coexist).\n\n  // Keyboard nav (Escape funnels through Radix → onOpenChange).\n  // v0.2.0 — `enabled` gates the listener entirely per `disableKeyboardNav`.\n  useStoryKeyboardNav({\n    isOpen,\n    enabled: !disableKeyboardNav,\n    onPrevItem: goToPrevItem,\n    onNextItem: goToNextItem,\n    onTogglePause: () => setPaused((p) => !p),\n  });\n\n  // v0.2.0 C9 — long-press pause (Instagram-canonical mobile gesture).\n  // Additive to v0.1 middle-tap pause; both coexist (Q-V8 lock).\n  const longPress = useLongPressPause({\n    isOpen,\n    longPressThresholdMs,\n    onPause: () => setPaused(true),\n    onResume: () => setPaused(false),\n  });\n\n  // v0.4.1 — Swipe gesture handlers. Coexist with longPress: pointerdown\n  // calls both; if drag intent is detected, longPress is cancelled.\n  const handlePointerDown = useCallback(\n    (e: React.PointerEvent<HTMLDivElement>) => {\n      if (disableStoryTransition) {\n        longPress.handlePointerDown();\n        return;\n      }\n      if (e.pointerType === \"mouse\" && e.button !== 0) {\n        longPress.handlePointerDown();\n        return;\n      }\n      const width = e.currentTarget.getBoundingClientRect().width;\n      swipeRef.current = {\n        pointerId: e.pointerId,\n        startX: e.clientX,\n        startY: e.clientY,\n        startTime: performance.now(),\n        active: false,\n        width,\n        suppressClick: false,\n      };\n      longPress.handlePointerDown();\n    },\n    [disableStoryTransition, longPress],\n  );\n\n  const handlePointerMove = useCallback(\n    (e: React.PointerEvent<HTMLDivElement>) => {\n      const swipe = swipeRef.current;\n      if (!swipe || swipe.pointerId !== e.pointerId) return;\n      const dx = e.clientX - swipe.startX;\n      const dy = e.clientY - swipe.startY;\n      if (!swipe.active) {\n        // Drag intent: Δx > 10px AND > Δy.\n        if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy)) {\n          swipe.active = true;\n          longPress.handlePointerCancel();\n          const prevStory = stories[cursor.storyIndex - 1];\n          const nextStory = stories[cursor.storyIndex + 1];\n          cubeBeginDrag({\n            prev:\n              prevStory && prevStory.items[0]\n                ? {\n                    storyId: prevStory.id,\n                    itemId: prevStory.items[0].id,\n                    itemIndex: 0,\n                    progress: 100,\n                  }\n                : null,\n            next:\n              nextStory && nextStory.items[0]\n                ? {\n                    storyId: nextStory.id,\n                    itemId: nextStory.items[0].id,\n                    itemIndex: 0,\n                    progress: 0,\n                  }\n                : null,\n          });\n          try {\n            e.currentTarget.setPointerCapture(e.pointerId);\n          } catch {\n            // setPointerCapture can throw if pointer was already released.\n          }\n        } else {\n          return;\n        }\n      }\n      e.preventDefault();\n      const hasPrev = cursor.storyIndex > 0;\n      const hasNext = cursor.storyIndex < stories.length - 1;\n      // Δx → angle. Drag right (Δx > 0) → +angle (reveals prev, left wall).\n      // Drag left (Δx < 0) → -angle (reveals next, right wall).\n      let angle = (dx / swipe.width) * 90;\n      if (angle > 0 && !hasPrev) angle = angle * 0.25;\n      if (angle < 0 && !hasNext) angle = angle * 0.25;\n      angle = Math.max(-90, Math.min(90, angle));\n      cubeSetDragAngle(angle);\n    },\n    [longPress, stories, cursor.storyIndex, cubeBeginDrag, cubeSetDragAngle],\n  );\n\n  const handlePointerEnd = useCallback(\n    (e: React.PointerEvent<HTMLDivElement>) => {\n      const swipe = swipeRef.current;\n      if (!swipe || swipe.pointerId !== e.pointerId) {\n        longPress.handlePointerUp();\n        return;\n      }\n      swipeRef.current = null;\n      if (!swipe.active) {\n        longPress.handlePointerUp();\n        return;\n      }\n      // Active drag release — snap decision.\n      const dx = e.clientX - swipe.startX;\n      const elapsedMs = Math.max(1, performance.now() - swipe.startTime);\n      const velocity = dx / elapsedMs;\n      const commitDistance = swipe.width * 0.3;\n      const commitVelocity = 0.5;\n      const hasPrev = cursor.storyIndex > 0;\n      const hasNext = cursor.storyIndex < stories.length - 1;\n\n      let snap: \"next\" | \"prev\" | \"back\" = \"back\";\n      if ((dx <= -commitDistance || velocity <= -commitVelocity) && hasNext) {\n        snap = \"next\";\n      } else if ((dx >= commitDistance || velocity >= commitVelocity) && hasPrev) {\n        snap = \"prev\";\n      }\n\n      // Suppress the click that would fire on this pointerup (tap zones).\n      swipeJustEndedRef.current = true;\n      try {\n        e.currentTarget.releasePointerCapture(swipe.pointerId);\n      } catch {\n        // Already released — safe to ignore.\n      }\n\n      if (snap === \"next\") {\n        cubeReleaseDrag({\n          targetDeg: -90,\n          onComplete: () => {\n            setPendingDragCommit(true);\n            goToNextStory();\n          },\n        });\n      } else if (snap === \"prev\") {\n        cubeReleaseDrag({\n          targetDeg: 90,\n          onComplete: () => {\n            setPendingDragCommit(true);\n            goToPrevStory();\n          },\n        });\n      } else {\n        cubeReleaseDrag({ targetDeg: 0 });\n      }\n    },\n    [cubeReleaseDrag, cursor.storyIndex, goToNextStory, goToPrevStory, longPress, stories.length],\n  );\n\n  const handleClickCapture = useCallback(\n    (e: React.MouseEvent<HTMLDivElement>) => {\n      if (swipeJustEndedRef.current) {\n        swipeJustEndedRef.current = false;\n        e.preventDefault();\n        e.stopPropagation();\n      }\n    },\n    [],\n  );\n\n\n  // ─── v0.2.0 engagement state (per-item like / reaction / reply counts) ──\n  const { getState: getEngagementState, dispatch: dispatchEngagement } =\n    useStoryEngagementState({\n      subscribe: engagementSubscribe,\n      onSubscribeDelta: onSubscribeEngagementDelta,\n    });\n\n  // (v0.2.0 `composerRef` + kebab state are declared earlier — above the\n  // imperative handle that closes over them; see the TDZ note there.\n  // Close-on-cursor-change for kebab lives in the consolidated render-time\n  // reset below, next to the panel resets.)\n\n  // ─── v0.3.5 engagement bar expand/collapse state ─────────────────────\n  // Default: collapsed (only the heart toggle visible). Tapping the toggle\n  // reveals the engagement icons with a staggered bottom-to-top animation;\n  // tapping anywhere else collapses it.\n  // v0.3.7: heart toggle moved inline beside the DM bar.\n  const [engagementExpanded, setEngagementExpanded] = useState(false);\n  const engagementColumnRef = useRef<HTMLDivElement | null>(null);\n  const engagementToggleRef = useRef<HTMLButtonElement | null>(null);\n  // (Close-on-cursor-change lives in the consolidated render-time reset below.)\n  // Outside-click: any pointer event outside BOTH the engagement column\n  // AND the inline toggle button collapses the column.\n  useEffect(() => {\n    if (!engagementExpanded) return;\n    const handleDown = (e: PointerEvent) => {\n      const target = e.target as Node | null;\n      if (!target) return;\n      const insideColumn = engagementColumnRef.current?.contains(target);\n      const insideToggle = engagementToggleRef.current?.contains(target);\n      if (!insideColumn && !insideToggle) {\n        setEngagementExpanded(false);\n      }\n    };\n    document.addEventListener(\"pointerdown\", handleDown);\n    return () => document.removeEventListener(\"pointerdown\", handleDown);\n  }, [engagementExpanded]);\n\n  // ─── v0.3.0 comments panel state — opens on comment-icon tap ──────────\n  const [commentsOpen, setCommentsOpen] = useState(false);\n  // ─── v0.3.1 share panel state — opens on share-icon tap ──────────────\n  const [shareOpen, setShareOpen] = useState(false);\n  const anyPanelOpen = commentsOpen || shareOpen;\n  // v0.3.2 introduced a `composerActive` state to fade the engagement\n  // overlay when the DM composer was focused. v0.3.3 reverted that —\n  // engagement stays always visible per UX feedback, and the Cancel\n  // button (which caused the collision) was removed. The state +\n  // onActiveChange prop stay available for future polish (e.g., a\n  // heart-toggle that only reveals the engagement column on demand).\n  // Auto-pause story timer while any v0.3 panel is open; resume on close.\n  // Mirrors the long-press-pause additive — opening a panel is an explicit\n  // focus change away from the media and the auto-advance should not race\n  // the user.\n  useEffect(() => {\n    if (!anyPanelOpen) return;\n    setPaused(true);\n    return () => setPaused(false);\n  }, [anyPanelOpen, setPaused]);\n  // Close transient chrome (kebab / engagement column / v0.3 panels) on\n  // cursor change (next/prev nav) — so none of it persists across story\n  // navigation. Render-time prev-state adjustment (the same guarded\n  // mid-render setState pattern as `trackedCursor` above; React docs\n  // \"adjusting state when a prop changes\") instead of setState-in-effect\n  // resets: the close lands in the SAME commit as the navigation, with no\n  // extra post-commit render showing stale-open chrome.\n  const [prevChromeCursor, setPrevChromeCursor] = useState({\n    storyIndex: cursor.storyIndex,\n    itemIndex: cursor.itemIndex,\n  });\n  if (\n    prevChromeCursor.storyIndex !== cursor.storyIndex ||\n    prevChromeCursor.itemIndex !== cursor.itemIndex\n  ) {\n    setPrevChromeCursor({\n      storyIndex: cursor.storyIndex,\n      itemIndex: cursor.itemIndex,\n    });\n    setKebabOpen(false);\n    setEngagementExpanded(false);\n    setCommentsOpen(false);\n    setShareOpen(false);\n  }\n  // Mutual exclusion — opening one closes the other.\n  const openCommentsPanel = useCallback(() => {\n    setShareOpen(false);\n    setCommentsOpen(true);\n  }, []);\n  const openSharePanel = useCallback(() => {\n    setCommentsOpen(false);\n    setShareOpen(true);\n  }, []);\n\n  // Resolved kebab items — kebabActions full-takeover wins; otherwise\n  // defaultStoryKebabActions assembles per permissions matrix + moderator section.\n  const kebabItems = useMemo(() => {\n    if (!currentStory || !currentItem) return [];\n    if (kebabActions) return kebabActions(currentStory, currentItem);\n    return defaultStoryKebabActions({\n      story: currentStory,\n      item: currentItem,\n      handlers: {\n        onSaveToHighlights,\n        onDeleteStory,\n        onShareToFeed,\n        onReport,\n        onBlockAuthor,\n        onMuteAuthor,\n        onCopyLink,\n        isSavedToHighlights,\n      },\n      labels: {\n        saveToHighlights: labels.saveToHighlights,\n        unsaveFromHighlights: labels.unsaveFromHighlights,\n        deleteStory: labels.deleteStory,\n        shareToFeed: labels.shareToFeed,\n        report: labels.report,\n        blockAuthor: labels.blockAuthor,\n        muteAuthor: labels.muteAuthor,\n        copyLink: labels.copyLink,\n      },\n      viewerMode,\n      permissions,\n      canPerformAction,\n      moderatorActions,\n    });\n  }, [\n    currentStory,\n    currentItem,\n    kebabActions,\n    onSaveToHighlights,\n    onDeleteStory,\n    onShareToFeed,\n    onReport,\n    onBlockAuthor,\n    onMuteAuthor,\n    onCopyLink,\n    isSavedToHighlights,\n    labels,\n    viewerMode,\n    permissions,\n    canPerformAction,\n    moderatorActions,\n  ]);\n\n  // Resolved engagement actions for the current item (rebuilt per cursor change)\n  const engagementOverlayMounted =\n    !!viewerMode && viewerMode === \"viewer\" && !disableEngagement && !!currentStory && !!currentItem;\n  // Reply composer mount gate — viewer mode + opt-in + story/item present.\n  // currentUser absent is HANDLED INSIDE the render (composerEmptyState fallback).\n  const replyComposerMounted =\n    !!viewerMode && viewerMode === \"viewer\" && !disableReplyComposer && !!currentStory && !!currentItem;\n  // Owner overlay mount gate — owner mode + opt-in + story/item present.\n  // Mutually exclusive with replyComposerMounted (owner vs viewer modes).\n  const ownerOverlayMounted =\n    !!viewerMode && viewerMode === \"owner\" && !disableOwnerOverlay && !!currentStory && !!currentItem;\n  // Slot helpers — passed to every renderXxx slot.\n  const slotHelpers = useMemo(\n    () => ({\n      cursor,\n      isPaused,\n      isMuted,\n      setPaused: (p: boolean) => setPaused(p),\n      setMuted: (m: boolean) => setMuted(m),\n      goToPrevItem,\n      goToNextItem,\n      goToPrevStory,\n      goToNextStory,\n      onClose,\n      labels,\n    }),\n    [\n      cursor,\n      isPaused,\n      isMuted,\n      setPaused,\n      setMuted,\n      goToPrevItem,\n      goToNextItem,\n      goToPrevStory,\n      goToNextStory,\n      onClose,\n      labels,\n    ],\n  );\n  const engagementActions = useMemo(() => {\n    if (!engagementOverlayMounted) return [];\n    const state = getEngagementState(currentStory!.id, currentItem!.id);\n    // False-positive suppression (known class: ref-reading event closures\n    // passed to an opaque builder called during render — media-editor\n    // v0.1.2 precedent). The builder only STORES onCommentClick (which\n    // reads composerRef.current) in the returned actions array; the ref is\n    // read at click time, never during render.\n    // eslint-disable-next-line react-hooks/refs\n    return buildStoryEngagementActionsWithMatrix({\n      story: currentStory!,\n      item: currentItem!,\n      state,\n      viewerMode,\n      permissions,\n      canPerformAction,\n      reactionKinds,\n      onLikeToggle: (next: boolean) => {\n        dispatchEngagement({\n          kind: \"like-toggle\",\n          storyId: currentStory!.id,\n          itemId: currentItem!.id,\n          nextLiked: next,\n        });\n        onLikeStory?.(currentStory!.id, currentItem!.id, next);\n      },\n      onReactSelect: (kind: string | null) => {\n        dispatchEngagement({\n          kind: \"reaction-select\",\n          storyId: currentStory!.id,\n          itemId: currentItem!.id,\n          reactionKind: kind,\n        });\n        onReactStory?.(currentStory!.id, currentItem!.id, kind);\n      },\n      onCommentClick: () => {\n        // v0.3.0 — open the comments panel (Instagram-canonical). When\n        // disableComments is set, fall back to focusing the DM input\n        // (v0.2.x behavior).\n        if (!disableComments) {\n          openCommentsPanel();\n        } else {\n          composerRef.current?.focus();\n        }\n      },\n      // v0.3.5 — kebab moved out of the engagement column to the header\n      // right cluster; not added to the actions array here.\n      onShareClick: () => {\n        // v0.3.1 — open the share panel (Instagram-canonical bottom-sheet).\n        // When disableSharePanel is set, fall back to firing onShareStory\n        // directly (v0.2.x system-share behavior).\n        if (!disableSharePanel) {\n          openSharePanel();\n        }\n        onShareStory?.(currentStory!.id, currentItem!.id);\n      },\n    });\n  }, [\n    engagementOverlayMounted,\n    currentStory,\n    currentItem,\n    getEngagementState,\n    viewerMode,\n    permissions,\n    canPerformAction,\n    reactionKinds,\n    dispatchEngagement,\n    onLikeStory,\n    onReactStory,\n    onShareStory,\n    disableComments,\n    disableSharePanel,\n    openCommentsPanel,\n    openSharePanel,\n  ]);\n\n  // v0.2.0 — wire trigger* refs to live handlers each render. These bypass\n  // the permissions matrix (the matrix gates UI affordances; the handle is\n  // the programmatic escape hatch). No-op when no story/item is current.\n  useEffect(() => {\n    triggerLikeRef.current = () => {\n      if (!currentStory || !currentItem) return;\n      const state = getEngagementState(currentStory.id, currentItem.id);\n      const nextLiked = !state.liked;\n      dispatchEngagement({\n        kind: \"like-toggle\",\n        storyId: currentStory.id,\n        itemId: currentItem.id,\n        nextLiked,\n      });\n      onLikeStory?.(currentStory.id, currentItem.id, nextLiked);\n    };\n    triggerReactionRef.current = (kind?: string) => {\n      if (!currentStory || !currentItem) return;\n      const nextKind = kind ?? null;\n      dispatchEngagement({\n        kind: \"reaction-select\",\n        storyId: currentStory.id,\n        itemId: currentItem.id,\n        reactionKind: nextKind,\n      });\n      onReactStory?.(currentStory.id, currentItem.id, nextKind);\n    };\n    triggerShareRef.current = () => {\n      if (!currentStory || !currentItem) return;\n      onShareStory?.(currentStory.id, currentItem.id);\n    };\n  });\n\n  const handleOpenChange = useCallback(\n    (open: boolean) => {\n      if (!open) onClose();\n    },\n    [onClose],\n  );\n\n  if (!currentStory || !currentItem) {\n    // Render the shell so closing animations still fire when stories empty mid-view.\n    return (\n      <ViewerShell\n        isOpen={isOpen}\n        onOpenChange={handleOpenChange}\n        ariaLabel={labels.viewerLabel}\n        className={contentClassName}\n      >\n        <span />\n      </ViewerShell>\n    );\n  }\n\n  return (\n    <ViewerShell\n      isOpen={isOpen}\n      onOpenChange={handleOpenChange}\n      ariaLabel={labels.viewerLabel}\n      className={contentClassName}\n    >\n      {/* v0.2.0 C7 — renderNavArrows slot wins as full takeover.\n          v0.2.0 C8 — `disableNavArrows` suppresses the default mount entirely. */}\n      {renderNavArrows ? (\n        renderNavArrows(slotHelpers)\n      ) : !disableNavArrows ? (\n        <NavArrows\n          canPrev={cursor.storyIndex > 0}\n          onPrev={goToPrevStory}\n          onNext={goToNextStory}\n          labels={labels}\n        />\n      ) : null}\n\n      <div\n        className={cn(\n          \"relative h-full w-full bg-black touch-pan-y\",\n          // v0.4.0 — `perspective` + `container-type: inline-size` engage only\n          // while the cube is mounted (auto / dragging / releasing); idle\n          // state is a flat surface (no 3D context, no container-query\n          // side-effects on consumer content).\n          cubeMounted && \"perspective-distant @container\",\n          className,\n        )}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerEnd}\n        onPointerCancel={handlePointerEnd}\n        onClickCapture={handleClickCapture}\n      >\n        {/* v0.4.0 — Cube rotator. When idle, contents render normally\n            (no transform, no preserve-3d). When mounted, the rotator's\n            transform reads `--story-cube-angle` (driven by the hook via\n            DOM + RAF / pointer events — not React state — so we don't\n            re-render per frame).\n            v0.4.2 — Prefix `translateZ(-50cqw)`. The faces themselves\n            sit at `translateZ(50cqw)` (front wall of the cube). Without\n            the rotator's `-50cqw` shift, the front face would land in\n            front of the perspective plane and CSS perspective would\n            magnify it (~1.2× at rest), causing a visible scale-up jump\n            the moment the cube engages on a swipe. With the shift, the\n            front face's world z = 0 at idle — natural CSS scale 1.0×,\n            no jump — and naturally scales DOWN as it rotates away (the\n            proper cube perspective behavior). The incoming face arrives\n            at world z=0 at ∓90° and ends at scale 1.0× too. */}\n        <div\n          ref={cubeSetRotatorRef}\n          className={cn(\n            \"absolute inset-0\",\n            cubeMounted && \"transform-3d\",\n          )}\n          style={\n            cubeMounted\n              ? {\n                  transform:\n                    \"translateZ(-50cqw) rotateY(var(--story-cube-angle, 0deg))\",\n                  transition: cubeUsesTransition\n                    ? `transform ${storyTransitionDurationMs}ms cubic-bezier(0.32, 0.72, 0, 1)`\n                    : \"none\",\n                }\n              : undefined\n          }\n        >\n        {/* v0.4.0 auto-mode — Leaving face (ghost). Pre-placed at the\n            front wall (`translateZ(50cqw)`). As the rotator swings ∓90deg\n            the ghost rotates out of view to the side. */}\n        {cubeState.phase === \"auto\" && autoLeavingStory && autoLeavingItem ? (\n          <div\n            className=\"absolute inset-0 backface-hidden pointer-events-none\"\n            style={{ transform: \"translateZ(50cqw)\" }}\n            aria-hidden=\"true\"\n          >\n            <StoryCubeFace\n              story={autoLeavingStory}\n              item={autoLeavingItem}\n              itemIndex={autoLeavingItemIndex}\n              progress={autoLeavingProgress}\n              isMuted={isMuted}\n              labels={labels}\n            />\n          </div>\n        ) : null}\n        {/* v0.4.1 drag-mode — Prev ghost on the LEFT wall (rotateY -90deg).\n            Mounted during dragging + releasing so the user can swipe to\n            either neighbor. */}\n        {(cubeState.phase === \"dragging\" || cubeState.phase === \"releasing\") &&\n        dragPrev ? (\n          <div\n            className=\"absolute inset-0 backface-hidden pointer-events-none\"\n            style={{ transform: \"rotateY(-90deg) translateZ(50cqw)\" }}\n            aria-hidden=\"true\"\n          >\n            <StoryCubeFace\n              story={dragPrev.story}\n              item={dragPrev.item}\n              itemIndex={dragPrev.itemIndex}\n              progress={dragPrev.progress}\n              isMuted={isMuted}\n              labels={labels}\n            />\n          </div>\n        ) : null}\n        {/* v0.4.1 drag-mode — Next ghost on the RIGHT wall (rotateY +90deg). */}\n        {(cubeState.phase === \"dragging\" || cubeState.phase === \"releasing\") &&\n        dragNext ? (\n          <div\n            className=\"absolute inset-0 backface-hidden pointer-events-none\"\n            style={{ transform: \"rotateY(90deg) translateZ(50cqw)\" }}\n            aria-hidden=\"true\"\n          >\n            <StoryCubeFace\n              story={dragNext.story}\n              item={dragNext.item}\n              itemIndex={dragNext.itemIndex}\n              progress={dragNext.progress}\n              isMuted={isMuted}\n              labels={labels}\n            />\n          </div>\n        ) : null}\n        {/* v0.4.0 — Live tree face. Position depends on cube phase:\n            – idle: no transform, renders inline (no cube structure)\n            – auto: pre-placed on the side wall opposite the leaving ghost\n              (right wall for \"next\", left wall for \"prev\") — rotator\n              swings it into view\n            – dragging / releasing: at the FRONT wall (translateZ only);\n              the prev/next ghosts swing in as the rotator rotates. */}\n        <div\n          className={cn(\n            \"absolute inset-0\",\n            cubeMounted && \"backface-hidden\",\n          )}\n          style={\n            cubeState.phase === \"auto\" && autoDirection\n              ? {\n                  transform:\n                    autoDirection === \"next\"\n                      ? \"rotateY(90deg) translateZ(50cqw)\"\n                      : \"rotateY(-90deg) translateZ(50cqw)\",\n                }\n              : cubeState.phase === \"dragging\" ||\n                  cubeState.phase === \"releasing\"\n                ? { transform: \"translateZ(50cqw)\" }\n                : undefined\n          }\n        >\n        {/* v0.2.0 C7 — renderProgress slot wins as full takeover.\n            v0.2.0 C8 — `disableProgressBars` suppresses the default mount;\n            timer still runs (drives auto-advance). */}\n        {renderProgress ? (\n          renderProgress(currentStory.items, cursor.itemIndex, progress, slotHelpers)\n        ) : !disableProgressBars ? (\n          <ProgressBars\n            items={currentStory.items}\n            currentItemIndex={cursor.itemIndex}\n            progress={progress}\n          />\n        ) : null}\n        {/* v0.2.0 C7 — renderHeader slot wins as full takeover. */}\n        {renderHeader ? (\n          renderHeader(currentStory, currentItem, slotHelpers)\n        ) : (\n          <ViewerHeader\n            story={currentStory}\n            item={currentItem}\n            isPaused={isPaused}\n            isMuted={isMuted}\n            onTogglePause={() => setPaused((p) => !p)}\n            onToggleMute={() => setMuted((m) => !m)}\n            onClose={onClose}\n            formatTime={labels.formatTime}\n            labels={labels}\n            onAuthorClick={onAuthorClick}\n            authorComponent={authorComponent}\n            onKebabClick={\n              viewerMode && kebabItems.length > 0\n                ? () => setKebabOpen(true)\n                : undefined\n            }\n          />\n        )}\n        {/* v0.3.0 — scaling wrapper: when the comments or share panel is\n            open, the entire visual stack (media + tap-zones + link CTA chip\n            + engagement column + heart toggle + DM bar) shrinks toward the\n            top with pointer-events disabled inside. Mirrors the Instagram\n            bottom-sheet UX. */}\n        <div\n          className={cn(\n            \"absolute inset-0 origin-top transition-transform duration-300 ease-out\",\n            anyPanelOpen && \"scale-[0.55] translate-y-[-18%] pointer-events-none\",\n          )}\n        >\n          <ItemView\n            item={currentItem}\n            story={currentStory}\n            totalItems={currentStory.items.length}\n            cursor={cursor}\n            isPaused={isPaused}\n            isMuted={isMuted}\n            onLoadedMetadata={handleVideoMetadata}\n            onEnded={goToNextItem}\n            renderItem={renderItem}\n            labels={labels}\n          />\n          {/* v0.2.0 C7 — renderTapZones slot wins as full takeover.\n              v0.2.0 C8 — `disableTapZones` suppresses the default mount entirely. */}\n          {renderTapZones ? (\n            renderTapZones(slotHelpers)\n          ) : !disableTapZones ? (\n            <TapZones\n              onPrev={goToPrevItem}\n              onTogglePause={() => setPaused((p) => !p)}\n              onNext={goToNextItem}\n            />\n          ) : null}\n\n          {/* v0.2.0 C9 — StoryItem.link CTA bottom button. Coexists with the\n              engagement overlay (sits above it) and the DM input (sits\n              below it in viewer mode). Polymorphic root via `linkComponent`. */}\n          {currentItem.link ? (\n            <LinkCta\n              story={currentStory}\n              item={currentItem}\n              linkComponent={linkComponent}\n              onLinkClick={onLinkClick}\n              labels={labels}\n            />\n          ) : null}\n\n          {/* v0.2.0 — engagement overlay (TikTok/Reels-style stacked right edge).\n              Renders only when viewerMode=\"viewer\" + !disableEngagement.\n              renderEngagementOverlay slot wins as full takeover.\n              v0.3.3 — engagement stays ALWAYS visible per UX feedback. The\n              DM gradient strip extends full width below the engagement\n              column; the column visually overlays the gradient. Cancel\n              button removed entirely so there's no collision to worry\n              about. */}\n          {engagementOverlayMounted ? (\n            renderEngagementOverlay ? (\n              renderEngagementOverlay(currentStory!, currentItem!, slotHelpers)\n            ) : (\n              <EngagementOverlay\n                story={currentStory!}\n                item={currentItem!}\n                actions={engagementActions}\n                labels={labels}\n                expanded={engagementExpanded}\n                containerRef={engagementColumnRef}\n              />\n            )\n          ) : null}\n\n          {/* v0.2.0 — DM composer at bottom (always-visible \"Reply to @user…\"\n              input — Instagram-canonical DM channel, NOT public comments).\n              Renders only when viewerMode=\"viewer\" + !disableReplyComposer.\n              currentUser absent → null + composerEmptyState rendered instead.\n              renderReplyComposer slot wins as full takeover. */}\n          {replyComposerMounted ? (\n            renderReplyComposer ? (\n              renderReplyComposer(currentStory!, currentItem!, slotHelpers)\n            ) : currentUser ? (\n              <ReplyComposer\n                story={currentStory!}\n                item={currentItem!}\n                currentUser={currentUser}\n                onAddReply={onAddReply}\n                onSetPaused={(p) => setPaused(p)}\n                labels={labels}\n                composerRef={composerRef}\n              />\n            ) : (\n              composerEmptyState\n            )\n          ) : null}\n\n          {/* v0.3.7 — heart engagement toggle inline with the DM bar.\n              Sits at right-3 bottom-3 — aligned with the DM input row.\n              Tapping toggles the EngagementOverlay (which sits above the\n              DM row, animating its icons in from bottom-to-top). */}\n          {engagementOverlayMounted && !renderEngagementOverlay ? (\n            <button\n              ref={engagementToggleRef}\n              type=\"button\"\n              onClick={() => setEngagementExpanded((v) => !v)}\n              aria-expanded={engagementExpanded ? \"true\" : \"false\"}\n              aria-label={\n                engagementExpanded\n                  ? labels.engagementHideLabel\n                  : labels.engagementShowLabel\n              }\n              className={cn(\n                \"absolute right-3 bottom-3 z-32 flex h-9 w-9 items-center justify-center rounded-full text-white pointer-events-auto\",\n                \"transition-colors hover:bg-white/15 focus-visible:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40\",\n              )}\n            >\n              <Heart\n                className={cn(\n                  \"h-5 w-5 transition-transform duration-200\",\n                  engagementExpanded && \"scale-110 fill-current text-rose-400\",\n                )}\n              />\n            </button>\n          ) : null}\n        </div>\n\n        {/* v0.3.0 — backdrop catcher: while any v0.3 panel is open, a\n            dimming layer above the (now-shrunk + pointer-events-none)\n            visual stack catches clicks and closes the active panel. Sits\n            below the panel itself (z-35 vs panel z-40). v0.3.1: added a\n            black/40 dim for visual hierarchy + smoother feel. */}\n        {anyPanelOpen ? (\n          <button\n            type=\"button\"\n            className=\"absolute inset-0 z-35 bg-black/40 transition-opacity duration-300\"\n            onClick={() => {\n              setCommentsOpen(false);\n              setShareOpen(false);\n            }}\n            aria-label={\n              commentsOpen ? labels.commentsCloseLabel : labels.shareCloseLabel\n            }\n          />\n        ) : null}\n\n        {/* v0.2.0 — owner overlay (view-count + lazy viewers list).\n            Renders only when viewerMode=\"owner\" + !disableOwnerOverlay.\n            Mutually exclusive with reply composer (owner vs viewer modes).\n            renderOwnerOverlay slot wins as full takeover. */}\n        {ownerOverlayMounted ? (\n          renderOwnerOverlay ? (\n            renderOwnerOverlay(currentStory!, currentItem!, slotHelpers)\n          ) : (\n            <OwnerOverlay\n              story={currentStory!}\n              item={currentItem!}\n              onLoadViewers={onLoadViewers}\n              labels={labels}\n            />\n          )\n        ) : null}\n\n        {/* v0.3.5 — kebab button now lives INSIDE ViewerHeader's right cluster\n            (passed via the `onKebabClick` prop). The standalone absolute\n            block that used to sit at right-16 top-4 is gone — caused\n            overlap risks with the close button on small viewports. */}\n\n        {/* v0.2.0 — kebab bottom-sheet panel (shared by engagement-overlay\n            kebab + header fallback). Rendered only when kebabOpen + items\n            non-empty. */}\n        <KebabPanel\n          open={kebabOpen}\n          items={kebabItems}\n          onClose={() => setKebabOpen(false)}\n          labels={labels}\n        />\n\n        {/* v0.3.0 — comments panel. Always mounted so consumer-side state\n            inside `renderCommentsPanel` (typically CommentThread's draft\n            composer) persists across open/close cycles. Hidden via\n            translate-y when closed. Mount-gated by viewerMode + !disableComments\n            so non-viewer modes / opt-outs don't ship the panel at all. */}\n        {!!viewerMode && viewerMode === \"viewer\" && !disableComments ? (\n          <CommentsPanel\n            story={currentStory}\n            item={currentItem}\n            open={commentsOpen}\n            onClose={() => setCommentsOpen(false)}\n            labels={labels}\n          >\n            {renderCommentsPanel?.(currentStory, currentItem, {\n              ...slotHelpers,\n              isCommentsOpen: commentsOpen,\n              closeCommentsPanel: () => setCommentsOpen(false),\n            })}\n          </CommentsPanel>\n        ) : null}\n\n        {/* v0.3.1 — share panel. Same mount gate as comments (viewer mode +\n            !disableSharePanel). Always-mounted so consumer-side state\n            (typically <ShareMenu />'s search query) persists across\n            open/close cycles. */}\n        {!!viewerMode && viewerMode === \"viewer\" && !disableSharePanel ? (\n          <SharePanel\n            story={currentStory}\n            item={currentItem}\n            open={shareOpen}\n            onClose={() => setShareOpen(false)}\n            labels={labels}\n          >\n            {renderSharePanel?.(currentStory, currentItem, {\n              ...slotHelpers,\n              isShareOpen: shareOpen,\n              closeSharePanel: () => setShareOpen(false),\n            })}\n          </SharePanel>\n        ) : null}\n        </div>{/* v0.4.0 — close incoming face */}\n        </div>{/* v0.4.0 — close cube rotator */}\n      </div>\n    </ViewerShell>\n  );\n}\n\nconst StoryViewer = memo(StoryViewerInner);\nStoryViewer.displayName = \"StoryViewer\";\n\nexport { StoryViewer };\n",
      "type": "registry:component",
      "target": "components/story-viewer/story-viewer.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/index.ts",
      "content": "export { StoryViewer } from \"./story-viewer\";\n\nexport {\n  storyViewerReducer,\n  useStoryViewerState,\n  type UseStoryViewerStateOptions,\n  type UseStoryViewerStateResult,\n} from \"./hooks/use-story-viewer-state\";\n\nexport {\n  useStoryProgress,\n  type UseStoryProgressOptions,\n} from \"./hooks/use-story-progress\";\n\nexport {\n  useStoryKeyboardNav,\n  type UseStoryKeyboardNavOptions,\n} from \"./hooks/use-story-keyboard-nav\";\n\nexport type {\n  // v0.1 core\n  Story,\n  StoryItem,\n  StoryItemLink,\n  StoryViewerProps,\n  StoryViewerHandle,\n  StoryViewerLabels,\n  ResolvedStoryViewer01Labels,\n  StoryViewerDelta,\n  StoryViewerLocalAction,\n  RenderItemContext,\n  Subscribe,\n  Unsubscribe,\n  // v0.2.0 — role / permissions\n  StoryViewerMode,\n  StoryViewerPermissions,\n  StoryPermissionAction,\n  // v0.2.0 — engagement realtime + actions\n  StoryEngagementDelta,\n  StoryEngagementLocalAction,\n  StoryEngagementAction,\n  StoryEngagementActionAlign,\n  StoryEngagementReactionKind,\n  StoryEngagementBarLabels,\n  // v0.2.0 — owner overlay + reactors\n  ViewerListItem,\n  StoryReactorProfile,\n  // v0.2.0 — DM composer / reply\n  StoryCurrentUser,\n  StoryReplyComposerLabels,\n  // v0.2.0 — kebab\n  StoryKebabMenuItem,\n  // v0.2.0 — slot helpers\n  StoryViewerSlotHelpers,\n} from \"./types\";\n\nexport { DEFAULT_STORY_VIEWER_LABELS } from \"./types\";\n\n",
      "type": "registry:component",
      "target": "components/story-viewer/index.ts"
    },
    {
      "path": "src/registry/components/media/story-viewer/types.ts",
      "content": "import type { ElementType, ReactNode } from \"react\";\n\n// ─── Data shapes ────────────────────────────────────────────────────────\n\n/**\n * v0.2.0 — optional link CTA on a story item. Surfaces as a bottom button\n * (per Q-V9 lock). Custom CTA label via `cta`; otherwise defaults to\n * `labels.openLink` (\"Open link\").\n */\nexport interface StoryItemLink {\n  url: string;\n  /** Custom CTA label. Defaults to `labels.openLink`. */\n  cta?: string;\n}\n\nexport interface StoryItem {\n  id: string;\n  type: \"image\" | \"video\";\n  src: string;\n  /** Display duration in seconds. Default 5 for images; for videos, viewer reads the actual duration via onLoadedMetadata. */\n  duration?: number;\n  /**\n   * v0.2.0 — optional link CTA rendered as a bottom button above the\n   * engagement overlay. Polymorphic root via {@link StoryViewerProps.linkComponent}.\n   */\n  link?: StoryItemLink;\n}\n\n/**\n * v0.2.0 — single viewer entry for the owner-mode viewers list. Relaxed\n * fields (parallel to engagement-bar's EngagementLikerProfile). Eager\n * seed via {@link Story.viewers}; lazy fetch via\n * {@link StoryViewerProps.onLoadViewers}.\n */\nexport interface ViewerListItem {\n  id: string;\n  name: string;\n  avatar?: string;\n  /** ISO date of when this user viewed the story. Optional. */\n  viewedAt?: string;\n}\n\nexport interface Story {\n  id: string;\n  /** Stable user id — useful for cross-rail wiring. */\n  userId: string;\n  username: string;\n  avatar?: string;\n  items: StoryItem[];\n  hasUnread?: boolean;\n  /** ISO date string (matches the pro-ui convention from event-card / news-card). */\n  createdAt: string;\n  /**\n   * v0.2.0 — eager view count for the owner overlay. Cheap byte (single number);\n   * the user list itself is lazy via {@link StoryViewerProps.onLoadViewers}.\n   */\n  viewerCount?: number;\n  /**\n   * v0.2.0 — optional eager seed for the viewers list (hybrid Q-V5 lock). If\n   * present, the owner overlay renders these immediately without waiting for\n   * `onLoadViewers`. If absent, the overlay shows the count + a tap target that\n   * triggers `onLoadViewers` on first expand.\n   */\n  viewers?: ViewerListItem[];\n}\n\n// ─── Realtime contract — viewer-state stream (v0.1.x; preserved) ─────────\n\nexport type Unsubscribe = () => void;\nexport type Subscribe<TDelta> = (handler: (delta: TDelta) => void) => Unsubscribe;\n\nexport type StoryViewerDelta =\n  | { kind: \"story-added\"; story: Story; position?: \"start\" | \"end\" }\n  | { kind: \"story-removed\"; storyId: string }\n  | { kind: \"item-added\"; storyId: string; item: StoryItem; position?: \"start\" | \"end\" }\n  | { kind: \"item-removed\"; storyId: string; itemId: string }\n  | { kind: \"story-viewed\"; storyId: string };\n\nexport type StoryViewerLocalAction =\n  | { kind: \"add-story\"; story: Story; position?: \"start\" | \"end\" }\n  | { kind: \"remove-story\"; storyId: string }\n  | { kind: \"add-item\"; storyId: string; item: StoryItem; position?: \"start\" | \"end\" }\n  | { kind: \"remove-item\"; storyId: string; itemId: string }\n  | { kind: \"patch-story\"; storyId: string; partial: Partial<Story> }\n  | { kind: \"subscribe-delta\"; delta: StoryViewerDelta }\n  | { kind: \"reset\"; next: Story[] };\n\n// ─── v0.2.0 — engagement realtime (SEPARATE stream from StoryViewerDelta) ──\n//\n// Per Q-V3 lock: post-card precedent has separate `engagementSubscribe` +\n// `commentSubscribe` streams. story-viewer mirrors with two streams:\n// the existing `subscribe` (StoryViewerDelta — story-list mutations) and the\n// new `engagementSubscribe` (StoryEngagementDelta — engagement events).\n// Per Q-V16 lock: existing `subscribe` prop name preserved (asymmetric but\n// zero v0.1 breakage).\n\nexport type StoryEngagementDelta =\n  | { kind: \"like-changed\"; storyId: string; itemId: string; count: number; liked?: boolean }\n  | { kind: \"reaction-changed\"; storyId: string; itemId: string; reactionKind?: string | null; count: number }\n  | { kind: \"reply-added\"; storyId: string; itemId: string; replyId: string }\n  | { kind: \"viewer-added\"; storyId: string; viewer: ViewerListItem }\n  | { kind: \"view-count-changed\"; storyId: string; count: number };\n\nexport type StoryEngagementLocalAction =\n  | { kind: \"like-toggle\"; storyId: string; itemId: string; nextLiked: boolean }\n  | { kind: \"reaction-select\"; storyId: string; itemId: string; reactionKind: string | null }\n  | { kind: \"subscribe-delta\"; delta: StoryEngagementDelta }\n  | { kind: \"reset\"; storyId: string; itemId: string };\n\n// ─── v0.2.0 — permissions (mirrors post-card v0.3.0 resolver) ─────────\n//\n// Per Q-V10 lock: resolution order verbatim from post-card:\n//   1. canPerformAction(action, story, item) returning true|false — wins\n//   2. permissions[canX] per-field — false denies, true allows\n//   3. viewerMode-derived defaults from PERMISSION_DEFAULTS_BY_MODE\n//   4. (legacy v0.1 mode short-circuits in defaultStoryKebabActions when\n//      all 3 role-aware inputs are undefined)\n\nexport type StoryViewerMode = \"owner\" | \"viewer\";\n\n/**\n * Discriminator for the universal `canPerformAction` predicate.\n *\n * - Owner-side: `saveToHighlights`, `deleteStory`, `shareToFeed`, `seeViewers`\n * - Viewer-side: `react`, `reply`, `share`, `dm`, `report`, `blockAuthor`, `muteAuthor`\n * - Orthogonal: `moderate` (per Q-V11 lock — never auto-derived from viewerMode)\n */\nexport type StoryPermissionAction =\n  | \"saveToHighlights\"\n  | \"deleteStory\"\n  | \"shareToFeed\"\n  | \"seeViewers\"\n  | \"react\"\n  | \"reply\"\n  | \"share\"\n  | \"dm\"\n  | \"report\"\n  | \"blockAuthor\"\n  | \"muteAuthor\"\n  | \"moderate\";\n\n/**\n * Per-action permissions matrix. Overrides the `viewerMode`-derived defaults.\n * Resolution order documented in {@link StoryViewerMode}.\n *\n * `canModerate` defaults to `false` in BOTH viewer modes — moderators must be\n * opted in explicitly by the host's permission resolver (mirrors post-card).\n *\n * Note: `canDM` casing intentional (DM as an acronym; not `canDm`). The\n * `actionToPermissionKey` helper in `lib/permissions.ts` special-cases this.\n */\nexport interface StoryViewerPermissions {\n  // owner-side\n  canSaveToHighlights?: boolean;\n  canDeleteStory?: boolean;\n  canShareToFeed?: boolean;\n  canSeeViewers?: boolean;\n  // viewer-side\n  canReact?: boolean;\n  canReply?: boolean;\n  canShare?: boolean;\n  canDM?: boolean;\n  canReport?: boolean;\n  canBlockAuthor?: boolean;\n  canMuteAuthor?: boolean;\n  // orthogonal (default false in BOTH modes — explicit-only, never auto-derived)\n  canModerate?: boolean;\n}\n\n// ─── v0.2.0 — inline-copies from engagement-bar (F-S1 Bug 3 fix) ──────\n//\n// engagement-bar is in `data/` category; story-viewer is in `media/`.\n// Cross-category `/types` imports do NOT survive the shadcn 4.6.0 path\n// rewriter (mangles to wrong-slug or invalid `<cat>/` prefix). Therefore\n// these shapes are INLINE-COPIED — kept structurally identical to upstream.\n//\n// SYNC WARNING: if @ilinxa/engagement-bar changes any of these shapes\n// in a future bump, this file MUST be updated manually. The upstream\n// definitions live at src/registry/components/data/engagement-bar/types.ts.\n//\n// NOTE — orphan arms: `kind: \"bookmark\"` + `kind: \"view-count\"` arms are\n// part of the inline-copy for structural sync with upstream, but story-\n// viewer-01 never produces them (bookmark was removed in v0.3.0; view-\n// count lives in the owner overlay rather than the engagement bar). The\n// `labels.bookmark` / `labels.unbookmark` keys on `StoryEngagementBarLabels`\n// are similarly orphaned. Kept as-is so that an upstream EngagementAction\n// passed through a slot still type-checks.\n\nexport type StoryEngagementActionAlign = \"left\" | \"right\" | \"auto\";\n\n/**\n * Inlined from engagement-bar's `EngagementReactionKind` (v0.3.x).\n * Used by the reaction action in {@link StoryEngagementAction}.\n */\nexport interface StoryEngagementReactionKind {\n  /** Stable identifier matching backend payloads (e.g. `\"love\"`, `\"laugh\"`). */\n  key: string;\n  /** Host-supplied icon node (lucide / emoji / image). Library does not ship reaction icons. */\n  icon: ReactNode;\n  /** Localized human label — used in picker tooltip + aria-label. */\n  label: string;\n  /** Seed tally for this kind. */\n  count: number;\n  /** Optional tint applied to the icon when this is the viewer's current reaction. */\n  color?: string;\n}\n\n/**\n * Inlined from engagement-bar's `EngagementAction` discriminated union (v0.3.x).\n * Strict — no extra fields per kind.\n */\nexport type StoryEngagementAction =\n  | {\n      kind: \"like\";\n      count: number;\n      liked?: boolean;\n      onToggle?: (next: boolean) => void;\n      onCountClick?: () => void;\n      align?: StoryEngagementActionAlign;\n    }\n  | {\n      kind: \"comment\";\n      count?: number;\n      onClick?: () => void;\n      align?: StoryEngagementActionAlign;\n    }\n  | {\n      kind: \"share\";\n      count?: number;\n      onClick?: () => void;\n      align?: StoryEngagementActionAlign;\n    }\n  | {\n      kind: \"bookmark\";\n      bookmarked?: boolean;\n      onToggle?: (next: boolean) => void;\n      align?: StoryEngagementActionAlign;\n    }\n  | {\n      kind: \"view-count\";\n      count: number;\n      align?: StoryEngagementActionAlign;\n    }\n  | {\n      kind: \"custom\";\n      id: string;\n      label: string;\n      icon?: ReactNode;\n      active?: boolean;\n      onClick?: () => void;\n      align?: StoryEngagementActionAlign;\n    }\n  | {\n      kind: \"reaction\";\n      kinds: StoryEngagementReactionKind[];\n      viewerReaction?: string | null;\n      onSelect?: (kind: string | null) => void;\n      clearOnTap?: boolean;\n      align?: StoryEngagementActionAlign;\n    };\n\n/**\n * Inlined from engagement-bar's `EngagementBarLabels` (v0.3.x; 13 keys).\n * Used as a nested forward inside {@link StoryViewerLabels.engagementLabels}.\n */\nexport interface StoryEngagementBarLabels {\n  like?: string;\n  unlike?: string;\n  openLikersPanel?: string;\n  comment?: string;\n  share?: string;\n  bookmark?: string;\n  unbookmark?: string;\n  viewCount?: string;\n  react?: string;\n  removeReaction?: string;\n  openReactionsPanel?: string;\n  reactionPickerLabel?: string;\n  formatCount?: (n: number) => string;\n}\n\n// ─── v0.2.0 — inline-copies from comment-thread (F-S1 Bug 3 fix) ─────\n//\n// CommentThreadCurrentUser shape — small enough that a local minimal type\n// matches the upstream contract structurally without needing the full inline-copy.\n// CommentComposer is called directly from `parts/reply-composer.tsx` using\n// its component import (cross-cat absolute-with-suffix path — works through\n// the rewriter for components).\n\nexport interface StoryCurrentUser {\n  id: string;\n  name: string;\n  avatar?: string;\n}\n\n/**\n * Inlined subset of CommentThreadLabels (the 3 composer-relevant keys).\n * Used as a nested forward inside {@link StoryViewerLabels.commentLabels}.\n */\nexport interface StoryReplyComposerLabels {\n  composerPlaceholder?: string;\n  composerSend?: string;\n  composerCancel?: string;\n}\n\n// ─── v0.2.0 — reactor list (parallel to viewers list) ────────────────────\n\nexport interface StoryReactorProfile {\n  id: string;\n  name?: string;\n  avatar?: string;\n  reactionKind?: string;\n}\n\n// ─── Render-prop helpers ────────────────────────────────────────────────\n\nexport interface RenderItemContext {\n  storyIndex: number;\n  itemIndex: number;\n  isPaused: boolean;\n  isMuted: boolean;\n}\n\n/**\n * v0.2.0 — shared helper context passed to all renderXxx slots (except renderItem).\n */\nexport interface StoryViewerSlotHelpers {\n  cursor: { storyIndex: number; itemIndex: number };\n  isPaused: boolean;\n  isMuted: boolean;\n  setPaused: (paused: boolean) => void;\n  setMuted: (muted: boolean) => void;\n  goToPrevItem: () => void;\n  goToNextItem: () => void;\n  goToPrevStory: () => void;\n  goToNextStory: () => void;\n  onClose: () => void;\n  labels: ResolvedStoryViewer01Labels;\n}\n\n// ─── i18n ───────────────────────────────────────────────────────────────\n\nexport interface StoryViewerLabels {\n  // v0.1 (preserved)\n  /** aria-label for the modal. Default: \"Story viewer\". */\n  viewerLabel?: string;\n  /** aria-label for the play button (when paused). Default: \"Play\". */\n  play?: string;\n  /** aria-label for the pause button (when playing). Default: \"Pause\". */\n  pause?: string;\n  /** aria-label for the mute button (when audio is on). Default: \"Mute\". */\n  mute?: string;\n  /** aria-label for the unmute button (when audio is off). Default: \"Unmute\". */\n  unmute?: string;\n  /** aria-label for the close button. Default: \"Close\". */\n  close?: string;\n  /** aria-label for the previous-story arrow. Default: \"Previous story\". */\n  prevStory?: string;\n  /** aria-label for the next-story arrow. Default: \"Next story\". */\n  nextStory?: string;\n  /** Format the relative time shown in the header. */\n  formatTime?: (date: Date) => string;\n  /** Default alt text for image items. */\n  itemImageAlt?: (story: Story, itemIndex: number, totalItems: number) => string;\n\n  // v0.2.0 — engagement overlay\n  /** aria-label on the reaction picker popover. Default: \"Pick a reaction\". */\n  reactionPickerLabel?: string;\n\n  // v0.2.0 — reply composer\n  /** Function so hosts can interpolate `${story.username}`. Default: `Reply to ${story.username}…`. */\n  replyComposerPlaceholder?: (story: Story) => string;\n  /** Default: \"Send\". */\n  replyComposerSend?: string;\n  /** Default: \"Cancel\". */\n  replyComposerCancel?: string;\n\n  // v0.2.0 — owner overlay\n  /** Function so hosts can pluralize / localize. Default: `${count} views · ${time}`. */\n  viewerCountLabel?: (count: number, time: string) => string;\n  /** Default: \"Viewers\". */\n  viewersHeading?: string;\n  /** Function so hosts can pluralize. Default: `+${count} more`. */\n  viewersMoreLabel?: (count: number) => string;\n\n  // v0.2.0 — owner kebab\n  /** Default: \"Save to highlights\". */\n  saveToHighlights?: string;\n  /** Default: \"Remove from highlights\". */\n  unsaveFromHighlights?: string;\n  /** Default: \"Delete story\". */\n  deleteStory?: string;\n  /** Default: \"Share to feed\". */\n  shareToFeed?: string;\n\n  // v0.2.0 — viewer kebab\n  /** Default: \"Report\". */\n  report?: string;\n  /** Default: \"Block author\". */\n  blockAuthor?: string;\n  /** Default: \"Mute author\". */\n  muteAuthor?: string;\n  /** Default: \"Copy link\". */\n  copyLink?: string;\n  /** aria-label on the kebab button itself. Default: \"Story actions\". */\n  kebabAriaLabel?: string;\n\n  // v0.2.0 — link CTA\n  /** Default fallback when StoryItem.link.cta absent. Default: \"Open link\". */\n  openLink?: string;\n  /** v0.3.9 — aria-label on the link-drawer close button. Default: \"Close link\". */\n  linkCloseLabel?: string;\n\n  // v0.3.9 — engagement column toggle (heart inline with DM bar)\n  /** aria-label on the heart toggle when the column is collapsed. Default: \"Show reactions\". */\n  engagementShowLabel?: string;\n  /** aria-label on the heart toggle when the column is expanded. Default: \"Hide reactions\". */\n  engagementHideLabel?: string;\n\n  // v0.3.9 — reply / DM input aria\n  /** Function so hosts can interpolate username. Default: `Reply to ${story.username}`. Used as the textarea aria-label. */\n  replyAriaLabel?: (story: Story) => string;\n\n  // v0.3.0 — comments panel\n  /** Panel heading. Default: \"Comments\". */\n  commentsHeading?: string;\n  /** aria-label on the panel-close button. Default: \"Close comments\". */\n  commentsCloseLabel?: string;\n  /** Default empty-state copy when no renderCommentsPanel is supplied. */\n  commentsDefaultEmptyState?: string;\n\n  // v0.3.1 — share panel\n  /** Panel heading. Default: \"Share\". */\n  shareHeading?: string;\n  /** aria-label on the share-panel close button. Default: \"Close share\". */\n  shareCloseLabel?: string;\n  /** Default empty-state copy when no renderSharePanel is supplied. */\n  shareDefaultEmptyState?: string;\n\n  // v0.2.0 — nested forwards\n  /** Nested forward to engagement-bar labels (per Q-V14 — defensive callback contravariance applied). */\n  engagementLabels?: StoryEngagementBarLabels;\n  /** Nested forward to comment-thread composer labels (subset of CommentThreadLabels). */\n  commentLabels?: StoryReplyComposerLabels;\n}\n\n/**\n * The resolved labels shape after merging DEFAULT_STORY_VIEWER_LABELS with\n * the optional `labels` prop. All standalone keys are required; nested\n * forwards (`engagementLabels` / `commentLabels`) stay optional — hosts opt\n * in. Used as the prop type for every part that accepts labels.\n */\nexport type ResolvedStoryViewer01Labels = Required<\n  Omit<StoryViewerLabels, \"engagementLabels\" | \"commentLabels\">\n> &\n  Pick<StoryViewerLabels, \"engagementLabels\" | \"commentLabels\">;\n\nexport const DEFAULT_STORY_VIEWER_LABELS: Required<\n  Omit<\n    StoryViewerLabels,\n    \"engagementLabels\" | \"commentLabels\"\n  >\n> = {\n  // v0.1 (preserved)\n  viewerLabel: \"Story viewer\",\n  play: \"Play\",\n  pause: \"Pause\",\n  mute: \"Mute\",\n  unmute: \"Unmute\",\n  close: \"Close\",\n  prevStory: \"Previous story\",\n  nextStory: \"Next story\",\n  formatTime: (date: Date) =>\n    new Intl.DateTimeFormat(undefined, { hour: \"2-digit\", minute: \"2-digit\" }).format(date),\n  itemImageAlt: (story: Story, idx: number, total: number) =>\n    `${story.username}, story image ${idx + 1} of ${total}`,\n  // v0.2.0\n  reactionPickerLabel: \"Pick a reaction\",\n  replyComposerPlaceholder: (story: Story) => `Reply to ${story.username}…`,\n  replyComposerSend: \"Send\",\n  replyComposerCancel: \"Cancel\",\n  viewerCountLabel: (count: number, time: string) => `${count} views · ${time}`,\n  viewersHeading: \"Viewers\",\n  viewersMoreLabel: (count: number) => `+${count} more`,\n  saveToHighlights: \"Save to highlights\",\n  unsaveFromHighlights: \"Remove from highlights\",\n  deleteStory: \"Delete story\",\n  shareToFeed: \"Share to feed\",\n  report: \"Report\",\n  blockAuthor: \"Block author\",\n  muteAuthor: \"Mute author\",\n  copyLink: \"Copy link\",\n  kebabAriaLabel: \"Story actions\",\n  openLink: \"Open link\",\n  linkCloseLabel: \"Close link\",\n  engagementShowLabel: \"Show reactions\",\n  engagementHideLabel: \"Hide reactions\",\n  replyAriaLabel: (story: Story) => `Reply to ${story.username}`,\n  commentsHeading: \"Comments\",\n  commentsCloseLabel: \"Close comments\",\n  commentsDefaultEmptyState: \"No comments yet. Be the first to reply.\",\n  shareHeading: \"Share\",\n  shareCloseLabel: \"Close share\",\n  shareDefaultEmptyState: \"Share targets not loaded yet.\",\n};\n\n// ─── Imperative handle ──────────────────────────────────────────────────\n\nexport interface StoryViewerHandle {\n  // v0.1 (preserved)\n  /** Jump to a specific story by index (item index resets to 0). */\n  goToStory: (index: number) => void;\n  /** Jump to a specific item within the current story. */\n  goToItem: (index: number) => void;\n  /** Force pause/resume from outside (matches the in-component pause state). */\n  setPaused: (paused: boolean) => void;\n  /** Read current navigation cursor. */\n  getCursor: () => { storyIndex: number; itemIndex: number };\n  /** Read current stories snapshot (post-realtime patches). */\n  getCurrentStories: () => Story[];\n  /** Re-seed external state push (always-uncontrolled escape hatch). */\n  reset: (next: Story[]) => void;\n  /** Surgical state edit. */\n  dispatch: (action: StoryViewerLocalAction) => void;\n\n  // v0.2.0 additions (6 new) — escape hatches that fire handlers without\n  // consulting the permissions matrix. The matrix gates UI affordances; the\n  // handle is the programmatic bypass. If the handler isn't wired, no-op.\n  /** Force mute/unmute from outside (parity with setPaused). */\n  setMuted: (muted: boolean) => void;\n  /** Programmatically toggle the like state — fires onLikeStory if wired. */\n  triggerLike: () => void;\n  /** Programmatically pick a reaction — fires onReactStory if wired. */\n  triggerReaction: (kind?: string) => void;\n  /** Focus the reply composer + optionally pre-fill content. */\n  triggerReply: (content?: string) => void;\n  /** Programmatically open the share affordance — fires onShareStory if wired. */\n  triggerShare: () => void;\n  /** Programmatically open the kebab dropdown. */\n  openKebab: () => void;\n}\n\n// ─── Props ──────────────────────────────────────────────────────────────\n\nexport interface StoryViewerProps {\n  // v0.1 (preserved)\n\n  /** Story list. **Mount-only** — subsequent prop changes are ignored; push updates via `ref.current.reset(next)` or `dispatch`. */\n  stories: Story[];\n  /** Which story to open (item always starts at 0). */\n  initialStoryIndex: number;\n  /** Controlled open state. */\n  isOpen: boolean;\n  /** Fires on Escape, backdrop click, close button, or auto-close at end of last story. */\n  onClose: () => void;\n  /** Imperative handle. */\n  ref?: React.Ref<StoryViewerHandle>;\n\n  // ─── Realtime ──────────────────────────────────────────────────────────\n\n  /** Story-list realtime stream. Memoize via useCallback over a stable channel ref. Mount-scoped. */\n  subscribe?: Subscribe<StoryViewerDelta>;\n  /** Fires for every story-list delta. */\n  onSubscribeDelta?: (delta: StoryViewerDelta) => void;\n\n  /**\n   * v0.2.0 — engagement realtime stream (SEPARATE from `subscribe`).\n   * Per Q-V16: asymmetric naming preserved for zero v0.1 breakage. The two\n   * streams are semantically different (story-list mutations vs engagement\n   * events) — the naming reflects that.\n   */\n  engagementSubscribe?: Subscribe<StoryEngagementDelta>;\n  /** v0.2.0 — fires for every engagement delta. */\n  onSubscribeEngagementDelta?: (delta: StoryEngagementDelta) => void;\n\n  // ─── Lifecycle callbacks (v0.1 preserved) ─────────────────────────────\n\n  /** Forward-completion only — fires when the user advances forward into the next story OR when the last item completes naturally. Does NOT fire on backward navigation (matches Instagram). */\n  onStoryViewed?: (storyId: string) => void;\n  /** Fires when an item completes naturally OR is navigated past forward. */\n  onItemViewed?: (storyId: string, itemId: string, itemIndex: number) => void;\n  /** Fires whenever the cursor moves (manual nav or auto-advance). */\n  onCursorChange?: (storyIndex: number, itemIndex: number) => void;\n  /** Fires synchronously immediately before `onClose()` when the auto-close triggers at end of the last story. */\n  onAutoCloseAtEnd?: () => void;\n\n  // ─── v0.2.0 — engagement handlers ─────────────────────────────────────\n\n  /** Fires on like toggle. Optimistic flow per Q-P5 lock — host can reject via reset. */\n  onLikeStory?: (storyId: string, itemId: string, nextLiked: boolean) => void;\n  /** Fires on reaction selection (or clear when reactionKind is null). */\n  onReactStory?: (storyId: string, itemId: string, reactionKind: string | null) => void;\n  /** Fires on share affordance. Host opens share-sheet / system share. */\n  onShareStory?: (storyId: string, itemId: string) => void;\n  // onBookmarkStory removed in v0.3.0 — stories are not bookmarkable.\n\n  // ─── v0.2.0 — DM composer (always-visible bottom input) ──────────────\n  //\n  // Per Instagram-canonical semantic: the always-visible bottom input is a\n  // DIRECT MESSAGE to the story author (content sent through to the author's\n  // chat alongside a snapshot of the current item). It is NOT a public\n  // comment — public comments live in the v0.3.0 comments panel (see below).\n\n  /**\n   * Fires on DM submit from the always-visible bottom input. Library does\n   * NOT optimistically render (DM is a chat message; the response surface\n   * lives outside the viewer entirely).\n   *\n   * The name `onAddReply` is preserved from v0.2.0 for back-compat —\n   * semantically equivalent to `onSendDirectMessage`.\n   */\n  onAddReply?: (storyId: string, itemId: string, content: string) => Promise<void> | void;\n\n  // ─── v0.2.0 — owner overlay ───────────────────────────────────────────\n\n  /** Lazy-fetch viewers when owner taps the view-count chip. Returns ViewerListItem[]. */\n  onLoadViewers?: (storyId: string) => Promise<ViewerListItem[]>;\n\n  // ─── v0.2.0 — role-aware mode (opt-in per Q-V11 lock) ─────────────────\n\n  /**\n   * Two-mode toggle for role-aware affordances. `undefined` = v0.1 legacy\n   * mode (no engagement overlay, no reply composer, no owner overlay, no\n   * kebab). No auto-derivation from `currentUser.id === story.userId` — host\n   * explicitly picks the mode to keep the library neutral across identity\n   * models (mirrors post-card precedent).\n   */\n  viewerMode?: StoryViewerMode;\n  /** Per-action permissions matrix. Overrides `viewerMode`-derived defaults. */\n  permissions?: StoryViewerPermissions;\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: StoryPermissionAction,\n    story: Story,\n    item: StoryItem,\n  ) => boolean | undefined;\n\n  // ─── v0.2.0 — kebab ───────────────────────────────────────────────────\n\n  /** Full kebab takeover — bypasses default role-aware assembly + moderator section. */\n  kebabActions?: (story: Story, item: StoryItem) => StoryKebabMenuItem[];\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\", …)`). Library inserts a divider above the\n   * first returned item via `separatorBefore: true`. Bypassed when\n   * `kebabActions` is supplied (full-takeover wins).\n   */\n  moderatorActions?: (story: Story, item: StoryItem) => StoryKebabMenuItem[];\n\n  // ─── v0.2.0 — kebab item handlers (mirrors post-card v0.2.0) ──────\n  //\n  // Flattened onto Props (rather than a discrete StoryMutationHandlers\n  // interface) to match the post-card mutation-handler convention. Each\n  // handler is optional; the default kebab assembly in\n  // `lib/kebab.ts::defaultStoryKebabActions` consults the permissions matrix\n  // before deciding which items to render, so a wired handler only surfaces\n  // its kebab item when the matrix allows it.\n\n  /** Owner action — save the current story to highlights. */\n  onSaveToHighlights?: (storyId: string) => void;\n  /** Owner action — delete the current story. */\n  onDeleteStory?: (storyId: string) => void;\n  /** Owner action — re-share the current story to the main feed. */\n  onShareToFeed?: (storyId: string) => void;\n  /** Viewer action — report the current story to moderators. */\n  onReport?: (storyId: string) => void;\n  /** Viewer action — block the story author. */\n  onBlockAuthor?: (authorId: string) => void;\n  /** Viewer action — mute the story author. */\n  onMuteAuthor?: (authorId: string) => void;\n  /** Viewer action — copy a deep link to the current story. */\n  onCopyLink?: (storyId: string) => void;\n  /**\n   * Owner side — whether the current story is already saved to highlights.\n   * Drives the default kebab item label toggle\n   * (`saveToHighlights` ↔ `unsaveFromHighlights`).\n   */\n  isSavedToHighlights?: boolean;\n\n  // ─── v0.2.0 — engagement overlay configuration ────────────────────────\n\n  /**\n   * Reaction kinds for the engagement overlay's reaction action. Host supplies\n   * (no defaults — same Q-V4 lock as engagement-bar v0.3.x). When omitted,\n   * the reaction action is suppressed even if `canReact` resolves true.\n   */\n  reactionKinds?: StoryEngagementReactionKind[];\n  /**\n   * @notImplemented The engagement overlay has no reactions-preview surface,\n   * so nothing reads this. Passing it warns in development. Render reactors\n   * yourself via `renderEngagementOverlay`.\n   */\n  reactors?: StoryReactorProfile[];\n  /**\n   * @notImplemented Paired with {@link StoryViewerProps.reactors}; there is no\n   * reactor list to tap, so this is never called. Warns in development.\n   */\n  onLoadReactors?: (storyId: string, itemId: string) => Promise<StoryReactorProfile[]>;\n\n  // ─── v0.2.0 — viewer identity ─────────────────────────────────────────\n\n  /** Viewer identity. Drives reply composer avatar + isOwn check on default kebab. Absent → composer hidden, composerEmptyState rendered. */\n  currentUser?: StoryCurrentUser;\n  /** Rendered in place of the reply composer when currentUser is absent. */\n  composerEmptyState?: ReactNode;\n\n  // ─── v0.2.0 — link CTA + polymorphic root ─────────────────────────────\n\n  /** Polymorphic root for the StoryItem.link CTA button. Default `\"a\"`. */\n  linkComponent?: ElementType;\n  /** Fired when the link CTA is tapped. Overrides default href navigation if provided. */\n  onLinkClick?: (storyId: string, itemId: string, url: string) => void;\n\n  // ─── v0.2.2 — author tap-target ───────────────────────────────────────\n\n  /**\n   * v0.2.2 — fires when the avatar + username strip in the header is\n   * tapped. When set, the strip renders as a `<button>` (or\n   * `authorComponent` if provided) instead of a static `<div>`, with a\n   * subtle hover opacity to telegraph affordance. Common use: navigate to\n   * the author's profile.\n   */\n  onAuthorClick?: (story: Story) => void;\n  /**\n   * v0.2.2 — polymorphic root for the author tap-target. Default:\n   * `\"button\"` when `onAuthorClick` is set, otherwise the strip stays a\n   * static `<div>`. Pass `\"a\"` / Next.js `<Link>` / react-router `<Link>`\n   * etc. for href-based nav — the root receives `onClick`, `className`,\n   * and the avatar + name as children; manage `href` inside the custom\n   * component if needed. F-cross-13 safe (polymorphic component\n   * identifiers are not touched by the rewriter).\n   */\n  authorComponent?: ElementType;\n\n  // ─── v0.2.0 — long-press pause ────────────────────────────────────────\n\n  /** Long-press hold threshold in ms before pause kicks in. Default 200 (Instagram-feel). */\n  longPressThresholdMs?: number;\n\n  // ─── Slots (v0.1) ─────────────────────────────────────────────────────\n\n  /** Custom item renderer. When provided, replaces the default branching on item.type. */\n  renderItem?: (item: StoryItem, context: RenderItemContext) => ReactNode;\n\n  // ─── v0.2.0 render slots (7 new connective seams) ─────────────────────\n\n  /** Replace the avatar/name/time/buttons header strip. */\n  renderHeader?: (story: Story, item: StoryItem, helpers: StoryViewerSlotHelpers) => ReactNode;\n  /** Replace the segmented progress bars. */\n  renderProgress?: (\n    items: StoryItem[],\n    currentItemIndex: number,\n    progress: number,\n    helpers: StoryViewerSlotHelpers,\n  ) => ReactNode;\n  /** Replace the desktop ← → nav arrows. */\n  renderNavArrows?: (helpers: StoryViewerSlotHelpers) => ReactNode;\n  /** Replace the touch nav tap-zone strip. */\n  renderTapZones?: (helpers: StoryViewerSlotHelpers) => ReactNode;\n  /** Replace the entire engagement overlay (default: <EngagementBar variant=\"stacked\">). */\n  renderEngagementOverlay?: (story: Story, item: StoryItem, helpers: StoryViewerSlotHelpers) => ReactNode;\n  /** Replace the reply composer. */\n  renderReplyComposer?: (story: Story, item: StoryItem, helpers: StoryViewerSlotHelpers) => ReactNode;\n  /** Replace the owner overlay (view-count + viewers list). */\n  renderOwnerOverlay?: (story: Story, item: StoryItem, helpers: StoryViewerSlotHelpers) => ReactNode;\n  /**\n   * v0.3.0 — replace the comments-panel content. When the panel is opened\n   * (tapping the comment icon in the engagement overlay), the visual content\n   * shrinks toward the top and this slot's return is rendered inside the\n   * bottom sheet. Hosts typically mount `<CommentThread />` here with\n   * per-item comments + `onAddComment` + `onLoadMore` wired.\n   *\n   * When omitted, the panel renders a thin default surface (heading +\n   * \"drop a comment\" hint + `composerEmptyState` fallback) — usable, but\n   * the canonical pattern is to provide CommentThread.\n   */\n  renderCommentsPanel?: (\n    story: Story,\n    item: StoryItem,\n    helpers: StoryViewerSlotHelpers & {\n      isCommentsOpen: boolean;\n      closeCommentsPanel: () => void;\n    },\n  ) => ReactNode;\n  /**\n   * v0.3.1 — replace the share-panel content. When the panel is opened\n   * (tapping the share icon in the engagement overlay), the visual content\n   * shrinks toward the top and this slot's return is rendered inside the\n   * bottom sheet. Hosts typically mount `<ShareMenu />` (from\n   * `@ilinxa/engagement-bar`) here with recent-shareable-users + onShareTo\n   * wired.\n   *\n   * When omitted, the panel renders a thin default empty-state surface.\n   */\n  renderSharePanel?: (\n    story: Story,\n    item: StoryItem,\n    helpers: StoryViewerSlotHelpers & {\n      isShareOpen: boolean;\n      closeSharePanel: () => void;\n    },\n  ) => ReactNode;\n\n  // ─── v0.2.0 disable opt-outs (8 new) ──────────────────────────────────\n\n  /** Suppress the touch tap-zone strip entirely. Keyboard + nav arrows + header pause still work. */\n  disableTapZones?: boolean;\n  /** Suppress the keyboard navigation hook entirely. */\n  disableKeyboardNav?: boolean;\n  /** Suppress the desktop ← → nav arrows. */\n  disableNavArrows?: boolean;\n  /** Suppress the auto-close at end of last story. `onAutoCloseAtEnd` still fires; viewer stays open. */\n  disableAutoClose?: boolean;\n  /** Suppress the segmented progress bars (timer still drives auto-advance). */\n  disableProgressBars?: boolean;\n  /** Suppress the engagement overlay entirely (kebab falls back to header right cluster). */\n  disableEngagement?: boolean;\n  /** Suppress the reply composer entirely. */\n  disableReplyComposer?: boolean;\n  /** Suppress the owner overlay (view-count + viewers list) entirely. */\n  disableOwnerOverlay?: boolean;\n  /**\n   * v0.3.0 — suppress the comments panel entirely. When set, the comment\n   * action in the engagement overlay falls back to focusing the DM input\n   * (v0.2.x behavior). The comment icon is NOT hidden — `disableEngagement`\n   * is the way to remove the whole overlay.\n   */\n  disableComments?: boolean;\n  /**\n   * v0.3.1 — suppress the share panel entirely. When set, the share action\n   * in the engagement overlay falls back to firing `onShareStory` directly\n   * (the v0.2.x system-share behavior, no overlay panel).\n   */\n  disableSharePanel?: boolean;\n  /**\n   * v0.4.0 — suppress the Instagram-canonical 3D cube transition between\n   * stories. When set, story-to-story navigation is a hard cut (the v0.3.x\n   * behavior). Item-to-item navigation within a story is always a hard cut\n   * either way (matches Instagram). Default `false`.\n   */\n  disableStoryTransition?: boolean;\n  /**\n   * v0.4.0 — duration of the 3D cube transition in milliseconds. Default 400\n   * (matches Instagram's perceived feel). Ignored when `disableStoryTransition`\n   * is true.\n   */\n  storyTransitionDurationMs?: number;\n\n  // ─── Defaults ──────────────────────────────────────────────────────────\n  /** Default item duration in seconds when neither item.duration nor video metadata is available. Default 5. */\n  defaultItemDuration?: number;\n\n  // ─── i18n ──────────────────────────────────────────────────────────────\n  labels?: StoryViewerLabels;\n\n  // ─── Style overrides ──────────────────────────────────────────────────\n  /** Extra classes for the inner content wrapper. */\n  className?: string;\n  /** Extra classes for the modal content surface. */\n  contentClassName?: string;\n}\n\n// ─── v0.2.0 — kebab menu item shape ──────────────────────────────────────\n//\n// Local definition (NOT inline-copied from comment-thread's\n// `CommentMenuItem`) — keeps story-viewer's public surface independent\n// from comment-thread type evolution. Structurally compatible by design.\n\nexport interface StoryKebabMenuItem {\n  label: string;\n  onClick?: () => void;\n  icon?: ReactNode;\n  destructive?: boolean;\n  disabled?: boolean;\n  /**\n   * Render a divider line above this item. Used by the moderator section in\n   * `defaultStoryKebabActions` (first moderator item carries `separatorBefore: true`).\n   */\n  separatorBefore?: boolean;\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/types.ts"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/viewer-shell.tsx",
      "content": "import { Dialog, DialogContent, DialogTitle } from \"@/components/ui/dialog\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ViewerShellProps {\n  isOpen: boolean;\n  onOpenChange: (open: boolean) => void;\n  ariaLabel: string;\n  className?: string;\n  children: React.ReactNode;\n}\n\n/**\n * Modal surface — owns the v0.1 CSS animation seam and the Radix\n * Dialog wiring. v0.4.1 — hardened mobile sizing: shadcn's DialogContent\n * ships `sm:max-w-sm` which caps width at 384px on viewports ≥640px,\n * overriding our `max-w-none`. We push the desktop break to `md:` and\n * explicitly clear the `sm:` caps with `sm:max-w-none sm:rounded-none`\n * so the modal stays truly full-screen across the entire mobile range.\n */\nexport function ViewerShell({\n  isOpen,\n  onOpenChange,\n  ariaLabel,\n  className,\n  children,\n}: ViewerShellProps) {\n  return (\n    <Dialog open={isOpen} onOpenChange={onOpenChange}>\n      <DialogContent\n        aria-label={ariaLabel}\n        aria-describedby={undefined}\n        className={cn(\n          // Reset shadcn defaults: no card chrome, no padding, no ring, no rounded.\n          \"block gap-0 overflow-hidden bg-black p-0 text-white ring-0\",\n          // Mobile + intermediate (< md, i.e. < 768px): TRULY full screen.\n          // `!` (Tailwind v4 important suffix) forces our utilities to win\n          // over shadcn DialogContent's baked-in `max-w-[calc(100%-2rem)]`\n          // and `sm:max-w-sm`. Without `!`, CSS cascade order determines\n          // the winner — which on Turbopack/v4 builds shadcn wins, leaving\n          // the modal floating at 384px wide with page chrome bleeding\n          // through the dialog's `bg-black/10` overlay.\n          \"h-dvh! w-screen! max-w-none! rounded-none!\",\n          \"sm:h-dvh! sm:w-screen! sm:max-w-none! sm:rounded-none!\",\n          // Desktop (md+): portrait modal centered (kasder-exact 400×700).\n          // Same `!` strategy so md utilities cleanly win on desktop.\n          \"md:h-175! md:w-100! md:max-w-100! md:rounded-2xl!\",\n          // F-cross-13 — suppress the dialog's default close button across\n          // both producer (Radix v4 with showCloseButton toggle) and consumer\n          // (older Radix that always renders a direct-child button). The\n          // default close button (in both backends) is rendered as a direct\n          // `<button class=\"absolute …\">` child positioned top-right; the\n          // viewer ships its own close affordance inside ViewerHeader (which\n          // is wrapped in a div, so this selector doesn't hit it).\n          \"[&>button.absolute]:hidden\",\n          className,\n        )}\n      >\n        <DialogTitle className=\"sr-only\">{ariaLabel}</DialogTitle>\n        {children}\n      </DialogContent>\n    </Dialog>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/viewer-shell.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/progress-bars.tsx",
      "content": "import type { StoryItem } from \"../types\";\n\nexport interface ProgressBarsProps {\n  items: StoryItem[];\n  currentItemIndex: number;\n  /** Current item progress percentage (0–100). */\n  progress: number;\n}\n\nexport function ProgressBars({ items, currentItemIndex, progress }: ProgressBarsProps) {\n  return (\n    <div className=\"absolute left-0 right-0 top-0 z-20 flex gap-1 p-2\">\n      {items.map((item, idx) => {\n        const width =\n          idx < currentItemIndex\n            ? 100\n            : idx === currentItemIndex\n              ? progress\n              : 0;\n        return (\n          <div\n            key={item.id}\n            className=\"h-0.5 flex-1 overflow-hidden rounded-full bg-white/30\"\n            role=\"progressbar\"\n            aria-valuemin={0}\n            aria-valuemax={100}\n            aria-valuenow={Math.round(width)}\n          >\n            <div\n              className=\"h-full bg-white transition-[width] duration-100 ease-linear\"\n              style={{ width: `${width}%` }}\n            />\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/progress-bars.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/viewer-header.tsx",
      "content": "import { type ElementType } from \"react\";\nimport { MoreVertical, Pause, Play, Volume2, VolumeX, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport { Button } from \"@/components/ui/button\";\nimport type { ResolvedStoryViewer01Labels, Story, StoryItem } from \"../types\";\n\nexport interface ViewerHeaderProps {\n  story: Story;\n  item: StoryItem;\n  isPaused: boolean;\n  isMuted: boolean;\n  onTogglePause: () => void;\n  onToggleMute: () => void;\n  onClose: () => void;\n  formatTime: (date: Date) => string;\n  labels: ResolvedStoryViewer01Labels;\n  /**\n   * v0.2.2 — when set, the avatar + name strip becomes interactive (rendered\n   * as `authorComponent` if provided, else `<button type=\"button\">`).\n   */\n  onAuthorClick?: (story: Story) => void;\n  /**\n   * v0.2.2 — polymorphic root override for the author tap-target. Defaults\n   * to `\"button\"` when onAuthorClick is set, `\"div\"` otherwise.\n   */\n  authorComponent?: ElementType;\n  /**\n   * v0.3.5 — when set, the kebab button renders in the header right cluster\n   * (between mute and close). Tapping it fires this callback (the caller\n   * owns the bottom-sheet panel state).\n   */\n  onKebabClick?: () => void;\n}\n\nexport function ViewerHeader({\n  story,\n  item,\n  isPaused,\n  isMuted,\n  onTogglePause,\n  onToggleMute,\n  onClose,\n  formatTime,\n  labels,\n  onAuthorClick,\n  authorComponent,\n  onKebabClick,\n}: ViewerHeaderProps) {\n  const initials = story.username.slice(0, 2).toUpperCase();\n  const isInteractive = !!onAuthorClick || !!authorComponent;\n  const AuthorRoot: ElementType =\n    authorComponent ?? (onAuthorClick ? \"button\" : \"div\");\n  const authorRootProps: Record<string, unknown> = {\n    className: cn(\n      \"flex items-center gap-3 rounded-full text-left\",\n      isInteractive &&\n        \"cursor-pointer transition-opacity hover:opacity-80 focus-visible:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60\",\n    ),\n  };\n  if (onAuthorClick) {\n    authorRootProps.onClick = () => onAuthorClick(story);\n  }\n  if (AuthorRoot === \"button\") {\n    authorRootProps.type = \"button\";\n  }\n  return (\n    <div className=\"absolute left-0 right-0 top-4 z-20 flex items-center justify-between px-4 pt-2\">\n      <AuthorRoot {...authorRootProps}>\n        <Avatar className=\"h-10 w-10 border-2 border-white\">\n          {story.avatar ? <AvatarImage src={story.avatar} alt=\"\" /> : null}\n          <AvatarFallback>{initials}</AvatarFallback>\n        </Avatar>\n        <div>\n          <p className=\"text-sm font-semibold text-white\">{story.username}</p>\n          <p className=\"text-xs text-white/60\">\n            {formatTime(new Date(story.createdAt))}\n          </p>\n        </div>\n      </AuthorRoot>\n      <div className=\"flex items-center gap-2\">\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"h-11 w-11 md:h-9 md:w-9 text-white hover:bg-white/20 hover:text-white\"\n          onClick={onTogglePause}\n          aria-label={isPaused ? labels.play : labels.pause}\n        >\n          {isPaused ? <Play className=\"h-4 w-4\" /> : <Pause className=\"h-4 w-4\" />}\n        </Button>\n        {item.type === \"video\" ? (\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"h-11 w-11 md:h-9 md:w-9 text-white hover:bg-white/20 hover:text-white\"\n            onClick={onToggleMute}\n            aria-label={isMuted ? labels.unmute : labels.mute}\n            aria-pressed={isMuted}\n          >\n            {isMuted ? <VolumeX className=\"h-4 w-4\" /> : <Volume2 className=\"h-4 w-4\" />}\n          </Button>\n        ) : null}\n        {onKebabClick ? (\n          <Button\n            type=\"button\"\n            variant=\"ghost\"\n            size=\"icon\"\n            className=\"h-11 w-11 md:h-9 md:w-9 text-white hover:bg-white/20 hover:text-white\"\n            onClick={onKebabClick}\n            aria-label={labels.kebabAriaLabel}\n          >\n            <MoreVertical className=\"h-5 w-5\" />\n          </Button>\n        ) : null}\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"h-11 w-11 md:h-9 md:w-9 text-white hover:bg-white/20 hover:text-white\"\n          onClick={onClose}\n          aria-label={labels.close}\n        >\n          <X className=\"h-5 w-5\" />\n        </Button>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/viewer-header.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/tap-zones.tsx",
      "content": "import type { MouseEvent as ReactMouseEvent } from \"react\";\n\nexport interface TapZonesProps {\n  onPrev: () => void;\n  onTogglePause: () => void;\n  onNext: () => void;\n}\n\n/**\n * Three full-height columns (1/3 each) over the item content:\n * - left = previous item\n * - middle = pause toggle\n * - right = next item\n *\n * Lives at z-10 so the header (z-20) and nav arrows (z-20) remain clickable.\n * `tabIndex={-1}` because tap zones are touch affordances; keyboard users\n * use the arrow-key handlers instead. `onMouseDown` preventDefault blocks\n * the click-induced focus that would otherwise leave the focused button\n * trapped inside the `aria-hidden` wrapper (browser console warning).\n */\nfunction preventFocusOnPress(e: ReactMouseEvent<HTMLButtonElement>) {\n  e.preventDefault();\n}\n\nexport function TapZones({ onPrev, onTogglePause, onNext }: TapZonesProps) {\n  return (\n    <div className=\"absolute inset-0 z-10 flex\" aria-hidden=\"true\">\n      <button\n        type=\"button\"\n        className=\"h-full w-1/3 cursor-pointer\"\n        onClick={(e) => {\n          e.stopPropagation();\n          onPrev();\n        }}\n        onMouseDown={preventFocusOnPress}\n        tabIndex={-1}\n      />\n      <button\n        type=\"button\"\n        className=\"h-full w-1/3 cursor-pointer\"\n        onClick={(e) => {\n          e.stopPropagation();\n          onTogglePause();\n        }}\n        onMouseDown={preventFocusOnPress}\n        tabIndex={-1}\n      />\n      <button\n        type=\"button\"\n        className=\"h-full w-1/3 cursor-pointer\"\n        onClick={(e) => {\n          e.stopPropagation();\n          onNext();\n        }}\n        onMouseDown={preventFocusOnPress}\n        tabIndex={-1}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/tap-zones.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/nav-arrows.tsx",
      "content": "import { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport type { ResolvedStoryViewer01Labels } from \"../types\";\n\nexport interface NavArrowsProps {\n  canPrev: boolean;\n  onPrev: () => void;\n  onNext: () => void;\n  labels: ResolvedStoryViewer01Labels;\n}\n\n/**\n * Desktop-only story-level nav (← → between stories).\n * Hidden on mobile; tap zones cover same-story item nav there.\n */\nexport function NavArrows({ canPrev, onPrev, onNext, labels }: NavArrowsProps) {\n  const base =\n    \"absolute top-1/2 z-30 hidden h-12 w-12 -translate-y-1/2 rounded-full bg-white/10 text-white hover:bg-white/20 hover:text-white md:flex md:items-center md:justify-center\";\n  return (\n    <>\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        size=\"icon\"\n        className={cn(base, \"left-4\")}\n        onClick={(e) => {\n          e.stopPropagation();\n          onPrev();\n        }}\n        disabled={!canPrev}\n        aria-label={labels.prevStory}\n      >\n        <ChevronLeft className=\"h-8 w-8\" />\n      </Button>\n      <Button\n        type=\"button\"\n        variant=\"ghost\"\n        size=\"icon\"\n        className={cn(base, \"right-4\")}\n        onClick={(e) => {\n          e.stopPropagation();\n          onNext();\n        }}\n        aria-label={labels.nextStory}\n      >\n        <ChevronRight className=\"h-8 w-8\" />\n      </Button>\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/nav-arrows.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/item-view.tsx",
      "content": "// F-S1 lock: same-category cross-procomp via relative + specific-file path.\nimport { VideoPlayer } from \"../../video-player/video-player\";\nimport type {\n  RenderItemContext,\n  ResolvedStoryViewer01Labels,\n  Story,\n  StoryItem,\n} from \"../types\";\n\nexport interface ItemViewProps {\n  item: StoryItem;\n  story: Story;\n  totalItems: number;\n  cursor: { storyIndex: number; itemIndex: number };\n  isPaused: boolean;\n  isMuted: boolean;\n  onLoadedMetadata: (duration: number) => void;\n  onEnded: () => void;\n  renderItem?: (item: StoryItem, context: RenderItemContext) => React.ReactNode;\n  labels: ResolvedStoryViewer01Labels;\n}\n\nexport function ItemView({\n  item,\n  story,\n  totalItems,\n  cursor,\n  isPaused,\n  isMuted,\n  onLoadedMetadata,\n  onEnded,\n  renderItem,\n  labels,\n}: ItemViewProps) {\n  if (renderItem) {\n    return (\n      <div className=\"absolute inset-0 flex items-center justify-center\">\n        {renderItem(item, {\n          storyIndex: cursor.storyIndex,\n          itemIndex: cursor.itemIndex,\n          isPaused,\n          isMuted,\n        })}\n      </div>\n    );\n  }\n\n  if (item.type === \"image\") {\n    return (\n      <div className=\"absolute inset-0 flex items-center justify-center\">\n        <img\n          src={item.src}\n          alt={labels.itemImageAlt(story, cursor.itemIndex, totalItems)}\n          className=\"h-full w-full object-cover\"\n        />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"absolute inset-0\">\n      <VideoPlayer\n        key={item.id}\n        src={item.src}\n        isActive={!isPaused}\n        muted={isMuted}\n        loop={false}\n        autoPlay\n        controls={false}\n        objectFit=\"cover\"\n        className=\"h-full w-full\"\n        videoClassName=\"h-full w-full\"\n        onLoadedMetadata={onLoadedMetadata}\n        onEnded={onEnded}\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/item-view.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/hooks/use-story-viewer-state.ts",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useReducer,\n  useRef,\n  useState,\n} from \"react\";\nimport type {\n  Story,\n  StoryItem,\n  StoryViewerDelta,\n  StoryViewerLocalAction,\n  Subscribe,\n} from \"../types\";\n\n// ─── Pure helpers ────────────────────────────────────────────────────────────\n\nfunction insertStoryAtPosition(\n  state: Story[],\n  story: Story,\n  position: \"start\" | \"end\" | undefined,\n): Story[] {\n  if (position === \"start\") return [story, ...state];\n  return [...state, story]; // default \"end\" — cursor-stable for ID-anchored cursor\n}\n\nfunction removeStoryById(state: Story[], id: string): Story[] {\n  const next = state.filter((s) => s.id !== id);\n  return next.length === state.length ? state : next;\n}\n\nfunction patchStoryById(\n  state: Story[],\n  id: string,\n  patch: Partial<Story>,\n): Story[] {\n  let mutated = false;\n  const next = state.map((s) => {\n    if (s.id !== id) return s;\n    mutated = true;\n    return { ...s, ...patch };\n  });\n  return mutated ? next : state;\n}\n\nfunction addItemToStory(\n  state: Story[],\n  storyId: string,\n  item: StoryItem,\n  position: \"start\" | \"end\" | undefined,\n): Story[] {\n  let mutated = false;\n  const next = state.map((s) => {\n    if (s.id !== storyId) return s;\n    mutated = true;\n    const items = position === \"start\" ? [item, ...s.items] : [...s.items, item];\n    return { ...s, items };\n  });\n  return mutated ? next : state;\n}\n\nfunction removeItemFromStory(\n  state: Story[],\n  storyId: string,\n  itemId: string,\n): Story[] {\n  let mutated = false;\n  const next = state.map((s) => {\n    if (s.id !== storyId) return s;\n    const items = s.items.filter((i) => i.id !== itemId);\n    if (items.length === s.items.length) return s;\n    mutated = true;\n    return { ...s, items };\n  });\n  return mutated ? next : state;\n}\n\n// ─── Reducer ─────────────────────────────────────────────────────────────────\n\n/** Pure reducer — exported for hosts driving their own state. */\nexport function storyViewerReducer(\n  state: Story[],\n  action: StoryViewerLocalAction,\n): Story[] {\n  switch (action.kind) {\n    case \"add-story\":\n      return insertStoryAtPosition(state, action.story, action.position);\n    case \"remove-story\":\n      return removeStoryById(state, action.storyId);\n    case \"add-item\":\n      return addItemToStory(state, action.storyId, action.item, action.position);\n    case \"remove-item\":\n      return removeItemFromStory(state, action.storyId, action.itemId);\n    case \"patch-story\":\n      return patchStoryById(state, action.storyId, action.partial);\n    case \"subscribe-delta\": {\n      const d = action.delta;\n      switch (d.kind) {\n        case \"story-added\":\n          return insertStoryAtPosition(state, d.story, d.position);\n        case \"story-removed\":\n          return removeStoryById(state, d.storyId);\n        case \"item-added\":\n          return addItemToStory(state, d.storyId, d.item, d.position);\n        case \"item-removed\":\n          return removeItemFromStory(state, d.storyId, d.itemId);\n        case \"story-viewed\":\n          return patchStoryById(state, d.storyId, { hasUnread: false });\n      }\n      return state;\n    }\n    case \"reset\":\n      return action.next;\n  }\n}\n\n// ─── Internal cursor (ID-anchored) ──────────────────────────────────────────\n\ninterface CursorIds {\n  storyId: string;\n  itemId: string;\n}\n\n// ─── Hook ────────────────────────────────────────────────────────────────────\n\nexport interface UseStoryViewerStateOptions {\n  /** Captured ON MOUNT only — subsequent prop changes are ignored. */\n  initialStories: Story[];\n  /** Re-seeds cursor whenever (initialStoryIndex, isOpen) pair changes. */\n  initialStoryIndex: number;\n  /** Re-seeds cursor whenever (initialStoryIndex, isOpen) pair changes. */\n  isOpen: boolean;\n\n  subscribe?: Subscribe<StoryViewerDelta>;\n  onSubscribeDelta?: (delta: StoryViewerDelta) => void;\n\n  onStoryViewed?: (storyId: string) => void;\n  onItemViewed?: (storyId: string, itemId: string, itemIndex: number) => void;\n  onCursorChange?: (storyIndex: number, itemIndex: number) => void;\n  onAutoCloseAtEnd?: () => void;\n  onClose: () => void;\n  /**\n   * v0.2.0 — when true, the auto-close at end of the last story fires\n   * `onAutoCloseAtEnd` but DOES NOT call `onClose()`. The viewer stays\n   * open at the last item. Default false. Mirrors the `disableAutoClose`\n   * opt-out on StoryViewerProps.\n   */\n  disableAutoClose?: boolean;\n}\n\nexport interface UseStoryViewerStateResult {\n  stories: Story[];\n  /** Public derived cursor (indices for caller; IDs are internal). */\n  cursor: { storyIndex: number; itemIndex: number };\n  isPaused: boolean;\n  isMuted: boolean;\n  setPaused: React.Dispatch<React.SetStateAction<boolean>>;\n  setMuted: React.Dispatch<React.SetStateAction<boolean>>;\n  goToPrevItem: () => void;\n  goToNextItem: () => void;\n  goToPrevStory: () => void;\n  goToNextStory: () => void;\n  goToStoryIndex: (index: number) => void;\n  goToItemIndex: (index: number) => void;\n  reset: (next: Story[]) => void;\n  dispatch: React.Dispatch<StoryViewerLocalAction>;\n}\n\nexport function useStoryViewerState(\n  opts: UseStoryViewerStateOptions,\n): UseStoryViewerStateResult {\n  const {\n    initialStories,\n    initialStoryIndex,\n    isOpen,\n    subscribe,\n    onSubscribeDelta,\n    onStoryViewed,\n    onItemViewed,\n    onCursorChange,\n    onAutoCloseAtEnd,\n    onClose,\n    disableAutoClose = false,\n  } = opts;\n  const disableAutoCloseRef = useRef(disableAutoClose);\n  useEffect(() => {\n    disableAutoCloseRef.current = disableAutoClose;\n  });\n\n  const [stories, dispatch] = useReducer(storyViewerReducer, initialStories);\n\n  const initialStoriesRef = useRef(stories);\n  useEffect(() => {\n    initialStoriesRef.current = stories;\n  });\n\n  // Refs-mirror for callbacks so effect deps stay narrow.\n  const onSubscribeDeltaRef = useRef(onSubscribeDelta);\n  const onStoryViewedRef = useRef(onStoryViewed);\n  const onItemViewedRef = useRef(onItemViewed);\n  const onCursorChangeRef = useRef(onCursorChange);\n  const onAutoCloseAtEndRef = useRef(onAutoCloseAtEnd);\n  const onCloseRef = useRef(onClose);\n  useEffect(() => {\n    onSubscribeDeltaRef.current = onSubscribeDelta;\n    onStoryViewedRef.current = onStoryViewed;\n    onItemViewedRef.current = onItemViewed;\n    onCursorChangeRef.current = onCursorChange;\n    onAutoCloseAtEndRef.current = onAutoCloseAtEnd;\n    onCloseRef.current = onClose;\n  });\n\n  // Subscribe wiring (mount-scoped; identity-stable per host convention).\n  useEffect(() => {\n    if (!subscribe) return;\n    const unsub = subscribe((delta) => {\n      onSubscribeDeltaRef.current?.(delta);\n      dispatch({ kind: \"subscribe-delta\", delta });\n    });\n    return unsub;\n  }, [subscribe]);\n\n  // ─── Cursor state (ID-anchored) ───────────────────────────────────────────\n  // Lazy initializer — runs once at mount, derived from props.\n  const [cursorIds, setCursorIds] = useState<CursorIds>(() => {\n    const idx = Math.max(0, Math.min(initialStoryIndex, initialStories.length - 1));\n    const story = initialStories[idx];\n    return {\n      storyId: story?.id ?? \"\",\n      itemId: story?.items[0]?.id ?? \"\",\n    };\n  });\n\n  // Reset cursor when (initialStoryIndex, isOpen) pair changes.\n  // Read stories from ref to keep deps narrow.\n  useEffect(() => {\n    if (!isOpen) return;\n    const snap = initialStoriesRef.current;\n    const idx = Math.max(0, Math.min(initialStoryIndex, snap.length - 1));\n    const story = snap[idx];\n    if (!story) return;\n    setCursorIds({\n      storyId: story.id,\n      itemId: story.items[0]?.id ?? \"\",\n    });\n  }, [initialStoryIndex, isOpen]);\n\n  // Derive indices from IDs.\n  const cursor = useMemo(() => {\n    const storyIndex = Math.max(0, stories.findIndex((s) => s.id === cursorIds.storyId));\n    const story = stories[storyIndex];\n    const itemIndex = story\n      ? Math.max(0, story.items.findIndex((i) => i.id === cursorIds.itemId))\n      : 0;\n    return { storyIndex, itemIndex };\n  }, [stories, cursorIds]);\n\n  // Fire onCursorChange whenever derived indices change.\n  const lastCursorRef = useRef(cursor);\n  useEffect(() => {\n    const last = lastCursorRef.current;\n    if (last.storyIndex !== cursor.storyIndex || last.itemIndex !== cursor.itemIndex) {\n      lastCursorRef.current = cursor;\n      onCursorChangeRef.current?.(cursor.storyIndex, cursor.itemIndex);\n    }\n  }, [cursor]);\n\n  // ─── Pause / mute ────────────────────────────────────────────────────────\n  const [isPaused, setPaused] = useState(false);\n  const [isMuted, setMuted] = useState(true);\n\n  // Reset pause on item key change — React 19 pattern: setState during render\n  // when a derived prop changes. Keeps resume from sticking after auto-advance.\n  const itemKey = `${cursorIds.storyId}:${cursorIds.itemId}`;\n  const [renderedItemKey, setRenderedItemKey] = useState(itemKey);\n  if (renderedItemKey !== itemKey) {\n    setRenderedItemKey(itemKey);\n    setPaused(false);\n  }\n\n  // ─── Navigation helpers ──────────────────────────────────────────────────\n  const goToPrevItem = useCallback(() => {\n    const snap = initialStoriesRef.current;\n    const sIdx = snap.findIndex((s) => s.id === cursorIds.storyId);\n    if (sIdx < 0) return;\n    const story = snap[sIdx];\n    const iIdx = story.items.findIndex((i) => i.id === cursorIds.itemId);\n    if (iIdx > 0) {\n      setCursorIds({ storyId: story.id, itemId: story.items[iIdx - 1].id });\n      return;\n    }\n    if (sIdx > 0) {\n      const prevStory = snap[sIdx - 1];\n      const lastItem = prevStory.items[prevStory.items.length - 1];\n      if (lastItem) {\n        setCursorIds({ storyId: prevStory.id, itemId: lastItem.id });\n      }\n    }\n    // else: at the very start; no-op.\n  }, [cursorIds]);\n\n  const goToNextItem = useCallback(() => {\n    const snap = initialStoriesRef.current;\n    const sIdx = snap.findIndex((s) => s.id === cursorIds.storyId);\n    if (sIdx < 0) return;\n    const story = snap[sIdx];\n    const iIdx = story.items.findIndex((i) => i.id === cursorIds.itemId);\n    const item = story.items[iIdx];\n    if (item) {\n      onItemViewedRef.current?.(story.id, item.id, iIdx);\n    }\n    if (iIdx < story.items.length - 1) {\n      setCursorIds({ storyId: story.id, itemId: story.items[iIdx + 1].id });\n      return;\n    }\n    // At last item — advance to next story (forward).\n    onStoryViewedRef.current?.(story.id);\n    if (sIdx < snap.length - 1) {\n      const nextStory = snap[sIdx + 1];\n      const firstItem = nextStory.items[0];\n      if (firstItem) {\n        setCursorIds({ storyId: nextStory.id, itemId: firstItem.id });\n        return;\n      }\n    }\n    // No next story — auto-close (gated by disableAutoClose per v0.2.0).\n    onAutoCloseAtEndRef.current?.();\n    if (!disableAutoCloseRef.current) onCloseRef.current();\n  }, [cursorIds]);\n\n  const goToPrevStory = useCallback(() => {\n    const snap = initialStoriesRef.current;\n    const sIdx = snap.findIndex((s) => s.id === cursorIds.storyId);\n    if (sIdx <= 0) return;\n    const prevStory = snap[sIdx - 1];\n    const firstItem = prevStory.items[0];\n    if (firstItem) {\n      // Backward — does NOT fire onStoryViewed.\n      setCursorIds({ storyId: prevStory.id, itemId: firstItem.id });\n    }\n  }, [cursorIds]);\n\n  const goToNextStory = useCallback(() => {\n    const snap = initialStoriesRef.current;\n    const sIdx = snap.findIndex((s) => s.id === cursorIds.storyId);\n    if (sIdx < 0) return;\n    const story = snap[sIdx];\n    onStoryViewedRef.current?.(story.id);\n    if (sIdx < snap.length - 1) {\n      const nextStory = snap[sIdx + 1];\n      const firstItem = nextStory.items[0];\n      if (firstItem) {\n        setCursorIds({ storyId: nextStory.id, itemId: firstItem.id });\n        return;\n      }\n    }\n    onAutoCloseAtEndRef.current?.();\n    if (!disableAutoCloseRef.current) onCloseRef.current();\n  }, [cursorIds]);\n\n  const goToStoryIndex = useCallback((index: number) => {\n    const snap = initialStoriesRef.current;\n    const sIdx = Math.max(0, Math.min(index, snap.length - 1));\n    const target = snap[sIdx];\n    if (!target) return;\n    const currentSIdx = snap.findIndex((s) => s.id === cursorIds.storyId);\n    // Forward jump — fire onStoryViewed for the leaving story.\n    if (sIdx > currentSIdx && currentSIdx >= 0) {\n      onStoryViewedRef.current?.(snap[currentSIdx].id);\n    }\n    setCursorIds({\n      storyId: target.id,\n      itemId: target.items[0]?.id ?? \"\",\n    });\n  }, [cursorIds]);\n\n  const goToItemIndex = useCallback((index: number) => {\n    const snap = initialStoriesRef.current;\n    const sIdx = snap.findIndex((s) => s.id === cursorIds.storyId);\n    if (sIdx < 0) return;\n    const story = snap[sIdx];\n    const iIdx = Math.max(0, Math.min(index, story.items.length - 1));\n    const item = story.items[iIdx];\n    if (!item) return;\n    setCursorIds({ storyId: story.id, itemId: item.id });\n  }, [cursorIds]);\n\n  const reset = useCallback((next: Story[]) => {\n    dispatch({ kind: \"reset\", next });\n  }, []);\n\n  return {\n    stories,\n    cursor,\n    isPaused,\n    isMuted,\n    setPaused,\n    setMuted,\n    goToPrevItem,\n    goToNextItem,\n    goToPrevStory,\n    goToNextStory,\n    goToStoryIndex,\n    goToItemIndex,\n    reset,\n    dispatch,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/hooks/use-story-viewer-state.ts"
    },
    {
      "path": "src/registry/components/media/story-viewer/hooks/use-story-progress.ts",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\nexport interface UseStoryProgressOptions {\n  /** Viewer is mounted-and-open. When false, timer is fully stopped. */\n  isOpen: boolean;\n  /** When true, the timer pauses but accumulator is preserved (resume continues from same %). */\n  isPaused: boolean;\n  /** Compound key — typically `${storyId}:${itemId}`. Any change resets the accumulator. */\n  itemKey: string;\n  /** Total item duration in milliseconds. */\n  itemDurationMs: number;\n  /** Fires once when progress reaches 100. */\n  onComplete: () => void;\n  /** Tick interval. Default 50ms. */\n  tickMs?: number;\n}\n\n/**\n * Accumulator-based story progress timer.\n *\n * Tracks an `accumulatedMs` accumulator across pause/resume cycles so users\n * can pause for arbitrary durations without losing playback position.\n * Naive `Date.now() - startTime` recomputes (kasder's original pattern) silently\n * advance ~50ms per pause/resume cycle. Switched to `performance.now()` here\n * for monotonic clock + immunity to NTP / DST jumps.\n */\nexport function useStoryProgress({\n  isOpen,\n  isPaused,\n  itemKey,\n  itemDurationMs,\n  onComplete,\n  tickMs = 50,\n}: UseStoryProgressOptions) {\n  const [progress, setProgress] = useState(0);\n  const [renderedItemKey, setRenderedItemKey] = useState(itemKey);\n  const accumulatedRef = useRef(0);\n  const startTimeRef = useRef<number | null>(null);\n  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);\n  const completedRef = useRef(false);\n  const onCompleteRef = useRef(onComplete);\n\n  useEffect(() => {\n    onCompleteRef.current = onComplete;\n  });\n\n  // Reset on item change — React 19 pattern: setState during render when\n  // a derivable prop changes. Refs get reset in the effect below.\n  if (renderedItemKey !== itemKey) {\n    setRenderedItemKey(itemKey);\n    setProgress(0);\n  }\n\n  // Refs reset on item change — runs before the timer effect (declaration order).\n  useEffect(() => {\n    accumulatedRef.current = 0;\n    completedRef.current = false;\n  }, [itemKey]);\n\n  useEffect(() => {\n    if (!isOpen) {\n      // Fully stop on close.\n      accumulatedRef.current = 0;\n      startTimeRef.current = null;\n      if (intervalRef.current !== null) {\n        clearInterval(intervalRef.current);\n        intervalRef.current = null;\n      }\n      return;\n    }\n\n    if (isPaused) {\n      // Snapshot elapsed into accumulator + clear interval.\n      if (startTimeRef.current !== null) {\n        accumulatedRef.current += performance.now() - startTimeRef.current;\n        startTimeRef.current = null;\n      }\n      if (intervalRef.current !== null) {\n        clearInterval(intervalRef.current);\n        intervalRef.current = null;\n      }\n      return;\n    }\n\n    // Run.\n    startTimeRef.current = performance.now();\n    intervalRef.current = setInterval(() => {\n      const startedAt = startTimeRef.current;\n      if (startedAt === null) return;\n      const elapsed = performance.now() - startedAt + accumulatedRef.current;\n      const pct = Math.min((elapsed / itemDurationMs) * 100, 100);\n      setProgress(pct);\n      if (pct >= 100 && !completedRef.current) {\n        completedRef.current = true;\n        accumulatedRef.current = 0;\n        startTimeRef.current = null;\n        if (intervalRef.current !== null) {\n          clearInterval(intervalRef.current);\n          intervalRef.current = null;\n        }\n        onCompleteRef.current();\n      }\n    }, tickMs);\n\n    return () => {\n      if (intervalRef.current !== null) {\n        clearInterval(intervalRef.current);\n        intervalRef.current = null;\n      }\n    };\n  }, [isOpen, isPaused, itemKey, itemDurationMs, tickMs]);\n\n  return progress;\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/hooks/use-story-progress.ts"
    },
    {
      "path": "src/registry/components/media/story-viewer/hooks/use-story-keyboard-nav.ts",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nexport interface UseStoryKeyboardNavOptions {\n  isOpen: boolean;\n  onPrevItem: () => void;\n  onNextItem: () => void;\n  onTogglePause: () => void;\n  /** Optional — Radix Dialog already handles Escape via `onOpenChange`; supply only if you want a parallel hook. */\n  onClose?: () => void;\n  /**\n   * v0.2.0 — when false, the window keydown listener does not attach.\n   * Default: true. Mirrors the `disableKeyboardNav` opt-out on\n   * StoryViewerProps.\n   */\n  enabled?: boolean;\n}\n\n/**\n * Window-scoped keyboard handlers for the story viewer.\n *\n * - ArrowLeft → onPrevItem\n * - ArrowRight → onNextItem\n * - Space → onTogglePause (preventDefault to stop page scroll)\n * - Escape → onClose (when supplied; deduplicated with Radix Dialog)\n *\n * Listener attaches on `isOpen=true` only and detaches on close — no leaks.\n * Refs-mirror pattern keeps the listener stable; only re-attaches on `isOpen` flip.\n */\nexport function useStoryKeyboardNav(opts: UseStoryKeyboardNavOptions) {\n  const refs = useRef(opts);\n  useEffect(() => {\n    refs.current = opts;\n  });\n\n  const { isOpen, enabled = true } = opts;\n  useEffect(() => {\n    if (!isOpen) return;\n    if (!enabled) return;\n    const handler = (e: KeyboardEvent) => {\n      switch (e.key) {\n        case \"ArrowLeft\":\n          refs.current.onPrevItem();\n          break;\n        case \"ArrowRight\":\n          refs.current.onNextItem();\n          break;\n        case \" \":\n          e.preventDefault();\n          refs.current.onTogglePause();\n          break;\n        case \"Escape\":\n          refs.current.onClose?.();\n          break;\n      }\n    };\n    window.addEventListener(\"keydown\", handler);\n    return () => window.removeEventListener(\"keydown\", handler);\n  }, [isOpen, enabled]);\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/hooks/use-story-keyboard-nav.ts"
    },
    {
      "path": "src/registry/components/media/story-viewer/hooks/use-story-engagement-state.ts",
      "content": "\"use client\";\n\nimport { useEffect, useReducer, useRef } from \"react\";\nimport type {\n  StoryEngagementDelta,\n  StoryEngagementLocalAction,\n  Subscribe,\n} from \"../types\";\n\n// ─── Per-item engagement state ──────────────────────────────────────────\n\nexport interface StoryItemEngagementState {\n  likeCount: number;\n  liked: boolean;\n  reactionCount: number;\n  viewerReaction: string | null;\n  replyCount: number;\n}\n\nconst DEFAULT_STATE: StoryItemEngagementState = {\n  likeCount: 0,\n  liked: false,\n  reactionCount: 0,\n  viewerReaction: null,\n  replyCount: 0,\n};\n\n/**\n * Compound key for the state map. Story-viewer-01's engagement state is\n * per-(storyId, itemId) — each item in each story tracks independently.\n */\nfunction key(storyId: string, itemId: string): string {\n  return `${storyId}:${itemId}`;\n}\n\n// ─── Reducer (pure) ─────────────────────────────────────────────────────\n\ntype EngagementStateMap = Record<string, StoryItemEngagementState>;\n\nexport function storyEngagementReducer(\n  state: EngagementStateMap,\n  action: StoryEngagementLocalAction,\n): EngagementStateMap {\n  switch (action.kind) {\n    case \"like-toggle\": {\n      const k = key(action.storyId, action.itemId);\n      const prev = state[k] ?? DEFAULT_STATE;\n      if (prev.liked === action.nextLiked) return state;\n      return {\n        ...state,\n        [k]: {\n          ...prev,\n          liked: action.nextLiked,\n          likeCount: action.nextLiked\n            ? prev.likeCount + 1\n            : Math.max(0, prev.likeCount - 1),\n        },\n      };\n    }\n    case \"reaction-select\": {\n      const k = key(action.storyId, action.itemId);\n      const prev = state[k] ?? DEFAULT_STATE;\n      if (prev.viewerReaction === action.reactionKind) return state;\n      // Clearing a reaction decrements; setting a new one (when none was set)\n      // increments. Switching from one kind to another keeps the count stable.\n      let nextCount = prev.reactionCount;\n      if (action.reactionKind == null && prev.viewerReaction != null) {\n        nextCount = Math.max(0, prev.reactionCount - 1);\n      } else if (action.reactionKind != null && prev.viewerReaction == null) {\n        nextCount = prev.reactionCount + 1;\n      }\n      return {\n        ...state,\n        [k]: {\n          ...prev,\n          viewerReaction: action.reactionKind,\n          reactionCount: nextCount,\n        },\n      };\n    }\n    case \"subscribe-delta\": {\n      const d = action.delta;\n      switch (d.kind) {\n        case \"like-changed\": {\n          const k = key(d.storyId, d.itemId);\n          const prev = state[k] ?? DEFAULT_STATE;\n          return {\n            ...state,\n            [k]: {\n              ...prev,\n              likeCount: d.count,\n              liked: d.liked ?? prev.liked,\n            },\n          };\n        }\n        case \"reaction-changed\": {\n          const k = key(d.storyId, d.itemId);\n          const prev = state[k] ?? DEFAULT_STATE;\n          return {\n            ...state,\n            [k]: {\n              ...prev,\n              reactionCount: d.count,\n              viewerReaction:\n                d.reactionKind !== undefined ? d.reactionKind : prev.viewerReaction,\n            },\n          };\n        }\n        case \"reply-added\": {\n          const k = key(d.storyId, d.itemId);\n          const prev = state[k] ?? DEFAULT_STATE;\n          return { ...state, [k]: { ...prev, replyCount: prev.replyCount + 1 } };\n        }\n        // viewer-added and view-count-changed don't affect per-item engagement\n        // (they're owner-overlay concerns); ignored here, handled in OwnerOverlay.\n        case \"viewer-added\":\n        case \"view-count-changed\":\n          return state;\n      }\n      return state;\n    }\n    case \"reset\": {\n      const k = key(action.storyId, action.itemId);\n      const { [k]: _drop, ...rest } = state;\n      return rest;\n    }\n  }\n}\n\n// ─── Hook ────────────────────────────────────────────────────────────────\n\nexport interface UseStoryEngagementStateOptions {\n  /** Wired engagementSubscribe stream from story-viewer props. */\n  subscribe?: Subscribe<StoryEngagementDelta>;\n  /** Forwarding callback — fires for every delta the subscription emits. */\n  onSubscribeDelta?: (delta: StoryEngagementDelta) => void;\n}\n\nexport interface UseStoryEngagementStateResult {\n  /** Reads per-(storyId, itemId) engagement state. Returns DEFAULT_STATE when no entry exists yet. */\n  getState: (storyId: string, itemId: string) => StoryItemEngagementState;\n  /** Dispatcher for local actions (used by overlay onToggle / onSelect). */\n  dispatch: React.Dispatch<StoryEngagementLocalAction>;\n}\n\n/**\n * Per-(storyId, itemId) engagement state hook. Subscribes to the optional\n * engagement realtime stream + reduces local actions (like / reaction toggles)\n * into a single state map.\n *\n * Mount-scoped subscribe wiring (matches the rest of the family's contract).\n */\nexport function useStoryEngagementState(\n  opts: UseStoryEngagementStateOptions,\n): UseStoryEngagementStateResult {\n  const [state, dispatch] = useReducer(storyEngagementReducer, {});\n\n  const onSubscribeDeltaRef = useRef(opts.onSubscribeDelta);\n  useEffect(() => {\n    onSubscribeDeltaRef.current = opts.onSubscribeDelta;\n  });\n\n  const subscribe = opts.subscribe;\n  useEffect(() => {\n    if (!subscribe) return;\n    const unsub = subscribe((delta) => {\n      onSubscribeDeltaRef.current?.(delta);\n      dispatch({ kind: \"subscribe-delta\", delta });\n    });\n    return unsub;\n  }, [subscribe]);\n\n  const getState = (storyId: string, itemId: string): StoryItemEngagementState =>\n    state[key(storyId, itemId)] ?? DEFAULT_STATE;\n\n  return { getState, dispatch };\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/hooks/use-story-engagement-state.ts"
    },
    {
      "path": "src/registry/components/media/story-viewer/lib/format-time.ts",
      "content": "export function defaultFormatTime(date: Date): string {\n  return new Intl.DateTimeFormat(undefined, {\n    hour: \"2-digit\",\n    minute: \"2-digit\",\n  }).format(date);\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/lib/format-time.ts"
    },
    {
      "path": "src/registry/components/media/story-viewer/lib/permissions.ts",
      "content": "/**\n * Pure permission resolver for story-viewer v0.2.0.\n *\n * Mirrors post-card v0.3.0's `lib/permissions.ts` shape verbatim per\n * Q-V10 lock. Resolution order (most → least specific):\n *   1. `canPerformAction(action, story, item)` returning `true | false` — wins\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 v0.1 mode — caller short-circuits before\n *      calling this resolver; see `lib/kebab.ts` dual-mode branch)\n *\n * Truth table (same shape as post-card):\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                                     |\n * | `\"owner\"`   | `{ ... }`    | owner defaults + permissions override per-field    |\n * | `\"viewer\"`  | `undefined`  | viewer defaults                                    |\n * | `\"viewer\"`  | `{ ... }`    | viewer defaults + permissions override per-field   |\n */\n\nimport type {\n  Story,\n  StoryItem,\n  StoryPermissionAction,\n  StoryViewerMode,\n  StoryViewerPermissions,\n} from \"../types\";\n\n/**\n * Library-baseline permission matrix by viewer mode. `Required<StoryViewerPermissions>`\n * ensures every field is resolved (no `undefined` leaks downstream).\n *\n * `canShare` defaults to `true` in BOTH modes (owners share their own; viewers\n * share other people's). `canModerate` defaults to `false` in BOTH — orthogonal\n * capability, never auto-derived from `viewerMode` (per Q-V11 lock — host opts\n * in explicitly via `permissions.canModerate: true` or `canPerformAction`).\n */\nexport const PERMISSION_DEFAULTS_BY_MODE: Record<\n  StoryViewerMode,\n  Required<StoryViewerPermissions>\n> = {\n  owner: {\n    // owner-side affordances\n    canSaveToHighlights: true,\n    canDeleteStory: true,\n    canShareToFeed: true,\n    canSeeViewers: true,\n    // viewer-side off for owners (they don't react to their own story)\n    canReact: false,\n    canReply: false,\n    canShare: true,\n    canDM: false,\n    canReport: false,\n    canBlockAuthor: false,\n    canMuteAuthor: false,\n    // moderation is orthogonal to viewerMode — never auto-derived\n    canModerate: false,\n  },\n  viewer: {\n    // owner-side off for viewers\n    canSaveToHighlights: false,\n    canDeleteStory: false,\n    canShareToFeed: false,\n    canSeeViewers: false,\n    // viewer-side full\n    canReact: true,\n    canReply: true,\n    canShare: true,\n    canDM: true,\n    canReport: true,\n    canBlockAuthor: true,\n    canMuteAuthor: true,\n    canModerate: false,\n  },\n};\n\n/**\n * Special-case map for action discriminator keys that don't auto-capitalize\n * cleanly. `dm` → `canDM` (DM as an acronym; not `canDm`). All other actions\n * use the generic `can${cap(action)}` pattern.\n */\nconst SPECIAL_KEYS: Partial<Record<StoryPermissionAction, keyof StoryViewerPermissions>> = {\n  dm: \"canDM\",\n};\n\n/**\n * Map a `StoryPermissionAction` string to its `StoryViewerPermissions` matrix key.\n *\n * `\"saveToHighlights\"` → `\"canSaveToHighlights\"`, `\"react\"` → `\"canReact\"`, etc.\n * `\"dm\"` → `\"canDM\"` via SPECIAL_KEYS (preserves acronym casing).\n * Pure, no side effects.\n */\nfunction actionToPermissionKey(\n  action: StoryPermissionAction,\n): keyof StoryViewerPermissions {\n  if (action in SPECIAL_KEYS) return SPECIAL_KEYS[action]!;\n  return `can${action.charAt(0).toUpperCase()}${action.slice(1)}` as keyof StoryViewerPermissions;\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 `canPerformStoryActionInternal`).\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 resolveStoryPermissions(\n  viewerMode: StoryViewerMode | undefined,\n  permissions: StoryViewerPermissions | undefined,\n): Required<StoryViewerPermissions> {\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={{ canReact: undefined }}`\n  // should fall through to the mode default, not override it with undefined.\n  const result: Required<StoryViewerPermissions> = { ...base };\n  for (const key of Object.keys(permissions) as (keyof StoryViewerPermissions)[]) {\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:\n *   1. `canPerformAction(action, story, item)` returning `true | false` wins\n *   2. Matrix value from `resolvedMatrix` (already includes mode defaults +\n *      permissions overrides)\n *\n * `canPerformAction` returning `undefined` falls through to step 2.\n */\nexport function canPerformStoryActionInternal(\n  action: StoryPermissionAction,\n  story: Story,\n  item: StoryItem,\n  resolvedMatrix: Required<StoryViewerPermissions>,\n  canPerformAction:\n    | ((action: StoryPermissionAction, story: Story, item: StoryItem) => boolean | undefined)\n    | undefined,\n): boolean {\n  if (canPerformAction) {\n    const result = canPerformAction(action, story, item);\n    if (result !== undefined) return result;\n  }\n  return resolvedMatrix[actionToPermissionKey(action)] !== false;\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/lib/permissions.ts"
    },
    {
      "path": "src/registry/components/media/story-viewer/lib/engagement-actions.ts",
      "content": "/**\n * Build the StoryEngagementAction[] for the engagement overlay's\n * <EngagementBar variant=\"stacked\">. Honors the resolved permissions matrix\n * + reactionKinds + per-item engagement state.\n *\n * Reaction action is only added when both `canReact` resolves true AND the\n * host supplied `reactionKinds` (per Q-V4 lock — engagement-bar v0.3.x reaction\n * kinds are host-supplied, no library defaults).\n */\n\nimport type {\n  Story,\n  StoryEngagementAction,\n  StoryEngagementReactionKind,\n  StoryItem,\n  StoryPermissionAction,\n  StoryViewerPermissions,\n} from \"../types\";\nimport type { StoryItemEngagementState } from \"../hooks/use-story-engagement-state\";\nimport {\n  canPerformStoryActionInternal,\n  resolveStoryPermissions,\n} from \"./permissions\";\n\nexport interface BuildEngagementActionsOptions {\n  story: Story;\n  item: StoryItem;\n  state: StoryItemEngagementState;\n  /** Resolved matrix from resolveStoryPermissions. */\n  matrix: Required<StoryViewerPermissions>;\n  canPerformAction?: (\n    action: StoryPermissionAction,\n    story: Story,\n    item: StoryItem,\n  ) => boolean | undefined;\n  reactionKinds?: StoryEngagementReactionKind[];\n  /** Like toggle handler — fires onLikeStory through to the consumer. */\n  onLikeToggle?: (nextLiked: boolean) => void;\n  /** Reaction select handler — fires onReactStory. */\n  onReactSelect?: (kind: string | null) => void;\n  /** Comment click handler — typically focuses the reply composer. */\n  onCommentClick?: () => void;\n  /** Share click handler. */\n  onShareClick?: () => void;\n}\n\nexport function buildStoryEngagementActions(\n  opts: BuildEngagementActionsOptions,\n): StoryEngagementAction[] {\n  const canDo = (action: StoryPermissionAction) =>\n    canPerformStoryActionInternal(\n      action,\n      opts.story,\n      opts.item,\n      opts.matrix,\n      opts.canPerformAction,\n    );\n\n  const actions: StoryEngagementAction[] = [];\n\n  // Like (gated by canReact for viewer mode; owners don't like their own — matrix handles it)\n  if (canDo(\"react\")) {\n    actions.push({\n      kind: \"like\",\n      count: opts.state.likeCount,\n      liked: opts.state.liked,\n      onToggle: opts.onLikeToggle,\n    });\n  }\n\n  // Reaction — only when canReact AND reactionKinds supplied\n  if (canDo(\"react\") && opts.reactionKinds && opts.reactionKinds.length > 0) {\n    actions.push({\n      kind: \"reaction\",\n      kinds: opts.reactionKinds,\n      viewerReaction: opts.state.viewerReaction,\n      onSelect: opts.onReactSelect,\n      clearOnTap: true, // Q-V4 lock — inherited engagement-bar v0.3 default\n    });\n  }\n\n  // Comment (always shown in viewer mode; opens reply composer)\n  if (canDo(\"reply\")) {\n    actions.push({\n      kind: \"comment\",\n      count: opts.state.replyCount > 0 ? opts.state.replyCount : undefined,\n      onClick: opts.onCommentClick,\n    });\n  }\n\n  // Share\n  if (canDo(\"share\")) {\n    actions.push({\n      kind: \"share\",\n      onClick: opts.onShareClick,\n    });\n  }\n\n  // Bookmark removed in v0.3.0 — stories are not bookmarkable.\n  //\n  // Kebab moved to header right cluster in v0.3.5 (user feedback —\n  // engagement-overlay kebab was visually inconsistent and not\n  // functional). The header always renders the kebab when viewerMode is\n  // set + kebab items exist.\n\n  return actions;\n}\n\n/**\n * Convenience wrapper — resolves permissions matrix + builds the actions in\n * one call. Used by story-viewer.tsx when assembling the overlay.\n */\nexport function buildStoryEngagementActionsWithMatrix(\n  opts: Omit<BuildEngagementActionsOptions, \"matrix\"> & {\n    viewerMode?: \"owner\" | \"viewer\";\n    permissions?: StoryViewerPermissions;\n  },\n): StoryEngagementAction[] {\n  const matrix = resolveStoryPermissions(opts.viewerMode, opts.permissions);\n  return buildStoryEngagementActions({ ...opts, matrix });\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/lib/engagement-actions.ts"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/engagement-overlay.tsx",
      "content": "\"use client\";\n\nimport { type Ref, memo } from \"react\";\nimport { cn } from \"@/lib/utils\";\n// F-S1 cross-cat component import — absolute-with-suffix preserves through\n// the shadcn 4.6.0 rewriter (Bug 3 only affects /types; /<slug>/<file> works).\nimport { EngagementBar } from \"@/registry/components/data/engagement-bar/engagement-bar\";\nimport type {\n  ResolvedStoryViewer01Labels,\n  Story,\n  StoryEngagementAction,\n  StoryItem,\n} from \"../types\";\n\nexport interface EngagementOverlayProps {\n  story: Story;\n  item: StoryItem;\n  /**\n   * Resolved StoryEngagementAction[] (built via lib/engagement-actions.ts).\n   * Structurally identical to engagement-bar's `EngagementAction[]` —\n   * cast at the EngagementBar boundary (inline-copy structural compat).\n   */\n  actions: StoryEngagementAction[];\n  labels: ResolvedStoryViewer01Labels;\n  /**\n   * v0.3.5 — whether the engagement bar is currently expanded. When false,\n   * the bar is hidden; when true, the icons appear with a staggered bottom-\n   * to-top animation. The toggle itself lives outside this part (v0.3.7:\n   * inline in the DM bar row, rendered by story-viewer.tsx).\n   */\n  expanded?: boolean;\n  className?: string;\n  /**\n   * v0.3.5 — caller-supplied ref for the column root. story-viewer uses\n   * this to detect outside-pointer events and collapse the column.\n   */\n  containerRef?: Ref<HTMLDivElement>;\n}\n\n/**\n * Engagement overlay — TikTok/Reels-style vertical stack on the right edge\n * of the viewer (above the DM bar). Per Q-V2 lock: variant=\"stacked\" is the\n * default; engagement-bar v0.3.x introduced the stacked variant explicitly\n * for portrait full-screen media.\n *\n * v0.3.5 — collapsed-by-default UX. Icons hidden by default; expanding\n * triggers a staggered bottom-to-top reveal. Kebab moved out of this part\n * to the header.\n *\n * v0.3.7 — heart toggle moved out of this part to the DM bar row (rendered\n * by story-viewer.tsx). This part now ONLY renders the engagement bar\n * itself.\n *\n * Mounting gated by `viewerMode === \"viewer\"` AND `!disableEngagement` (the\n * caller — story-viewer.tsx — handles the gate; this part trusts the\n * caller's decision).\n */\nfunction EngagementOverlayInner(props: EngagementOverlayProps) {\n  const { expanded = false, containerRef } = props;\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        // v0.3.7: bottom-20 leaves room for the DM bar row (which sits at\n        // bottom-0 with ~56px height including padding). Heart toggle lives\n        // inline in the DM bar; this column only renders the icons.\n        \"absolute right-3 bottom-20 z-30 flex flex-col items-center gap-3 text-white\",\n        // Each direct child of the bar is an ActionButton; animate each.\n        \"[&>div>*]:transition-all [&>div>*]:duration-300 [&>div>*]:ease-out\",\n        // Collapsed: hidden + offset down.\n        !expanded &&\n          \"pointer-events-none [&>div>*]:opacity-0 [&>div>*]:translate-y-4 [&>div>*]:pointer-events-none\",\n        // Expanded: visible + at rest.\n        expanded && \"[&>div>*]:opacity-100 [&>div>*]:translate-y-0\",\n        // Bottom-to-top stagger when expanding — last child first.\n        \"[&>div>*:nth-last-child(1)]:delay-0\",\n        \"[&>div>*:nth-last-child(2)]:delay-75\",\n        \"[&>div>*:nth-last-child(3)]:delay-150\",\n        \"[&>div>*:nth-last-child(4)]:delay-200\",\n        props.className,\n      )}\n      aria-hidden={expanded ? undefined : \"true\"}\n    >\n      {/* Structural-equivalence cast: StoryEngagementAction is an inline-copy\n          of EngagementAction (F-S1 Bug 3 fix). TypeScript can't unify the\n          two distinct types even though they share structure; the cast at\n          the single composition boundary keeps the rest of the file fully\n          typed. */}\n      <EngagementBar\n        variant=\"stacked\"\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        actions={props.actions as any}\n        labels={props.labels.engagementLabels}\n      />\n    </div>\n  );\n}\n\nexport const EngagementOverlay = memo(EngagementOverlayInner);\nEngagementOverlay.displayName = \"EngagementOverlay\";\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/engagement-overlay.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/reply-composer.tsx",
      "content": "\"use client\";\n\nimport { memo, useCallback, useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n// F-S1 cross-cat component import — absolute-with-suffix preserves through\n// the shadcn 4.6.0 rewriter (Bug 3 only affects /types).\nimport {\n  CommentComposer,\n  type CommentComposerHandle,\n} from \"@/registry/components/data/comment-thread/parts/comment-composer\";\nimport type {\n  ResolvedStoryViewer01Labels,\n  Story,\n  StoryCurrentUser,\n  StoryItem,\n} from \"../types\";\n\nexport interface ReplyComposerProps {\n  story: Story;\n  item: StoryItem;\n  /** Viewer identity — required for composer mount. Absent → composer hidden. */\n  currentUser?: StoryCurrentUser;\n  /** Submit handler — story-viewer forwards via onAddReply prop. */\n  onAddReply?: (storyId: string, itemId: string, content: string) => Promise<void> | void;\n  /** Pause the story while user types; resume on submit/cancel. */\n  onSetPaused: (paused: boolean) => void;\n  labels: ResolvedStoryViewer01Labels;\n  className?: string;\n  /** Optional handle ref so the parent can focus / pre-fill the composer (triggerReply). */\n  composerRef?: React.Ref<CommentComposerHandle>;\n  /**\n   * @deprecated v0.3.3 — Cancel button removed and engagement column\n   *   redesigned as a heart-toggle reveal in v0.3.5/v0.3.7. This callback\n   *   is kept as a forward-compat hook for future host-side polish (e.g.,\n   *   conditional engagement reveal tied to composer focus). Currently\n   *   fired but not consumed by `story-viewer.tsx`.\n   */\n  onActiveChange?: (active: boolean) => void;\n}\n\n/**\n * Reply composer mounted at the bottom of the viewer per Q-V1 lock (always-\n * visible Instagram-default bottom bar). Auto-pauses the story timer on\n * first character; resumes on submit / cancel.\n *\n * Mounting gated by `viewerMode === \"viewer\"` AND `!disableReplyComposer` AND\n * `currentUser` present — caller handles those gates. This part trusts the\n * caller's decision.\n *\n * When `currentUser` is absent, the part returns null — story-viewer.tsx\n * mounts `composerEmptyState` slot instead (sign-in CTA for unauth viewers).\n */\nfunction ReplyComposerInner(props: ReplyComposerProps) {\n  // Destructure ALL props to locals up front. `composerRef` is a ref-typed\n  // prop; reading it as `props.composerRef` inside the JSX makes the React\n  // Compiler's aliasing analysis treat the whole `props` object as a ref\n  // container and flag every later `props.*` read as \"ref access during\n  // render\" (known false-positive class — see blackboard lesson:\n  // destructure to locals). Locals keep the analysis precise.\n  const {\n    story,\n    item,\n    currentUser,\n    onAddReply,\n    onSetPaused,\n    labels,\n    className,\n    composerRef,\n    onActiveChange,\n  } = props;\n  // Track whether user has typed anything since last submit; auto-pause\n  // fires only on the FIRST keystroke (cheap idempotent setPaused call).\n  const pausedRef = useRef(false);\n  // v0.3.2 — focus + content tracking still fires `onActiveChange` for\n  // host-side polish (e.g., a future heart-toggle that only reveals the\n  // engagement column when composer is active). v0.3.3 stopped USING the\n  // signal internally — gradient is full-width always; engagement stays\n  // always visible; the Cancel button (which caused the collision) was\n  // removed. The signal is kept for forward compat.\n  const [hasFocus, setHasFocus] = useState(false);\n  const [hasContent, setHasContent] = useState(false);\n  const active = hasFocus || hasContent;\n  useEffect(() => {\n    onActiveChange?.(active);\n  }, [active, onActiveChange]);\n\n  const handleChange = useCallback(\n    (value: string) => {\n      setHasContent(value.length > 0);\n      if (value.length > 0 && !pausedRef.current) {\n        pausedRef.current = true;\n        onSetPaused(true);\n      } else if (value.length === 0 && pausedRef.current) {\n        // User cleared the composer manually — resume the story.\n        pausedRef.current = false;\n        onSetPaused(false);\n      }\n    },\n    [onSetPaused],\n  );\n\n  const handleSubmit = useCallback(\n    async (content: string) => {\n      try {\n        await onAddReply?.(story.id, item.id, content);\n      } finally {\n        pausedRef.current = false;\n        setHasContent(false);\n        onSetPaused(false);\n      }\n    },\n    [onAddReply, story.id, item.id, onSetPaused],\n  );\n\n  if (!currentUser) return null;\n\n  // CommentComposer accepts a stricter currentUser shape (CommentThreadCurrentUser).\n  // StoryCurrentUser is structurally identical — cast at the boundary.\n  return (\n    <div\n      onFocusCapture={() => setHasFocus(true)}\n      onBlurCapture={() => setHasFocus(false)}\n      className={cn(\n        // v0.3.3: full-width gradient strip at the bottom (right-0 always).\n        // z-31 keeps the input above the engagement overlay (z-30) on the\n        // OS focus stack so taps reliably reach the textarea.\n        // v0.3.6: tightened vertical padding (`pt-3 pb-3` was `pt-8 pb-4`).\n        // v0.3.7: right padding bumped to `pr-16` so the input doesn't\n        // extend under the heart toggle that now sits inline in the DM bar\n        // row (rendered by story-viewer.tsx at right-3 bottom-3).\n        \"absolute right-0 bottom-0 left-0 z-31 pl-4 pr-16 pt-3 pb-3 pointer-events-auto\",\n        \"bg-linear-to-t from-black/60 via-black/40 to-transparent\",\n        className,\n      )}\n    >\n      <CommentComposer\n        ref={composerRef}\n        // eslint-disable-next-line @typescript-eslint/no-explicit-any\n        currentUser={currentUser as any}\n        placeholder={labels.replyComposerPlaceholder(story)}\n        onChange={handleChange}\n        onSubmit={handleSubmit}\n        // v0.3.3: Cancel button removed per UX feedback — Instagram-canonical\n        // story DM has no Cancel; engagement column stays visible. handleCancel\n        // is still wired internally for future use (e.g. Escape key handler\n        // on the textarea), but no Cancel button is rendered.\n        // v0.3.4: removed `pr-12` — the engagement column sits at `bottom-24`\n        // while the input is at `bottom-0`, so they don't overlap vertically\n        // and the input can extend full width.\n        // v0.3.6: override shadcn Textarea's baked-in `min-h-16` (64px) via\n        // arbitrary-selector className passthrough — bring the textarea down\n        // to a single-row height that matches the 8x8 avatar (`min-h-9`)\n        // with tighter vertical padding. The textarea still auto-grows up\n        // to `maxRows={3}` while typing.\n        className=\"[&_textarea]:min-h-9 [&_textarea]:py-1.5 [&_textarea]:text-sm\"\n        submitOnEnter\n        minRows={1}\n        maxRows={3}\n        ariaLabel={labels.replyAriaLabel(story)}\n        labels={\n          labels.commentLabels\n            ? {\n                composerPlaceholder: labels.commentLabels.composerPlaceholder,\n                composerSend: labels.commentLabels.composerSend ?? labels.replyComposerSend,\n                composerCancel: labels.commentLabels.composerCancel ?? labels.replyComposerCancel,\n              }\n            : {\n                composerSend: labels.replyComposerSend,\n                composerCancel: labels.replyComposerCancel,\n              }\n        }\n      />\n    </div>\n  );\n}\n\nexport const ReplyComposer = memo(ReplyComposerInner);\nReplyComposer.displayName = \"ReplyComposer\";\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/reply-composer.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/owner-overlay.tsx",
      "content": "\"use client\";\n\nimport { memo, useCallback, useState } from \"react\";\nimport { Eye, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport type {\n  ResolvedStoryViewer01Labels,\n  Story,\n  StoryItem,\n  ViewerListItem,\n} from \"../types\";\n\nexport interface OwnerOverlayProps {\n  story: Story;\n  item: StoryItem;\n  /**\n   * Lazy-fetch viewers when owner taps the view-count chip. Returns\n   * ViewerListItem[]. Called once per panel-open; result cached locally.\n   */\n  onLoadViewers?: (storyId: string) => Promise<ViewerListItem[]>;\n  labels: ResolvedStoryViewer01Labels;\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\n/**\n * Owner overlay — bottom-anchored view-count chip + tap-to-expand viewers\n * list panel. Mounts only when `viewerMode === \"owner\"` + `!disableOwnerOverlay`.\n *\n * Per Q-V5 hybrid lock: count is eager (cheap byte from `story.viewerCount`),\n * users list is lazy (fetched on first tap via `onLoadViewers`). Optional\n * eager seed via `story.viewers` short-circuits the fetch.\n *\n * The expanded panel renders avatars + names inline (a simple list — not the\n * cross-cat LikersStrip — to keep the F-S1 surface minimal; LikersStrip can\n * be wired via `renderOwnerOverlay` slot if the host wants the swipable\n * variant).\n */\nfunction OwnerOverlayInner(props: OwnerOverlayProps) {\n  const eagerCount = props.story.viewerCount ?? props.story.viewers?.length ?? 0;\n  const [open, setOpen] = useState(false);\n  const [viewers, setViewers] = useState<ViewerListItem[]>(props.story.viewers ?? []);\n  const [loading, setLoading] = useState(false);\n  const [didFetch, setDidFetch] = useState(false);\n\n  // Reset panel state when story changes (cursor navigation). Render-time\n  // prev-props adjustment (React docs \"adjusting state when a prop changes\")\n  // instead of an effect — same reset semantics without the\n  // set-state-in-effect cascading-render re-render (and no one-frame stale\n  // panel while the effect waits to fire).\n  const [prevStoryId, setPrevStoryId] = useState(props.story.id);\n  if (prevStoryId !== props.story.id) {\n    setPrevStoryId(props.story.id);\n    setOpen(false);\n    setViewers(props.story.viewers ?? []);\n    setDidFetch(false);\n  }\n\n  const handleToggle = useCallback(async () => {\n    if (open) {\n      setOpen(false);\n      return;\n    }\n    setOpen(true);\n    if (viewers.length === 0 && props.onLoadViewers && !loading && !didFetch) {\n      setLoading(true);\n      try {\n        const list = await props.onLoadViewers(props.story.id);\n        setViewers(list);\n        setDidFetch(true);\n      } catch {\n        // Swallow; host can re-attempt via re-tap. No retry-with-backoff for v0.2.0.\n      } finally {\n        setLoading(false);\n      }\n    }\n  }, [open, viewers.length, loading, didFetch, props.onLoadViewers, props.story.id]);\n\n  const timeStr = props.labels.formatTime(new Date(props.story.createdAt));\n  const ariaLabel = props.labels.viewerCountLabel(eagerCount, timeStr);\n\n  return (\n    <>\n      {/* View-count chip — bottom-left of viewer (replaces reply composer in owner mode). */}\n      <div\n        className={cn(\n          \"absolute left-0 right-0 bottom-0 z-30 flex items-center px-4 pb-4 pt-8\",\n          \"bg-linear-to-t from-black/60 via-black/40 to-transparent\",\n          props.className,\n        )}\n      >\n        <button\n          type=\"button\"\n          onClick={handleToggle}\n          className={cn(\n            buttonVariants({ variant: \"ghost\", size: \"sm\" }),\n            \"h-11 text-white hover:bg-white/20 hover:text-white\",\n          )}\n          aria-label={ariaLabel}\n          aria-expanded={open}\n        >\n          <Eye className=\"mr-2 h-4 w-4\" />\n          <span className=\"font-semibold\">{eagerCount}</span>\n          <span className=\"ml-2 text-white/70\">· {timeStr}</span>\n        </button>\n      </div>\n\n      {/* Viewers panel — bottom-anchored sheet that slides up. */}\n      {open ? (\n        <div className=\"absolute inset-x-0 bottom-0 z-40 max-h-[60%] overflow-hidden rounded-t-2xl bg-card text-foreground shadow-2xl\">\n          <div className=\"flex items-center justify-between border-b border-border px-4 py-3\">\n            <h3 className=\"text-sm font-semibold\">{props.labels.viewersHeading}</h3>\n            <button\n              type=\"button\"\n              onClick={() => setOpen(false)}\n              className={cn(\n                buttonVariants({ variant: \"ghost\", size: \"icon\" }),\n                \"h-9 w-9\",\n              )}\n              aria-label={props.labels.close}\n            >\n              <X className=\"h-4 w-4\" />\n            </button>\n          </div>\n          <div className=\"max-h-[50vh] overflow-y-auto\">\n            {loading ? (\n              <p className=\"px-4 py-6 text-center text-sm text-muted-foreground\">\n                Loading viewers…\n              </p>\n            ) : viewers.length === 0 ? (\n              <p className=\"px-4 py-6 text-center text-sm text-muted-foreground\">\n                No viewers yet.\n              </p>\n            ) : (\n              <ul className=\"flex flex-col\">\n                {viewers.map((viewer) => (\n                  <li\n                    key={viewer.id}\n                    className=\"flex items-center gap-3 border-b border-border/30 px-4 py-2 last:border-b-0\"\n                  >\n                    <Avatar className=\"h-8 w-8 shrink-0\">\n                      {viewer.avatar ? <AvatarImage src={viewer.avatar} alt=\"\" /> : null}\n                      <AvatarFallback className=\"text-xs\">{initials(viewer.name)}</AvatarFallback>\n                    </Avatar>\n                    <span className=\"flex-1 text-sm font-medium\">{viewer.name}</span>\n                    {viewer.viewedAt ? (\n                      <span className=\"text-xs text-muted-foreground\">\n                        {props.labels.formatTime(new Date(viewer.viewedAt))}\n                      </span>\n                    ) : null}\n                  </li>\n                ))}\n              </ul>\n            )}\n          </div>\n        </div>\n      ) : null}\n    </>\n  );\n}\n\nexport const OwnerOverlay = memo(OwnerOverlayInner);\nOwnerOverlay.displayName = \"OwnerOverlay\";\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/owner-overlay.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/kebab-panel.tsx",
      "content": "\"use client\";\n\nimport { Fragment, memo, useEffect } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport type {\n  ResolvedStoryViewer01Labels,\n  StoryKebabMenuItem,\n} from \"../types\";\n\nexport interface KebabPanelProps {\n  open: boolean;\n  items: StoryKebabMenuItem[];\n  onClose: () => void;\n  labels: ResolvedStoryViewer01Labels;\n  className?: string;\n}\n\n/**\n * Bottom-sheet action panel triggered by the kebab button in\n * ViewerHeader's right cluster (moved here in v0.3.5 — previously this\n * panel was triggered by a 6th custom action in the engagement-bar, but\n * the engagement column was reduced to a collapsible heart toggle and the\n * kebab moved into the header for consistency).\n *\n * Renders as a fixed-position bottom sheet rather than a DropdownMenu — this\n * matches Instagram / Snapchat / TikTok action-sheet UX AND avoids the\n * F-cross-13 `<DropdownMenuTrigger asChild>{<Button>}` rewriter trap entirely.\n *\n * Items honor `separatorBefore: true` for the moderator section visual break.\n * Destructive items get `text-destructive` styling.\n *\n * Closes on item click + on backdrop tap + on Escape (handled at parent via\n * onClose dispatch).\n */\nfunction KebabPanelInner(props: KebabPanelProps) {\n  // Escape key handler — closes the panel when open.\n  useEffect(() => {\n    if (!props.open) return;\n    const handleKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") props.onClose();\n    };\n    window.addEventListener(\"keydown\", handleKey);\n    return () => window.removeEventListener(\"keydown\", handleKey);\n  }, [props.open, props.onClose]);\n\n  if (!props.open || props.items.length === 0) return null;\n\n  return (\n    <>\n      {/* Backdrop — taps close the panel. Above engagement overlay, below the sheet. */}\n      <button\n        type=\"button\"\n        aria-hidden=\"true\"\n        tabIndex={-1}\n        className=\"absolute inset-0 z-40 cursor-default bg-black/40\"\n        onClick={props.onClose}\n      />\n      {/* Action sheet — bottom-anchored. */}\n      <div\n        role=\"menu\"\n        aria-label={props.labels.kebabAriaLabel}\n        className={cn(\n          \"absolute inset-x-0 bottom-0 z-50 max-h-[70%] overflow-y-auto rounded-t-2xl bg-card text-foreground shadow-2xl\",\n          props.className,\n        )}\n      >\n        <div className=\"flex items-center justify-between border-b border-border px-4 py-3\">\n          <span className=\"text-sm font-semibold\">{props.labels.kebabAriaLabel}</span>\n          <button\n            type=\"button\"\n            onClick={props.onClose}\n            className={cn(\n              buttonVariants({ variant: \"ghost\", size: \"icon\" }),\n              \"h-9 w-9\",\n            )}\n            aria-label={props.labels.close}\n          >\n            <X className=\"h-4 w-4\" />\n          </button>\n        </div>\n        <ul className=\"flex flex-col py-2\">\n          {props.items.map((item, i) => (\n            <Fragment key={`${item.label}-${i}`}>\n              {item.separatorBefore && i > 0 ? (\n                <li role=\"separator\" className=\"my-2 border-t border-border\" aria-hidden=\"true\" />\n              ) : null}\n              <li>\n                <button\n                  type=\"button\"\n                  role=\"menuitem\"\n                  disabled={item.disabled}\n                  onClick={() => {\n                    item.onClick?.();\n                    props.onClose();\n                  }}\n                  className={cn(\n                    \"flex w-full items-center gap-3 px-4 py-3 text-left text-sm transition-colors\",\n                    \"hover:bg-muted focus:bg-muted focus:outline-none\",\n                    \"disabled:opacity-50 disabled:pointer-events-none\",\n                    item.destructive && \"text-destructive hover:bg-destructive/10 focus:bg-destructive/10\",\n                  )}\n                >\n                  {item.icon ? <span className=\"shrink-0\">{item.icon}</span> : null}\n                  <span className=\"flex-1\">{item.label}</span>\n                </button>\n              </li>\n            </Fragment>\n          ))}\n        </ul>\n      </div>\n    </>\n  );\n}\n\nexport const KebabPanel = memo(KebabPanelInner);\nKebabPanel.displayName = \"KebabPanel\";\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/kebab-panel.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/lib/kebab.ts",
      "content": "/**\n * Default story kebab items builder. Dual-mode helper mirroring\n * post-card v0.3.0's `defaultPostKebabActions`:\n *\n *   - **Legacy mode (v0.1 compat):** when `viewerMode` / `permissions` /\n *     `canPerformAction` are ALL undefined, returns []. Kebab is not rendered\n *     in legacy mode (v0.1 story viewer had no kebab).\n *\n *   - **Role-aware mode (v0.2.0):** when ANY of the three role-aware args is\n *     set, the helper calls `resolveStoryPermissions` +\n *     `canPerformStoryActionInternal` to produce role-appropriate items per\n *     the resolution order in `lib/permissions.ts`.\n *\n * Owner mode: Save to highlights / Delete story / Share to feed / Copy link.\n * Viewer mode: Copy link / Mute author / Block author / Report (destructive\n * at end).\n * Moderator section: only when `canModerate` resolves true + `moderatorActions`\n * supplied. First moderator item carries `separatorBefore: true`.\n *\n * `kebabActions` full-takeover bypasses this entirely (handled in story-viewer.tsx\n * before this is called).\n */\n\nimport type { ReactNode } from \"react\";\nimport type {\n  Story,\n  StoryItem,\n  StoryKebabMenuItem,\n  StoryPermissionAction,\n  StoryViewerLabels,\n  StoryViewerMode,\n  StoryViewerPermissions,\n} from \"../types\";\nimport {\n  canPerformStoryActionInternal,\n  resolveStoryPermissions,\n} from \"./permissions\";\n\nexport interface DefaultStoryKebabHandlers {\n  /** Owner-side: save story to highlights. */\n  onSaveToHighlights?: (storyId: string) => void;\n  /** Owner-side: delete story. */\n  onDeleteStory?: (storyId: string) => void;\n  /** Owner-side: share to feed. */\n  onShareToFeed?: (storyId: string) => void;\n  /** Viewer-side: report. */\n  onReport?: (storyId: string) => void;\n  /** Viewer-side: block author. */\n  onBlockAuthor?: (authorId: string) => void;\n  /** Viewer-side: mute author. */\n  onMuteAuthor?: (authorId: string) => void;\n  /** Common: copy link to the story. */\n  onCopyLink?: (storyId: string) => void;\n  /** Whether the story is currently saved to highlights (toggles label). */\n  isSavedToHighlights?: boolean;\n}\n\nexport interface BuildStoryKebabActionsOptions {\n  story: Story;\n  item: StoryItem;\n  handlers: DefaultStoryKebabHandlers;\n  labels: Partial<\n    Pick<\n      Required<StoryViewerLabels>,\n      | \"saveToHighlights\"\n      | \"unsaveFromHighlights\"\n      | \"deleteStory\"\n      | \"shareToFeed\"\n      | \"report\"\n      | \"blockAuthor\"\n      | \"muteAuthor\"\n      | \"copyLink\"\n    >\n  >;\n  viewerMode?: StoryViewerMode;\n  permissions?: StoryViewerPermissions;\n  canPerformAction?: (\n    action: StoryPermissionAction,\n    story: Story,\n    item: StoryItem,\n  ) => boolean | undefined;\n  /** Optional moderator section supplier (additive — does NOT replace defaults). */\n  moderatorActions?: (story: Story, item: StoryItem) => StoryKebabMenuItem[];\n  /** Optional icons for each canonical action (host can override). */\n  icons?: Partial<Record<\n    | \"saveToHighlights\"\n    | \"deleteStory\"\n    | \"shareToFeed\"\n    | \"copyLink\"\n    | \"muteAuthor\"\n    | \"blockAuthor\"\n    | \"report\",\n    ReactNode\n  >>;\n}\n\nexport function defaultStoryKebabActions(\n  opts: BuildStoryKebabActionsOptions,\n): StoryKebabMenuItem[] {\n  const roleAwareMode =\n    opts.viewerMode !== undefined ||\n    opts.permissions !== undefined ||\n    opts.canPerformAction !== undefined;\n\n  // Legacy mode — no kebab in v0.1. Return empty.\n  if (!roleAwareMode) return [];\n\n  const matrix = resolveStoryPermissions(opts.viewerMode, opts.permissions);\n  const canDo = (action: StoryPermissionAction) =>\n    canPerformStoryActionInternal(action, opts.story, opts.item, matrix, opts.canPerformAction);\n\n  const items: StoryKebabMenuItem[] = [];\n\n  // ─── Owner-side items ─────────────────────────────────────────────────\n  if (canDo(\"saveToHighlights\") && opts.handlers.onSaveToHighlights) {\n    items.push({\n      label: opts.handlers.isSavedToHighlights\n        ? (opts.labels.unsaveFromHighlights ?? \"Remove from highlights\")\n        : (opts.labels.saveToHighlights ?? \"Save to highlights\"),\n      icon: opts.icons?.saveToHighlights,\n      onClick: () => opts.handlers.onSaveToHighlights!(opts.story.id),\n    });\n  }\n  if (canDo(\"shareToFeed\") && opts.handlers.onShareToFeed) {\n    items.push({\n      label: opts.labels.shareToFeed ?? \"Share to feed\",\n      icon: opts.icons?.shareToFeed,\n      onClick: () => opts.handlers.onShareToFeed!(opts.story.id),\n    });\n  }\n\n  // ─── Common items ─────────────────────────────────────────────────────\n  if (opts.handlers.onCopyLink) {\n    items.push({\n      label: opts.labels.copyLink ?? \"Copy link\",\n      icon: opts.icons?.copyLink,\n      onClick: () => opts.handlers.onCopyLink!(opts.story.id),\n    });\n  }\n\n  // ─── Moderator section (additive; gated by canModerate + moderatorActions) ──\n  if (canDo(\"moderate\") && opts.moderatorActions) {\n    const modItems = opts.moderatorActions(opts.story, opts.item);\n    if (modItems.length > 0) {\n      // First moderator item carries `separatorBefore: true` (visual section break).\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\") && opts.handlers.onMuteAuthor) {\n    items.push({\n      label: opts.labels.muteAuthor ?? \"Mute author\",\n      icon: opts.icons?.muteAuthor,\n      onClick: () => opts.handlers.onMuteAuthor!(opts.story.userId),\n    });\n  }\n  if (canDo(\"blockAuthor\") && opts.handlers.onBlockAuthor) {\n    items.push({\n      label: opts.labels.blockAuthor ?? \"Block author\",\n      icon: opts.icons?.blockAuthor,\n      destructive: true,\n      onClick: () => opts.handlers.onBlockAuthor!(opts.story.userId),\n    });\n  }\n  if (canDo(\"report\") && opts.handlers.onReport) {\n    items.push({\n      label: opts.labels.report ?? \"Report\",\n      icon: opts.icons?.report,\n      destructive: true,\n      onClick: () => opts.handlers.onReport!(opts.story.id),\n    });\n  }\n\n  // ─── Owner destructive (Delete — always last) ─────────────────────────\n  if (canDo(\"deleteStory\") && opts.handlers.onDeleteStory) {\n    items.push({\n      label: opts.labels.deleteStory ?? \"Delete story\",\n      icon: opts.icons?.deleteStory,\n      destructive: true,\n      onClick: () => opts.handlers.onDeleteStory!(opts.story.id),\n    });\n  }\n\n  return items;\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/lib/kebab.ts"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/link-cta.tsx",
      "content": "\"use client\";\n\nimport { type ComponentProps, type ElementType, useEffect, useRef, useState } from \"react\";\nimport { Link as LinkIcon, X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport type { ResolvedStoryViewer01Labels, Story, StoryItem } from \"../types\";\n\nexport interface LinkCtaProps {\n  story: Story;\n  item: StoryItem;\n  /**\n   * Polymorphic root for the CTA. Defaults to `\"a\"`; consumers pass\n   * framework-specific link components (e.g. Next.js `<Link>`, react-router\n   * `<Link>`) to get client-side nav semantics. The rewriter does NOT touch\n   * polymorphic component identifiers, so this is safe across registry CLI\n   * installs (F-cross-13 safe).\n   */\n  linkComponent?: ElementType;\n  onLinkClick?: (storyId: string, itemId: string, url: string) => void;\n  labels: ResolvedStoryViewer01Labels;\n}\n\n/**\n * Top-anchored link affordance for `StoryItem.link`.\n *\n * v0.2.0 — was a bottom CTA button at `bottom-32` (collided visually with\n * the DM bar + the engagement column).\n *\n * v0.3.8 — redesigned as a collapsible drawer at the top of the visual\n * area:\n *   - Collapsed: small rounded chip in the top-right (link icon + host\n *     domain) — minimal visual footprint.\n *   - Expanded: the chip stays, plus a wider banner slides down below it\n *     with the host domain + the CTA button + a close X. Tap the chip\n *     again or X to collapse.\n *   - The actual link/anchor is rendered on the CTA button inside the\n *     expanded banner; tapping the chip alone does NOT navigate — it\n *     reveals the banner. This matches Instagram-canonical link-sticker UX.\n *\n * Polymorphic root via `linkComponent`. `target=\"_blank\"` +\n * `rel=\"noopener noreferrer\"` are applied ONLY when the root is the\n * default `\"a\"`. If `onLinkClick` is supplied, it fires alongside the\n * default href navigation (host can `preventDefault` inside the handler).\n *\n * Resets to collapsed on cursor change (caller controls mount/unmount via\n * key on the storyId+itemId pair effectively because the part is mounted\n * conditionally on `item.link`; story-viewer.tsx unmounts + remounts\n * across items naturally).\n */\nexport function LinkCta({\n  item,\n  linkComponent: LinkComponent = \"a\",\n  onLinkClick,\n  labels,\n  story,\n}: LinkCtaProps) {\n  const [expanded, setExpanded] = useState(false);\n  const containerRef = useRef<HTMLDivElement | null>(null);\n\n  // Outside-pointer-down collapses the drawer.\n  useEffect(() => {\n    if (!expanded) return;\n    const handler = (e: PointerEvent) => {\n      const target = e.target as Node | null;\n      if (containerRef.current && target && !containerRef.current.contains(target)) {\n        setExpanded(false);\n      }\n    };\n    document.addEventListener(\"pointerdown\", handler);\n    return () => document.removeEventListener(\"pointerdown\", handler);\n  }, [expanded]);\n\n  if (!item.link) return null;\n  const isDefaultAnchor = LinkComponent === \"a\";\n\n  // Parse host for the chip subtitle. Falls back to the raw URL.\n  let host = \"\";\n  try {\n    host = new URL(item.link.url).host;\n  } catch {\n    host = item.link.url;\n  }\n\n  const handleNavClick = onLinkClick\n    ? () => onLinkClick(story.id, item.id, item.link!.url)\n    : undefined;\n  const anchorAttrs = isDefaultAnchor\n    ? {\n        target: \"_blank\" as const,\n        rel: \"noopener noreferrer\" as const,\n      }\n    : {};\n  const ctaProps: ComponentProps<\"a\"> = {\n    href: item.link.url,\n    onClick: handleNavClick,\n    className: cn(\n      buttonVariants({ variant: \"default\", size: \"default\" }),\n      \"h-10 w-full text-sm font-semibold\",\n    ),\n    ...anchorAttrs,\n  };\n\n  const ctaLabel = item.link.cta ?? labels.openLink;\n\n  return (\n    <div\n      ref={containerRef}\n      // ProgressBars sit at top z-20 + ViewerHeader at top z-20 with `top-4`.\n      // Place the chip at `top-16` so it sits just under the header strip.\n      className=\"pointer-events-auto absolute top-16 right-3 z-25 flex flex-col items-end gap-2\"\n    >\n      {/* Chip — always visible while the link is set. Tapping toggles the\n          drawer below. */}\n      <button\n        type=\"button\"\n        onClick={() => setExpanded((v) => !v)}\n        aria-expanded={expanded ? \"true\" : \"false\"}\n        aria-label={ctaLabel}\n        className={cn(\n          \"flex items-center gap-2 rounded-full bg-black/55 px-3 py-1.5 text-xs font-medium text-white\",\n          \"backdrop-blur-sm transition-colors\",\n          \"hover:bg-black/70 focus-visible:bg-black/70 focus-visible:outline-none\",\n          \"focus-visible:ring-2 focus-visible:ring-white/40\",\n        )}\n      >\n        <LinkIcon className=\"h-3.5 w-3.5\" />\n        <span className=\"max-w-32 truncate\">{host}</span>\n      </button>\n      {/* Drawer — slides down + fades in when expanded. Closes via the X\n          button (top-right of the drawer) or outside-pointer-down. */}\n      <div\n        className={cn(\n          \"w-72 max-w-[calc(100vw-1.5rem)] rounded-xl bg-background text-foreground shadow-2xl\",\n          \"origin-top-right transition-all duration-200 ease-out\",\n          expanded\n            ? \"scale-100 opacity-100 translate-y-0\"\n            : \"pointer-events-none scale-95 opacity-0 -translate-y-2\",\n        )}\n        aria-hidden={expanded ? undefined : \"true\"}\n      >\n        <div className=\"flex items-center justify-between gap-2 border-b border-border px-3 py-2\">\n          <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n            <LinkIcon className=\"h-3.5 w-3.5 shrink-0 text-muted-foreground\" />\n            <span className=\"min-w-0 truncate text-xs text-muted-foreground\">\n              {host}\n            </span>\n          </div>\n          <button\n            type=\"button\"\n            onClick={() => setExpanded(false)}\n            aria-label={labels.linkCloseLabel}\n            className={cn(\n              \"flex h-7 w-7 shrink-0 items-center justify-center rounded-full\",\n              \"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\",\n              \"focus-visible:bg-muted focus-visible:text-foreground focus-visible:outline-none\",\n              \"focus-visible:ring-2 focus-visible:ring-ring\",\n            )}\n          >\n            <X className=\"h-3.5 w-3.5\" />\n          </button>\n        </div>\n        <div className=\"p-3\">\n          <LinkComponent {...ctaProps}>{ctaLabel}</LinkComponent>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/link-cta.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/hooks/use-long-press-pause.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef } from \"react\";\n\nexport interface UseLongPressPauseOptions {\n  isOpen: boolean;\n  /**\n   * Hold duration in ms before pause kicks in. Default 200 — matches the\n   * Instagram-feel mobile gesture. Tunable via\n   * {@link StoryViewerProps.longPressThresholdMs}.\n   */\n  longPressThresholdMs?: number;\n  onPause: () => void;\n  onResume: () => void;\n}\n\n/**\n * v0.2.0 — additive long-press-to-pause gesture (Instagram-canonical mobile\n * pattern). Coexists with v0.1 middle-tap-pause (TapZones middle column) —\n * long-press is the primary mobile gesture; tap-to-pause stays as desktop\n * fallback (Q-V8 lock).\n *\n * Behavior: pointerdown starts a timer; if held past\n * `longPressThresholdMs`, `onPause()` fires + the press is marked active.\n * On pointerup (or pointercancel), if the press WAS active, `onResume()`\n * fires; if not yet active (released early), the timer is cleared without\n * pausing — so short taps continue to flow through TapZones normally.\n *\n * Wire onto the outer wrapping `<div>` of the viewer. Returns three handlers\n * to be spread as `onPointerDown` / `onPointerUp` / `onPointerCancel`. The\n * hook does NOT call `preventDefault` — TapZones' click handlers still fire\n * for short taps, preserving v0.1 single-tap-pause behavior.\n */\nexport function useLongPressPause(opts: UseLongPressPauseOptions) {\n  const { isOpen, longPressThresholdMs = 200, onPause, onResume } = opts;\n  const timerRef = useRef<number | null>(null);\n  const isPressingRef = useRef(false);\n\n  // Refs-mirror so the handlers are stable identity across renders.\n  const onPauseRef = useRef(onPause);\n  const onResumeRef = useRef(onResume);\n  useEffect(() => {\n    onPauseRef.current = onPause;\n    onResumeRef.current = onResume;\n  });\n\n  const handlePointerDown = useCallback(() => {\n    if (!isOpen) return;\n    if (timerRef.current != null) return;\n    timerRef.current = window.setTimeout(() => {\n      isPressingRef.current = true;\n      onPauseRef.current();\n    }, longPressThresholdMs);\n  }, [isOpen, longPressThresholdMs]);\n\n  const handlePointerUp = useCallback(() => {\n    if (timerRef.current != null) {\n      clearTimeout(timerRef.current);\n      timerRef.current = null;\n    }\n    if (isPressingRef.current) {\n      isPressingRef.current = false;\n      onResumeRef.current();\n    }\n  }, []);\n\n  // Cleanup on unmount.\n  useEffect(\n    () => () => {\n      if (timerRef.current != null) clearTimeout(timerRef.current);\n    },\n    [],\n  );\n\n  return {\n    handlePointerDown,\n    handlePointerUp,\n    handlePointerCancel: handlePointerUp,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/hooks/use-long-press-pause.ts"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/comments-panel.tsx",
      "content": "\"use client\";\n\nimport { type ReactNode } from \"react\";\nimport type { ResolvedStoryViewer01Labels, Story, StoryItem } from \"../types\";\nimport { BottomSheet } from \"./bottom-sheet\";\n\nexport interface CommentsPanelProps {\n  story: Story;\n  item: StoryItem;\n  open: boolean;\n  onClose: () => void;\n  labels: ResolvedStoryViewer01Labels;\n  /**\n   * Custom content (typically `<CommentThread />` wired with per-item\n   * comments + onAddComment + onLoadMore). When absent, a default\n   * empty-state is rendered.\n   */\n  children?: ReactNode;\n}\n\n/**\n * v0.3.0 — Instagram-style comments panel. Thin wrapper over `BottomSheet`\n * with comments-specific labels and an empty-state fallback.\n */\nexport function CommentsPanel({\n  story,\n  open,\n  onClose,\n  labels,\n  children,\n}: CommentsPanelProps) {\n  void story;\n  return (\n    <BottomSheet\n      open={open}\n      onClose={onClose}\n      heading={labels.commentsHeading}\n      closeLabel={labels.commentsCloseLabel}\n    >\n      {children ?? (\n        <div className=\"flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground\">\n          {labels.commentsDefaultEmptyState}\n        </div>\n      )}\n    </BottomSheet>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/comments-panel.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/bottom-sheet.tsx",
      "content": "\"use client\";\n\nimport { type ReactNode } from \"react\";\nimport { X } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Button } from \"@/components/ui/button\";\n\nexport interface BottomSheetProps {\n  /** Whether the sheet is currently open. Drives translateY animation. */\n  open: boolean;\n  /** Fires when the user dismisses the sheet via the close button. */\n  onClose: () => void;\n  /** Heading label shown in the chrome row. */\n  heading: string;\n  /** aria-label on the close-X button. */\n  closeLabel: string;\n  /** Sheet height as a percentage of the parent. Default 62. */\n  heightPct?: number;\n  /** Sheet body. Sheet owns scroll. */\n  children?: ReactNode;\n  /** Optional className override for the outer sheet wrapper. */\n  className?: string;\n}\n\n/**\n * v0.3.1 — shared bottom-sheet chrome used by the comments panel + share\n * panel (and potentially other future v0.3+ panels). Renders a rounded-top\n * sheet with a drag-handle bar + heading row + close button + scrollable\n * content area.\n *\n * Always mounted (DOM persistence keeps consumer-side state — typically the\n * draft composer inside `<CommentThread />` or the search-query state\n * inside `<ShareMenu />` — alive across open/close cycles). Hidden via\n * `translate-y-full` when `open` is false; pointer-events disabled in that\n * state so clicks pass through to the visual content below.\n */\nexport function BottomSheet({\n  open,\n  onClose,\n  heading,\n  closeLabel,\n  heightPct = 62,\n  children,\n  className,\n}: BottomSheetProps) {\n  return (\n    <div\n      className={cn(\n        \"absolute right-0 bottom-0 left-0 z-40 flex flex-col rounded-t-2xl bg-background text-foreground shadow-2xl transition-transform duration-300 ease-out\",\n        open ? \"translate-y-0 pointer-events-auto\" : \"translate-y-full pointer-events-none\",\n        className,\n      )}\n      style={{ height: `${heightPct}%` }}\n      role=\"dialog\"\n      aria-modal={open ? \"true\" : undefined}\n      aria-label={heading}\n      aria-hidden={open ? undefined : \"true\"}\n    >\n      {/* Drag handle bar — visual affordance only (no drag logic in v0.3). */}\n      <div className=\"flex shrink-0 items-center justify-center pt-2 pb-1\">\n        <div className=\"h-1 w-10 rounded-full bg-muted-foreground/30\" />\n      </div>\n      <div className=\"flex shrink-0 items-center justify-between border-b border-border px-4 py-3\">\n        <p className=\"text-sm font-semibold\">{heading}</p>\n        <Button\n          type=\"button\"\n          variant=\"ghost\"\n          size=\"icon\"\n          className=\"h-9 w-9\"\n          onClick={onClose}\n          aria-label={closeLabel}\n        >\n          <X className=\"h-5 w-5\" />\n        </Button>\n      </div>\n      <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\">\n        {children}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/bottom-sheet.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/share-panel.tsx",
      "content": "\"use client\";\n\nimport { type ReactNode } from \"react\";\nimport type { ResolvedStoryViewer01Labels, Story, StoryItem } from \"../types\";\nimport { BottomSheet } from \"./bottom-sheet\";\n\nexport interface SharePanelProps {\n  story: Story;\n  item: StoryItem;\n  open: boolean;\n  onClose: () => void;\n  labels: ResolvedStoryViewer01Labels;\n  /**\n   * Custom content (typically `<ShareMenu />` from engagement-bar wired\n   * with the recent-shareable-users list + onShareTo). When absent, a\n   * default empty-state is rendered.\n   */\n  children?: ReactNode;\n}\n\n/**\n * v0.3.1 — Instagram-style share panel. Mirrors `CommentsPanel` for the\n * share affordance; opens when the share icon is tapped in the engagement\n * overlay. Host typically mounts `<ShareMenu />` (from `@ilinxa/engagement-bar`)\n * inside via `renderSharePanel`.\n */\nexport function SharePanel({\n  story,\n  open,\n  onClose,\n  labels,\n  children,\n}: SharePanelProps) {\n  void story;\n  return (\n    <BottomSheet\n      open={open}\n      onClose={onClose}\n      heading={labels.shareHeading}\n      closeLabel={labels.shareCloseLabel}\n    >\n      {children ?? (\n        <div className=\"flex h-full items-center justify-center px-6 text-center text-sm text-muted-foreground\">\n          {labels.shareDefaultEmptyState}\n        </div>\n      )}\n    </BottomSheet>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/share-panel.tsx"
    },
    {
      "path": "src/registry/components/media/story-viewer/hooks/use-cube-transition.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\n/**\n * v0.4.0 — Instagram-canonical 3D cube transition controller.\n * v0.4.1 — Extended with `dragging` mode for finger-following swipe.\n *\n * Modes:\n *   - \"auto\" — click / auto-advance / keyboard / programmatic. Mounts ONE\n *     leaving face. Cube animates `0 → ∓90deg` over `durationMs`, then idles.\n *   - \"dragging\" — user drag in progress. Mounts BOTH prev + next ghost\n *     faces. Angle is driven directly by pointer events via the\n *     `--story-cube-angle` CSS var (no React render per frame).\n *   - \"releasing\" — drag released. Angle animates from its current value to\n *     the target (`-90` commit-next / `+90` commit-prev / `0` snap-back) over\n *     `durationMs`. After completion, the supplied `onComplete` callback\n *     fires and state returns to idle.\n *\n * Angle is ALWAYS driven via DOM CSS variable mutated through the rotator\n * ref — never React state. That avoids cascading-render warnings AND\n * skips re-renders per animation frame.\n */\nexport type CubeDirection = \"next\" | \"prev\";\n\nexport interface CubeFaceSnapshot {\n  storyId: string;\n  itemId: string;\n  itemIndex: number;\n  progress: number;\n}\n\nexport type CubeTransitionState =\n  | { phase: \"idle\" }\n  | {\n      // Auto-triggered (click / auto-advance / keyboard / programmatic).\n      // ONE ghost face: the leaving story. Cube animates 0 → ∓90deg.\n      phase: \"auto\";\n      direction: CubeDirection;\n      leaving: CubeFaceSnapshot;\n    }\n  | {\n      // User is actively dragging. BOTH prev + next ghosts mounted (drag\n      // could go either way). Angle = pointer-driven.\n      phase: \"dragging\";\n      prev: CubeFaceSnapshot | null;\n      next: CubeFaceSnapshot | null;\n    }\n  | {\n      // User released; angle is animating to its commit/snapback target.\n      // Same dual-ghost mount as dragging until the animation completes.\n      phase: \"releasing\";\n      prev: CubeFaceSnapshot | null;\n      next: CubeFaceSnapshot | null;\n    };\n\nexport interface UseCubeTransitionResult {\n  state: CubeTransitionState;\n  /** Callback ref — attach to the cube rotator element. */\n  setRotatorRef: (el: HTMLDivElement | null) => void;\n\n  /**\n   * Auto-animate path. Called when a story-level cursor change is\n   * detected. Renders the cube with a single ghost, sweeps the rotator\n   * from `0 → ∓90deg`, then returns to idle.\n   */\n  startAuto: (args: {\n    direction: CubeDirection;\n    leaving: CubeFaceSnapshot;\n  }) => void;\n\n  /**\n   * Begin a drag gesture. Mounts the cube with BOTH prev + next ghosts\n   * (whichever exist). Angle starts at 0 and is driven by `setDragAngle`.\n   */\n  beginDrag: (args: {\n    prev: CubeFaceSnapshot | null;\n    next: CubeFaceSnapshot | null;\n  }) => void;\n\n  /**\n   * Drive the cube angle during drag. Mutates `--story-cube-angle`\n   * directly on the rotator — does NOT trigger a React render.\n   */\n  setDragAngle: (deg: number) => void;\n\n  /**\n   * Release a drag. Animates the cube from its current angle to the\n   * target. `onComplete` fires once the timer elapses (use it to commit\n   * a cursor change if `target` is ±90, or do nothing on snap-back).\n   * The cube returns to idle automatically.\n   */\n  releaseDrag: (args: {\n    targetDeg: number;\n    onComplete?: () => void;\n  }) => void;\n\n  /** Snap the cube to idle immediately (no animation). Used on viewer close. */\n  forceIdle: () => void;\n}\n\nexport function useCubeTransition(durationMs = 400): UseCubeTransitionResult {\n  const [state, setState] = useState<CubeTransitionState>({ phase: \"idle\" });\n  const rotatorRef = useRef<HTMLDivElement | null>(null);\n  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const rafRef = useRef<number | null>(null);\n  // Pending onComplete for `releaseDrag` — fires when its timer elapses.\n  const completeRef = useRef<(() => void) | null>(null);\n\n  // ─── Helpers ────────────────────────────────────────────────────────────\n  const writeAngle = useCallback((value: string | null) => {\n    const el = rotatorRef.current;\n    if (!el) return;\n    if (value == null) {\n      el.style.removeProperty(\"--story-cube-angle\");\n    } else {\n      el.style.setProperty(\"--story-cube-angle\", value);\n    }\n  }, []);\n\n  const clearTimers = useCallback(() => {\n    if (rafRef.current != null) {\n      cancelAnimationFrame(rafRef.current);\n      rafRef.current = null;\n    }\n    if (timerRef.current) {\n      clearTimeout(timerRef.current);\n      timerRef.current = null;\n    }\n  }, []);\n\n  // ─── Auto-mode driver ──────────────────────────────────────────────────\n  // When state enters \"auto\", run the 0 → ∓90 sweep then return to idle.\n  useEffect(() => {\n    if (state.phase !== \"auto\") return;\n    const el = rotatorRef.current;\n    if (!el) {\n      // Rotator hasn't mounted yet — fall back to a timer to drop back to\n      // idle so we don't leak the auto phase.\n      const fallback = setTimeout(() => setState({ phase: \"idle\" }), durationMs);\n      return () => clearTimeout(fallback);\n    }\n    // Frame 1: pin at 0deg.\n    writeAngle(\"0deg\");\n    // Force commit so the next write actually animates.\n    void el.offsetWidth;\n    // Frame 2: commit target. CSS transition runs.\n    rafRef.current = requestAnimationFrame(() => {\n      writeAngle(state.direction === \"next\" ? \"-90deg\" : \"90deg\");\n    });\n    timerRef.current = setTimeout(() => {\n      setState({ phase: \"idle\" });\n    }, durationMs);\n    return () => {\n      clearTimers();\n      writeAngle(null);\n    };\n  }, [state, durationMs, writeAngle, clearTimers]);\n\n  // ─── Releasing-mode driver ─────────────────────────────────────────────\n  // When state enters \"releasing\", the angle has already been written by\n  // `releaseDrag` (so the CSS transition picks up between the current and\n  // target angles). We only need the timer for the onComplete + idle.\n  useEffect(() => {\n    if (state.phase !== \"releasing\") return;\n    timerRef.current = setTimeout(() => {\n      const cb = completeRef.current;\n      completeRef.current = null;\n      cb?.();\n      setState({ phase: \"idle\" });\n    }, durationMs);\n    return () => {\n      clearTimers();\n      writeAngle(null);\n    };\n  }, [state, durationMs, writeAngle, clearTimers]);\n\n  // ─── Public API ────────────────────────────────────────────────────────\n  const setRotatorRef = useCallback((el: HTMLDivElement | null) => {\n    rotatorRef.current = el;\n  }, []);\n\n  const startAuto = useCallback<UseCubeTransitionResult[\"startAuto\"]>(\n    (args) => {\n      setState({ phase: \"auto\", direction: args.direction, leaving: args.leaving });\n    },\n    [],\n  );\n\n  const beginDrag = useCallback<UseCubeTransitionResult[\"beginDrag\"]>((args) => {\n    setState({ phase: \"dragging\", prev: args.prev, next: args.next });\n    // Pin angle to 0 initially. Pointer events drive subsequent updates.\n    // Hold a microtask so the rotator ref is wired in the upcoming render.\n    queueMicrotask(() => writeAngle(\"0deg\"));\n  }, [writeAngle]);\n\n  const setDragAngle = useCallback<UseCubeTransitionResult[\"setDragAngle\"]>(\n    (deg) => {\n      writeAngle(`${deg}deg`);\n    },\n    [writeAngle],\n  );\n\n  const releaseDrag = useCallback<UseCubeTransitionResult[\"releaseDrag\"]>(\n    (args) => {\n      completeRef.current = args.onComplete ?? null;\n      // Commit target angle immediately — the rotator already has its CSS\n      // transition wired, so the browser animates from the current\n      // pointer-driven angle to the target.\n      writeAngle(`${args.targetDeg}deg`);\n      // Move state to releasing — keeps the cube structure mounted while\n      // the CSS transition completes; the effect above schedules the\n      // idle reset + onComplete.\n      setState((prev) =>\n        prev.phase === \"dragging\"\n          ? { phase: \"releasing\", prev: prev.prev, next: prev.next }\n          : prev,\n      );\n    },\n    [writeAngle],\n  );\n\n  const forceIdle = useCallback(() => {\n    completeRef.current = null;\n    clearTimers();\n    writeAngle(null);\n    // Bail if already idle — avoids a needless re-render that can chain\n    // through effects depending on the returned `cube` object.\n    setState((prev) => (prev.phase === \"idle\" ? prev : { phase: \"idle\" }));\n  }, [clearTimers, writeAngle]);\n\n  return {\n    state,\n    setRotatorRef,\n    startAuto,\n    beginDrag,\n    setDragAngle,\n    releaseDrag,\n    forceIdle,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/hooks/use-cube-transition.ts"
    },
    {
      "path": "src/registry/components/media/story-viewer/parts/story-cube-face.tsx",
      "content": "import { ProgressBars } from \"./progress-bars\";\nimport { ViewerHeader } from \"./viewer-header\";\nimport type { ResolvedStoryViewer01Labels, Story, StoryItem } from \"../types\";\n\nexport interface StoryCubeFaceProps {\n  story: Story;\n  item: StoryItem;\n  itemIndex: number;\n  progress: number;\n  isMuted: boolean;\n  labels: ResolvedStoryViewer01Labels;\n}\n\n/**\n * v0.4.0 — Static ghost render of the LEAVING story during a cube\n * transition. Renders progress bars + header chrome + the bare media\n * element (image or video poster), all visually frozen. No interactivity\n * (pointer-events: none on the host face). Lives ~`durationMs` and then\n * unmounts.\n *\n * For video items we mount a raw `<video>` with `preload=\"metadata\"` and\n * no autoplay — it shows the first frame as a poster. The fully-fledged\n * VideoPlayer is intentionally NOT used here (a fresh mount would\n * restart playback from t=0 on the ghost face, which is jankier than a\n * static poster).\n */\nexport function StoryCubeFace({\n  story,\n  item,\n  itemIndex,\n  progress,\n  isMuted,\n  labels,\n}: StoryCubeFaceProps) {\n  return (\n    <>\n      <ProgressBars\n        items={story.items}\n        currentItemIndex={itemIndex}\n        progress={progress}\n      />\n      <ViewerHeader\n        story={story}\n        item={item}\n        isPaused={false}\n        isMuted={isMuted}\n        onTogglePause={() => {}}\n        onToggleMute={() => {}}\n        onClose={() => {}}\n        formatTime={labels.formatTime}\n        labels={labels}\n      />\n      <div className=\"absolute inset-0\">\n        {item.type === \"image\" ? (\n          <img\n            src={item.src}\n            alt={labels.itemImageAlt(story, itemIndex, story.items.length)}\n            className=\"h-full w-full object-cover\"\n          />\n        ) : (\n          <video\n            src={item.src}\n            muted\n            playsInline\n            preload=\"metadata\"\n            className=\"h-full w-full object-cover\"\n          />\n        )}\n      </div>\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/story-viewer/parts/story-cube-face.tsx"
    }
  ],
  "categories": [
    "media"
  ],
  "type": "registry:block"
}