{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-stack",
  "title": "Card Stack",
  "author": "AlgorizeTech",
  "description": "A layered deck of cards featuring fluid drag-to-discard gestures, button-driven directional slide animations, and realistic depth perspective.",
  "dependencies": [
    "gsap",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/CardStackBlock.tsx",
      "content": "\"use client\";\n\nimport React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport gsap from \"gsap\";\nimport { ArrowLeft, ArrowRight, ArrowUpRight, RotateCcw } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { bezierEase, prefersReducedMotion } from \"./gsap-motion\";\nimport { springEase } from \"./motion-curves\";\n\nexport interface CardStackItem {\n  id: string | number;\n  title: string;\n  description?: string;\n  category?: string;\n  date?: string;\n  meta?: string;\n  icon?: React.ReactNode | string;\n  href?: string;\n  [key: string]: unknown;\n}\n\nexport interface CardStackProps extends React.HTMLAttributes<HTMLDivElement> {\n  items: CardStackItem[];\n  title?: string;\n  /** Maximum number of cards visually stacked (front card + cards behind). Defaults to 3. */\n  visibleCards?: number;\n  /** Direction the card slides off on next: \"right\" | \"left\". Defaults to \"right\". */\n  slideDirection?: \"right\" | \"left\";\n  /** Whether the stack loops infinitely or stops at ends. Defaults to false. */\n  loop?: boolean;\n  /** Initial active card index. Defaults to 0. */\n  defaultIndex?: number;\n  /** Callback fired when the active card index changes. */\n  onCardChange?: (index: number) => void;\n  /** Callback fired when a card is dismissed/swiped. */\n  onDismiss?: (item: CardStackItem, direction: \"left\" | \"right\") => void;\n  /** Custom render function for card content. */\n  renderCard?: (item: CardStackItem, index: number, isTop: boolean) => React.ReactNode;\n  /** Custom class for individual cards. */\n  cardClassName?: string;\n  /** Enable dragging and swiping the top card to discard (left or right). Defaults to false. */\n  swipeToDismiss?: boolean;\n  /** Whether to show header navigation buttons (arrows). Defaults to true. Set to false for pure swipe-to-discard mode. */\n  showNavigation?: boolean;\n  /** Whether to render the header bar (title, counter, and navigation). Defaults to true. */\n  showHeader?: boolean;\n  /** Whether the last card can be dismissed in swipe mode. Defaults to false. */\n  canDismissLastCard?: boolean;\n}\n\n// ============================================================================\n// MOTION\n// ============================================================================\n\n/** Cards settle into their slot through this spring (position, tilt, fade). */\nconst SETTLE = springEase({ stiffness: 280, damping: 26, mass: 0.8 });\n/** A card leaving the deck flies out over a fixed, fast-out curve. */\nconst EXIT = { duration: 0.26, ease: bezierEase([0.25, 1, 0.5, 1]) };\n\n/** The card follows the pointer at 80% (an elastic pull against its slot). */\nconst DRAG_ELASTIC = 0.8;\n/** Pointer travel before a press becomes a drag. */\nconst DRAG_START_PX = 3;\n/** Release past this distance, or faster than this speed (px/s), to discard. */\nconst SWIPE_OFFSET = 80;\nconst SWIPE_VELOCITY = 280;\n/** A drag released short of discarding springs back to the slot. */\nconst RETURN_SPRING = { stiffness: 200, damping: 40 };\n\n/** Drag tilt: ±18° across ±260px of travel, clamped beyond. */\nconst dragTilt = (x: number) => Math.min(Math.max((x / 260) * 18, -18), 18);\n\ninterface CardPose {\n  x: number;\n  y: number;\n  rotation: number;\n  opacity: number;\n  scale: number;\n  zIndex: number;\n}\n\n/** Where a card rests at depth `offset` (0 is the front card). */\nconst restPose = (offset: number, count: number): CardPose => ({\n  x: offset * 8,\n  y: -offset * 4,\n  rotation: offset * 1.6,\n  opacity: 1,\n  scale: 1,\n  zIndex: count - offset,\n});\n\n/**\n * Where a card joining the deck starts: going back, the new front card swings\n * in from the slide side; any other newcomer fades in at its slot.\n */\nconst enterPose = (\n  offset: number,\n  count: number,\n  direction: number,\n  slideMultiplier: number,\n): CardPose =>\n  offset === 0 && direction < 0\n    ? {\n        x: 220 * slideMultiplier,\n        y: -10,\n        rotation: 8 * slideMultiplier,\n        opacity: 0,\n        scale: 0.98,\n        zIndex: count + 2,\n      }\n    : { ...restPose(offset, count), opacity: 0 };\n\n/** The first-paint style for a pose, so server HTML already shows the fan. */\nconst poseStyle = (pose: CardPose): React.CSSProperties => ({\n  transform: `translate(${pose.x}px, ${pose.y}px) rotate(${pose.rotation}deg) scale(${pose.scale})`,\n  opacity: pose.opacity,\n  zIndex: pose.zIndex,\n});\n\n/** Pointer speed (px/s) over the last 100ms of samples. */\nfunction releaseVelocity(history: { x: number; t: number }[]) {\n  if (history.length < 2) return 0;\n  const last = history[history.length - 1];\n  let sample = history[0];\n  for (let i = history.length - 1; i >= 0; i--) {\n    sample = history[i];\n    if (last.t - sample.t > 100) break;\n  }\n  const seconds = (last.t - sample.t) / 1000;\n  return seconds === 0 ? 0 : (last.x - sample.x) / seconds;\n}\n\n// ============================================================================\n// CARD\n// ============================================================================\n\ninterface StackCardItemProps {\n  item: CardStackItem;\n  offset: number;\n  isTop: boolean;\n  count: number;\n  /** The pose this card mounts in; read once, on mount. */\n  start: CardPose;\n  /** Set once the card has left the deck: it flies out, then unmounts. */\n  exit: { x: number; rotation: number } | null;\n  isDraggable: boolean;\n  onSwipe: (dir: \"left\" | \"right\") => void;\n  onExited: (id: CardStackItem[\"id\"]) => void;\n  renderCard?: (item: CardStackItem, index: number, isTop: boolean) => React.ReactNode;\n  cardClassName?: string;\n}\n\nfunction StackCardItem({\n  item,\n  offset,\n  isTop,\n  count,\n  start,\n  exit,\n  isDraggable,\n  onSwipe,\n  onExited,\n  renderCard,\n  cardClassName,\n}: StackCardItemProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const [startPose] = useState(start);\n  const [startStyle] = useState(() => poseStyle(start));\n  /** Horizontal drag offset, for telling a drag from a click on the link. */\n  const dragX = useRef(0);\n  const endDrag = useRef<(() => void) | null>(null);\n  const latestSwipe = useRef(onSwipe);\n\n  useLayoutEffect(() => {\n    latestSwipe.current = onSwipe;\n  });\n\n  const rest = restPose(offset, count);\n  const { x, y, rotation, zIndex } = rest;\n  const exiting = exit !== null;\n  const exitX = exit?.x ?? 0;\n  const exitRotation = exit?.rotation ?? 0;\n  const id = item.id;\n\n  // Mount: place the card in its starting pose before the first paint.\n  useLayoutEffect(() => {\n    if (ref.current) gsap.set(ref.current, startPose);\n  }, [startPose]);\n\n  // Settle into the slot, or fly out once the card has left the deck.\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const reduce = prefersReducedMotion();\n\n    if (exiting) {\n      endDrag.current?.();\n      const target = { x: exitX, y: -10, rotation: exitRotation, opacity: 0, scale: 0.96, zIndex: count + 2 };\n      if (reduce) {\n        gsap.set(el, target);\n        onExited(id);\n        return;\n      }\n      const tween = gsap.to(el, {\n        ...target,\n        duration: EXIT.duration,\n        ease: EXIT.ease,\n        overwrite: \"auto\",\n        onComplete: () => onExited(id),\n      });\n      return () => {\n        tween.kill();\n      };\n    }\n\n    const target = { x, y, rotation, opacity: 1, scale: 1, zIndex };\n    if (reduce) {\n      gsap.set(el, target);\n      return;\n    }\n    const tween = gsap.to(el, {\n      ...target,\n      duration: SETTLE.duration,\n      ease: SETTLE.ease,\n      overwrite: \"auto\",\n    });\n    return () => {\n      tween.kill();\n    };\n  }, [x, y, rotation, zIndex, exiting, exitX, exitRotation, count, id, onExited]);\n\n  // Unmount: drop any drag in progress and the drag's return spring.\n  useEffect(() => {\n    const el = ref.current;\n    return () => {\n      endDrag.current?.();\n      if (el) gsap.killTweensOf(el);\n    };\n  }, []);\n\n  const paintDrag = (el: HTMLElement, value: number) => {\n    dragX.current = value;\n    gsap.set(el, { x: value, rotation: dragTilt(value) });\n  };\n\n  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    const el = ref.current;\n    if (!el || !isDraggable || exiting) return;\n    if (e.pointerType === \"mouse\" ? e.button !== 0 : e.isPrimary === false) return;\n    endDrag.current?.();\n\n    const pointerId = e.pointerId;\n    const startX = e.pageX;\n    const startY = e.pageY;\n    const origin = Number(gsap.getProperty(el, \"x\")) || 0;\n    const history = [{ x: e.pageX, t: performance.now() }];\n    let lastX = e.pageX;\n    let dragging = false;\n\n    const move = (ev: PointerEvent) => {\n      if (ev.pointerId !== pointerId) return;\n      lastX = ev.pageX;\n      history.push({ x: ev.pageX, t: performance.now() });\n      const dx = ev.pageX - startX;\n      if (!dragging) {\n        if (Math.hypot(dx, ev.pageY - startY) < DRAG_START_PX) return;\n        dragging = true;\n        gsap.killTweensOf(el, \"x,rotation\");\n      }\n      paintDrag(el, (origin + dx) * DRAG_ELASTIC);\n    };\n\n    const stop = () => {\n      window.removeEventListener(\"pointermove\", move);\n      window.removeEventListener(\"pointerup\", end);\n      window.removeEventListener(\"pointercancel\", end);\n      endDrag.current = null;\n    };\n\n    const end = (ev: PointerEvent) => {\n      if (ev.pointerId !== pointerId) return;\n      stop();\n      if (!dragging) return;\n      const offsetX = (ev.type === \"pointercancel\" ? lastX : ev.pageX) - startX;\n      const velocity = releaseVelocity(history);\n      if (offsetX > SWIPE_OFFSET || velocity > SWIPE_VELOCITY) {\n        latestSwipe.current(\"right\");\n      } else if (offsetX < -SWIPE_OFFSET || velocity < -SWIPE_VELOCITY) {\n        latestSwipe.current(\"left\");\n      } else {\n        const from = dragX.current;\n        if (from === 0) return;\n        if (prefersReducedMotion()) {\n          paintDrag(el, 0);\n          return;\n        }\n        const { duration, ease } = springEase({\n          ...RETURN_SPRING,\n          restDelta: Math.min(1, 1 / Math.abs(from)),\n        });\n        gsap.to(el, {\n          x: 0,\n          duration,\n          ease,\n          overwrite: \"auto\",\n          onUpdate: () => paintDrag(el, Number(gsap.getProperty(el, \"x\")) || 0),\n        });\n      }\n    };\n\n    window.addEventListener(\"pointermove\", move);\n    window.addEventListener(\"pointerup\", end);\n    window.addEventListener(\"pointercancel\", end);\n    endDrag.current = stop;\n  };\n\n  return (\n    <div\n      ref={ref}\n      onPointerDown={handlePointerDown}\n      style={{ transformOrigin: \"bottom center\", ...startStyle }}\n      className={cn(\n        \"absolute inset-x-0 bottom-0 top-0 rounded-[26px] border border-foreground/10 dark:border-foreground/15 bg-card p-6 shadow-[0_10px_25px_-5px_rgba(0,0,0,0.06),0_2px_6px_-2px_rgba(0,0,0,0.04)] dark:shadow-[0_12px_28px_-6px_rgba(0,0,0,0.6),0_2px_6px_-2px_rgba(0,0,0,0.4)] flex flex-col justify-between overflow-hidden\",\n        isDraggable\n          ? \"cursor-grab active:cursor-grabbing touch-none select-none\"\n          : isTop\n          ? \"cursor-default select-none\"\n          : \"pointer-events-none select-none\",\n        cardClassName,\n      )}\n    >\n      {renderCard ? (\n        renderCard(item, offset, isTop)\n      ) : (\n        <>\n          {/* Card Header: Icon, Title, and Action Link */}\n          <div className=\"flex items-start justify-between gap-3\">\n            <div className=\"flex items-center gap-3.5 min-w-0\">\n              {item.icon && (\n                <div className=\"size-12 shrink-0 rounded-2xl overflow-hidden flex items-center justify-center pointer-events-none\">\n                  {typeof item.icon === \"string\" ? (\n                    <img\n                      src={item.icon}\n                      alt={item.title}\n                      className=\"size-full object-cover\"\n                    />\n                  ) : (\n                    item.icon\n                  )}\n                </div>\n              )}\n              <h3 className=\"font-bold text-xl text-foreground tracking-tight truncate pointer-events-none\">\n                {item.title}\n              </h3>\n            </div>\n            {item.href && (\n              <a\n                href={item.href}\n                target=\"_blank\"\n                rel=\"noreferrer\"\n                aria-label={`Open ${item.title}`}\n                onClick={(e) => {\n                  // Prevent navigation if user was dragging\n                  if (Math.abs(dragX.current) > 5) {\n                    e.preventDefault();\n                  }\n                }}\n                className=\"text-foreground hover:text-primary transition-colors p-0.5 shrink-0 z-20\"\n              >\n                <ArrowUpRight className=\"size-6 stroke-[2.2]\" />\n              </a>\n            )}\n          </div>\n\n          {/* Description */}\n          {item.description && (\n            <p className=\"mt-3 text-[15px] text-muted-foreground line-clamp-2 leading-relaxed font-normal pointer-events-none\">\n              {item.description}\n            </p>\n          )}\n\n          {/* Footer Meta */}\n          {(item.category || item.date || item.meta) && (\n            <div className=\"mt-4 flex items-center gap-2 text-xs font-medium text-muted-foreground/80 pointer-events-none\">\n              {item.category && <span>{item.category}</span>}\n              {item.category && (item.date || item.meta) && (\n                <span>·</span>\n              )}\n              {item.date && <span>{item.date}</span>}\n              {!item.date && item.meta && <span>{item.meta}</span>}\n            </div>\n          )}\n        </>\n      )}\n    </div>\n  );\n}\n\n// ============================================================================\n// DECK (keeps leaving cards mounted until they have flown out)\n// ============================================================================\n\ninterface DeckEntry {\n  item: CardStackItem;\n  offset: number;\n}\n\ninterface ExitingCard extends DeckEntry {\n  exit: { x: number; rotation: number };\n}\n\nconst sameDeck = (a: DeckEntry[], b: DeckEntry[]) =>\n  a.length === b.length &&\n  a.every((entry, i) => entry.item.id === b[i].item.id && entry.offset === b[i].offset);\n\ninterface CardDeckProps {\n  visible: DeckEntry[];\n  count: number;\n  direction: 1 | -1;\n  slideMultiplier: number;\n  swipeToDismiss: boolean;\n  canDismiss: boolean;\n  onSwipe: (dir: \"left\" | \"right\") => void;\n  renderCard?: (item: CardStackItem, index: number, isTop: boolean) => React.ReactNode;\n  cardClassName?: string;\n}\n\nfunction CardDeck({\n  visible,\n  count,\n  direction,\n  slideMultiplier,\n  swipeToDismiss,\n  canDismiss,\n  onSwipe,\n  renderCard,\n  cardClassName,\n}: CardDeckProps) {\n  const [shown, setShown] = useState(visible);\n  const [exiting, setExiting] = useState<ExitingCard[]>([]);\n  // The cards on screen when the deck mounts appear in place; any card that\n  // joins later animates in.\n  const [animateEntries, setAnimateEntries] = useState(false);\n\n  if (!sameDeck(shown, visible)) {\n    const ids = new Set(visible.map((entry) => entry.item.id));\n    const left = shown.filter((entry) => !ids.has(entry.item.id));\n    const flyX = 320 * (swipeToDismiss ? 1 : slideMultiplier);\n    const flyRotation = 14 * (swipeToDismiss ? 1 : slideMultiplier);\n    setExiting((prev) => [\n      // A card that comes back mid-flight rejoins the deck from where it is.\n      ...prev.filter((entry) => !ids.has(entry.item.id)),\n      ...left.map((entry) => ({\n        ...entry,\n        exit: {\n          x: direction > 0 ? flyX : -flyX,\n          rotation: direction > 0 ? flyRotation : -flyRotation,\n        },\n      })),\n    ]);\n    setShown(visible);\n    setAnimateEntries(true);\n  }\n\n  const handleExited = useCallback((id: CardStackItem[\"id\"]) => {\n    setExiting((prev) => prev.filter((entry) => entry.item.id !== id));\n  }, []);\n\n  const entries = [\n    ...exiting.filter((entry) => !visible.some((v) => v.item.id === entry.item.id)),\n    ...visible\n      .slice()\n      .reverse()\n      .map((entry) => ({ ...entry, exit: null })),\n  ];\n\n  return (\n    <>\n      {entries.map(({ item, offset, exit }) => {\n        const isTop = offset === 0;\n        return (\n          <StackCardItem\n            key={item.id}\n            item={item}\n            offset={offset}\n            isTop={isTop}\n            count={count}\n            start={\n              animateEntries\n                ? enterPose(offset, count, direction, slideMultiplier)\n                : restPose(offset, count)\n            }\n            exit={exit}\n            isDraggable={isTop && swipeToDismiss && canDismiss}\n            onSwipe={onSwipe}\n            onExited={handleExited}\n            renderCard={renderCard}\n            cardClassName={cardClassName}\n          />\n        );\n      })}\n    </>\n  );\n}\n\n// ============================================================================\n// MAIN COMPONENT\n// ============================================================================\n\nexport const CardStack = React.forwardRef<HTMLDivElement, CardStackProps>(\n  (\n    {\n      items,\n      title,\n      visibleCards = 3,\n      slideDirection = \"right\",\n      loop = false,\n      defaultIndex = 0,\n      onCardChange,\n      onDismiss,\n      renderCard,\n      className,\n      cardClassName,\n      swipeToDismiss = false,\n      showNavigation = true,\n      showHeader = true,\n      canDismissLastCard = false,\n      ...props\n    },\n    ref,\n  ) => {\n    const [currentIndex, setCurrentIndex] = useState(\n      Math.min(Math.max(0, defaultIndex), Math.max(0, items.length - 1)),\n    );\n    const [direction, setDirection] = useState<1 | -1>(1);\n\n    if (!items || items.length === 0) {\n      return null;\n    }\n\n    const total = items.length;\n    const isCompleted = currentIndex >= total;\n    const canPrev = loop ? true : currentIndex > 0;\n    const canNext = loop ? true : currentIndex < total - 1;\n    const canDismiss = loop || canDismissLastCard || currentIndex < total - 1;\n\n    const handlePrev = () => {\n      if (!canPrev) return;\n      setDirection(-1);\n      const nextIdx = currentIndex > 0 ? currentIndex - 1 : total - 1;\n      setCurrentIndex(nextIdx);\n      onCardChange?.(nextIdx);\n    };\n\n    const handleNext = () => {\n      if (!canNext) return;\n      setDirection(1);\n      const nextIdx = currentIndex < total - 1 ? currentIndex + 1 : 0;\n      setCurrentIndex(nextIdx);\n      onCardChange?.(nextIdx);\n    };\n\n    const handleDismissCard = (dismissDir: \"left\" | \"right\") => {\n      if (!canDismiss) return;\n      const currentItem = items[currentIndex % total];\n      setDirection(dismissDir === \"right\" ? 1 : -1);\n      const nextIdx = loop ? (currentIndex + 1) % total : currentIndex + 1;\n      setCurrentIndex(nextIdx);\n      onDismiss?.(currentItem, dismissDir);\n      onCardChange?.(nextIdx % total);\n    };\n\n    // Calculate visible cards from current index\n    const visibleItems: DeckEntry[] = [];\n    const count = Math.min(visibleCards, total);\n\n    if (!isCompleted) {\n      for (let i = 0; i < count; i++) {\n        const itemIndex = (currentIndex + i) % total;\n        if (!loop && currentIndex + i >= total) {\n          break;\n        }\n        visibleItems.push({\n          item: items[itemIndex],\n          offset: i,\n        });\n      }\n    }\n\n    const slideMultiplier = slideDirection === \"left\" ? -1 : 1;\n\n    return (\n      <div\n        ref={ref}\n        className={cn(\"w-full max-w-[500px] select-none\", className)}\n        {...props}\n      >\n        {/* Header: Title/Counter on left, Navigation Arrows on right */}\n        {showHeader && (\n          <div className=\"flex items-center justify-between mb-4 px-1\">\n            {title ? (\n              <h2 className=\"text-xl font-bold tracking-tight text-foreground\">\n                {title}\n              </h2>\n            ) : (\n              <span className=\"text-sm font-medium text-muted-foreground/80 tabular-nums\">\n                {isCompleted ? \"0 left\" : `${Math.min(currentIndex + 1, total)} of ${total}`}\n              </span>\n            )}\n            <div className=\"flex items-center gap-3\">\n              {title && (\n                <span className=\"text-sm font-medium text-muted-foreground/80 tabular-nums\">\n                  {isCompleted ? \"0 left\" : `${Math.min(currentIndex + 1, total)} of ${total}`}\n                </span>\n              )}\n              {showNavigation && (\n                <div className=\"flex items-center gap-1.5\">\n                  <button\n                    type=\"button\"\n                    onClick={handlePrev}\n                    disabled={!canPrev}\n                    aria-label=\"Previous card\"\n                    className=\"size-9 rounded-xl border border-foreground/10 dark:border-foreground/15 bg-card hover:bg-muted active:scale-95 transition-all flex items-center justify-center text-foreground disabled:opacity-30 disabled:pointer-events-none shadow-xs\"\n                  >\n                    <ArrowLeft className=\"size-4\" />\n                  </button>\n                  <button\n                    type=\"button\"\n                    onClick={handleNext}\n                    disabled={!canNext}\n                    aria-label=\"Next card\"\n                    className=\"size-9 rounded-xl border border-foreground/10 dark:border-foreground/15 bg-card hover:bg-muted active:scale-95 transition-all flex items-center justify-center text-foreground disabled:opacity-30 disabled:pointer-events-none shadow-xs\"\n                  >\n                    <ArrowRight className=\"size-4\" />\n                  </button>\n                </div>\n              )}\n            </div>\n          </div>\n        )}\n\n        {/* Stack Container with generous padding so stacked cards and shadows never clip */}\n        <div className=\"relative w-full overflow-hidden pt-7 pr-12 pb-8 pl-2\">\n          <div className=\"relative h-[195px] sm:h-[200px] w-full\">\n            {isCompleted ? (\n              <div className=\"size-full rounded-[26px] border border-dashed border-foreground/15 dark:border-foreground/20 flex flex-col items-center justify-center gap-3 p-6 text-center bg-muted/20\">\n                <p className=\"text-sm font-medium text-muted-foreground\">\n                  All cards have been reviewed!\n                </p>\n                <button\n                  type=\"button\"\n                  onClick={() => setCurrentIndex(0)}\n                  className=\"inline-flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold bg-primary text-primary-foreground hover:opacity-90 active:scale-95 transition-all shadow-xs\"\n                >\n                  <RotateCcw className=\"size-3.5\" />\n                  Replay deck\n                </button>\n              </div>\n            ) : (\n              <CardDeck\n                visible={visibleItems}\n                count={count}\n                direction={direction}\n                slideMultiplier={slideMultiplier}\n                swipeToDismiss={swipeToDismiss}\n                canDismiss={canDismiss}\n                onSwipe={handleDismissCard}\n                renderCard={renderCard}\n                cardClassName={cardClassName}\n              />\n            )}\n          </div>\n        </div>\n      </div>\n    );\n  },\n);\n\nCardStack.displayName = \"CardStack\";\n\nexport default CardStack;\n",
      "type": "registry:component",
      "target": "components/custom/blocks/CardStackBlock.tsx"
    },
    {
      "path": "src/components/custom/blocks/gsap-motion.ts",
      "content": "import gsap from \"gsap\";\nimport { cubicBezier, type FrameTicker } from \"./motion-curves\";\n\n/**\n * GSAP-side glue for the GSAP builds of components that also ship a Motion\n * Motion twin. Kept apart from motion-curves.ts so the library-free curves\n * never pull GSAP into a CSS or Motion build.\n */\n\n/** `gsap.ticker` as a FrameTicker, so spring followers run on GSAP's loop. */\nexport const gsapTicker: FrameTicker = {\n  add(callback) {\n    const tick = (_time: number, deltaTime: number) => callback(deltaTime);\n    gsap.ticker.add(tick);\n    return () => gsap.ticker.remove(tick);\n  },\n};\n\n/** A Motion / CSS cubic-bezier array as a GSAP ease. */\nexport const bezierEase = (curve: readonly [number, number, number, number] | readonly number[]) =>\n  cubicBezier(curve[0], curve[1], curve[2], curve[3]);\n\n/** `(prefers-reduced-motion: reduce)` right now. */\nexport const prefersReducedMotion = () =>\n  typeof window !== \"undefined\" &&\n  window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches === true;\n",
      "type": "registry:component",
      "target": "components/custom/blocks/gsap-motion.ts"
    },
    {
      "path": "src/components/custom/blocks/motion-curves.ts",
      "content": "/**\n * Motion curves with no animation library behind them.\n *\n * The components here ship as CSS, GSAP or Motion builds that must move\n * identically, so the curves are computed once, from the same numbers, and\n * handed to whichever engine runs them:\n *\n *   - `cubicBezier(x1, y1, x2, y2)` → an easing function (GSAP accepts any\n *     `(progress) => value` as `ease`), exact for Motion's `ease: [..]` arrays\n *     and for CSS `cubic-bezier()`.\n *   - `spring({ stiffness, damping, mass })` → the analytic solution Motion\n *     Motion uses, as an easing function plus the time it takes to settle, or\n *     as a CSS `linear()` string for a fixed start and end.\n *   - `createSpringFollower` → a value that chases a moving target through the\n *     same spring every frame, like Motion's `useSpring`, for pointer-driven\n *     motion that no fixed curve can describe.\n */\n\n// ---------------------------------------------------------------------------\n// Cubic bezier\n// ---------------------------------------------------------------------------\n\n/**\n * The CSS / Motion cubic-bezier as a function of progress. Newton-Raphson\n * with a bisection fallback, the same solver browsers use.\n */\nexport function cubicBezier(x1: number, y1: number, x2: number, y2: number) {\n  if (x1 === y1 && x2 === y2) return (t: number) => t;\n\n  const a = (p1: number, p2: number) => 1 - 3 * p2 + 3 * p1;\n  const b = (p1: number, p2: number) => 3 * p2 - 6 * p1;\n  const c = (p1: number) => 3 * p1;\n  const at = (t: number, p1: number, p2: number) =>\n    ((a(p1, p2) * t + b(p1, p2)) * t + c(p1)) * t;\n  const slope = (t: number, p1: number, p2: number) =>\n    3 * a(p1, p2) * t * t + 2 * b(p1, p2) * t + c(p1);\n\n  const solveX = (x: number) => {\n    let t = x;\n    for (let i = 0; i < 8; i++) {\n      const s = slope(t, x1, x2);\n      if (Math.abs(s) < 1e-6) break;\n      const err = at(t, x1, x2) - x;\n      if (Math.abs(err) < 1e-7) return t;\n      t -= err / s;\n    }\n    let lo = 0;\n    let hi = 1;\n    t = x;\n    for (let i = 0; i < 30 && hi - lo > 1e-7; i++) {\n      const value = at(t, x1, x2);\n      if (value < x) lo = t;\n      else hi = t;\n      t = (lo + hi) / 2;\n    }\n    return t;\n  };\n\n  return (x: number) => (x <= 0 ? 0 : x >= 1 ? 1 : at(solveX(x), y1, y2));\n}\n\n/** Motion's named eases, as the cubic-beziers they are. */\nexport const MOTION_EASE = {\n  linear: [0, 0, 1, 1],\n  easeIn: [0.42, 0, 1, 1],\n  easeOut: [0, 0, 0.58, 1],\n  easeInOut: [0.42, 0, 0.58, 1],\n  circIn: [0.55, 0, 1, 0.45],\n  circOut: [0.55, 0, 0.45, 1],\n  circInOut: [0.85, 0, 0.15, 1],\n  backIn: [0.31, 0.01, 0.66, -0.59],\n  backOut: [0.33, 1.53, 0.69, 0.99],\n  anticipate: [0.66, -0.56, 0.34, 1.35],\n} as const;\n\n/**\n * Motion's default layout transition (`layout` / `layoutId`):\n * `{ duration: 0.45, ease: [0.4, 0, 0.1, 1] }`.\n */\nexport const LAYOUT_TRANSITION = { duration: 0.45, ease: [0.4, 0, 0.1, 1] as const };\n\n// ---------------------------------------------------------------------------\n// Spring\n// ---------------------------------------------------------------------------\n\nexport interface SpringOptions {\n  /** Motion default 100. */\n  stiffness?: number;\n  /** Motion default 10. */\n  damping?: number;\n  /** Motion default 1. */\n  mass?: number;\n  /** Starting velocity, in units of the whole distance per second. */\n  velocity?: number;\n  /** How close to the end counts as settled, as a fraction of the distance. */\n  restDelta?: number;\n}\n\n/**\n * Normalised position (0 → 1) of a spring released from 0 towards 1, at time\n * `t` seconds. The closed-form solution Motion's `spring()` generator\n * uses for the under-, critically and over-damped cases.\n */\nexport function springAt(options: SpringOptions = {}) {\n  const { stiffness = 100, damping = 10, mass = 1, velocity = 0 } = options;\n  const zeta = damping / (2 * Math.sqrt(stiffness * mass));\n  const omega0 = Math.sqrt(stiffness / mass);\n  // Motion's convention: distance still to cover, velocity toward it negated.\n  const delta = 1;\n  const v0 = -velocity;\n\n  if (zeta < 1) {\n    const omegaD = omega0 * Math.sqrt(1 - zeta * zeta);\n    return (t: number) => {\n      const envelope = Math.exp(-zeta * omega0 * t);\n      return (\n        1 -\n        envelope *\n          (((v0 + zeta * omega0 * delta) / omegaD) * Math.sin(omegaD * t) +\n            delta * Math.cos(omegaD * t))\n      );\n    };\n  }\n  if (zeta === 1) {\n    return (t: number) => 1 - Math.exp(-omega0 * t) * (delta + (v0 + omega0 * delta) * t);\n  }\n  const omegaD = omega0 * Math.sqrt(zeta * zeta - 1);\n  return (t: number) => {\n    const envelope = Math.exp(-zeta * omega0 * t);\n    const freq = Math.min(omegaD * t, 300);\n    return (\n      1 -\n      (envelope *\n        ((v0 + zeta * omega0 * delta) * Math.sinh(freq) + omegaD * delta * Math.cosh(freq))) /\n        omegaD\n    );\n  };\n}\n\n/** Seconds until the spring stays within `restDelta` of its end. */\nexport function springDuration(options: SpringOptions = {}) {\n  const at = springAt(options);\n  const restDelta = options.restDelta ?? 0.001;\n  const step = 1 / 240;\n  let lastOutside = 0;\n  for (let t = 0; t <= 10; t += step) {\n    if (Math.abs(1 - at(t)) > restDelta) lastOutside = t;\n    else if (t - lastOutside > 0.1) break;\n  }\n  return Math.max(lastOutside + step, step);\n}\n\n/**\n * A spring as a duration and an easing function over it — what GSAP takes:\n *\n *   const { duration, ease } = springEase({ stiffness: 300, damping: 20 });\n *   gsap.to(el, { y: 0, duration, ease });\n *\n * Exact for a fixed start and end; a spring that has to react mid-flight to a\n * new target needs `createSpringFollower` instead.\n */\nexport function springEase(options: SpringOptions = {}) {\n  const duration = springDuration(options);\n  const at = springAt(options);\n  return {\n    duration,\n    ease: (progress: number) => (progress >= 1 ? 1 : at(progress * duration)),\n  };\n}\n\n/**\n * A spring as a CSS `linear()` easing and its duration in ms, for\n * `transition` / `animation` with a fixed start and end.\n *\n *   const { easing, duration } = springLinear({ stiffness: 400, damping: 30 });\n *   el.style.transition = `transform ${duration}ms ${easing}`;\n */\nexport function springLinear(options: SpringOptions = {}, pointsPerSecond = 60) {\n  const duration = springDuration(options);\n  const at = springAt(options);\n  const count = Math.max(Math.ceil(duration * pointsPerSecond), 8);\n  const points: string[] = [];\n  for (let i = 0; i <= count; i++) {\n    const t = (i / count) * duration;\n    points.push(String(Math.round((i === count ? 1 : at(t)) * 10000) / 10000));\n  }\n  return { easing: `linear(${points.join(\", \")})`, duration: Math.round(duration * 1000) };\n}\n\n// ---------------------------------------------------------------------------\n// Spring follower (useSpring equivalent)\n// ---------------------------------------------------------------------------\n\n/** Something that calls back once per frame with the time since the last, in ms. */\nexport interface FrameTicker {\n  add: (callback: (deltaMs: number) => void) => () => void;\n}\n\n/** requestAnimationFrame as a ticker — the library-free default. */\nexport const rafTicker: FrameTicker = {\n  add(callback) {\n    let last = performance.now();\n    let frame = requestAnimationFrame(function tick(now) {\n      callback(now - last);\n      last = now;\n      frame = requestAnimationFrame(tick);\n    });\n    return () => cancelAnimationFrame(frame);\n  },\n};\n\nexport interface SpringFollower {\n  /** Chase a new target from wherever the value is now, keeping its velocity. */\n  set: (target: number) => void;\n  /** Move to a value instantly, with no motion. */\n  jump: (value: number) => void;\n  get: () => number;\n  destroy: () => void;\n}\n\n/**\n * A value that follows its target through a spring, integrating the same\n * equation of motion Motion's `useSpring` solves — so a pointer-driven spring\n * reads the same in every build. Runs only while moving; `onUpdate` receives\n * each new value.\n */\nexport function createSpringFollower(\n  initial: number,\n  onUpdate: (value: number) => void,\n  options: SpringOptions & { restSpeed?: number; ticker?: FrameTicker } = {},\n): SpringFollower {\n  const { stiffness = 100, damping = 10, mass = 1, ticker = rafTicker } = options;\n  const restDelta = options.restDelta ?? 0.01;\n  const restSpeed = options.restSpeed ?? 0.01;\n  let value = initial;\n  let target = initial;\n  let velocity = 0;\n  let stop: (() => void) | null = null;\n\n  const step = (deltaMs: number) => {\n    // Fixed 1ms sub-steps keep the integration stable for stiff springs and\n    // independent of the frame rate.\n    const steps = Math.min(Math.max(Math.round(deltaMs), 1), 250);\n    for (let i = 0; i < steps; i++) {\n      const force = -stiffness * (value - target) - damping * velocity;\n      velocity += (force / mass) * 0.001;\n      value += velocity * 0.001;\n    }\n    if (Math.abs(target - value) < restDelta && Math.abs(velocity) < restSpeed) {\n      value = target;\n      velocity = 0;\n      stop?.();\n      stop = null;\n    }\n    onUpdate(value);\n  };\n\n  return {\n    set(next) {\n      target = next;\n      if (!stop && value !== target) stop = ticker.add(step);\n    },\n    jump(next) {\n      stop?.();\n      stop = null;\n      value = target = next;\n      velocity = 0;\n      onUpdate(value);\n    },\n    get: () => value,\n    destroy() {\n      stop?.();\n      stop = null;\n    },\n  };\n}\n",
      "type": "registry:component",
      "target": "components/custom/blocks/motion-curves.ts"
    }
  ],
  "docs": "Named export: CardStack (also default). Accepts items, visibleCards, swipeToDismiss, canDismissLastCard, loop, showHeader, and showNavigation.",
  "categories": [
    "data-display",
    "marketing",
    "actions"
  ],
  "type": "registry:component"
}
