{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"image-gallery","type":"registry:block","title":"Image Gallery","description":"Photo gallery laid out as a masonry or uniform grid sized by its own container width, with hover zoom and caption reveal, category filters behind a gliding chip with spring layout reflow, and a lightbox that grows out of the clicked tile with prev/next, arrow keys, Escape, swipe, a counter and a focus trap; reduced motion turns every move into a fade.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/image-gallery.tsx","type":"registry:component","target":"@components/motion/image-gallery.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/blocks/image-gallery\n\nimport { ChevronLeft, ChevronRight, X } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  type PanInfo,\n  motion,\n  type Transition,\n  useReducedMotion,\n  type Variants,\n} from \"motion/react\";\nimport {\n  type ReactNode,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { EASE_OUT, EASE_OUT_CSS, SPRING_LAYOUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { useHoverCapable } from \"@/lib/hooks/use-hover-capable\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface GalleryItem {\n  /** Stable id; also scopes the shared-layout morph into the lightbox. */\n  id: string;\n  title: string;\n  /** Shown in the hover caption and under the lightbox image. */\n  caption?: ReactNode;\n  /** Filter group, e.g. \"Architecture\". */\n  category?: string;\n  /** Width / height. Drives masonry heights and the lightbox frame. Default 1. */\n  aspectRatio?: number;\n  /** Image URL. Rendered as a cover `<img>`. */\n  src?: string;\n  /** Alt text for `src`. Defaults to `title`. */\n  alt?: string;\n  /** Any node instead of `src` (illustration, canvas, gradient scene). */\n  content?: ReactNode;\n}\n\nexport interface ImageGalleryProps {\n  items: GalleryItem[];\n  /** \"masonry\" keeps each item's aspect ratio; \"grid\" crops to uniform tiles. */\n  layout?: \"masonry\" | \"grid\";\n  /** Tile aspect ratio (width / height) for `layout=\"grid\"`. Default 1. */\n  gridAspectRatio?: number;\n  /** Most columns the gallery will use. Default 3. */\n  columns?: number;\n  /** Narrowest a column may get before one is dropped, in px. Default 170. */\n  minColumnWidth?: number;\n  /** Show category chips. Default true when items span 2+ categories. */\n  filterable?: boolean;\n  /** Category order. Defaults to first-seen order in `items`. */\n  categories?: string[];\n  /** Label of the chip that shows everything. Default \"All\". */\n  allLabel?: string;\n  /** Controlled active category (`allLabel` or a category). */\n  category?: string;\n  defaultCategory?: string;\n  onCategoryChange?: (category: string) => void;\n  /** Called whenever the lightbox opens on, or navigates to, an item. */\n  onOpenItem?: (item: GalleryItem) => void;\n  /** Accessible name for the gallery region. Default \"Gallery\". */\n  label?: string;\n  className?: string;\n}\n\nconst GAP_PX = 12; // matches gap-3 between lanes/tiles\nconst SWIPE_DISTANCE = 80;\nconst SWIPE_VELOCITY = 400;\n\nconst FADE: Transition = { duration: 0.2, ease: EASE_OUT };\nconst EXIT: Transition = { duration: 0.14, ease: EASE_OUT };\n\n// Lightbox slides: `custom` is the signed offset, read at exit time through\n// AnimatePresence so a direction change also flips the leaving image.\nconst SLIDE: Variants = {\n  enter: (offset: number) => ({ opacity: 0, x: offset }),\n  center: { opacity: 1, x: 0 },\n  exit: (offset: number) => ({ opacity: 0, x: -offset, transition: EXIT }),\n};\n\nconst FOCUSABLE =\n  'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\n\n/** Column count from the gallery's own width; server/first render uses `max`. */\nfunction useLaneCount(\n  ref: React.RefObject<HTMLDivElement | null>,\n  max: number,\n  minWidth: number,\n) {\n  const [lanes, setLanes] = useState(max);\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const measure = () => {\n      const width = el.clientWidth;\n      const fit = Math.floor((width + GAP_PX) / (minWidth + GAP_PX));\n      setLanes(Math.max(1, Math.min(max, fit)));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [ref, max, minWidth]);\n  return lanes;\n}\n\n/** Greedy shortest-lane placement, deterministic for a given order. */\nfunction distribute(items: GalleryItem[], lanes: number) {\n  const out: GalleryItem[][] = Array.from({ length: lanes }, () => []);\n  const heights = new Array<number>(lanes).fill(0);\n  for (const item of items) {\n    let target = 0;\n    for (let i = 1; i < lanes; i++) if (heights[i] < heights[target]) target = i;\n    out[target].push(item);\n    heights[target] += 1 / (item.aspectRatio ?? 1);\n  }\n  return out;\n}\n\nfunction Media({ item, className }: { item: GalleryItem; className?: string }) {\n  if (item.content) {\n    return <span className={cn(\"block h-full w-full\", className)}>{item.content}</span>;\n  }\n  if (item.src) {\n    return (\n      // biome-ignore lint/performance/noImgElement: library component, framework-agnostic.\n      <img\n        src={item.src}\n        alt={item.alt ?? item.title}\n        draggable={false}\n        className={cn(\"block h-full w-full object-cover\", className)}\n      />\n    );\n  }\n  return <span className={cn(\"block h-full w-full bg-muted\", className)} />;\n}\n\n/**\n * Photo gallery: masonry or uniform grid of media tiles that zoom slightly and\n * reveal their caption on hover, category chips with a gliding pill that\n * reflow the tiles with shared layout, and a lightbox that grows out of the\n * clicked tile (shared `layoutId`) with prev/next, arrow keys, Escape, swipe,\n * a counter and a focus trap. Reduced motion swaps every move for a fade.\n */\nexport function ImageGallery({\n  items,\n  layout = \"masonry\",\n  gridAspectRatio = 1,\n  columns = 3,\n  minColumnWidth = 170,\n  filterable,\n  categories: categoriesProp,\n  allLabel = \"All\",\n  category: categoryProp,\n  defaultCategory,\n  onCategoryChange,\n  onOpenItem,\n  label = \"Gallery\",\n  className,\n}: ImageGalleryProps) {\n  const reduce = useReducedMotion() ?? false;\n  const canHover = useHoverCapable();\n  const uid = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const lanes = useLaneCount(rootRef, columns, minColumnWidth);\n\n  const categories = useMemo(() => {\n    if (categoriesProp) return categoriesProp;\n    const seen: string[] = [];\n    for (const item of items) {\n      if (item.category && !seen.includes(item.category)) seen.push(item.category);\n    }\n    return seen;\n  }, [items, categoriesProp]);\n  const showFilters = filterable ?? categories.length > 1;\n\n  const [internalCategory, setInternalCategory] = useState(\n    defaultCategory ?? allLabel,\n  );\n  const active = categoryProp ?? internalCategory;\n  const setCategory = (next: string) => {\n    if (categoryProp === undefined) setInternalCategory(next);\n    onCategoryChange?.(next);\n  };\n\n  const visible = useMemo(\n    () =>\n      active === allLabel ? items : items.filter((item) => item.category === active),\n    [items, active, allLabel],\n  );\n\n  // Lightbox: index into `visible`; `originId` is the tile it grew out of.\n  // Only that item carries the shared layoutId, so prev/next slide instead of\n  // flying in from other tiles.\n  const [openIndex, setOpenIndex] = useState<number | null>(null);\n  const [originId, setOriginId] = useState<string | null>(null);\n  const [direction, setDirection] = useState<1 | -1>(1);\n  const current = openIndex === null ? null : (visible[openIndex] ?? null);\n\n  const open = (index: number) => {\n    setOriginId(visible[index]?.id ?? null);\n    setDirection(1);\n    setOpenIndex(index);\n    const item = visible[index];\n    if (item) onOpenItem?.(item);\n  };\n\n  const step = useCallback(\n    (dir: 1 | -1) => {\n      if (openIndex === null || visible.length < 2) return;\n      const next = (openIndex + dir + visible.length) % visible.length;\n      setDirection(dir);\n      setOriginId(null);\n      setOpenIndex(next);\n      const item = visible[next];\n      if (item) onOpenItem?.(item);\n    },\n    [openIndex, visible, onOpenItem],\n  );\n\n  const close = useCallback(() => setOpenIndex(null), []);\n\n  // Close if filtering removes the open item (e.g. a controlled category change).\n  useEffect(() => {\n    if (openIndex !== null && !visible[openIndex]) setOpenIndex(null);\n  }, [openIndex, visible]);\n\n  const shared = (id: string) => (reduce ? undefined : `${uid}-media-${id}`);\n  const layoutTransition = reduce ? { duration: 0 } : SPRING_LAYOUT;\n  const zoom = canHover && !reduce;\n\n  const tile = (item: GalleryItem, index: number) => {\n    const ratio = layout === \"grid\" ? gridAspectRatio : (item.aspectRatio ?? 1);\n    return (\n      // Layout animation is the point: tiles glide to their new lanes and\n      // slots when a filter changes. layoutId carries them across lanes.\n      <motion.li\n        key={item.id}\n        layoutId={reduce ? undefined : `${uid}-tile-${item.id}`}\n        initial={{ opacity: 0, scale: reduce ? 1 : 0.94 }}\n        animate={{ opacity: 1, scale: 1 }}\n        exit={{ opacity: 0, scale: reduce ? 1 : 0.94, transition: EXIT }}\n        transition={{ layout: layoutTransition, ...FADE }}\n        className=\"list-none\"\n      >\n        <button\n          type=\"button\"\n          onClick={() => open(index)}\n          aria-label={`Open ${item.title}`}\n          aria-haspopup=\"dialog\"\n          className=\"group/tile relative block w-full overflow-hidden rounded-xl bg-muted text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\"\n          style={{ aspectRatio: ratio }}\n        >\n          <motion.span\n            layoutId={shared(item.id)}\n            transition={layoutTransition}\n            className=\"absolute inset-0 block overflow-hidden\"\n          >\n            {/* Hover zoom lives on an inner layer so it never fights the\n                shared-layout transform on the element above. */}\n            <span\n              className={cn(\n                \"block h-full w-full transition-transform duration-500\",\n                zoom && \"group-hover/tile:scale-[1.06]\",\n              )}\n              style={{ transitionTimingFunction: EASE_OUT_CSS }}\n            >\n              <Media item={item} />\n            </span>\n          </motion.span>\n          {/* Caption over a scrim. Photos are dark or light regardless of the\n              theme, so the scrim and its text stay fixed black/white. */}\n          <span\n            className={cn(\n              \"pointer-events-none absolute inset-x-0 bottom-0 flex flex-col gap-0.5 bg-linear-to-t from-black/70 via-black/25 to-transparent px-3 pt-8 pb-2.5 text-white transition duration-300\",\n              canHover\n                ? \"opacity-0 group-hover/tile:opacity-100 group-focus-visible/tile:opacity-100\"\n                : \"opacity-100\",\n              zoom && \"translate-y-1 group-hover/tile:translate-y-0 group-focus-visible/tile:translate-y-0\",\n            )}\n            style={{ transitionTimingFunction: EASE_OUT_CSS }}\n          >\n            <span className=\"truncate text-sm font-medium\">{item.title}</span>\n            {item.category ? (\n              <span className=\"truncate text-xs text-white/75\">{item.category}</span>\n            ) : null}\n          </span>\n        </button>\n      </motion.li>\n    );\n  };\n\n  const indexOf = new Map(visible.map((item, i) => [item.id, i]));\n\n  return (\n    <section aria-label={label} className={cn(\"@container w-full text-foreground\", className)}>\n      <LayoutGroup id={uid}>\n        {showFilters ? (\n          <fieldset className=\"-mx-1 mb-4 flex min-w-0 gap-1 overflow-x-auto px-1 py-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n            <legend className=\"sr-only\">Filter by category</legend>\n            {[allLabel, ...categories].map((name) => {\n              const selected = name === active;\n              return (\n                <button\n                  key={name}\n                  type=\"button\"\n                  aria-pressed={selected}\n                  onClick={() => setCategory(name)}\n                  className={cn(\n                    \"relative h-8 shrink-0 rounded-full px-3.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring\",\n                    selected\n                      ? \"text-primary-foreground\"\n                      : \"text-muted-foreground hover:text-foreground\",\n                  )}\n                >\n                  {selected ? (\n                    <motion.span\n                      aria-hidden\n                      layoutId={`${uid}-chip`}\n                      transition={layoutTransition}\n                      className=\"absolute inset-0 rounded-full bg-primary\"\n                    />\n                  ) : null}\n                  <span className=\"relative\">{name}</span>\n                </button>\n              );\n            })}\n          </fieldset>\n        ) : null}\n\n        <div ref={rootRef}>\n          {layout === \"masonry\" ? (\n            <div className=\"flex items-start gap-3\">\n              {distribute(visible, lanes).map((lane, laneIndex) => (\n                <ul\n                  // biome-ignore lint/suspicious/noArrayIndexKey: lanes are positional.\n                  key={laneIndex}\n                  className=\"m-0 flex min-w-0 flex-1 flex-col gap-3 p-0\"\n                >\n                  <AnimatePresence initial={false} mode=\"popLayout\">\n                    {lane.map((item) => tile(item, indexOf.get(item.id) ?? 0))}\n                  </AnimatePresence>\n                </ul>\n              ))}\n            </div>\n          ) : (\n            <ul\n              className=\"m-0 grid gap-3 p-0\"\n              style={{ gridTemplateColumns: `repeat(${lanes}, minmax(0, 1fr))` }}\n            >\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {visible.map((item, i) => tile(item, i))}\n              </AnimatePresence>\n            </ul>\n          )}\n          {visible.length === 0 ? (\n            <p className=\"rounded-2xl border border-dashed border-border-strong px-5 py-10 text-center text-sm text-muted-foreground\">\n              Nothing in this category yet.\n            </p>\n          ) : null}\n        </div>\n\n        <Lightbox\n          item={current}\n          index={openIndex ?? 0}\n          total={visible.length}\n          direction={direction}\n          sharedId={current && current.id === originId ? shared(current.id) : undefined}\n          reduce={reduce}\n          onStep={step}\n          onClose={close}\n        />\n      </LayoutGroup>\n    </section>\n  );\n}\n\nfunction Lightbox({\n  item,\n  index,\n  total,\n  direction,\n  sharedId,\n  reduce,\n  onStep,\n  onClose,\n}: {\n  item: GalleryItem | null;\n  index: number;\n  total: number;\n  direction: 1 | -1;\n  sharedId: string | undefined;\n  reduce: boolean;\n  onStep: (dir: 1 | -1) => void;\n  onClose: () => void;\n}) {\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  if (!mounted) return null;\n  return createPortal(\n    <AnimatePresence>\n      {item ? (\n        <LightboxSurface\n          key=\"lightbox\"\n          item={item}\n          index={index}\n          total={total}\n          direction={direction}\n          sharedId={sharedId}\n          reduce={reduce}\n          onStep={onStep}\n          onClose={onClose}\n        />\n      ) : null}\n    </AnimatePresence>,\n    document.body,\n  );\n}\n\nfunction LightboxSurface({\n  item,\n  index,\n  total,\n  direction,\n  sharedId,\n  reduce,\n  onStep,\n  onClose,\n}: {\n  item: GalleryItem;\n  index: number;\n  total: number;\n  direction: 1 | -1;\n  sharedId: string | undefined;\n  reduce: boolean;\n  onStep: (dir: 1 | -1) => void;\n  onClose: () => void;\n}) {\n  const panelRef = useRef<HTMLDivElement>(null);\n  const closeRef = useRef<HTMLButtonElement>(null);\n  const handlers = useRef({ onStep, onClose });\n  handlers.current = { onStep, onClose };\n  const multiple = total > 1;\n  const ratio = item.aspectRatio ?? 1;\n\n  // Focus, keyboard, focus trap, scroll lock and focus return — once per open.\n  useEffect(() => {\n    const returnTo =\n      document.activeElement instanceof HTMLElement ? document.activeElement : null;\n    const body = document.body;\n    const previousOverflow = body.style.overflow;\n    body.style.overflow = \"hidden\";\n    const frame = requestAnimationFrame(() => closeRef.current?.focus({ preventScroll: true }));\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") {\n        event.preventDefault();\n        handlers.current.onClose();\n      } else if (event.key === \"ArrowRight\") {\n        event.preventDefault();\n        handlers.current.onStep(1);\n      } else if (event.key === \"ArrowLeft\") {\n        event.preventDefault();\n        handlers.current.onStep(-1);\n      } else if (event.key === \"Tab\") {\n        const panel = panelRef.current;\n        if (!panel) return;\n        const focusable = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE));\n        if (focusable.length === 0) return;\n        const first = focusable[0];\n        const last = focusable[focusable.length - 1];\n        const activeEl = document.activeElement;\n        const outside = !panel.contains(activeEl);\n        if (event.shiftKey && (activeEl === first || outside)) {\n          event.preventDefault();\n          last.focus();\n        } else if (!event.shiftKey && (activeEl === last || outside)) {\n          event.preventDefault();\n          first.focus();\n        }\n      }\n    };\n    document.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      cancelAnimationFrame(frame);\n      document.removeEventListener(\"keydown\", onKeyDown);\n      body.style.overflow = previousOverflow;\n      if (returnTo?.isConnected) returnTo.focus({ preventScroll: true });\n    };\n  }, []);\n\n  const onDragEnd = (_: unknown, info: PanInfo) => {\n    if (!multiple) return;\n    if (info.offset.x < -SWIPE_DISTANCE || info.velocity.x < -SWIPE_VELOCITY) onStep(1);\n    else if (info.offset.x > SWIPE_DISTANCE || info.velocity.x > SWIPE_VELOCITY) onStep(-1);\n  };\n\n  const slide = reduce ? 0 : 48 * direction;\n  const navClass =\n    \"pointer-events-auto absolute top-1/2 z-10 grid size-10 -translate-y-1/2 place-items-center rounded-full border border-border bg-card/80 text-foreground shadow-md backdrop-blur outline-none transition-colors hover:bg-card focus-visible:ring-2 focus-visible:ring-ring\";\n\n  return (\n    <div\n      ref={panelRef}\n      role=\"dialog\"\n      aria-modal=\"true\"\n      aria-label={`${item.title}, image ${index + 1} of ${total}`}\n      className=\"fixed inset-0 z-[100] flex items-center justify-center p-4\"\n    >\n      <motion.button\n        type=\"button\"\n        tabIndex={-1}\n        aria-label=\"Close\"\n        onClick={onClose}\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        exit={{ opacity: 0, transition: EXIT }}\n        transition={FADE}\n        className=\"absolute inset-0 h-full w-full cursor-default bg-background/85 backdrop-blur-md\"\n      />\n\n      <motion.div\n        initial={{ opacity: 0 }}\n        animate={{ opacity: 1 }}\n        exit={{ opacity: 0, transition: EXIT }}\n        transition={FADE}\n        className=\"pointer-events-none absolute inset-x-0 top-0 z-10 flex items-center justify-between p-4\"\n      >\n        <p aria-live=\"polite\" className=\"rounded-full border border-border bg-card/80 px-3 py-1 text-xs font-medium tabular-nums text-foreground backdrop-blur\">\n          {index + 1} / {total}\n        </p>\n        <button\n          ref={closeRef}\n          type=\"button\"\n          aria-label=\"Close gallery\"\n          onClick={onClose}\n          className=\"pointer-events-auto grid size-10 place-items-center rounded-full border border-border bg-card/80 text-foreground shadow-md backdrop-blur outline-none transition-colors hover:bg-card focus-visible:ring-2 focus-visible:ring-ring\"\n        >\n          <X aria-hidden className=\"size-4\" />\n        </button>\n      </motion.div>\n\n      <figure className=\"pointer-events-none relative z-0 m-0 flex max-h-full flex-col items-center gap-3\">\n        <AnimatePresence initial={false} mode=\"popLayout\" custom={slide}>\n          <motion.div\n            key={item.id}\n            custom={slide}\n            variants={SLIDE}\n            initial={sharedId ? false : \"enter\"}\n            animate=\"center\"\n            exit=\"exit\"\n            transition={reduce ? FADE : SPRING_PANEL}\n            className=\"pointer-events-auto\"\n          >\n            <motion.div\n              drag={multiple && !reduce ? \"x\" : false}\n              dragConstraints={{ left: 0, right: 0 }}\n              dragElastic={0.6}\n              onDragEnd={onDragEnd}\n              className=\"touch-pan-y\"\n            >\n              <motion.div\n                layoutId={sharedId}\n                transition={SPRING_LAYOUT}\n                className=\"overflow-hidden rounded-2xl bg-muted shadow-2xl\"\n                style={{\n                  aspectRatio: ratio,\n                  // Fit within ~88% of the viewport width and ~72% of its height.\n                  width: `min(88vw, 56rem, calc(72dvh * ${ratio}))`,\n                }}\n              >\n                <Media item={item} className=\"pointer-events-none select-none\" />\n              </motion.div>\n            </motion.div>\n          </motion.div>\n        </AnimatePresence>\n        <motion.figcaption\n          key={`caption-${item.id}`}\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0, transition: EXIT }}\n          transition={{ ...FADE, delay: 0.08 }}\n          className=\"pointer-events-auto max-w-xl px-2 text-center\"\n        >\n          <span className=\"block text-sm font-medium text-foreground\">{item.title}</span>\n          {item.caption ? (\n            <span className=\"mt-0.5 block text-xs leading-5 text-muted-foreground\">\n              {item.caption}\n            </span>\n          ) : null}\n        </motion.figcaption>\n      </figure>\n\n      {multiple ? (\n        <motion.div\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0, transition: EXIT }}\n          transition={FADE}\n          className=\"pointer-events-none absolute inset-x-0 top-1/2 z-10\"\n        >\n          <button\n            type=\"button\"\n            aria-label=\"Previous image\"\n            onClick={() => onStep(-1)}\n            className={cn(navClass, \"left-3 sm:left-6\")}\n          >\n            <ChevronLeft aria-hidden className=\"size-4\" />\n          </button>\n          <button\n            type=\"button\"\n            aria-label=\"Next image\"\n            onClick={() => onStep(1)}\n            className={cn(navClass, \"right-3 sm:right-6\")}\n          >\n            <ChevronRight aria-hidden className=\"size-4\" />\n          </button>\n        </motion.div>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/hooks/use-hover-capable.ts","type":"registry:hook","target":"@lib/hooks/use-hover-capable.ts","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Returns true only on devices that have a true hover (mouse / trackpad).\n * Touch devices fire phantom `:hover` on tap that sticks until tap-elsewhere\n * — gate hover-only effects (scale lifts, magnetic pulls) behind this.\n */\nexport function useHoverCapable() {\n  const [canHover, setCanHover] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    const update = () => setCanHover(mq.matches);\n    update();\n    mq.addEventListener?.(\"change\", update);\n    return () => mq.removeEventListener?.(\"change\", update);\n  }, []);\n\n  return canHover;\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}