{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "story-rail",
  "title": "Story Rail",
  "author": "ilinxa",
  "description": "Horizontal story rail with unread gradient rings, drag-free skim scrolling, an add-story tile, and edge-fade gradients.",
  "dependencies": [
    "lucide-react",
    "embla-carousel-react"
  ],
  "registryDependencies": [
    "avatar"
  ],
  "files": [
    {
      "path": "src/registry/components/data/story-rail/story-rail.tsx",
      "content": "\"use client\";\n\nimport {\n  memo,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n} from \"react\";\nimport useEmblaCarousel from \"embla-carousel-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  DEFAULT_STORY_RAIL_LABELS,\n  type StoryRailHandle,\n  type StoryRailLabels,\n  type StoryRailProps,\n  type StoryRailItem,\n} from \"./types\";\nimport { useStoryRailState } from \"./hooks/use-story-rail-state\";\nimport { StoryThumbnail } from \"./parts/story-thumbnail\";\n\ninterface StoryRailInnerProps extends StoryRailProps {\n  ref?: React.Ref<StoryRailHandle>;\n}\n\nfunction StoryRailInner(props: StoryRailInnerProps) {\n  const {\n    items: initialItems,\n    leading,\n    framed = true,\n    subscribe,\n    onSubscribeDelta,\n    onItemClick,\n    linkComponent,\n    getHref,\n    renderThumbnail,\n    emptyState,\n    labels: labelsProp,\n    className,\n    thumbnailClassName,\n    ref,\n  } = props;\n\n  // Adapter for internal parts (StoryThumbnail + renderThumbnail slot) that\n  // still use a positional (item, index) shape. Maps to the public object-\n  // shape `onItemClick({ item, index })`.\n  const handlePartItemClick: ((item: StoryRailItem, index: number) => void) | undefined =\n    onItemClick\n      ? (item, index) => onItemClick({ item, index })\n      : undefined;\n\n  const labels = useMemo<\n    Required<Omit<StoryRailLabels, \"thumbnailAriaLabel\">> & {\n      thumbnailAriaLabel?: (item: StoryRailItem) => string;\n    }\n  >(\n    () => ({\n      ...DEFAULT_STORY_RAIL_LABELS,\n      ...labelsProp,\n    }),\n    [labelsProp],\n  );\n\n  const { items, dispatch } = useStoryRailState({\n    initialItems,\n    subscribe,\n    onSubscribeDelta,\n  });\n\n  const emblaOptions = useMemo(\n    () => ({\n      align: \"start\" as const,\n      containScroll: \"trimSnaps\" as const,\n      dragFree: true,\n    }),\n    [],\n  );\n  const [emblaRef, emblaApi] = useEmblaCarousel(emblaOptions);\n\n  // Stable ref for the imperative handle.\n  const itemsRef = useRef<StoryRailItem[]>(items);\n  useEffect(() => {\n    itemsRef.current = items;\n  });\n\n  useImperativeHandle(\n    ref,\n    () => ({\n      scrollTo: (index: number) => {\n        emblaApi?.scrollTo(index);\n      },\n      getCurrentItems: () => itemsRef.current,\n      reset: (next: StoryRailItem[]) => dispatch({ kind: \"reset\", next }),\n      dispatch,\n      markViewed: (itemId: string) =>\n        dispatch({ kind: \"viewed\", itemId }),\n    }),\n    [emblaApi, dispatch],\n  );\n\n  const rootId = useId();\n  const showEmpty = items.length === 0 && !subscribe && !leading;\n  const showRail = !showEmpty;\n\n  return (\n    <section\n      role=\"region\"\n      aria-label={labels.railLabel}\n      className={cn(\n        \"relative overflow-hidden\",\n        framed ? \"rounded-xl border border-border/50 bg-card p-4\" : \"\",\n        className,\n      )}\n    >\n      {showRail ? (\n        <>\n          {/* Edge fade gradients render only when items are present (Q-P22). */}\n          <div\n            aria-hidden=\"true\"\n            className={cn(\n              \"pointer-events-none absolute inset-y-0 z-10 w-12 bg-linear-to-r to-transparent\",\n              framed ? \"left-4 from-card\" : \"left-0 from-background\",\n            )}\n          />\n          <div\n            aria-hidden=\"true\"\n            className={cn(\n              \"pointer-events-none absolute inset-y-0 z-10 w-12 bg-linear-to-l to-transparent\",\n              framed ? \"right-4 from-card\" : \"right-0 from-background\",\n            )}\n          />\n          <div className=\"overflow-hidden\" ref={emblaRef}>\n            <div className=\"flex gap-3 px-2\">\n              {leading ? <div className=\"shrink-0\">{leading}</div> : null}\n              {items.map((item, index) => (\n                <div key={item.id} className=\"shrink-0\">\n                  {renderThumbnail\n                    ? renderThumbnail(item, !!item.hasUnread, {\n                        index,\n                        onClick: () => handlePartItemClick?.(item, index),\n                        baseId: `${rootId}-${item.id}`,\n                      })\n                    : (\n                        <StoryThumbnail\n                          item={item}\n                          index={index}\n                          baseId={`${rootId}-${item.id}`}\n                          onItemClick={handlePartItemClick}\n                          getHref={getHref}\n                          linkComponent={linkComponent}\n                          labels={labels}\n                          className={thumbnailClassName}\n                        />\n                      )}\n                </div>\n              ))}\n            </div>\n          </div>\n        </>\n      ) : (\n        emptyState ?? (\n          <p\n            role=\"status\"\n            className=\"py-6 text-center text-sm text-muted-foreground\"\n          >\n            {labels.emptyState}\n          </p>\n        )\n      )}\n    </section>\n  );\n}\n\nconst StoryRail = memo(StoryRailInner);\nStoryRail.displayName = \"StoryRail\";\n\nexport { StoryRail };\n",
      "type": "registry:component",
      "target": "components/story-rail/story-rail.tsx"
    },
    {
      "path": "src/registry/components/data/story-rail/index.ts",
      "content": "export { StoryRail } from \"./story-rail\";\nexport { AddStoryThumbnail } from \"./parts/add-story-thumbnail\";\nexport type { AddStoryThumbnailProps } from \"./parts/add-story-thumbnail\";\n\nexport {\n  storyRailReducer,\n  useStoryRailState,\n  type UseStoryRailStateOptions,\n  type UseStoryRailStateResult,\n} from \"./hooks/use-story-rail-state\";\n\nexport type {\n  StoryRailItem,\n  StoryRailProps,\n  StoryRailHandle,\n  StoryRailLabels,\n  StoryRailDelta,\n  StoryRailLocalAction,\n  ThumbnailRenderHelpers,\n  Subscribe,\n  Unsubscribe,\n} from \"./types\";\n\nexport { DEFAULT_STORY_RAIL_LABELS } from \"./types\";\n\n",
      "type": "registry:component",
      "target": "components/story-rail/index.ts"
    },
    {
      "path": "src/registry/components/data/story-rail/types.ts",
      "content": "import type { ElementType, ReactNode } from \"react\";\n\nexport interface StoryRailItem {\n  id: string;\n  username: string;\n  avatar?: string;\n  /** Rectangular preview shown inside the gradient/muted ring. */\n  previewImage: string;\n  /** Drives ring visual: gradient (unread) vs muted (read). Default false. */\n  hasUnread?: boolean;\n  /** Optional — exposed for downstream story-viewer wiring. */\n  userId?: string;\n}\n\nexport type StoryRailDelta =\n  | { kind: \"added\"; item: StoryRailItem; position?: \"start\" | \"end\" }\n  | { kind: \"removed\"; itemId: string }\n  | { kind: \"viewed\"; itemId: string }\n  | {\n      kind: \"updated\";\n      itemId: string;\n      partial: Partial<StoryRailItem>;\n    };\n\nexport type Unsubscribe = () => void;\nexport type Subscribe<T> = (handler: (delta: T) => void) => Unsubscribe;\n\nexport interface StoryRailLabels {\n  /** Default \"Stories\". aria-label on the rail's <section role=\"region\">. */\n  railLabel?: string;\n  /** Default \"Add story\". Visible label below the AddStoryThumbnail. */\n  addStoryLabel?: string;\n  /** Default \"Add a story\". aria-label for the AddStoryThumbnail button. */\n  addStoryAriaLabel?: string;\n  /** Function so hosts can pluralize / localize. Default returns \"{username}, unread story\" / \"{username}, viewed\". */\n  thumbnailAriaLabel?: (item: StoryRailItem) => string;\n  /** Default \"No stories yet.\" Renders when items.length === 0 AND no leading AND no realtime. */\n  emptyState?: string;\n}\n\nexport const DEFAULT_STORY_RAIL_LABELS: Required<\n  Omit<StoryRailLabels, \"thumbnailAriaLabel\">\n> = {\n  railLabel: \"Stories\",\n  addStoryLabel: \"Add story\",\n  addStoryAriaLabel: \"Add a story\",\n  emptyState: \"No stories yet.\",\n};\n\nexport type StoryRailLocalAction =\n  | { kind: \"add\"; item: StoryRailItem; position?: \"start\" | \"end\" }\n  | { kind: \"remove\"; itemId: string }\n  | { kind: \"viewed\"; itemId: string }\n  | { kind: \"update\"; itemId: string; partial: Partial<StoryRailItem> }\n  | { kind: \"subscribe-delta\"; delta: StoryRailDelta }\n  | { kind: \"reset\"; next: StoryRailItem[] };\n\nexport interface ThumbnailRenderHelpers {\n  index: number;\n  onClick: () => void;\n  /** Stable id for ARIA wiring inside the slot. */\n  baseId: string;\n}\n\nexport interface StoryRailProps {\n  /** Items rendered in the rail. Mount-only initial state — use ref.current.reset() to push updates. */\n  items: StoryRailItem[];\n\n  /** Optional content rendered before items (typically <AddStoryThumbnail>). */\n  leading?: ReactNode;\n\n  /** Wrap the rail in card-style chrome (bg-card + border + rounded). Default true. */\n  framed?: boolean;\n\n  /** Realtime delta stream. Identity-stable required (useCallback host-side). */\n  subscribe?: Subscribe<StoryRailDelta>;\n  onSubscribeDelta?: (delta: StoryRailDelta) => void;\n\n  /**\n   * Click handler — object-shape. Host typically opens a story viewer.\n   * v0.2 cutover from positional `(item, index)`.\n   */\n  onItemClick?: (args: { item: StoryRailItem; index: number }) => void;\n\n  /** Polymorphic root for the thumbnail when getHref provided. Default `\"a\"`. */\n  linkComponent?: ElementType;\n  /** When provided, thumbnail renders as `<linkComponent href={getHref(item)}>` instead of `<button>`. Click also fires onItemClick if both wired. */\n  getHref?: (item: StoryRailItem) => string;\n\n  /** Full-takeover render slot for the thumbnail visual. */\n  renderThumbnail?: (\n    item: StoryRailItem,\n    isUnread: boolean,\n    helpers: ThumbnailRenderHelpers,\n  ) => ReactNode;\n\n  /** Empty-state slot. Wins over labels.emptyState fallback. */\n  emptyState?: ReactNode;\n\n  labels?: StoryRailLabels;\n\n  className?: string;\n  thumbnailClassName?: string;\n\n  ref?: React.Ref<StoryRailHandle>;\n}\n\nexport interface StoryRailHandle {\n  scrollTo: (index: number) => void;\n  getCurrentItems: () => StoryRailItem[];\n  reset: (next: StoryRailItem[]) => void;\n  dispatch: (action: StoryRailLocalAction) => void;\n  /** Mark a story as viewed (sets hasUnread=false). Call from your viewer's onClose. */\n  markViewed: (itemId: string) => void;\n}\n",
      "type": "registry:component",
      "target": "components/story-rail/types.ts"
    },
    {
      "path": "src/registry/components/data/story-rail/parts/story-thumbnail.tsx",
      "content": "\"use client\";\n\nimport { memo, type ElementType } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\";\nimport type { StoryRailLabels, StoryRailItem } from \"../types\";\n\nexport interface StoryThumbnailProps {\n  item: StoryRailItem;\n  index: number;\n  baseId: string;\n  onItemClick?: (item: StoryRailItem, index: number) => void;\n  getHref?: (item: StoryRailItem) => string;\n  linkComponent?: ElementType;\n  labels: Required<Omit<StoryRailLabels, \"thumbnailAriaLabel\">> & {\n    thumbnailAriaLabel?: (item: StoryRailItem) => string;\n  };\n  className?: string;\n}\n\nfunction initials(name: string): string {\n  return (\n    name\n      .trim()\n      .split(/\\s+/)\n      .map((p) => p[0]?.toUpperCase() ?? \"\")\n      .slice(0, 2)\n      .join(\"\") || \"?\"\n  );\n}\n\nfunction defaultAriaLabel(item: StoryRailItem): string {\n  return `${item.username}, ${item.hasUnread ? \"unread story\" : \"viewed\"}`;\n}\n\nfunction StoryThumbnailInner({\n  item,\n  index,\n  baseId,\n  onItemClick,\n  getHref,\n  linkComponent,\n  labels,\n  className,\n}: StoryThumbnailProps) {\n  const isUnread = !!item.hasUnread;\n  const ariaLabel = (labels.thumbnailAriaLabel ?? defaultAriaLabel)(item);\n  const hasLink = !!getHref;\n  const href = hasLink ? getHref!(item) : undefined;\n  const handleClick = () => onItemClick?.(item, index);\n\n  const inner = (\n    <>\n      <div\n        className={cn(\n          \"rounded-2xl p-0.5 transition-all\",\n          isUnread\n            ? \"bg-linear-to-br from-accent via-warning to-destructive\"\n            : \"bg-muted\",\n        )}\n      >\n        <div className=\"h-28 w-20 overflow-hidden rounded-[14px] border-2 border-card bg-card\">\n          <img\n            src={item.previewImage}\n            alt=\"\"\n            className=\"h-full w-full object-cover transition-transform duration-300 motion-safe:group-hover:scale-105\"\n          />\n        </div>\n      </div>\n      <div className=\"flex items-center gap-1.5\">\n        <Avatar className=\"h-5 w-5 border border-card\">\n          {item.avatar ? <AvatarImage src={item.avatar} alt=\"\" /> : null}\n          <AvatarFallback className=\"text-[8px]\">\n            {initials(item.username)}\n          </AvatarFallback>\n        </Avatar>\n        <span\n          id={`${baseId}-name`}\n          className=\"max-w-14 truncate text-center text-xs font-medium\"\n        >\n          {item.username}\n        </span>\n      </div>\n    </>\n  );\n\n  if (hasLink && href) {\n    const LinkComponent = linkComponent ?? \"a\";\n    return (\n      <LinkComponent\n        href={href}\n        onClick={onItemClick ? handleClick : undefined}\n        aria-label={ariaLabel}\n        className={cn(\n          \"group flex shrink-0 flex-col items-center gap-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-2xl\",\n          className,\n        )}\n      >\n        {inner}\n      </LinkComponent>\n    );\n  }\n\n  return (\n    <button\n      type=\"button\"\n      onClick={handleClick}\n      aria-label={ariaLabel}\n      className={cn(\n        \"group flex shrink-0 flex-col items-center gap-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-2xl\",\n        className,\n      )}\n    >\n      {inner}\n    </button>\n  );\n}\n\nexport const StoryThumbnail = memo(StoryThumbnailInner);\nStoryThumbnail.displayName = \"StoryThumbnail\";\n",
      "type": "registry:component",
      "target": "components/story-rail/parts/story-thumbnail.tsx"
    },
    {
      "path": "src/registry/components/data/story-rail/parts/add-story-thumbnail.tsx",
      "content": "\"use client\";\n\nimport { memo } from \"react\";\nimport { Plus } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface AddStoryThumbnailProps {\n  /** Avatar shown at 50% opacity inside the dashed-border placeholder. */\n  userAvatar?: string;\n  onClick?: () => void;\n  /** Visible label below. Default \"Add story\". */\n  label?: string;\n  /** aria-label on the button. Default \"Add a story\". */\n  ariaLabel?: string;\n  className?: string;\n}\n\nfunction AddStoryThumbnailInner({\n  userAvatar,\n  onClick,\n  label = \"Add story\",\n  ariaLabel = \"Add a story\",\n  className,\n}: AddStoryThumbnailProps) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      aria-label={ariaLabel}\n      className={cn(\n        \"group flex shrink-0 flex-col items-center gap-2\",\n        className,\n      )}\n    >\n      <div className=\"relative\">\n        <div className=\"h-28 w-20 overflow-hidden rounded-2xl border-2 border-dashed border-muted-foreground/30 bg-muted/50 transition-colors group-hover:border-primary/50\">\n          {userAvatar ? (\n            <img\n              src={userAvatar}\n              alt=\"\"\n              className=\"h-full w-full object-cover opacity-50\"\n            />\n          ) : (\n            <div\n              className=\"h-full w-full bg-muted\"\n              aria-hidden=\"true\"\n            />\n          )}\n        </div>\n        <span\n          aria-hidden=\"true\"\n          className=\"absolute bottom-1 right-1 flex h-6 w-6 items-center justify-center rounded-full border-2 border-card bg-primary text-primary-foreground shadow-md\"\n        >\n          <Plus className=\"h-3.5 w-3.5\" />\n        </span>\n      </div>\n      <span className=\"w-20 truncate text-center text-xs font-medium text-muted-foreground\">\n        {label}\n      </span>\n    </button>\n  );\n}\n\nexport const AddStoryThumbnail = memo(AddStoryThumbnailInner);\nAddStoryThumbnail.displayName = \"AddStoryThumbnail\";\n",
      "type": "registry:component",
      "target": "components/story-rail/parts/add-story-thumbnail.tsx"
    },
    {
      "path": "src/registry/components/data/story-rail/hooks/use-story-rail-state.ts",
      "content": "\"use client\";\n\nimport { useEffect, useReducer, useRef } from \"react\";\nimport type {\n  StoryRailDelta,\n  StoryRailItem,\n  StoryRailLocalAction,\n  Subscribe,\n} from \"../types\";\n\n// ─── Pure helpers ────────────────────────────────────────────────────────────\n\nfunction insertAtPosition(\n  state: StoryRailItem[],\n  item: StoryRailItem,\n  position: \"start\" | \"end\" | undefined,\n): StoryRailItem[] {\n  if (position === \"end\") return [...state, item];\n  return [item, ...state]; // default \"start\"\n}\n\nfunction removeById(state: StoryRailItem[], id: string): StoryRailItem[] {\n  const next = state.filter((i) => i.id !== id);\n  return next.length === state.length ? state : next;\n}\n\nfunction patchById(\n  state: StoryRailItem[],\n  id: string,\n  patch: Partial<StoryRailItem>,\n): StoryRailItem[] {\n  let mutated = false;\n  const next = state.map((i) => {\n    if (i.id !== id) return i;\n    mutated = true;\n    return { ...i, ...patch };\n  });\n  return mutated ? next : state;\n}\n\n// ─── Reducer ─────────────────────────────────────────────────────────────────\n\n/** Pure reducer — exported for hosts driving their own state. */\nexport function storyRailReducer(\n  state: StoryRailItem[],\n  action: StoryRailLocalAction,\n): StoryRailItem[] {\n  switch (action.kind) {\n    case \"add\":\n      return insertAtPosition(state, action.item, action.position);\n    case \"remove\":\n      return removeById(state, action.itemId);\n    case \"viewed\":\n      return patchById(state, action.itemId, { hasUnread: false });\n    case \"update\":\n      return patchById(state, action.itemId, action.partial);\n    case \"subscribe-delta\": {\n      const d = action.delta;\n      switch (d.kind) {\n        case \"added\":\n          return insertAtPosition(state, d.item, d.position);\n        case \"removed\":\n          return removeById(state, d.itemId);\n        case \"viewed\":\n          return patchById(state, d.itemId, { hasUnread: false });\n        case \"updated\":\n          return patchById(state, d.itemId, d.partial);\n      }\n      return state;\n    }\n    case \"reset\":\n      return action.next;\n  }\n}\n\n// ─── Hook ────────────────────────────────────────────────────────────────────\n\nexport interface UseStoryRailStateOptions {\n  /** Captured ON MOUNT only — subsequent prop changes are ignored. */\n  initialItems: StoryRailItem[];\n  subscribe?: Subscribe<StoryRailDelta>;\n  onSubscribeDelta?: (delta: StoryRailDelta) => void;\n}\n\nexport interface UseStoryRailStateResult {\n  items: StoryRailItem[];\n  dispatch: React.Dispatch<StoryRailLocalAction>;\n}\n\nexport function useStoryRailState(\n  opts: UseStoryRailStateOptions,\n): UseStoryRailStateResult {\n  const [items, dispatch] = useReducer(storyRailReducer, opts.initialItems);\n\n  const onSubscribeDeltaRef = useRef(opts.onSubscribeDelta);\n  useEffect(() => {\n    onSubscribeDeltaRef.current = opts.onSubscribeDelta;\n  });\n\n  const subscribe = opts.subscribe;\n  useEffect(() => {\n    if (!subscribe) return;\n    const unsub = subscribe((delta) => {\n      onSubscribeDeltaRef.current?.(delta);\n      dispatch({ kind: \"subscribe-delta\", delta });\n    });\n    return unsub;\n  }, [subscribe]);\n\n  return { items, dispatch };\n}\n",
      "type": "registry:component",
      "target": "components/story-rail/hooks/use-story-rail-state.ts"
    }
  ],
  "categories": [
    "data"
  ],
  "type": "registry:block"
}