{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"citations","type":"registry:block","title":"Citations","description":"Inline citation chips with a hover source-preview popover, plus source cards and a staggered source list for the answer footer.","author":"UI Lab","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/citations/index.tsx","type":"registry:component","target":"@components/motion/citations/index.tsx","content":"\"use client\";\n// ui-lab-ten.vercel.app/components/blocks/citations\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Children, type ReactNode, useCallback, useRef, useState } from \"react\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CitationSource {\n  title: ReactNode;\n  domain: ReactNode;\n  favicon?: ReactNode;\n  snippet?: ReactNode;\n  href?: string;\n}\n\nconst CHIP_CLASSNAME =\n  \"inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-black/[0.07] px-1 text-[10px] font-medium text-muted-foreground align-super dark:bg-white/10\";\n\nconst OPEN_DELAY_MS = 150;\nconst CLOSE_DELAY_MS = 200;\n\nexport interface CitationChipProps {\n  index: number;\n  source?: CitationSource;\n  className?: string;\n}\n\n/**\n * Small circular `[n]` badge dropped inline after a claim in body copy. With\n * no `source` it's a static, non-interactive pill (`vertical-align: super`\n * via `align-super`, standing in for a real `<sup>` without the cramped\n * default line-height). With a `source`, it becomes a button that reveals a\n * preview popover on hover (150ms open delay, 200ms close delay) or focus —\n * a single shared timeout ref means moving the pointer from the chip into\n * the popover itself cancels the pending close rather than dismissing it.\n */\nexport function CitationChip({ index, source, className }: CitationChipProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [open, setOpen] = useState(false);\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const clearTimer = useCallback(() => {\n    if (timeoutRef.current) {\n      clearTimeout(timeoutRef.current);\n      timeoutRef.current = null;\n    }\n  }, []);\n\n  const scheduleOpen = useCallback(() => {\n    clearTimer();\n    timeoutRef.current = setTimeout(() => setOpen(true), OPEN_DELAY_MS);\n  }, [clearTimer]);\n\n  const scheduleClose = useCallback(() => {\n    clearTimer();\n    timeoutRef.current = setTimeout(() => setOpen(false), CLOSE_DELAY_MS);\n  }, [clearTimer]);\n\n  const openNow = useCallback(() => {\n    clearTimer();\n    setOpen(true);\n  }, [clearTimer]);\n\n  if (!source) {\n    return <span className={cn(CHIP_CLASSNAME, className)}>{index}</span>;\n  }\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: hover/focus preview trigger wrapping the real button below; the span itself holds no semantics.\n    <span\n      className=\"relative inline-block\"\n      onMouseEnter={scheduleOpen}\n      onMouseLeave={scheduleClose}\n      onFocus={openNow}\n      onBlur={scheduleClose}\n    >\n      <button\n        type=\"button\"\n        aria-haspopup=\"dialog\"\n        aria-expanded={open}\n        className={cn(\n          CHIP_CLASSNAME,\n          \"transition-colors hover:bg-[#339CFF] hover:text-white\",\n          className,\n        )}\n      >\n        {index}\n      </button>\n      <AnimatePresence>\n        {open ? (\n          <motion.span\n            role=\"tooltip\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 4 }}\n            animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 4 }}\n            transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}\n            style={{\n              transformOrigin: \"bottom center\",\n              boxShadow:\n                \"0 0 0 0.5px var(--citation-hairline), 0 3px 7.5px rgba(0,0,0,0.04), 0 0 20px rgba(0,0,0,0.05)\",\n            }}\n            className={cn(\n              // Span-based throughout (styled block via classes): the chip is\n              // designed to sit inside <p> prose, where a <div> descendant is\n              // invalid HTML and would break hydration.\n              \"absolute bottom-[calc(100%+6px)] left-1/2 z-30 block w-64 -translate-x-1/2 rounded-xl bg-white/95 p-3 text-left normal-case backdrop-blur-xl\",\n              \"[--citation-hairline:rgba(0,0,0,0.08)] dark:bg-neutral-800/95 dark:[--citation-hairline:rgba(255,255,255,0.15)]\",\n            )}\n          >\n            <span className=\"flex items-center gap-1.5\">\n              {source.favicon ? (\n                <span className=\"flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground\">\n                  {source.favicon}\n                </span>\n              ) : null}\n              <span className=\"truncate text-muted-foreground text-xs\">{source.domain}</span>\n            </span>\n            <span className=\"mt-1 line-clamp-2 font-medium text-[13px]\">\n              {source.href ? (\n                <a href={source.href} target=\"_blank\" rel=\"noreferrer\" className=\"hover:underline\">\n                  {source.title}\n                </a>\n              ) : (\n                source.title\n              )}\n            </span>\n            {source.snippet ? (\n              <span className=\"mt-1 line-clamp-2 text-muted-foreground text-xs\">{source.snippet}</span>\n            ) : null}\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport interface SourceCardProps {\n  source: CitationSource;\n  index?: number;\n  className?: string;\n}\n\n/**\n * One row in a `SourceList` — a favicon slot, then domain (with an optional\n * static index badge floated to the right, styled like `CitationChip` but\n * inert) over the title over a two-line snippet. The whole card links out\n * when `source.href` is set.\n */\nexport function SourceCard({ source, index, className }: SourceCardProps) {\n  const rootClassName = cn(\n    \"flex gap-3 rounded-xl p-3 transition-colors hover:bg-black/[0.03] dark:hover:bg-white/5\",\n    className,\n  );\n\n  const content = (\n    <>\n      <span className=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-black/5 dark:bg-white/10\">\n        {source.favicon}\n      </span>\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex items-center gap-1.5\">\n          <span className=\"truncate text-muted-foreground text-xs\">{source.domain}</span>\n          {index !== undefined ? (\n            <span className=\"ml-auto inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-black/[0.07] px-1 text-[10px] font-medium text-muted-foreground dark:bg-white/10\">\n              {index}\n            </span>\n          ) : null}\n        </div>\n        <div className=\"truncate font-medium text-sm\">{source.title}</div>\n        {source.snippet ? (\n          <div className=\"line-clamp-2 text-[13px] text-muted-foreground\">{source.snippet}</div>\n        ) : null}\n      </div>\n    </>\n  );\n\n  if (source.href) {\n    return (\n      <a href={source.href} target=\"_blank\" rel=\"noreferrer\" className={rootClassName}>\n        {content}\n      </a>\n    );\n  }\n\n  return <div className={rootClassName}>{content}</div>;\n}\n\nexport interface SourceListProps {\n  title?: ReactNode;\n  children?: ReactNode;\n  className?: string;\n}\n\n/**\n * Vertical stack of `SourceCard`s under an optional muted title row (e.g.\n * \"Sources · 3\"). Children fade/slide in together with a per-card stagger\n * (`delay: index * 0.05`); `useReducedMotion()` renders the list directly\n * with no entrance animation.\n */\nexport function SourceList({ title, children, className }: SourceListProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <div className={cn(\"flex flex-col gap-1\", className)}>\n      {title ? (\n        <div className=\"px-3 pb-1 font-medium text-muted-foreground text-xs\">{title}</div>\n      ) : null}\n      {reduce\n        ? children\n        : Children.map(children, (child, index) => (\n            <motion.div\n              initial={{ opacity: 0, y: 4 }}\n              animate={{ opacity: 1, y: 0 }}\n              transition={{ duration: 0.3, ease: EASE_OUT, delay: index * 0.05 }}\n            >\n              {child}\n            </motion.div>\n          ))}\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"},{"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"}]}