{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"evidence-carousel","type":"registry:component","title":"Evidence Carousel","description":"A large media carousel for case studies — drag, keys, captions, no autoplay.","dependencies":[],"categories":["media","carousel","gallery","case-study","drag"],"files":[{"path":"components/lab/evidence-carousel/evidence-carousel.tsx","target":"components/lab/evidence-carousel/evidence-carousel.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  type CSSProperties,\n  type KeyboardEvent,\n  type ReactNode,\n} from \"react\";\nimport \"./evidence-carousel.css\";\n\nexport type CarouselItem = {\n  id: string;\n  caption: string;\n  meta?: string;\n  /** Any node: an image, a video poster, a live component. */\n  content: ReactNode;\n};\n\ntype EvidenceCarouselProps = {\n  items: CarouselItem[];\n  /** Accessible name for the carousel region. */\n  label: string;\n  /** CSS aspect-ratio applied to every slide. */\n  ratio?: string;\n  /** Show the neighbouring slides at the viewport edges. */\n  peek?: boolean;\n  /** Wrap from last to first when the buttons are used. */\n  loop?: boolean;\n  /** Show the caption row under the strip. */\n  captions?: boolean;\n  onIndexChange?: (index: number) => void;\n  className?: string;\n};\n\nconst pad = (value: number) => String(value).padStart(2, \"0\");\n\n/**\n * EVIDENCE CAROUSEL — one large view at a time.\n *\n * The strip is a plain horizontal scroller with scroll-snap: the\n * browser owns the touch gesture and the layout, the index is derived\n * from where the strip has come to rest, and the buttons and arrow keys\n * drive the same scroll position — so every way of moving it stays in\n * sync. Nothing advances on its own; the reader is the only thing that\n * moves this.\n */\nexport function EvidenceCarousel({\n  items,\n  label,\n  ratio = \"16 / 10\",\n  peek = true,\n  loop = false,\n  captions = true,\n  onIndexChange,\n  className,\n}: EvidenceCarouselProps) {\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const [index, setIndex] = useState(0);\n  const reducedRef = useRef(false);\n  const jumpRef = useRef(true);\n\n  /* The strip's own geometry is the source of truth. Positions are\n     measured against the viewport box rather than offsetLeft, so the\n     centring is correct wherever the carousel sits on the page. */\n  const slideCentre = useCallback((slide: HTMLElement, viewport: HTMLElement) => {\n    const slideBox = slide.getBoundingClientRect();\n    const viewportBox = viewport.getBoundingClientRect();\n    return (\n      slideBox.left - viewportBox.left + viewport.scrollLeft + slideBox.width / 2\n    );\n  }, []);\n\n  const activeFromScroll = useCallback((): number => {\n    const viewport = viewportRef.current;\n    if (!viewport) return 0;\n    const slides = Array.from(\n      viewport.querySelectorAll<HTMLElement>(\"[data-ec-slide]\"),\n    );\n    const centre = viewport.scrollLeft + viewport.clientWidth / 2;\n    let best = 0;\n    let bestDistance = Number.POSITIVE_INFINITY;\n    slides.forEach((slide, slideIndex) => {\n      const distance = Math.abs(slideCentre(slide, viewport) - centre);\n      if (distance < bestDistance) {\n        bestDistance = distance;\n        best = slideIndex;\n      }\n    });\n    return best;\n  }, [slideCentre]);\n\n  const goTo = useCallback(\n    (next: number, smooth = true) => {\n      const viewport = viewportRef.current;\n      if (!viewport) return;\n      const slides = Array.from(\n        viewport.querySelectorAll<HTMLElement>(\"[data-ec-slide]\"),\n      );\n      if (slides.length === 0) return;\n      const clamped = loop\n        ? ((next % slides.length) + slides.length) % slides.length\n        : Math.max(0, Math.min(slides.length - 1, next));\n      const slide = slides[clamped];\n      if (!slide) return;\n      const left =\n        slideCentre(slide, viewport) - viewport.clientWidth / 2;\n      viewport.scrollTo({\n        left,\n        behavior: reducedRef.current || !smooth ? \"auto\" : \"smooth\",\n      });\n    },\n    [loop, slideCentre],\n  );\n\n  /* the index follows the strip */\n  useEffect(() => {\n    const viewport = viewportRef.current;\n    if (!viewport) return;\n    const motion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = motion.matches;\n    const onMotion = () => {\n      reducedRef.current = motion.matches;\n    };\n    motion.addEventListener(\"change\", onMotion);\n\n    let frame = 0;\n    const measure = () => {\n      frame = 0;\n      setIndex(activeFromScroll());\n    };\n    const onScroll = () => {\n      if (!frame) frame = window.requestAnimationFrame(measure);\n    };\n    viewport.addEventListener(\"scroll\", onScroll, { passive: true });\n    window.addEventListener(\"resize\", onScroll);\n    if (jumpRef.current) {\n      jumpRef.current = false;\n      goTo(0, false);\n    }\n    measure();\n\n    return () => {\n      if (frame) window.cancelAnimationFrame(frame);\n      viewport.removeEventListener(\"scroll\", onScroll);\n      window.removeEventListener(\"resize\", onScroll);\n      motion.removeEventListener(\"change\", onMotion);\n    };\n  }, [activeFromScroll, goTo]);\n\n  useEffect(() => {\n    onIndexChange?.(index);\n  }, [index, onIndexChange]);\n\n  /* a mouse drag moves the strip; touch already scrolls natively */\n  useEffect(() => {\n    const viewport = viewportRef.current;\n    if (!viewport) return;\n    let dragging = false;\n    let startX = 0;\n    let startLeft = 0;\n    let travelled = 0;\n\n    const down = (event: PointerEvent) => {\n      if (event.pointerType !== \"mouse\" || event.button !== 0) return;\n      dragging = true;\n      travelled = 0;\n      startX = event.clientX;\n      startLeft = viewport.scrollLeft;\n      viewport.setPointerCapture(event.pointerId);\n      viewport.dataset.dragging = \"true\";\n    };\n    const move = (event: PointerEvent) => {\n      if (!dragging) return;\n      const dx = event.clientX - startX;\n      travelled = Math.max(travelled, Math.abs(dx));\n      viewport.scrollLeft = startLeft - dx;\n    };\n    const up = (event: PointerEvent) => {\n      if (!dragging) return;\n      dragging = false;\n      delete viewport.dataset.dragging;\n      if (viewport.hasPointerCapture(event.pointerId)) {\n        viewport.releasePointerCapture(event.pointerId);\n      }\n      if (travelled > 6) goTo(activeFromScroll());\n    };\n\n    viewport.addEventListener(\"pointerdown\", down);\n    viewport.addEventListener(\"pointermove\", move);\n    viewport.addEventListener(\"pointerup\", up);\n    viewport.addEventListener(\"pointercancel\", up);\n    return () => {\n      viewport.removeEventListener(\"pointerdown\", down);\n      viewport.removeEventListener(\"pointermove\", move);\n      viewport.removeEventListener(\"pointerup\", up);\n      viewport.removeEventListener(\"pointercancel\", up);\n    };\n  }, [goTo, activeFromScroll]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"ArrowRight\") {\n      event.preventDefault();\n      goTo(index + 1);\n    } else if (event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      goTo(index - 1);\n    } else if (event.key === \"Home\") {\n      event.preventDefault();\n      goTo(0);\n    } else if (event.key === \"End\") {\n      event.preventDefault();\n      goTo(items.length - 1);\n    }\n  };\n\n  if (items.length === 0) return null;\n\n  const atStart = !loop && index === 0;\n  const atEnd = !loop && index === items.length - 1;\n\n  return (\n    <section\n      className={`lab-carousel${className ? ` ${className}` : \"\"}`}\n      role=\"group\"\n      aria-roledescription=\"carousel\"\n      aria-label={label}\n      data-peek={peek ? \"true\" : \"false\"}\n    >\n      <div\n        className=\"lab-carousel-viewport\"\n        ref={viewportRef}\n        tabIndex={0}\n        onKeyDown={onKeyDown}\n        aria-label={`${label}: ${items.length} slides. Use the previous and next buttons or the arrow keys.`}\n      >\n        <ul className=\"lab-carousel-track\">\n          {items.map((item, itemIndex) => (\n            <li\n              className=\"lab-carousel-slide\"\n              key={item.id}\n              data-ec-slide\n              data-active={itemIndex === index ? \"true\" : \"false\"}\n              aria-label={`${itemIndex + 1} of ${items.length}: ${item.caption}`}\n              aria-roledescription=\"slide\"\n              role=\"group\"\n              style={{ \"--ec-ratio\": ratio } as CSSProperties}\n            >\n              <div className=\"lab-carousel-media\">{item.content}</div>\n            </li>\n          ))}\n        </ul>\n      </div>\n\n      <div className=\"lab-carousel-bar\">\n        <div className=\"lab-carousel-controls\">\n          <button\n            type=\"button\"\n            className=\"lab-carousel-btn\"\n            onClick={() => goTo(index - 1)}\n            disabled={atStart}\n            aria-label=\"Previous slide\"\n          >\n            <span aria-hidden=\"true\">←</span>\n          </button>\n          <button\n            type=\"button\"\n            className=\"lab-carousel-btn\"\n            onClick={() => goTo(index + 1)}\n            disabled={atEnd}\n            aria-label=\"Next slide\"\n          >\n            <span aria-hidden=\"true\">→</span>\n          </button>\n        </div>\n\n        {captions ? (\n          <p className=\"lab-carousel-caption\" aria-live=\"polite\">\n            <span className=\"lab-carousel-caption-text\">\n              {items[index]?.caption ?? \"\"}\n            </span>\n            {items[index]?.meta ? (\n              <span className=\"lab-carousel-meta\">{items[index]?.meta}</span>\n            ) : null}\n          </p>\n        ) : (\n          <span className=\"lab-carousel-caption\" />\n        )}\n\n        <p className=\"lab-carousel-count\">\n          <span className=\"lab-carousel-count-now\">{pad(index + 1)}</span>\n          <span aria-hidden=\"true\" className=\"lab-carousel-count-sep\">\n            /\n          </span>\n          <span>{pad(items.length)}</span>\n        </p>\n      </div>\n\n      <div className=\"lab-carousel-progress\" aria-hidden=\"true\">\n        <span\n          style={{ transform: `scaleX(${(index + 1) / items.length})` }}\n        />\n      </div>\n    </section>\n  );\n}\n"},{"path":"components/lab/evidence-carousel/evidence-carousel.css","target":"components/lab/evidence-carousel/evidence-carousel.css","type":"registry:file","content":"/* ============================================================\n   EVIDENCE CAROUSEL — self-contained styles.\n   ============================================================ */\n\n.lab-carousel {\n  --ec-ink: #141418;\n  --ec-muted: #5f5f68;\n  --ec-faint: #8c8c94;\n  --ec-line: rgba(20, 20, 24, 0.12);\n  --ec-line-soft: rgba(20, 20, 24, 0.07);\n  --ec-surface: #ffffff;\n  --ec-accent: #4f27e0;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.lab-carousel-viewport {\n  overflow-x: auto;\n  overscroll-behavior-x: contain;\n  scrollbar-width: none;\n  scroll-snap-type: x mandatory;\n  padding-inline: max(0px, calc((100% - var(--ec-slide-w)) / 2));\n  -webkit-overflow-scrolling: touch;\n}\n\n.lab-carousel-viewport::-webkit-scrollbar {\n  display: none;\n}\n\n.lab-carousel-viewport:focus-visible {\n  outline: 2px solid var(--ec-accent);\n  outline-offset: 4px;\n  border-radius: 4px;\n}\n\n.lab-carousel-viewport[data-dragging=\"true\"] {\n  scroll-snap-type: none;\n  cursor: grabbing;\n}\n\n.lab-carousel-viewport[data-dragging=\"true\"] .lab-carousel-slide {\n  user-select: none;\n}\n\n.lab-carousel-track {\n  display: flex;\n  gap: 14px;\n  margin: 0;\n  padding: 0;\n  list-style: none;\n}\n\n.lab-carousel-slide {\n  flex: 0 0 auto;\n  width: var(--ec-slide-w);\n  scroll-snap-align: center;\n  opacity: 0.45;\n  transition: opacity 320ms cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n.lab-carousel-slide[data-active=\"true\"] {\n  opacity: 1;\n}\n\n.lab-carousel {\n  --ec-slide-w: 100%;\n}\n\n.lab-carousel[data-peek=\"true\"] {\n  --ec-slide-w: 74%;\n}\n\n.lab-carousel-media {\n  aspect-ratio: var(--ec-ratio, 16 / 10);\n  border-radius: 12px;\n  overflow: hidden;\n  background: #ededeb;\n  border: 1px solid var(--ec-line-soft);\n}\n\n.lab-carousel-media > * {\n  width: 100%;\n  height: 100%;\n}\n\n.lab-carousel-bar {\n  display: flex;\n  align-items: center;\n  gap: 14px;\n  padding-inline: 2px;\n}\n\n.lab-carousel-controls {\n  display: flex;\n  gap: 6px;\n}\n\n.lab-carousel-btn {\n  appearance: none;\n  border: 1px solid var(--ec-line);\n  background: var(--ec-surface);\n  color: var(--ec-ink);\n  width: 34px;\n  height: 34px;\n  border-radius: 999px;\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  cursor: pointer;\n  font-size: 0.9rem;\n  line-height: 1;\n  transition: border-color 150ms ease, color 150ms ease, opacity 150ms ease;\n}\n\n.lab-carousel-btn:hover:not(:disabled) {\n  border-color: rgba(20, 20, 24, 0.55);\n}\n\n.lab-carousel-btn:disabled {\n  opacity: 0.35;\n  cursor: not-allowed;\n}\n\n.lab-carousel-caption {\n  flex: 1;\n  min-width: 0;\n  margin: 0;\n  display: flex;\n  flex-wrap: wrap;\n  align-items: baseline;\n  gap: 4px 10px;\n  font-size: 0.875rem;\n  color: var(--ec-muted);\n  line-height: 1.45;\n}\n\n.lab-carousel-caption-text {\n  color: var(--ec-ink);\n}\n\n.lab-carousel-meta {\n  font-family: ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, monospace;\n  font-size: 0.625rem;\n  letter-spacing: 0.09em;\n  text-transform: uppercase;\n  color: var(--ec-faint);\n}\n\n.lab-carousel-count {\n  margin: 0;\n  display: flex;\n  align-items: baseline;\n  gap: 3px;\n  font-family: ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, monospace;\n  font-size: 0.6875rem;\n  letter-spacing: 0.05em;\n  color: var(--ec-faint);\n}\n\n.lab-carousel-count-now {\n  color: var(--ec-ink);\n}\n\n.lab-carousel-count-sep {\n  opacity: 0.6;\n}\n\n.lab-carousel-progress {\n  height: 2px;\n  border-radius: 2px;\n  background: var(--ec-line-soft);\n  overflow: hidden;\n}\n\n.lab-carousel-progress span {\n  display: block;\n  height: 100%;\n  background: var(--ec-ink);\n  transform-origin: left center;\n  transition: transform 320ms cubic-bezier(0.22, 1, 0.36, 1);\n}\n\n@media (max-width: 640px) {\n  .lab-carousel[data-peek=\"true\"] {\n    --ec-slide-w: 88%;\n  }\n\n  .lab-carousel-bar {\n    flex-wrap: wrap;\n  }\n\n  .lab-carousel-caption {\n    order: 3;\n    flex-basis: 100%;\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .lab-carousel-slide,\n  .lab-carousel-progress span {\n    transition: none;\n  }\n}\n"}]}