{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "citation",
  "title": "Citation",
  "description": "A source's pill in an answer, opening a card with its publication, headline and date.",
  "dependencies": [
    "@base-ui/react",
    "@hugeicons/core-free-icons",
    "@hugeicons/react",
    "@stylexjs/stylex"
  ],
  "registryDependencies": [
    "https://constella.aayush.cv/r/base.json",
    "https://constella.aayush.cv/r/badge.json",
    "https://constella.aayush.cv/r/hover-card.json"
  ],
  "files": [
    {
      "path": "src/components/ai/citation.tsx",
      "content": "\"use client\";\n\nimport { Avatar as AvatarPrimitive } from \"@base-ui/react/avatar\";\nimport { Globe02Icon } from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { createContext, use, useMemo } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { Badge } from \"@/components/primitives/badge\";\nimport {\n  createHoverCardHandle,\n  HoverCard,\n  HoverCardContent,\n  HoverCardTrigger,\n} from \"@/components/primitives/hover-card\";\nimport type { Sx } from \"@/lib/constella/styles\";\nimport { stateStyles, typeStyles } from \"@/lib/constella/styles\";\nimport {\n  colors,\n  durations,\n  fontWeights,\n  radii,\n  sizes,\n  space,\n  strokes,\n} from \"@/lib/constella/tokens.stylex\";\n\ninterface Source {\n  /** When it was published, as it should read: \"August 3, 2026\". */\n  date?: string;\n  /** A line or two from the page: what it says about the claim. */\n  description?: string;\n  /** The publication's logo; a globe shows without one. */\n  icon?: string;\n  /** The publication: \"Acme Journal\". */\n  name: string;\n  /** The page's headline. */\n  title: string;\n  url: string;\n}\n\ntype Sources = readonly [Source, ...Source[]];\n\nconst styles = stylex.create({\n  // A logo is a box, not a glyph with its own whitespace, so like a Button's Kbd it sits as\n  // far from the side as from the top and bottom.\n  pill: {\n    maxWidth: sizes.citation,\n    paddingInlineStart: `calc((${sizes.controlXxs} - ${sizes.iconXs}) / 2 - ${strokes.border})`,\n  },\n  name: {\n    minWidth: 0,\n    overflow: \"hidden\",\n    textOverflow: \"ellipsis\",\n  },\n  more: {\n    color: colors.textMuted,\n    flexShrink: 0,\n  },\n  // A menu's inset, so each source's hover fill nests in the card's corner.\n  card: {\n    paddingBlockEnd: space.xxs,\n    paddingBlockStart: space.xxs,\n    paddingInlineEnd: space.xxs,\n    paddingInlineStart: space.xxs,\n  },\n  list: {\n    display: \"flex\",\n    flexDirection: \"column\",\n    gap: space.xxs,\n  },\n  group: {\n    alignItems: \"center\",\n    display: \"flex\",\n    flexWrap: \"wrap\",\n    gap: space.xxs,\n    minWidth: 0,\n  },\n  // One unit the row can't split, so the count never wraps onto a line alone.\n  groupMore: {\n    alignItems: \"center\",\n    display: \"flex\",\n    gap: space.xxs,\n    minWidth: 0,\n  },\n  groupCount: {\n    color: colors.textMuted,\n    flexShrink: 0,\n    paddingInlineEnd: space.xxs,\n    paddingInlineStart: space.xxs,\n    whiteSpace: \"nowrap\",\n  },\n  source: {\n    borderStartStartRadius: radii.sm,\n    borderStartEndRadius: radii.sm,\n    borderEndStartRadius: radii.sm,\n    borderEndEndRadius: radii.sm,\n    color: \"inherit\",\n    display: \"flex\",\n    flexDirection: \"column\",\n    gap: space.xs,\n    paddingBlockEnd: space.row,\n    paddingBlockStart: space.row,\n    paddingInlineEnd: space.row,\n    paddingInlineStart: space.row,\n    textDecorationLine: \"none\",\n    transitionDuration: durations.hover,\n    transitionProperty: \"background-color\",\n    transitionTimingFunction: \"ease\",\n  },\n  publication: {\n    alignItems: \"center\",\n    color: colors.textSecondary,\n    display: \"flex\",\n    // The row's padding, so the logo sits as far from its name as from the row's edge.\n    gap: space.row,\n  },\n  body: {\n    display: \"flex\",\n    flexDirection: \"column\",\n    gap: space.xxs,\n  },\n  clamp: {\n    display: \"-webkit-box\",\n    overflow: \"hidden\",\n    WebkitBoxOrient: \"vertical\",\n    WebkitLineClamp: 2,\n  },\n  title: {\n    fontWeight: fontWeights.medium,\n  },\n  description: {\n    color: colors.textSecondary,\n  },\n  date: {\n    color: colors.textMuted,\n  },\n  logo: {\n    display: \"flex\",\n    flexShrink: 0,\n  },\n  logoPill: {\n    height: sizes.iconXs,\n    width: sizes.iconXs,\n  },\n  logoCard: {\n    height: sizes.iconSm,\n    width: sizes.iconSm,\n  },\n  logoImage: {\n    borderStartStartRadius: radii.xs,\n    borderStartEndRadius: radii.xs,\n    borderEndStartRadius: radii.xs,\n    borderEndEndRadius: radii.xs,\n    height: \"100%\",\n    objectFit: \"cover\",\n    width: \"100%\",\n  },\n  logoFallback: {\n    color: colors.textSecondary,\n    display: \"flex\",\n    height: \"100%\",\n    width: \"100%\",\n  },\n});\n\n// oxlint-disable-next-line unicorn/prefer-number-coercion -- `Number(\"600ms\")` is NaN\nconst fallbackDelay = Number.parseFloat(durations.fallbackDelay);\n\n// A bare favicon, not an Avatar: no tile or edge around it.\nfunction Logo({ source, sx }: { source: Source; sx: Sx }) {\n  return (\n    <AvatarPrimitive.Root\n      aria-hidden\n      data-icon=\"inline-start\"\n      {...stylex.props(styles.logo, sx)}\n    >\n      {source.icon === undefined ? null : (\n        <AvatarPrimitive.Image\n          alt=\"\"\n          referrerPolicy=\"no-referrer\"\n          src={source.icon}\n          {...stylex.props(styles.logoImage)}\n        />\n      )}\n      {/* Held back while a logo loads, so the pill doesn't flash the globe before it. */}\n      <AvatarPrimitive.Fallback\n        delay={source.icon === undefined ? undefined : fallbackDelay}\n        {...stylex.props(styles.logoFallback)}\n      >\n        <HugeiconsIcon\n          aria-hidden\n          icon={Globe02Icon}\n          size=\"100%\"\n          strokeWidth={Number(strokes.icon)}\n        />\n      </AvatarPrimitive.Fallback>\n    </AvatarPrimitive.Root>\n  );\n}\n\nconst CitationsContext = createContext<ReturnType<\n  typeof createHoverCardHandle<Sources>\n> | null>(null);\n\n/** Holds the one card every `Citation` inside opens, so it moves to each citation as you hover it. */\nfunction Citations({ children, sx }: { children: ReactNode; sx?: Sx }) {\n  const handle = useMemo(() => createHoverCardHandle<Sources>(), []);\n  return (\n    <CitationsContext value={handle}>\n      {children}\n      <HoverCard handle={handle}>\n        {({ payload }) => (\n          <HoverCardContent sx={[styles.card, sx]}>\n            {payload === undefined ? null : (\n              <div {...stylex.props(styles.list)}>\n                {payload.map((source) => (\n                  <a\n                    href={source.url}\n                    key={source.url}\n                    rel=\"noreferrer\"\n                    target=\"_blank\"\n                    {...stylex.props(styles.source, stateStyles.ghost)}\n                  >\n                    <span {...stylex.props(styles.publication)}>\n                      <Logo source={source} sx={styles.logoCard} />\n                      {source.name}\n                    </span>\n                    <span {...stylex.props(styles.body)}>\n                      <span\n                        {...stylex.props(\n                          typeStyles.textBase,\n                          styles.clamp,\n                          styles.title\n                        )}\n                      >\n                        {source.title}\n                      </span>\n                      {source.description === undefined ? null : (\n                        <span\n                          {...stylex.props(styles.clamp, styles.description)}\n                        >\n                          {source.description}\n                        </span>\n                      )}\n                    </span>\n                    {source.date === undefined ? null : (\n                      <span {...stylex.props(styles.date)}>{source.date}</span>\n                    )}\n                  </a>\n                ))}\n              </div>\n            )}\n          </HoverCardContent>\n        )}\n      </HoverCard>\n    </CitationsContext>\n  );\n}\n\n/** A Badge linking to the first source, counting the rest; it opens the `Citations` card. */\nfunction Citation({ sources, sx }: { sources: Sources; sx?: Sx }) {\n  const handle = use(CitationsContext);\n  if (handle === null) {\n    throw new Error(\"Citation must be inside Citations.\");\n  }\n  const [first] = sources;\n  return (\n    <Badge\n      render={\n        <HoverCardTrigger\n          handle={handle}\n          href={first.url}\n          payload={sources}\n          rel=\"noreferrer\"\n          target=\"_blank\"\n        />\n      }\n      sx={[styles.pill, sx]}\n    >\n      <Logo source={first} sx={styles.logoPill} />\n      <span {...stylex.props(styles.name)}>{first.name}</span>\n      {sources.length > 1 && (\n        <span {...stylex.props(styles.more)}>+{sources.length - 1}</span>\n      )}\n    </Badge>\n  );\n}\n\n/** A wrapping row of citations: the sources a search found, say. */\nfunction CitationGroup({ children, sx }: { children: ReactNode; sx?: Sx }) {\n  return (\n    <div data-slot=\"citation-group\" {...stylex.props(styles.group, sx)}>\n      {children}\n    </div>\n  );\n}\n\n/** The group's last citation and a count of the sources not shown, kept on one line. */\nfunction CitationGroupMore({\n  children,\n  count,\n  sx,\n}: {\n  children: ReactNode;\n  count: number;\n  sx?: Sx;\n}) {\n  return (\n    <span\n      data-slot=\"citation-group-more\"\n      {...stylex.props(styles.groupMore, sx)}\n    >\n      {children}\n      <span {...stylex.props(typeStyles.textSm, styles.groupCount)}>\n        {count} more\n      </span>\n    </span>\n  );\n}\n\nexport { Citation, CitationGroup, CitationGroupMore, Citations };\nexport type { Source };\n",
      "type": "registry:component"
    }
  ],
  "docs": "Usage: https://constella.aayush.cv/docs/components/citation/index.md",
  "type": "registry:ui"
}