{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-stack",
  "title": "Card Stack",
  "description": "A pile of things with no order: a scattered heap to drag through, a fan that spreads, a swipe to decide on each or notification rows that open into a list.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json",
    "https://miseui.junsu4621.workers.dev/r/button.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/card-stack.tsx",
      "content": "import type * as React from \"react\"\nimport { Cascade } from \"@/registry/miseui/ui/card-stack-cascade\"\nimport { DEFAULT_LABELS, type CardStackItem, type CardStackLabels } from \"@/registry/miseui/ui/card-stack-card\"\nimport { Fan } from \"@/registry/miseui/ui/card-stack-fan\"\nimport type { SwipeChoice } from \"@/registry/miseui/ui/card-stack-math\"\nimport { Pile } from \"@/registry/miseui/ui/card-stack-pile\"\nimport { Swipe } from \"@/registry/miseui/ui/card-stack-swipe\"\n\ntype CardStackVariant = \"pile\" | \"fan\" | \"swipe\" | \"cascade\"\ntype CardStackEffect = \"autoplay\"\n\nconst AUTOPLAY_MS = 3600\n\ntype CardStackProps = Omit<React.ComponentProps<\"div\">, \"ref\" | \"children\"> & {\n  items: readonly CardStackItem[]\n  /** Names the stack for assistive tech. */\n  label: string\n  /** `pile` a scattered heap, drag the top away or press Next, `fan` spreads on hover, focus or tap, `swipe` decide on each card, `cascade` rows peeking from behind that open into a list. */\n  variant?: CardStackVariant\n  /** `autoplay` sends the top card to the back on a timer, and stops on hover and focus. It does nothing for `swipe`: a decision is not something to make for someone. */\n  effect?: CardStackEffect\n  /** Milliseconds between turns under `autoplay`. */\n  interval?: number\n  /** Holds autoplay. Wire it to a control: motion past five seconds needs a way to stop it. */\n  paused?: boolean\n  /** Another seed scatters the `pile` differently, the same on the server and the client. */\n  seed?: number\n  /** `swipe`: called with the card's id and the side it went to. */\n  onDecide?: (id: string, choice: SwipeChoice) => void\n  /** The words on the controls. */\n  labels?: Partial<CardStackLabels>\n}\n\nfunction CardStack({ items, label, variant = \"pile\", effect, interval = AUTOPLAY_MS, paused = false, seed = 1, onDecide, labels, ...props }: CardStackProps) {\n  const shared = {\n    items,\n    label,\n    autoplay: effect === \"autoplay\",\n    paused,\n    interval,\n    seed,\n    labels: { ...DEFAULT_LABELS, ...labels },\n    rootProps: { ...props, \"data-slot\": \"card-stack\", \"data-variant\": variant, \"data-effect\": effect } as React.ComponentProps<\"div\">,\n  }\n  if (variant === \"fan\") return <Fan {...shared} />\n  if (variant === \"swipe\") return <Swipe {...shared} autoplay={false} onDecide={onDecide} />\n  if (variant === \"cascade\") return <Cascade {...shared} />\n  return <Pile {...shared} />\n}\n\nexport { CardStack, type CardStackProps, type CardStackVariant, type CardStackEffect, type CardStackItem, type CardStackLabels, type SwipeChoice }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/card-stack-card.tsx",
      "content": "import type * as React from \"react\"\r\nimport { useEffect, useRef, useState } from \"react\"\r\nimport { useBackgroundPlayback } from \"@/registry/miseui/ui/background-loop\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\nexport interface CardStackItem {\r\n  id: string\r\n  /** Announced when it comes to the top, and named in the status line. */\r\n  label: string\r\n  content: React.ReactNode\r\n}\r\n\r\nexport interface CardStackLabels {\r\n  next: string\r\n  spread: string\r\n  gather: string\r\n  showAll: string\r\n  collapse: string\r\n  left: string\r\n  right: string\r\n  done: string\r\n  again: string\r\n}\r\n\r\nexport const DEFAULT_LABELS: CardStackLabels = {\r\n  next: \"Next\",\r\n  spread: \"Spread\",\r\n  gather: \"Stack up\",\r\n  showAll: \"Show all\",\r\n  collapse: \"Collapse\",\r\n  left: \"Skip\",\r\n  right: \"Keep\",\r\n  done: \"All done\",\r\n  again: \"Start over\",\r\n}\r\n\r\n/** What each look receives from the root. */\r\nexport interface StackVariantProps {\r\n  items: readonly CardStackItem[]\r\n  label?: string\r\n  autoplay: boolean\r\n  paused: boolean\r\n  interval: number\r\n  seed: number\r\n  labels: CardStackLabels\r\n  rootProps: React.ComponentProps<\"div\">\r\n}\r\n\r\nexport const CARD_CLASS = \"rounded-xl border bg-card p-4 text-card-foreground\"\r\n/** Every card shares one grid cell, so the cell is exactly as tall as the tallest card and nothing jumps when the top changes. */\r\nexport const STACK_CELL = \"[grid-area:1/1] min-w-0\"\r\nexport const SURFACE_SHADOW = \"shadow-[0_1px_2px_rgb(0_0_0/0.05),0_8px_16px_-12px_rgb(0_0_0/0.2)] dark:shadow-[0_1px_2px_rgb(0_0_0/0.5),0_8px_16px_-12px_rgb(0_0_0/0.7)]\"\r\nexport const CONTROL_ROW = \"flex min-h-10 flex-wrap items-center justify-between gap-2\"\r\n/** A card dragged past the edge of a narrow column would otherwise widen the whole page. The margin leaves it room to read as moving away. */\r\nexport const DRAG_CLIP = \"overflow-x-clip [overflow-clip-margin:24px]\"\r\n\r\n/** A card out of reach must be out of reach for everything: no focus, no reading, no clicking through. */\r\nexport function reachable(active: boolean): { inert?: boolean; \"aria-hidden\"?: true } {\r\n  return active ? {} : { inert: true, \"aria-hidden\": true }\r\n}\r\n\r\n/** A tap focuses a control too, and that must not count as the keyboard arriving, or the same tap would open and then toggle shut. */\r\nexport function isKeyboardFocus(target: EventTarget) {\r\n  try {\r\n    return target instanceof Element && target.matches(\":focus-visible\")\r\n  } catch {\r\n    return true\r\n  }\r\n}\r\n\r\nconst INTERACTIVE = \"a, button, input, select, textarea, label, [role='button'], [tabindex]\"\r\n\r\n/** A press on the stack toggles it, unless it landed on a control inside a card. */\r\nexport function pressedControl(target: EventTarget) {\r\n  return target instanceof Element && target.closest(INTERACTIVE) !== null\r\n}\r\n\r\ninterface StackStatusProps {\r\n  text: string\r\n  /** Off while autoplay turns the cards: being interrupted every few seconds is worse than not being told. */\r\n  live?: boolean\r\n}\r\n\r\nexport function StackStatus({ text, live = true }: StackStatusProps) {\r\n  return (\r\n    <p data-slot=\"card-stack-status\" className=\"min-w-0 text-[13px] text-muted-foreground tabular-nums\" aria-live={live ? \"polite\" : \"off\"}>\r\n      {text}\r\n    </p>\r\n  )\r\n}\r\n\r\ninterface AutoplayOptions {\r\n  enabled: boolean\r\n  paused: boolean\r\n  /** Hover or focus inside the stack, or a state where turning would pull something away from someone. */\r\n  held: boolean\r\n  intervalMs: number\r\n  onTurn: () => void\r\n}\r\n\r\n/** Same loop rules as a background: on screen, visible tab, not paused, not reduced motion. */\r\nexport function useAutoplay(rootRef: React.RefObject<HTMLElement | null>, { enabled, paused, held, intervalMs, onTurn }: AutoplayOptions) {\r\n  const { playing } = useBackgroundPlayback(rootRef, { paused, loops: enabled })\r\n  const latestTurn = useRef(onTurn)\r\n  useEffect(() => {\r\n    latestTurn.current = onTurn\r\n  })\r\n  const running = enabled && playing && !held\r\n  useEffect(() => {\r\n    if (!running) return\r\n    const timer = window.setInterval(() => latestTurn.current(), intervalMs)\r\n    return () => window.clearInterval(timer)\r\n  }, [running, intervalMs])\r\n  return running\r\n}\r\n\r\n/** Tracks pointer and focus inside the stack, so autoplay stops while someone is on it. */\r\nexport function useHeld() {\r\n  const [hovered, setHovered] = useState(false)\r\n  const [focused, setFocused] = useState(false)\r\n  return {\r\n    held: hovered || focused,\r\n    heldProps: {\r\n      // A finger fires enter on touch down and leave on lift, so this also holds still under a drag.\r\n      onPointerEnter: () => setHovered(true),\r\n      onPointerLeave: () => setHovered(false),\r\n      onFocus: () => setFocused(true),\r\n      onBlur: (event: React.FocusEvent<HTMLElement>) => {\r\n        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false)\r\n      },\r\n    },\r\n  }\r\n}\r\n\r\ntype RootHandlers = Pick<React.ComponentProps<\"div\">, \"onPointerEnter\" | \"onPointerLeave\" | \"onFocus\" | \"onBlur\" | \"onKeyDown\">\r\n\r\nfunction chain<Event>(owner?: (event: Event) => void, own?: (event: Event) => void) {\r\n  return (event: Event) => {\r\n    owner?.(event)\r\n    own?.(event)\r\n  }\r\n}\r\n\r\n/** The root needs these for itself, and they arrive in the same props the owner uses. Spreading one over the other would silently drop the owner's. */\r\nexport function composeHandlers(owner: RootHandlers, own: RootHandlers): RootHandlers {\r\n  return {\r\n    onPointerEnter: chain(owner.onPointerEnter, own.onPointerEnter),\r\n    onPointerLeave: chain(owner.onPointerLeave, own.onPointerLeave),\r\n    onFocus: chain(owner.onFocus, own.onFocus),\r\n    onBlur: chain(owner.onBlur, own.onBlur),\r\n    onKeyDown: chain(owner.onKeyDown, own.onKeyDown),\r\n  }\r\n}\r\n\r\nexport function StackRoot({ className, children, ...props }: React.ComponentProps<\"div\">) {\r\n  return (\r\n    <div {...props} role=\"group\" aria-roledescription=\"card stack\" className={cn(\"grid w-full min-w-0 grid-cols-[minmax(0,1fr)] gap-3\", className)}>\r\n      {children}\r\n    </div>\r\n  )\r\n}\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/card-stack-cascade.tsx",
      "content": "import { useEffect, useRef, useState } from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { Button } from \"@/registry/miseui/ui/button\"\nimport { CARD_CLASS, composeHandlers, CONTROL_ROW, isKeyboardFocus, pressedControl, reachable, STACK_CELL, StackRoot, StackStatus, useAutoplay, useHeld, type StackVariantProps } from \"@/registry/miseui/ui/card-stack-card\"\nimport { cascadePose, CASCADE_PEEK_ROOM, depthOf } from \"@/registry/miseui/ui/card-stack-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\nfunction useRowHeight(rowsRef: React.RefObject<HTMLElement | null>) {\n  const [height, setHeight] = useState(0)\n  useEffect(() => {\n    const row = rowsRef.current?.querySelector<HTMLElement>('[data-slot=\"card-stack-card\"]')\n    if (!row || typeof ResizeObserver === \"undefined\") return\n    // offsetHeight ignores transforms, so the peek's scale does not shrink the measure.\n    const observer = new ResizeObserver(() => setHeight(row.offsetHeight))\n    observer.observe(row)\n    return () => observer.disconnect()\n  }, [rowsRef])\n  return height\n}\n\n/**\n * Open rows float over what follows instead of pushing it down: the page around them never moves, and the closed box\n * keeps its size. A list that grew in place would shift everything below it on every open and close.\n */\nexport function Cascade({ items, label, autoplay, paused, interval, labels, rootProps }: StackVariantProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  const rootRef = useRef<HTMLDivElement>(null)\n  const rowsRef = useRef<HTMLDivElement>(null)\n  const toggleRef = useRef<HTMLButtonElement>(null)\n  const [open, setOpen] = useState(false)\n  const [top, setTop] = useState(0)\n  const { held, heldProps } = useHeld()\n  const count = items.length\n  const rowHeight = useRowHeight(rowsRef)\n  const running = useAutoplay(rootRef, { enabled: autoplay, paused, held: open || held, intervalMs: interval, onTurn: () => setTop((current) => (current + 1) % Math.max(count, 1)) })\n\n  return (\n    <StackRoot\n      {...rootProps}\n      ref={rootRef}\n      aria-label={label}\n      data-open={open}\n      data-playing={autoplay ? String(running) : undefined}\n      className={cn(\"relative\", open && \"z-20\", rootProps.className)}\n      {...composeHandlers(rootProps, {\n        onPointerEnter: heldProps.onPointerEnter,\n        onPointerLeave: heldProps.onPointerLeave,\n        onFocus: (event) => {\n          heldProps.onFocus()\n          if (isKeyboardFocus(event.target)) setOpen(true)\n        },\n        onBlur: (event) => {\n          heldProps.onBlur(event)\n          if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false)\n        },\n      })}\n      // Written here, not composed: it holds a ref, and a closure over a ref must not pass through a call during render.\n      onKeyDown={(event) => {\n        rootProps.onKeyDown?.(event)\n        if (event.key !== \"Escape\" || !open) return\n        event.stopPropagation()\n        setOpen(false)\n        toggleRef.current?.focus()\n      }}\n    >\n      <div className={CONTROL_ROW}>\n        <StackStatus text={count === 1 ? \"1 update\" : `${count} updates`} live={false} />\n        <Button ref={toggleRef} variant=\"ghost\" size=\"sm\" className=\"pointer-coarse:h-10\" aria-expanded={open} onClick={() => setOpen(!open)}>\n          {open ? labels.collapse : labels.showAll}\n        </Button>\n      </div>\n      <div\n        ref={rowsRef}\n        data-slot=\"card-stack-rows\"\n        className=\"grid grid-cols-[minmax(0,1fr)]\"\n        style={{ paddingBottom: CASCADE_PEEK_ROOM }}\n        onClick={(event) => {\n          if (!open && !pressedControl(event.target)) setOpen(true)\n        }}\n      >\n        {items.map((item, index) => {\n          const depth = depthOf(index, top, count)\n          return (\n            <motion.div\n              key={item.id}\n              {...reachable(open || depth === 0)}\n              data-slot=\"card-stack-card\"\n              data-depth={depth}\n              className={cn(STACK_CELL, CARD_CLASS, \"shadow-sm dark:shadow-black/40\")}\n              style={{ zIndex: count - depth, transformOrigin: \"50% 100%\" }}\n              initial={false}\n              animate={cascadePose(depth, open, rowHeight)}\n              transition={reduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n            >\n              {item.content}\n            </motion.div>\n          )\n        })}\n      </div>\n    </StackRoot>\n  )\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/card-stack-fan.tsx",
      "content": "import { useEffect, useRef, useState } from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { Button } from \"@/registry/miseui/ui/button\"\nimport { CARD_CLASS, composeHandlers, CONTROL_ROW, isKeyboardFocus, pressedControl, reachable, STACK_CELL, StackRoot, SURFACE_SHADOW, useAutoplay, type StackVariantProps } from \"@/registry/miseui/ui/card-stack-card\"\nimport { depthOf, fanPose, fitFan, type FanFit } from \"@/registry/miseui/ui/card-stack-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\nconst FINE_POINTER_QUERY = \"(hover: hover) and (pointer: fine)\"\nconst FIRST_FIT: FanFit = { spread: 8, gap: 0 }\n\n/** Measured, not assumed: how far a fan may open depends on the cell it landed in and on how tall its cards are. */\nfunction useFanFit(rootRef: React.RefObject<HTMLElement | null>, count: number) {\n  const [fit, setFit] = useState(FIRST_FIT)\n  useEffect(() => {\n    const root = rootRef.current\n    const card = root?.querySelector<HTMLElement>('[data-slot=\"card-stack-card\"]')\n    if (!root || !card || typeof ResizeObserver === \"undefined\") return\n    // offsetWidth and offsetHeight ignore transforms, so the card's own rotation does not feed back into this.\n    const measure = () => setFit(fitFan(count, root.offsetWidth, card.offsetWidth, card.offsetHeight))\n    const observer = new ResizeObserver(measure)\n    observer.observe(root)\n    observer.observe(card)\n    return () => observer.disconnect()\n  }, [rootRef, count])\n  return fit\n}\n\nexport function Fan({ items, label, autoplay, paused, interval, labels, rootProps }: StackVariantProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  const rootRef = useRef<HTMLDivElement>(null)\n  const [open, setOpen] = useState(false)\n  const [top, setTop] = useState(0)\n  const count = items.length\n  const fit = useFanFit(rootRef, count)\n  const running = useAutoplay(rootRef, { enabled: autoplay, paused, held: open, intervalMs: interval, onTurn: () => setTop((current) => (current + 1) % Math.max(count, 1)) })\n\n  return (\n    <StackRoot\n      {...rootProps}\n      ref={rootRef}\n      aria-label={label}\n      data-open={open}\n      data-playing={autoplay ? String(running) : undefined}\n      {...composeHandlers(rootProps, {\n        onPointerEnter: (event) => {\n          if (event.pointerType === \"mouse\" && window.matchMedia(FINE_POINTER_QUERY).matches) setOpen(true)\n        },\n        onPointerLeave: (event) => {\n          if (event.pointerType === \"mouse\" && !event.currentTarget.contains(document.activeElement)) setOpen(false)\n        },\n        onFocus: (event) => {\n          if (isKeyboardFocus(event.target)) setOpen(true)\n        },\n        onBlur: (event) => {\n          if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false)\n        },\n      })}\n    >\n      {/* The bottom room is where a corner swings to at full spread: reserved now, so opening moves nothing around it. */}\n      <div\n        data-slot=\"card-stack-fan\"\n        className=\"grid grid-cols-[minmax(0,1fr)] justify-items-center pt-2 pb-8\"\n        onClick={(event) => {\n          if (!window.matchMedia(FINE_POINTER_QUERY).matches && !pressedControl(event.target)) setOpen(!open)\n        }}\n      >\n        {items.map((item, index) => {\n          const depth = depthOf(index, top, count)\n          return (\n            <motion.div\n              key={item.id}\n              {...reachable(open || depth === 0)}\n              data-slot=\"card-stack-card\"\n              data-depth={depth}\n              className={cn(STACK_CELL, CARD_CLASS, SURFACE_SHADOW, \"w-[54%]\")}\n              style={{ zIndex: count - depth, transformOrigin: \"50% 100%\" }}\n              initial={false}\n              animate={fanPose(count - 1 - depth, count, open, fit)}\n              transition={reduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n            >\n              {item.content}\n            </motion.div>\n          )\n        })}\n      </div>\n      <div className={CONTROL_ROW}>\n        <p data-slot=\"card-stack-status\" className=\"text-[13px] text-muted-foreground tabular-nums\">\n          {count === 1 ? \"1 card\" : `${count} cards`}\n        </p>\n        <Button variant=\"outline\" size=\"sm\" className=\"pointer-coarse:h-10\" aria-expanded={open} onClick={() => setOpen(!open)}>\n          {open ? labels.gather : labels.spread}\n        </Button>\n      </div>\n    </StackRoot>\n  )\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/card-stack-math.ts",
      "content": "import { between, seededRandom } from \"@/registry/miseui/ui/background-seed\"\r\n\r\nexport type CardPose = {\r\n  x: number\r\n  y: number\r\n  rotate: number\r\n  scale: number\r\n  opacity: number\r\n}\r\n\r\nconst MIN_FLING_PX = 80\r\nconst FLING_SHARE = 0.35\r\nconst FLICK_VELOCITY = 500\r\nconst SHOWN_IN_PILE = 3\r\n\r\n/** How far behind the top a card sits, once `top` has rotated round. */\r\nexport function depthOf(index: number, top: number, count: number) {\r\n  if (count <= 0) return 0\r\n  return ((index - top) % count + count) % count\r\n}\r\n\r\n/** A flick counts even when short: speed says what someone meant as much as distance does. */\r\nexport function shouldLeave(offsetX: number, velocityX: number, width: number) {\r\n  return Math.abs(offsetX) > Math.max(MIN_FLING_PX, width * FLING_SHARE) || Math.abs(velocityX) > FLICK_VELOCITY\r\n}\r\n\r\n/** Seeded so server and client agree, and so the scatter stays put when the order changes: a card keeps its own tilt. */\r\nexport function pilePose(index: number, depth: number, count: number, seed = 1): CardPose {\r\n  const random = seededRandom(seed * 7919 + index * 104729)\r\n  const shown = Math.min(depth, SHOWN_IN_PILE)\r\n  // The top card sits true, so its text reads level. The others show their own scatter behind it.\r\n  const tilt = depth === 0 ? 0 : between(random, -3.5, 3.5)\r\n  const shiftX = depth === 0 ? 0 : between(random, -6, 6)\r\n  const shiftY = depth === 0 ? 0 : between(random, 2, 8)\r\n  return {\r\n    x: shiftX,\r\n    y: shiftY + shown * 2,\r\n    rotate: tilt,\r\n    scale: 1 - shown * 0.015,\r\n    opacity: depth < count && depth <= SHOWN_IN_PILE ? 1 : 0,\r\n  }\r\n}\r\n\r\nconst FAN_CLOSED_DEGREES = 2.5\r\nconst FAN_OPEN_DEGREES = 11\r\nconst FAN_MAX_OUTER_DEGREES = 22\r\n\r\nexport interface FanFit {\r\n  /** Degrees between neighbours when open. */\r\n  spread: number\r\n  /** Pixels between neighbours when open. */\r\n  gap: number\r\n}\r\n\r\n/** The widest a fan can open and still keep its outer corners inside the root, whatever the card and cell sizes are. */\r\nexport function fitFan(count: number, rootWidth: number, cardWidth: number, cardHeight: number): FanFit {\r\n  if (count < 2 || rootWidth <= 0 || cardWidth <= 0) return { spread: FAN_OPEN_DEGREES, gap: 0 }\r\n  const half = (count - 1) / 2\r\n  // Past this outer angle a bottom corner dips below the reserved room.\r\n  let spread = Math.min(FAN_OPEN_DEGREES, FAN_MAX_OUTER_DEGREES / half)\r\n  // Ten shrinks are plenty: the first one that fits is taken, and most cards fit at full spread.\r\n  for (let attempt = 0; attempt < 10; attempt += 1) {\r\n    const outer = (spread * half * Math.PI) / 180\r\n    const reach = (cardWidth / 2) * Math.cos(outer) + cardHeight * Math.sin(outer)\r\n    const room = rootWidth / 2 - reach\r\n    if (room >= 0) return { spread, gap: Math.min(room / Math.max(half, 1), cardWidth * 0.18) }\r\n    spread *= 0.82\r\n  }\r\n  return { spread, gap: 0 }\r\n}\r\n\r\nexport function fanPose(index: number, count: number, open: boolean, fit: FanFit): CardPose {\r\n  const offset = index - (count - 1) / 2\r\n  if (!open) return { x: 0, y: 0, rotate: offset * (FAN_CLOSED_DEGREES / Math.max((count - 1) / 2, 1)), scale: 1, opacity: 1 }\r\n  return { x: offset * fit.gap, y: Math.abs(offset) * 3, rotate: offset * fit.spread, scale: 1, opacity: 1 }\r\n}\r\n\r\nconst PEEK_PX = 8\r\nconst PEEK_SCALE = 0.04\r\nconst PEEKS = 2\r\n\r\nexport const CASCADE_GAP = 8\r\n/** Bottom room the peeking rows need when closed. */\r\nexport const CASCADE_PEEK_ROOM = PEEK_PX * PEEKS\r\n\r\nexport function cascadePose(depth: number, open: boolean, rowHeight: number): CardPose {\r\n  if (open) return { x: 0, y: depth * (rowHeight + CASCADE_GAP), rotate: 0, scale: 1, opacity: 1 }\r\n  // Scaling from the row's own bottom edge: the peek stays a thin strip however tall the row is.\r\n  const shown = Math.min(depth, PEEKS)\r\n  return { x: 0, y: shown * PEEK_PX, rotate: 0, scale: 1 - shown * PEEK_SCALE, opacity: depth > PEEKS ? 0 : 1 }\r\n}\r\n\r\nexport type SwipeChoice = \"left\" | \"right\"\r\n\r\n/** Past this share of the way, the tint has said all it can. */\r\nexport function swipeTint(offsetX: number, width: number) {\r\n  const room = Math.max(width * FLING_SHARE, MIN_FLING_PX)\r\n  return Math.min(Math.abs(offsetX) / room, 1)\r\n}\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/card-stack-pile.tsx",
      "content": "import { useEffect, useRef, useState } from \"react\"\r\nimport { ChevronRight } from \"lucide-react\"\r\nimport { motion, useReducedMotion } from \"motion/react\"\r\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\r\nimport { Button } from \"@/registry/miseui/ui/button\"\r\nimport { CARD_CLASS, composeHandlers, CONTROL_ROW, DRAG_CLIP, reachable, STACK_CELL, StackRoot, StackStatus, SURFACE_SHADOW, useAutoplay, useHeld, type StackVariantProps } from \"@/registry/miseui/ui/card-stack-card\"\r\nimport { depthOf, pilePose, shouldLeave } from \"@/registry/miseui/ui/card-stack-math\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\nconst FLING_MS = 200\r\nconst FLING_PX = 220\r\n\r\n/** The top card leaves sideways first, then drops to the back. Going straight to the back would pass through the cards above it. */\r\nfunction usePileOrder(count: number, reduceMotion: boolean) {\r\n  const [top, setTop] = useState(0)\r\n  const [leaving, setLeaving] = useState<{ index: number; direction: number } | null>(null)\r\n  const timer = useRef(0)\r\n  useEffect(() => () => window.clearTimeout(timer.current), [])\r\n  const current = count > 0 ? top % count : 0\r\n\r\n  const sendBack = (direction: number) => {\r\n    if (count < 2) return\r\n    window.clearTimeout(timer.current)\r\n    if (reduceMotion) {\r\n      setTop((current + 1) % count)\r\n      return\r\n    }\r\n    // Pressed again mid-flight: the card in flight lands at once and the next one goes.\r\n    const from = leaving ? (current + 1) % count : current\r\n    setTop(from)\r\n    setLeaving({ index: from, direction })\r\n    timer.current = window.setTimeout(() => {\r\n      setLeaving(null)\r\n      setTop((from + 1) % count)\r\n    }, FLING_MS)\r\n  }\r\n  return { top: current, leaving, sendBack }\r\n}\r\n\r\nexport function Pile({ items, label, autoplay, paused, interval, seed, labels, rootProps }: StackVariantProps) {\r\n  const reduceMotion = useReducedMotion() ?? false\r\n  const rootRef = useRef<HTMLDivElement>(null)\r\n  const dragged = useRef(false)\r\n  const { held, heldProps } = useHeld()\r\n  const count = items.length\r\n  const { top, leaving, sendBack } = usePileOrder(count, reduceMotion)\r\n  const running = useAutoplay(rootRef, { enabled: autoplay, paused, held, intervalMs: interval, onTurn: () => sendBack(1) })\r\n  const current = items[top]\r\n\r\n  return (\r\n    <StackRoot {...rootProps} {...composeHandlers(rootProps, heldProps)} ref={rootRef} aria-label={label} data-playing={autoplay ? String(running) : undefined}>\r\n      <div className={cn(\"grid grid-cols-[minmax(0,1fr)] px-4 pt-2 pb-6\", DRAG_CLIP)}>\r\n        {items.map((item, index) => {\r\n          const depth = depthOf(index, top, count)\r\n          const isTop = depth === 0\r\n          const flying = leaving?.index === index\r\n          const direction = leaving?.direction ?? 1\r\n          return (\r\n            <motion.div\r\n              key={item.id}\r\n              {...reachable(isTop && !flying)}\r\n              data-slot=\"card-stack-card\"\r\n              data-depth={depth}\r\n              className={cn(STACK_CELL, CARD_CLASS, SURFACE_SHADOW, isTop && count > 1 && \"cursor-grab active:cursor-grabbing\")}\r\n              style={{ zIndex: flying ? count + 1 : count - depth, transformOrigin: \"50% 100%\" }}\r\n              initial={false}\r\n              animate={flying ? { x: direction * FLING_PX, y: 0, rotate: direction * 6, scale: 1, opacity: 0 } : pilePose(index, depth, count, seed)}\r\n              transition={reduceMotion ? { duration: 0 } : flying ? { type: \"tween\", duration: FLING_MS / 1000, ease: [0.2, 0, 0.6, 1] } : SPRING_DEFAULT}\r\n              drag={isTop && count > 1 && !reduceMotion ? \"x\" : false}\r\n              dragSnapToOrigin\r\n              dragElastic={0.6}\r\n              dragTransition={{ bounceStiffness: 400, bounceDamping: 40 }}\r\n              onDragStart={() => {\r\n                dragged.current = true\r\n              }}\r\n              onDragEnd={(_event, info) => {\r\n                if (shouldLeave(info.offset.x, info.velocity.x, rootRef.current?.offsetWidth ?? 0)) sendBack(info.offset.x >= 0 ? 1 : -1)\r\n              }}\r\n              onClickCapture={(event) => {\r\n                // Finishing a drag over a link must not also follow it. A plain press still does.\r\n                if (dragged.current) event.preventDefault()\r\n                dragged.current = false\r\n              }}\r\n            >\r\n              {item.content}\r\n            </motion.div>\r\n          )\r\n        })}\r\n      </div>\r\n      <div className={CONTROL_ROW}>\r\n        <StackStatus text={current ? `${top + 1} of ${count}: ${current.label}` : \"\"} live={!running} />\r\n        <Button variant=\"outline\" size=\"sm\" className=\"pointer-coarse:h-10\" disabled={count < 2} onClick={() => sendBack(1)}>\r\n          {labels.next}\r\n          <ChevronRight aria-hidden=\"true\" />\r\n        </Button>\r\n      </div>\r\n    </StackRoot>\r\n  )\r\n}\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/card-stack-swipe.tsx",
      "content": "import { useEffect, useMemo, useRef, useState } from \"react\"\nimport { Check, RotateCcw, X } from \"lucide-react\"\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { Button } from \"@/registry/miseui/ui/button\"\nimport { CARD_CLASS, CONTROL_ROW, DRAG_CLIP, reachable, STACK_CELL, StackRoot, StackStatus, SURFACE_SHADOW, type StackVariantProps } from \"@/registry/miseui/ui/card-stack-card\"\nimport { shouldLeave, swipeTint, type SwipeChoice } from \"@/registry/miseui/ui/card-stack-math\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\nconst LEAVE_PX = 260\nconst LEAVE_SECONDS = 0.2\nconst PEEK_PX = 8\nconst PEEK_SCALE = 0.03\n\n/** Fixed hues: the choice means yes or no whatever the accent is, and an accent may itself be red or green. Words go with them, so colour is not the only signal. */\nconst TINT: Record<SwipeChoice, { wash: string; ink: string; align: string }> = {\n  right: { wash: \"bg-emerald-500/12\", ink: \"text-emerald-700 dark:text-emerald-300\", align: \"justify-start\" },\n  left: { wash: \"bg-red-500/12\", ink: \"text-red-700 dark:text-red-300\", align: \"justify-end\" },\n}\n\ninterface ChoiceTintProps {\n  choice: SwipeChoice\n  x: MotionValue<number>\n  width: number\n  word: string\n}\n\nfunction ChoiceTint({ choice, x, width, word }: ChoiceTintProps) {\n  const opacity = useTransform(x, (value) => ((choice === \"right\" ? value > 0 : value < 0) ? swipeTint(value, width) : 0))\n  const Icon = choice === \"right\" ? Check : X\n  return (\n    <motion.div\n      aria-hidden=\"true\"\n      data-slot={`card-stack-tint-${choice}`}\n      className={cn(\"pointer-events-none absolute inset-0 flex items-start rounded-[inherit] p-3 text-[13px] font-medium\", TINT[choice].wash, TINT[choice].ink, TINT[choice].align)}\n      style={{ opacity }}\n    >\n      <span className=\"flex items-center gap-1 rounded-md bg-background px-2 py-1 shadow-sm\">\n        <Icon className=\"size-3.5\" />\n        {word}\n      </span>\n    </motion.div>\n  )\n}\n\nexport function Swipe({ items, label, labels, rootProps, onDecide }: StackVariantProps & { onDecide?: (id: string, choice: SwipeChoice) => void }) {\n  const reduceMotion = useReducedMotion() ?? false\n  const rootRef = useRef<HTMLDivElement>(null)\n  const againRef = useRef<HTMLButtonElement>(null)\n  const dragged = useRef(false)\n  const [position, setPosition] = useState(0)\n  const [choices, setChoices] = useState<readonly SwipeChoice[]>([])\n  const [width, setWidth] = useState(0)\n  const x = useMotionValue(0)\n  const count = items.length\n  const done = position >= count\n  const current = items[position]\n  const kept = useMemo(() => choices.filter((choice) => choice === \"right\").length, [choices])\n\n  useEffect(() => {\n    const root = rootRef.current\n    if (!root || typeof ResizeObserver === \"undefined\") return\n    const observer = new ResizeObserver(() => setWidth(root.offsetWidth))\n    observer.observe(root)\n    return () => observer.disconnect()\n  }, [])\n\n  const commit = (choice: SwipeChoice) => {\n    if (!current) return\n    onDecide?.(current.id, choice)\n    setChoices([...choices, choice])\n    setPosition(position + 1)\n    x.set(0)\n  }\n  const decide = (choice: SwipeChoice) => {\n    if (!current) return\n    if (reduceMotion) {\n      commit(choice)\n      return\n    }\n    void animate(x, choice === \"right\" ? LEAVE_PX : -LEAVE_PX, { duration: LEAVE_SECONDS, ease: [0.2, 0, 0.6, 1], onComplete: () => commit(choice) })\n  }\n  // The buttons that held focus are gone, and focus must not fall back to the top of the page.\n  useEffect(() => {\n    if (done && count > 0) againRef.current?.focus()\n  }, [done, count])\n\n  return (\n    <StackRoot {...rootProps} ref={rootRef} aria-label={label}>\n      <div className={cn(\"grid grid-cols-[minmax(0,1fr)] pt-2 pb-5\", DRAG_CLIP)}>\n        {items.map((item, index) => {\n          const depth = index - position\n          const isTop = depth === 0\n          const hidden = depth < 0 || depth > 2\n          return (\n            <motion.div\n              key={item.id}\n              {...reachable(isTop)}\n              data-slot=\"card-stack-card\"\n              data-depth={depth}\n              className={cn(STACK_CELL, CARD_CLASS, SURFACE_SHADOW, \"relative touch-pan-y\", hidden && \"invisible\", isTop && \"cursor-grab active:cursor-grabbing\")}\n              style={{ zIndex: count - index, transformOrigin: \"50% 100%\", ...(isTop ? { x } : {}) }}\n              initial={false}\n              animate={isTop ? { y: 0, scale: 1 } : { y: Math.max(depth, 0) * PEEK_PX, scale: 1 - Math.max(depth, 0) * PEEK_SCALE }}\n              transition={reduceMotion ? { duration: 0 } : SPRING_DEFAULT}\n              drag={isTop && !reduceMotion ? \"x\" : false}\n              dragSnapToOrigin\n              dragElastic={0.7}\n              dragTransition={{ bounceStiffness: 400, bounceDamping: 40 }}\n              onDragStart={() => {\n                dragged.current = true\n              }}\n              onDragEnd={(_event, info) => {\n                if (shouldLeave(info.offset.x, info.velocity.x, width)) decide(info.offset.x >= 0 ? \"right\" : \"left\")\n              }}\n              onClickCapture={(event) => {\n                if (dragged.current) event.preventDefault()\n                dragged.current = false\n              }}\n            >\n              {item.content}\n              {isTop ? (\n                <>\n                  <ChoiceTint choice=\"right\" x={x} width={width} word={labels.right} />\n                  <ChoiceTint choice=\"left\" x={x} width={width} word={labels.left} />\n                </>\n              ) : null}\n            </motion.div>\n          )\n        })}\n        {done ? (\n          <div data-slot=\"card-stack-done\" className={cn(STACK_CELL, \"flex flex-col items-center justify-center gap-1 rounded-xl border border-dashed p-4 text-center\")}>\n            <p className=\"text-[15px] font-medium text-foreground\">{labels.done}</p>\n            <p className=\"text-[13px] text-muted-foreground tabular-nums\">\n              {kept} {labels.right.toLowerCase()}, {count - kept} {labels.left.toLowerCase()}\n            </p>\n          </div>\n        ) : null}\n      </div>\n      <div className={CONTROL_ROW}>\n        <StackStatus text={done ? labels.done : `${position + 1} of ${count}: ${current?.label ?? \"\"}`} />\n        {done ? (\n          <Button\n            ref={againRef}\n            variant=\"outline\"\n            size=\"sm\"\n            className=\"pointer-coarse:h-10\"\n            onClick={() => {\n              setPosition(0)\n              setChoices([])\n            }}\n          >\n            <RotateCcw aria-hidden=\"true\" />\n            {labels.again}\n          </Button>\n        ) : (\n          <div className=\"flex gap-2\">\n            <Button variant=\"outline\" size=\"sm\" className=\"pointer-coarse:h-10\" onClick={() => decide(\"left\")}>\n              <X aria-hidden=\"true\" className=\"text-red-600 dark:text-red-400\" />\n              {labels.left}\n            </Button>\n            <Button variant=\"outline\" size=\"sm\" className=\"pointer-coarse:h-10\" onClick={() => decide(\"right\")}>\n              <Check aria-hidden=\"true\" className=\"text-emerald-600 dark:text-emerald-400\" />\n              {labels.right}\n            </Button>\n          </div>\n        )}\n      </div>\n    </StackRoot>\n  )\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/background-loop.ts",
      "content": "import type * as React from \"react\"\nimport { useEffect, useState, useSyncExternalStore } from \"react\"\nimport { useReducedMotion } from \"motion/react\"\n\n/**\n * The accent as a light source, held in a lightness band per theme. A near-black accent would read as smoke on a light\n * page and a dark blue would pull muted text under 4.5:1 where a beam crosses it, so the band's floor is 0.6.\n * Older browsers keep plain --mise, which is only a stronger tint.\n */\nexport const LIGHT_TOKEN_CLASS = [\n  \"[--mise-light:var(--mise)]\",\n  \"supports-[color:oklch(from_red_l_c_h)]:[--mise-light:oklch(from_var(--mise)_clamp(0.6,l,0.76)_c_h)]\",\n  \"dark:supports-[color:oklch(from_red_l_c_h)]:[--mise-light:oklch(from_var(--mise)_clamp(0.62,l,0.82)_c_h)]\",\n].join(\" \")\n\n/** Before hydration the hook cannot know the preference yet. A paused animation holds its seeded frame. */\nexport const LOOP_CLASS = \"motion-reduce:[animation-play-state:paused]!\"\n\nconst subscribeToVisibility = (onChange: () => void) => {\n  document.addEventListener(\"visibilitychange\", onChange)\n  return () => document.removeEventListener(\"visibilitychange\", onChange)\n}\n\nfunction useTabVisible() {\n  return useSyncExternalStore(\n    subscribeToVisibility,\n    () => !document.hidden,\n    () => true,\n  )\n}\n\nfunction useOnScreen(rootRef: React.RefObject<HTMLElement | null>, watching: boolean) {\n  const [onScreen, setOnScreen] = useState(true)\n  useEffect(() => {\n    const root = rootRef.current\n    if (!watching || !root || typeof IntersectionObserver === \"undefined\") return\n    const view = new IntersectionObserver(([entry]) => setOnScreen(entry?.isIntersecting ?? true))\n    view.observe(root)\n    return () => view.disconnect()\n  }, [rootRef, watching])\n  return onScreen\n}\n\ninterface PlaybackOptions {\n  /** The owner's stop control. */\n  paused?: boolean\n  /** False when this look never moves, so nothing is watched at all. */\n  loops?: boolean\n}\n\n/** `still` draws one finished frame. `playing` is only true on screen, in a visible tab and not paused. */\nexport function useBackgroundPlayback(rootRef: React.RefObject<HTMLElement | null>, { paused = false, loops = true }: PlaybackOptions) {\n  const reduceMotion = useReducedMotion() ?? false\n  const moving = loops && !reduceMotion\n  const onScreen = useOnScreen(rootRef, moving)\n  const tabVisible = useTabVisible()\n  return { still: !moving, playing: moving && !paused && onScreen && tabVisible }\n}\n\n/** Longhands, because the shorthand would reset the play state each element reads from the layer. */\nexport function loopStyle(name: string, seconds: number, delay: number, timing = \"linear\", direction = \"normal\"): React.CSSProperties {\n  return {\n    animationName: name,\n    animationDuration: `${seconds}s`,\n    animationDelay: `${delay}s`,\n    animationTimingFunction: timing,\n    animationDirection: direction,\n    animationIterationCount: \"infinite\",\n    animationPlayState: \"var(--mise-bg-play)\",\n  }\n}\n\n/** Set once on the layer, so pausing writes one property however many elements are moving. */\nexport function playStyle(playing: boolean) {\n  return { \"--mise-bg-play\": playing ? \"running\" : \"paused\" } as React.CSSProperties\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/background-seed.ts",
      "content": "/** Mulberry32. Server and client walk the same numbers, so a seeded layout hydrates without a mismatch. */\nexport function seededRandom(seed: number) {\n  let state = seed >>> 0\n  return () => {\n    state = (state + 0x6d2b79f5) >>> 0\n    let mixed = state\n    mixed = Math.imul(mixed ^ (mixed >>> 15), mixed | 1)\n    mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61)\n    return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296\n  }\n}\n\n/** Two decimals: enough for a percent or a second, and short in the inline style. */\nexport function between(random: () => number, min: number, max: number) {\n  return Math.round((min + random() * (max - min)) * 100) / 100\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "interactions"
  ],
  "type": "registry:ui"
}