"use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Children, type ReactNode, useCallback, useRef, useState } from "react"; import { EASE_OUT, SPRING_PANEL } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface CitationSource { title: ReactNode; domain: ReactNode; favicon?: ReactNode; snippet?: ReactNode; href?: string; } const CHIP_CLASSNAME = "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"; const OPEN_DELAY_MS = 150; const CLOSE_DELAY_MS = 200; export interface CitationChipProps { index: number; source?: CitationSource; className?: string; } /** * Small circular `[n]` badge dropped inline after a claim in body copy. With * no `source` it's a static, non-interactive pill (`vertical-align: super` * via `align-super`, standing in for a real `` without the cramped * default line-height). With a `source`, it becomes a button that reveals a * preview popover on hover (150ms open delay, 200ms close delay) or focus — * a single shared timeout ref means moving the pointer from the chip into * the popover itself cancels the pending close rather than dismissing it. */ export function CitationChip({ index, source, className }: CitationChipProps) { const reduce = useReducedMotion() ?? false; const [open, setOpen] = useState(false); const timeoutRef = useRef | null>(null); const clearTimer = useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } }, []); const scheduleOpen = useCallback(() => { clearTimer(); timeoutRef.current = setTimeout(() => setOpen(true), OPEN_DELAY_MS); }, [clearTimer]); const scheduleClose = useCallback(() => { clearTimer(); timeoutRef.current = setTimeout(() => setOpen(false), CLOSE_DELAY_MS); }, [clearTimer]); const openNow = useCallback(() => { clearTimer(); setOpen(true); }, [clearTimer]); if (!source) { return {index}; } return ( // biome-ignore lint/a11y/noStaticElementInteractions: hover/focus preview trigger wrapping the real button below; the span itself holds no semantics. {open ? ( prose, where a
descendant is // invalid HTML and would break hydration. "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", "[--citation-hairline:rgba(0,0,0,0.08)] dark:bg-neutral-800/95 dark:[--citation-hairline:rgba(255,255,255,0.15)]", )} > {source.favicon ? ( {source.favicon} ) : null} {source.domain} {source.href ? ( {source.title} ) : ( source.title )} {source.snippet ? ( {source.snippet} ) : null} ) : null} ); } export interface SourceCardProps { source: CitationSource; index?: number; className?: string; } /** * One row in a `SourceList` — a favicon slot, then domain (with an optional * static index badge floated to the right, styled like `CitationChip` but * inert) over the title over a two-line snippet. The whole card links out * when `source.href` is set. */ export function SourceCard({ source, index, className }: SourceCardProps) { const rootClassName = cn( "flex gap-3 rounded-xl p-3 transition-colors hover:bg-black/[0.03] dark:hover:bg-white/5", className, ); const content = ( <> {source.favicon}
{source.domain} {index !== undefined ? ( {index} ) : null}
{source.title}
{source.snippet ? (
{source.snippet}
) : null}
); if (source.href) { return ( {content} ); } return
{content}
; } export interface SourceListProps { title?: ReactNode; children?: ReactNode; className?: string; } /** * Vertical stack of `SourceCard`s under an optional muted title row (e.g. * "Sources · 3"). Children fade/slide in together with a per-card stagger * (`delay: index * 0.05`); `useReducedMotion()` renders the list directly * with no entrance animation. */ export function SourceList({ title, children, className }: SourceListProps) { const reduce = useReducedMotion() ?? false; return (
{title ? (
{title}
) : null} {reduce ? children : Children.map(children, (child, index) => ( {child} ))}
); }