{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "media-carousel",
  "title": "Media Carousel",
  "author": "ilinxa",
  "description": "Embla image and video carousel — gallery or linear variants, coordinated video pause for inactive slides.",
  "dependencies": [
    "embla-carousel-react",
    "embla-carousel",
    "lucide-react"
  ],
  "registryDependencies": [
    "@ilinxa/video-player",
    "button"
  ],
  "files": [
    {
      "path": "src/registry/components/media/media-carousel/media-carousel.tsx",
      "content": "\"use client\";\n\nimport { memo, useMemo } from \"react\";\nimport {\n  DEFAULT_MEDIA_CAROUSEL_LABELS,\n  type MediaCarouselHandle,\n  type MediaCarouselLabels,\n  type MediaCarouselProps,\n} from \"./types\";\nimport { CarouselTrack } from \"./parts/carousel-track\";\nimport { SingleItem } from \"./parts/single-item\";\n\ninterface MediaCarouselInnerProps extends MediaCarouselProps {\n  ref?: React.Ref<MediaCarouselHandle>;\n}\n\nfunction MediaCarouselInner({\n  items,\n  variant,\n  peekRatio = 0.075,\n  aspect = \"square\",\n  loop,\n  showIndicators = true,\n  showNavButtons = true,\n  rtl = false,\n  renderItem,\n  onSlideChange,\n  onDoubleTap,\n  labels: labelsProp,\n  className,\n  slideClassName,\n  ref,\n}: MediaCarouselInnerProps) {\n  const labels = useMemo<Required<MediaCarouselLabels>>(\n    () => ({ ...DEFAULT_MEDIA_CAROUSEL_LABELS, ...labelsProp }),\n    [labelsProp],\n  );\n\n  if (items.length === 0) return null;\n\n  if (items.length === 1) {\n    return (\n      <SingleItem\n        item={items[0]}\n        renderItem={renderItem}\n        onDoubleTap={onDoubleTap}\n        labels={labels}\n        className={className}\n        slideClassName={slideClassName}\n        variant={variant}\n        aspect={aspect}\n      />\n    );\n  }\n\n  return (\n    <CarouselTrack\n      ref={ref}\n      items={items}\n      variant={variant}\n      peekRatio={peekRatio}\n      aspect={aspect}\n      loop={loop ?? true}\n      showIndicators={showIndicators}\n      showNavButtons={showNavButtons}\n      rtl={rtl}\n      renderItem={renderItem}\n      onSlideChange={onSlideChange}\n      onDoubleTap={onDoubleTap}\n      labels={labels}\n      className={className}\n      slideClassName={slideClassName}\n    />\n  );\n}\n\nconst MediaCarousel = memo(MediaCarouselInner);\nMediaCarousel.displayName = \"MediaCarousel\";\n\nexport { MediaCarousel };\nexport default MediaCarousel;\n",
      "type": "registry:component",
      "target": "components/media-carousel/media-carousel.tsx"
    },
    {
      "path": "src/registry/components/media/media-carousel/index.ts",
      "content": "export { default as MediaCarousel } from \"./media-carousel\";\n\nexport type {\n  MediaCarouselProps,\n  MediaCarouselHandle,\n  MediaCarouselLabels,\n  MediaCarouselVariant,\n  MediaItem,\n  RenderItemContext,\n  LinearAspect,\n} from \"./types\";\n\nexport { DEFAULT_MEDIA_CAROUSEL_LABELS } from \"./types\";\n\n",
      "type": "registry:component",
      "target": "components/media-carousel/index.ts"
    },
    {
      "path": "src/registry/components/media/media-carousel/types.ts",
      "content": "import type { ReactNode } from \"react\";\n\nexport type MediaCarouselVariant = \"gallery\" | \"linear\";\n\nexport type LinearAspect = \"square\" | \"video\" | \"portrait\" | \"auto\";\n\n/** Discriminated union — strict shape. */\nexport type MediaItem =\n  | {\n      id: string;\n      type: \"image\";\n      url: string;\n      alt?: string;\n    }\n  | {\n      id: string;\n      type: \"video\";\n      url: string;\n      alt?: string;\n      poster?: string;\n    };\n\nexport interface RenderItemContext {\n  /** True when this slide is the currently centered/snapped slide. */\n  isActive: boolean;\n  /** Index of this slide in the items array. */\n  index: number;\n}\n\nexport interface MediaCarouselLabels {\n  /** Default: \"Media carousel\". aria-label on the carousel region. */\n  carouselLabel?: string;\n  /** Default: \"Previous slide\". aria-label on the prev nav button. */\n  previousSlide?: string;\n  /** Default: \"Next slide\". aria-label on the next nav button. */\n  nextSlide?: string;\n  /** Default: \"Go to slide\". aria-label prefix on indicator dots; full label = \"{prefix} {n}\". */\n  goToSlide?: string;\n  /** Default: \"Slide {index} of {total}\". {index} and {total} are placeholders. */\n  slideAriaLabel?: string;\n}\n\nexport interface MediaCarouselProps {\n  /** Items to render. Required. */\n  items: MediaItem[];\n  /** Variant — `gallery` (peek-scale) or `linear` (snap). Required. */\n  variant: MediaCarouselVariant;\n\n  // ─── Per-variant config ──────────────────────────────────────────\n  /** Gallery only: peek ratio per side (e.g., 0.075 = 7.5%). Default: 0.075. */\n  peekRatio?: number;\n  /** Linear only: aspect ratio. Default: \"square\". */\n  aspect?: LinearAspect;\n\n  // ─── Carousel behavior ───────────────────────────────────────────\n  /** Loop slides. Default: items.length > 1. */\n  loop?: boolean;\n  /** Show indicator dots. Default: true. Always hidden when items.length <= 1. */\n  showIndicators?: boolean;\n  /** Show side nav buttons. Default: true. Always hidden when items.length <= 1. */\n  showNavButtons?: boolean;\n  /** RTL direction. Default: false. */\n  rtl?: boolean;\n\n  // ─── Customization ───────────────────────────────────────────────\n  /** Custom per-slide renderer — bypasses both built-in handlers. */\n  renderItem?: (item: MediaItem, ctx: RenderItemContext) => ReactNode;\n\n  // ─── Callbacks ───────────────────────────────────────────────────\n  /** Fires on Embla `select` event (post-snap; once per slide commit). */\n  onSlideChange?: (index: number) => void;\n  /** Fires on double-tap of any slide. Unified for image + video items. */\n  onDoubleTap?: (item: MediaItem, index: number) => void;\n\n  // ─── i18n ────────────────────────────────────────────────────────\n  /** Localized labels. Defaults are English. */\n  labels?: MediaCarouselLabels;\n\n  // ─── Style overrides ─────────────────────────────────────────────\n  /** Override classes for the wrapping <div>. */\n  className?: string;\n  /** Override classes per slide wrapper. */\n  slideClassName?: string;\n}\n\nexport interface MediaCarouselHandle {\n  /** Scroll to a specific slide (0-indexed). */\n  scrollTo: (index: number) => void;\n  /** Scroll to previous slide. */\n  scrollPrev: () => void;\n  /** Scroll to next slide. */\n  scrollNext: () => void;\n  /** Get the currently active slide index. */\n  getCurrentIndex: () => number;\n}\n\n/** Default English labels — exported for consumer composition. */\nexport const DEFAULT_MEDIA_CAROUSEL_LABELS: Required<MediaCarouselLabels> = {\n  carouselLabel: \"Media carousel\",\n  previousSlide: \"Previous slide\",\n  nextSlide: \"Next slide\",\n  goToSlide: \"Go to slide\",\n  slideAriaLabel: \"Slide {index} of {total}\",\n};\n",
      "type": "registry:component",
      "target": "components/media-carousel/types.ts"
    },
    {
      "path": "src/registry/components/media/media-carousel/parts/single-item.tsx",
      "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n// F-S1 lock: same-category cross-procomp via relative + specific-file path.\nimport { useDoubleTap } from \"../../video-player/hooks/use-double-tap\";\nimport { SlideRenderer } from \"./slide-renderer\";\nimport type {\n  LinearAspect,\n  MediaCarouselLabels,\n  MediaCarouselVariant,\n  MediaItem,\n  RenderItemContext,\n} from \"../types\";\n\ninterface SingleItemProps {\n  item: MediaItem;\n  renderItem?: (item: MediaItem, ctx: RenderItemContext) => ReactNode;\n  onDoubleTap?: (item: MediaItem, index: number) => void;\n  labels: Required<MediaCarouselLabels>;\n  variant: MediaCarouselVariant;\n  aspect: LinearAspect;\n  className?: string;\n  slideClassName?: string;\n}\n\n/**\n * Single-item shortcut — bypass Embla entirely.\n *\n * Lives in its own component so `useDoubleTap` is called unconditionally\n * within it (own hook count, distinct from `<CarouselTrack>`). Switching\n * between single-item and carousel modes happens via component swap at\n * the root, not conditional hook calls.\n */\nexport function SingleItem({\n  item,\n  renderItem,\n  onDoubleTap,\n  labels,\n  variant,\n  aspect,\n  className,\n  slideClassName,\n}: SingleItemProps) {\n  const handleDoubleTap = useDoubleTap(\n    onDoubleTap ? () => onDoubleTap(item, 0) : undefined,\n  );\n\n  return (\n    <div\n      className={cn(\n        \"relative aspect-square overflow-hidden bg-muted\",\n        className,\n      )}\n      onClick={handleDoubleTap}\n      aria-label={labels.carouselLabel}\n    >\n      <SlideRenderer\n        item={item}\n        ctx={{ isActive: true, index: 0 }}\n        renderItem={renderItem}\n        // Already handled at wrapper level; don't double-fire.\n        onDoubleTap={undefined}\n        variant={variant}\n        aspect={aspect}\n        slideClassName={slideClassName}\n        isSingleItem\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-carousel/parts/single-item.tsx"
    },
    {
      "path": "src/registry/components/media/media-carousel/parts/carousel-track.tsx",
      "content": "\"use client\";\n\nimport type { KeyboardEvent as ReactKeyboardEvent, ReactNode, Ref } from \"react\";\nimport {\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n// F-S1 lock: same-category cross-procomp via relative + specific-file path.\nimport { useDoubleTap } from \"../../video-player/hooks/use-double-tap\";\nimport { useEmblaWithState } from \"../hooks/use-embla-with-state\";\nimport type {\n  LinearAspect,\n  MediaCarouselHandle,\n  MediaCarouselLabels,\n  MediaCarouselVariant,\n  MediaItem,\n  RenderItemContext,\n} from \"../types\";\nimport { IndicatorDots } from \"./indicator-dots\";\nimport { NavButtons } from \"./nav-buttons\";\nimport { SlideRenderer } from \"./slide-renderer\";\n\ninterface CarouselTrackProps {\n  items: MediaItem[];\n  variant: MediaCarouselVariant;\n  peekRatio: number;\n  aspect: LinearAspect;\n  loop: boolean;\n  showIndicators: boolean;\n  showNavButtons: boolean;\n  rtl: boolean;\n  renderItem?: (item: MediaItem, ctx: RenderItemContext) => ReactNode;\n  onSlideChange?: (index: number) => void;\n  onDoubleTap?: (item: MediaItem, index: number) => void;\n  labels: Required<MediaCarouselLabels>;\n  className?: string;\n  slideClassName?: string;\n  ref?: Ref<MediaCarouselHandle>;\n}\n\ninterface SlideProps {\n  item: MediaItem;\n  index: number;\n  total: number;\n  isActive: boolean;\n  isFirst: boolean;\n  isLast: boolean;\n  loop: boolean;\n  variant: MediaCarouselVariant;\n  peekRatio: number;\n  aspect: LinearAspect;\n  renderItem?: (item: MediaItem, ctx: RenderItemContext) => ReactNode;\n  onDoubleTap?: (item: MediaItem, index: number) => void;\n  labels: Required<MediaCarouselLabels>;\n  slideClassName?: string;\n}\n\nfunction Slide({\n  item,\n  index,\n  total,\n  isActive,\n  isFirst,\n  isLast,\n  loop,\n  variant,\n  peekRatio,\n  aspect,\n  renderItem,\n  onDoubleTap,\n  labels,\n  slideClassName,\n}: SlideProps) {\n  // Image items: attach double-tap on the slide wrapper. Video items: handled\n  // inside SlideRenderer via VideoPlayer's onDoubleTap. Both fire the same\n  // unified onDoubleTap callback.\n  const imageDoubleTap = useDoubleTap(\n    item.type === \"image\" && onDoubleTap\n      ? () => onDoubleTap(item, index)\n      : undefined,\n  );\n\n  const slideAriaLabel = labels.slideAriaLabel\n    .replace(\"{index}\", String(index + 1))\n    .replace(\"{total}\", String(total));\n\n  // Match kasder layout exactly: outer slide gets flex-basis + small horizontal\n  // gutter via `mx-1`; first/last get larger edge padding so the peek shows on\n  // both ends (only meaningful when loop=false; under loop the clones eat it).\n  // Inner wrapper is the visual frame — scale + opacity for inactive neighbors,\n  // matching the kasder peek-scale aesthetic.\n  if (variant === \"gallery\") {\n    // Edge peek padding only matters when NOT looping. Under loop, Embla clones\n    // the slides so first/last in DOM != first/last in source — applying edge\n    // padding to the source first/last would create asymmetric slide widths\n    // that break Embla's loop math.\n    const edgePeekPercent = peekRatio * 100;\n    return (\n      <div\n        role=\"group\"\n        aria-roledescription=\"slide\"\n        aria-label={slideAriaLabel}\n        inert={!isActive}\n        className={cn(\"min-w-0 mx-1 flex-[0_0_85%]\")}\n        style={{\n          marginLeft: !loop && isFirst ? `${edgePeekPercent}%` : undefined,\n          marginRight: !loop && isLast ? `${edgePeekPercent}%` : undefined,\n        }}\n        onClick={item.type === \"image\" ? imageDoubleTap : undefined}\n      >\n        <div\n          className={cn(\n            \"overflow-hidden rounded-lg transition-all duration-300\",\n            isActive\n              ? \"scale-100 opacity-100\"\n              : \"scale-95 opacity-60 blur-[1px]\",\n          )}\n        >\n          <SlideRenderer\n            item={item}\n            ctx={{ isActive, index }}\n            renderItem={renderItem}\n            onDoubleTap={onDoubleTap}\n            variant={variant}\n            aspect={aspect}\n            slideClassName={slideClassName}\n          />\n        </div>\n      </div>\n    );\n  }\n\n  // Linear: full-width slide, no peek, no scale.\n  return (\n    <div\n      role=\"group\"\n      aria-roledescription=\"slide\"\n      aria-label={slideAriaLabel}\n      inert={!isActive}\n      className={cn(\"min-w-0 flex-[0_0_100%]\")}\n      onClick={item.type === \"image\" ? imageDoubleTap : undefined}\n    >\n      <SlideRenderer\n        item={item}\n        ctx={{ isActive, index }}\n        renderItem={renderItem}\n        onDoubleTap={onDoubleTap}\n        variant={variant}\n        aspect={aspect}\n        slideClassName={slideClassName}\n      />\n    </div>\n  );\n}\n\nexport function CarouselTrack({\n  items,\n  variant,\n  peekRatio,\n  aspect,\n  loop,\n  showIndicators,\n  showNavButtons,\n  rtl,\n  renderItem,\n  onSlideChange,\n  onDoubleTap,\n  labels,\n  className,\n  slideClassName,\n  ref: forwardedRef,\n}: CarouselTrackProps) {\n  // Memoize Embla options — useEmblaCarousel re-initializes on options identity change.\n  // Under loop, Embla force-disables containScroll; we don't set it explicitly there.\n  // Under linear+no-loop, we trim duplicate edge snaps so prev/next stop cleanly.\n  const emblaOptions = useMemo(\n    () => ({\n      align: variant === \"gallery\" ? (\"center\" as const) : (\"start\" as const),\n      ...(variant === \"linear\" && !loop\n        ? { containScroll: \"trimSnaps\" as const }\n        : {}),\n      loop,\n      direction: rtl ? (\"rtl\" as const) : (\"ltr\" as const),\n    }),\n    [variant, loop, rtl],\n  );\n\n  const { ref, currentIndex, scrollTo, scrollPrev, scrollNext } =\n    useEmblaWithState({ ...emblaOptions, onSelect: onSlideChange });\n\n  // Stable ref-mirror for currentIndex — keeps imperative handle identity\n  // stable across slide changes. Ref write inside passive useEffect.\n  const currentIndexRef = useRef(currentIndex);\n  useEffect(() => {\n    currentIndexRef.current = currentIndex;\n  });\n\n  useImperativeHandle(\n    forwardedRef,\n    () => ({\n      scrollTo,\n      scrollPrev,\n      scrollNext,\n      getCurrentIndex: () => currentIndexRef.current,\n    }),\n    [scrollTo, scrollPrev, scrollNext],\n  );\n\n  // Gallery variant gets soft edge fades (mirrors kasder UX). Linear variant doesn't\n  // — full-width slides have no peek to soften.\n  const showEdgeGradients = variant === \"gallery\";\n\n  // Keyboard nav — APG carousel pattern: ArrowLeft/Right scroll prev/next\n  // (RTL-aware), Home/End jump to first/last. Wired on the focusable region\n  // wrapper so users tabbing into the carousel get arrow-key control without\n  // first focusing a child slide.\n  const handleKeyDown = useCallback(\n    (e: ReactKeyboardEvent<HTMLDivElement>) => {\n      if (e.key === \"ArrowLeft\") {\n        e.preventDefault();\n        if (rtl) scrollNext();\n        else scrollPrev();\n      } else if (e.key === \"ArrowRight\") {\n        e.preventDefault();\n        if (rtl) scrollPrev();\n        else scrollNext();\n      } else if (e.key === \"Home\") {\n        e.preventDefault();\n        scrollTo(0);\n      } else if (e.key === \"End\") {\n        e.preventDefault();\n        scrollTo(items.length - 1);\n      }\n    },\n    [rtl, scrollPrev, scrollNext, scrollTo, items.length],\n  );\n\n  return (\n    <div\n      role=\"region\"\n      aria-roledescription=\"carousel\"\n      aria-label={labels.carouselLabel}\n      tabIndex={0}\n      onKeyDown={handleKeyDown}\n      className={cn(\n        \"relative outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-md\",\n        className,\n      )}\n    >\n      <div className=\"overflow-hidden\" ref={ref}>\n        <div className=\"flex\">\n          {items.map((item, index) => (\n            <Slide\n              key={item.id}\n              item={item}\n              index={index}\n              total={items.length}\n              isActive={index === currentIndex}\n              isFirst={index === 0}\n              isLast={index === items.length - 1}\n              loop={loop}\n              variant={variant}\n              peekRatio={peekRatio}\n              aspect={aspect}\n              renderItem={renderItem}\n              onDoubleTap={onDoubleTap}\n              labels={labels}\n              slideClassName={slideClassName}\n            />\n          ))}\n        </div>\n      </div>\n\n      {showEdgeGradients ? (\n        <>\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-y-0 left-0 z-5 w-12 bg-linear-to-r from-background to-transparent\"\n          />\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-y-0 right-0 z-5 w-12 bg-linear-to-l from-background to-transparent\"\n          />\n        </>\n      ) : null}\n\n      {showNavButtons && (\n        <NavButtons\n          scrollPrev={scrollPrev}\n          scrollNext={scrollNext}\n          labels={labels}\n        />\n      )}\n\n      {showIndicators && (\n        <IndicatorDots\n          total={items.length}\n          currentIndex={currentIndex}\n          scrollTo={scrollTo}\n          labels={labels}\n        />\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-carousel/parts/carousel-track.tsx"
    },
    {
      "path": "src/registry/components/media/media-carousel/parts/slide-renderer.tsx",
      "content": "\"use client\";\n\nimport type { ReactNode } from \"react\";\nimport { cn } from \"@/lib/utils\";\n// F-S1 lock: same-category cross-procomp via relative + specific-file path.\nimport { VideoPlayer } from \"../../video-player/video-player\";\nimport type {\n  LinearAspect,\n  MediaCarouselVariant,\n  MediaItem,\n  RenderItemContext,\n} from \"../types\";\n\ninterface SlideRendererProps {\n  item: MediaItem;\n  ctx: RenderItemContext;\n  renderItem?: (item: MediaItem, ctx: RenderItemContext) => ReactNode;\n  onDoubleTap?: (item: MediaItem, index: number) => void;\n  variant: MediaCarouselVariant;\n  aspect: LinearAspect;\n  slideClassName?: string;\n  /** True when this is the single-item shortcut. */\n  isSingleItem?: boolean;\n}\n\nconst ASPECT_CLASS: Record<LinearAspect, string> = {\n  square: \"aspect-square\",\n  video: \"aspect-video\",\n  portrait: \"aspect-[3/4]\",\n  auto: \"\",\n};\n\n/**\n * Renders a single slide. Three paths:\n *   1. `renderItem` provided → consumer takes over.\n *   2. `item.type === \"image\"` → built-in <img> handler.\n *   3. `item.type === \"video\"` → built-in <VideoPlayer> handler with isActive propagation.\n *\n * The video handler is a deliberate cross-folder import — see Q-P1 in the plan\n * doc and the project memory note on the refined sealed-folder rule.\n */\nexport function SlideRenderer({\n  item,\n  ctx,\n  renderItem,\n  onDoubleTap,\n  variant,\n  aspect,\n  slideClassName,\n  isSingleItem = false,\n}: SlideRendererProps) {\n  // Aspect ratio: gallery + single-item are always square; linear uses prop.\n  const aspectClass =\n    variant === \"gallery\" || isSingleItem\n      ? \"aspect-square\"\n      : ASPECT_CLASS[aspect];\n\n  if (renderItem) {\n    return (\n      <div className={cn(aspectClass, \"overflow-hidden\", slideClassName)}>\n        {renderItem(item, ctx)}\n      </div>\n    );\n  }\n\n  if (item.type === \"image\") {\n    return (\n      <div className={cn(aspectClass, \"overflow-hidden\", slideClassName)}>\n        <img\n          src={item.url}\n          alt={item.alt ?? \"\"}\n          loading=\"lazy\"\n          className=\"h-full w-full object-cover\"\n        />\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn(aspectClass, \"overflow-hidden\", slideClassName)}>\n      <VideoPlayer\n        src={item.url}\n        poster={item.poster}\n        isActive={ctx.isActive}\n        onDoubleTap={\n          onDoubleTap ? () => onDoubleTap(item, ctx.index) : undefined\n        }\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-carousel/parts/slide-renderer.tsx"
    },
    {
      "path": "src/registry/components/media/media-carousel/parts/nav-buttons.tsx",
      "content": "\"use client\";\n\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\n\ninterface NavButtonsProps {\n  scrollPrev: () => void;\n  scrollNext: () => void;\n  labels: { previousSlide: string; nextSlide: string };\n}\n\nexport function NavButtons({ scrollPrev, scrollNext, labels }: NavButtonsProps) {\n  return (\n    <>\n      <Button\n        type=\"button\"\n        variant=\"secondary\"\n        size=\"icon\"\n        className=\"absolute top-1/2 left-2 z-10 h-8 w-8 -translate-y-1/2 rounded-full opacity-80 hover:opacity-100 rtl:rotate-180\"\n        onClick={scrollPrev}\n        aria-label={labels.previousSlide}\n      >\n        <ChevronLeft className=\"h-4 w-4\" aria-hidden=\"true\" />\n      </Button>\n      <Button\n        type=\"button\"\n        variant=\"secondary\"\n        size=\"icon\"\n        className=\"absolute top-1/2 right-2 z-10 h-8 w-8 -translate-y-1/2 rounded-full opacity-80 hover:opacity-100 rtl:rotate-180\"\n        onClick={scrollNext}\n        aria-label={labels.nextSlide}\n      >\n        <ChevronRight className=\"h-4 w-4\" aria-hidden=\"true\" />\n      </Button>\n    </>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-carousel/parts/nav-buttons.tsx"
    },
    {
      "path": "src/registry/components/media/media-carousel/parts/indicator-dots.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\n\ninterface IndicatorDotsProps {\n  total: number;\n  currentIndex: number;\n  scrollTo: (index: number) => void;\n  labels: { goToSlide: string };\n}\n\nexport function IndicatorDots({\n  total,\n  currentIndex,\n  scrollTo,\n  labels,\n}: IndicatorDotsProps) {\n  return (\n    <div className=\"absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 gap-1.5\">\n      {Array.from({ length: total }, (_, i) => {\n        const isActive = i === currentIndex;\n        return (\n          <button\n            key={i}\n            type=\"button\"\n            onClick={() => scrollTo(i)}\n            aria-label={`${labels.goToSlide} ${i + 1}`}\n            aria-current={isActive ? \"true\" : undefined}\n            className={cn(\n              \"h-1.5 rounded-full transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n              isActive\n                ? \"w-4 bg-primary\"\n                : \"w-1.5 bg-primary/40 hover:bg-primary/60\",\n            )}\n          />\n        );\n      })}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/media-carousel/parts/indicator-dots.tsx"
    },
    {
      "path": "src/registry/components/media/media-carousel/hooks/use-embla-with-state.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useRef, useSyncExternalStore } from \"react\";\nimport useEmblaCarousel from \"embla-carousel-react\";\nimport type {\n  EmblaCarouselType,\n  EmblaOptionsType,\n} from \"embla-carousel\";\n\nexport interface UseEmblaWithStateOptions extends EmblaOptionsType {\n  /** Fires on slide change. Does NOT fire on initial mount-sync. */\n  onSelect?: (index: number) => void;\n}\n\nexport interface UseEmblaWithStateResult {\n  ref: (node: HTMLElement | null) => void;\n  api: EmblaCarouselType | null;\n  currentIndex: number;\n  scrollTo: (index: number) => void;\n  scrollPrev: () => void;\n  scrollNext: () => void;\n}\n\nconst SERVER_INDEX = 0;\n\n/**\n * Wraps `useEmblaCarousel` with currentIndex state via `useSyncExternalStore`\n * (avoids `setState`-in-effect lint violations) and lifecycle callbacks.\n *\n * **Mount-sync semantics:** `currentIndex` syncs to Embla's initial snap\n * automatically on mount. The user's `onSelect` callback does NOT fire on\n * the mount-sync — only on real slide changes (tracked via `isFirstFireRef`).\n *\n * Embla options should be memoized by the caller (Embla re-initializes on\n * options-object identity change).\n */\nexport function useEmblaWithState({\n  onSelect,\n  ...emblaOptions\n}: UseEmblaWithStateOptions): UseEmblaWithStateResult {\n  const [emblaRef, emblaApi] = useEmblaCarousel(emblaOptions);\n\n  // External-store subscription for currentIndex — no setState in effect.\n  const subscribe = useCallback(\n    (callback: () => void) => {\n      if (!emblaApi) return () => {};\n      emblaApi.on(\"select\", callback);\n      return () => {\n        emblaApi.off(\"select\", callback);\n      };\n    },\n    [emblaApi],\n  );\n\n  const getSnapshot = useCallback(\n    () => emblaApi?.selectedScrollSnap() ?? SERVER_INDEX,\n    [emblaApi],\n  );\n\n  const getServerSnapshot = useCallback(() => SERVER_INDEX, []);\n\n  const currentIndex = useSyncExternalStore(\n    subscribe,\n    getSnapshot,\n    getServerSnapshot,\n  );\n\n  // Stable ref for onSelect callback — avoids effect cascade on identity change.\n  const onSelectRef = useRef(onSelect);\n  useEffect(() => {\n    onSelectRef.current = onSelect;\n  });\n\n  // Fire user `onSelect` only on real slide changes — skip the initial mount.\n  const isFirstFireRef = useRef(true);\n  useEffect(() => {\n    if (isFirstFireRef.current) {\n      isFirstFireRef.current = false;\n      return;\n    }\n    onSelectRef.current?.(currentIndex);\n  }, [currentIndex]);\n\n  const scrollTo = useCallback(\n    (index: number) => {\n      emblaApi?.scrollTo(index);\n    },\n    [emblaApi],\n  );\n  const scrollPrev = useCallback(() => {\n    emblaApi?.scrollPrev();\n  }, [emblaApi]);\n  const scrollNext = useCallback(() => {\n    emblaApi?.scrollNext();\n  }, [emblaApi]);\n\n  return {\n    ref: emblaRef,\n    api: emblaApi ?? null,\n    currentIndex,\n    scrollTo,\n    scrollPrev,\n    scrollNext,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/media-carousel/hooks/use-embla-with-state.ts"
    }
  ],
  "categories": [
    "media"
  ],
  "type": "registry:block"
}