{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-badge",
  "title": "AI Badge",
  "description": "A provenance chip for AI-touched content — generated / assisted / human-edited, with the model and timestamp behind a tooltip or popover and a contrast-safe overlay mode for images.",
  "dependencies": [
    "lucide-react",
    "radix-ui"
  ],
  "registryDependencies": [
    "tooltip",
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/ai-badge.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { PenLine, Sparkles, WandSparkles } from \"lucide-react\"\nimport { Popover as PopoverPrimitive } from \"radix-ui\"\n\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\"\nimport { cn } from \"@/lib/utils\"\n\n/* -------------------------------------------------------------------------- *\n * Provenance table\n *\n * One editable row per claim the badge is allowed to make. The scale is\n * deliberately short — \"who did the work\" is a three-step ladder (model only,\n * model + person, model draft a person signed off on); anything finer belongs\n * in the details panel, not on the chip.\n *\n * Colors are theme tokens, never literals: swapping --chart-* re-skins the\n * whole ladder and dark mode comes for free.\n * -------------------------------------------------------------------------- */\n\nexport type AiBadgeProvenance = \"generated\" | \"assisted\" | \"edited\"\n\ninterface ProvenanceMeta {\n  label: string\n  /** One sentence stating what the label actually claims — shown in the details panel. */\n  description: string\n  color: string\n  glyph: (className: string) => React.ReactElement\n}\n\nconst PROVENANCE: Record<AiBadgeProvenance, ProvenanceMeta> = {\n  generated: {\n    label: \"AI generated\",\n    description: \"Produced by a model from a prompt. No person wrote this draft.\",\n    color: \"var(--chart-1)\",\n    glyph: className => <Sparkles className={className} />,\n  },\n  assisted: {\n    label: \"AI assisted\",\n    description: \"Written by a person with model help — drafting, rewriting or translation.\",\n    color: \"var(--chart-4)\",\n    glyph: className => <WandSparkles className={className} />,\n  },\n  edited: {\n    label: \"Human edited\",\n    description: \"A model drafted it; a person reviewed and edited it before publishing.\",\n    color: \"var(--chart-2)\",\n    glyph: className => <PenLine className={className} />,\n  },\n}\n\nconst SIZES = {\n  sm: { box: \"h-5 gap-1 px-1.5\", square: \"size-5\", glyph: \"size-3\", text: \"text-[0.6875rem]\" },\n  md: { box: \"h-6 gap-1.5 px-2\", square: \"size-6\", glyph: \"size-3.5\", text: \"text-xs\" },\n  lg: { box: \"h-7 gap-1.5 px-2.5\", square: \"size-7\", glyph: \"size-4\", text: \"text-sm\" },\n} as const\n\nexport type AiBadgeSize = keyof typeof SIZES\n\nconst CORNERS = {\n  \"top-left\": \"top-2 left-2\",\n  \"top-right\": \"top-2 right-2\",\n  \"bottom-left\": \"bottom-2 left-2\",\n  \"bottom-right\": \"bottom-2 right-2\",\n} as const\n\nexport type AiBadgeCorner = keyof typeof CORNERS\n\n/**\n * The one-shot arrival sweep. Keyframes travel with the component through a\n * React 19 hoisted <style> — no Tailwind config edit, and every instance on the\n * page dedupes by href. The skew lives inside the keyframes because an\n * `animation` transform replaces any transform utility on the same element,\n * and `forwards` parks the band off the right edge so the last frame cannot\n * flash back over the chip before React drops the node.\n */\nconst KEYFRAMES = `@keyframes ai-badge-sweep{from{transform:translateX(-140%) skewX(-12deg)}to{transform:translateX(420%) skewX(-12deg)}}`\n\n/* -------------------------------------------------------------------------- *\n * Details panel\n * -------------------------------------------------------------------------- */\n\nexport interface AiBadgeField {\n  label: string\n  value: React.ReactNode\n}\n\n/**\n * Default timestamp formatting. It runs inside the panel, which only mounts\n * once the reader opens it — so the server never renders a locale/timezone\n * dependent string and there is nothing for hydration to disagree about.\n */\nfunction formatTimestamp(value: Date): string {\n  return new Intl.DateTimeFormat(undefined, { dateStyle: \"medium\", timeStyle: \"short\" }).format(value)\n}\n\ninterface DetailsBodyProps {\n  accent: string\n  date?: Date | string\n  description: React.ReactNode\n  fields?: readonly AiBadgeField[]\n  footer?: React.ReactNode\n  formatDate: (value: Date) => string\n  glyph: React.ReactNode\n  /** Tooltips paint on an inverted surface, where text-muted-foreground has no contrast. */\n  inverted?: boolean\n  label: React.ReactNode\n  model?: string\n}\n\nfunction DetailsBody({\n  accent,\n  date,\n  description,\n  fields,\n  footer,\n  formatDate,\n  glyph,\n  inverted = false,\n  label,\n  model,\n}: DetailsBodyProps) {\n  const rows: AiBadgeField[] = []\n  if (model) rows.push({ label: \"Model\", value: model })\n  if (date !== undefined) {\n    // A string passes through verbatim (already localized, or relative like\n    // \"2 hours ago\"); an unparseable Date is dropped rather than printed as\n    // \"Invalid Date\".\n    const stamp = typeof date === \"string\" ? date : Number.isNaN(date.getTime()) ? null : formatDate(date)\n    if (stamp) rows.push({ label: \"Generated\", value: stamp })\n  }\n  if (fields) rows.push(...fields)\n\n  const muted = inverted ? \"opacity-70\" : \"text-muted-foreground\"\n\n  return (\n    <div className=\"flex min-w-0 flex-col gap-1.5 text-left\">\n      {/* A div, not a <p>: `label` is consumer-supplied and may carry markup. */}\n      <div className=\"flex items-center gap-1.5 text-xs font-medium\">\n        <span\n          aria-hidden=\"true\"\n          className=\"inline-flex shrink-0 items-center\"\n          style={inverted ? undefined : { color: accent }}\n        >\n          {glyph}\n        </span>\n        {label}\n      </div>\n\n      {description ? <p className={cn(\"text-xs leading-snug text-pretty\", muted)}>{description}</p> : null}\n\n      {rows.length > 0 ? (\n        <dl className=\"grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-xs\">\n          {rows.map((row, i) => (\n            <React.Fragment key={`${row.label}-${i}`}>\n              <dt className={cn(\"font-normal\", muted)}>{row.label}</dt>\n              <dd className=\"min-w-0 font-medium wrap-anywhere\">{row.value}</dd>\n            </React.Fragment>\n          ))}\n        </dl>\n      ) : null}\n\n      {footer ? <div className=\"mt-0.5 border-t pt-1.5 text-xs\">{footer}</div> : null}\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------------------- *\n * Component\n * -------------------------------------------------------------------------- */\n\nexport interface AiBadgeProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** Which claim the chip makes. Also picks the default label, glyph and accent token. */\n  provenance?: AiBadgeProvenance\n  /** Override the chip text. The default comes from `provenance`. */\n  label?: React.ReactNode\n  /** Sentence shown at the top of the details panel; pass `null` to drop it. */\n  description?: React.ReactNode\n  size?: AiBadgeSize\n  /** soft = tinted capsule, outline = hairline capsule. Ignored while `overlay` is set. */\n  tone?: \"soft\" | \"outline\"\n  /** Accent override — pass a theme token such as \"var(--primary)\", never a literal color. */\n  color?: string\n  /** `undefined` uses the provenance glyph; `null` / `false` renders none; any node replaces it. */\n  icon?: React.ReactNode\n  /** false shrinks the chip to a square glyph; the accessible name is kept for screen readers. */\n  showLabel?: boolean\n  /**\n   * Disclosure surface. Defaults to `\"popover\"` when a `footer` is passed\n   * (interactive content), `\"tooltip\"` when there is metadata to reveal, and\n   * `\"none\"` otherwise — a chip with nothing behind it never looks clickable.\n   */\n  details?: \"none\" | \"tooltip\" | \"popover\"\n  /** Model that produced the content, e.g. \"Claude Opus 4.6\". */\n  model?: string\n  /** When it was produced. A Date is formatted with `formatDate`; a string prints as given. */\n  date?: Date | string\n  /** Extra provenance rows (prompt id, reviewer, dataset…), rendered after model and date. */\n  fields?: readonly AiBadgeField[]\n  /** Panel footer — a policy link or button. Rendered in `popover` mode only, the one a pointer can reach. */\n  footer?: React.ReactNode\n  /** Pins the chip to a corner of the nearest positioned ancestor with a contrast-safe scrim. */\n  overlay?: AiBadgeCorner\n  side?: \"top\" | \"right\" | \"bottom\" | \"left\"\n  align?: \"start\" | \"center\" | \"end\"\n  formatDate?: (value: Date) => string\n  /** Tooltip open delay in ms. Ignored in popover mode. */\n  delayDuration?: number\n  /** Play a single arrival sweep the first time the chip mounts. Never replays; skipped under reduced motion. */\n  flash?: boolean\n}\n\nexport const AiBadge = React.forwardRef<HTMLElement, AiBadgeProps>(function AiBadge(\n  {\n    provenance = \"generated\",\n    label,\n    description,\n    size = \"md\",\n    tone = \"soft\",\n    color,\n    icon,\n    showLabel = true,\n    details,\n    model,\n    date,\n    fields,\n    footer,\n    overlay,\n    side = \"top\",\n    align = \"center\",\n    formatDate = formatTimestamp,\n    delayDuration = 200,\n    flash = false,\n    className,\n    style,\n    ...props\n  },\n  ref,\n) {\n  const meta = PROVENANCE[provenance]\n  const sizes = SIZES[size]\n  const accent = color ?? meta.color\n  const text = label ?? meta.label\n  const blurb = description === undefined ? meta.description : description\n\n  const glyph = icon === undefined ? meta.glyph(sizes.glyph) : icon\n  // An icon-only chip with no icon would be an empty capsule: keep the text.\n  const labelVisible = showLabel || !glyph\n\n  const hasMeta = Boolean(model || date !== undefined || (fields && fields.length > 0) || footer)\n  // Affordance follows content: with nothing to reveal there is no focus stop\n  // and no pointer cursor. A footer holds interactive content, which a tooltip\n  // can never make reachable, so it upgrades the default to a popover.\n  const mode = details ?? (footer ? \"popover\" : hasMeta ? \"tooltip\" : \"none\")\n  const interactive = mode !== \"none\"\n\n  /* One-shot arrival sweep. The lock is the animation itself: a CSS animation\n     only starts when the node is inserted, so re-rendering, opening the panel\n     or flipping the theme cannot replay it — no timer, nothing to clean up,\n     and the same markup on server and client (no hydration mismatch). The\n     `animationend` handler then drops the node; under reduced motion the band\n     is display:none, the event never fires and it stays harmlessly hidden. */\n  const [sweptOut, setSweptOut] = React.useState(false)\n\n  /* Overlay owns the surface: over an arbitrary photo the only contrast\n     guarantee left is the theme's own background/foreground pair behind a\n     scrim, so the accent survives on the glyph alone. */\n  const surface: React.CSSProperties = overlay\n    ? {}\n    : tone === \"soft\"\n      ? { backgroundColor: `color-mix(in oklab, ${accent} 14%, transparent)`, color: accent }\n      : { borderColor: `color-mix(in oklab, ${accent} 45%, transparent)`, color: accent }\n\n  const content = (\n    <>\n      {flash ? (\n        <style href=\"zyeon-ai-badge\" precedence=\"medium\">\n          {KEYFRAMES}\n        </style>\n      ) : null}\n\n      {glyph ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"inline-flex shrink-0 items-center\"\n          style={overlay ? { color: accent } : undefined}\n        >\n          {glyph}\n        </span>\n      ) : null}\n\n      {labelVisible ? <span className=\"min-w-0 truncate\">{text}</span> : <span className=\"sr-only\">{text}</span>}\n\n      {flash && !sweptOut ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-y-0 left-0 w-1/4 [animation:ai-badge-sweep_900ms_ease-out_1_forwards] motion-reduce:hidden\"\n          onAnimationEnd={() => setSweptOut(true)}\n          style={{\n            backgroundImage:\n              \"linear-gradient(90deg, transparent, color-mix(in oklab, var(--foreground) 25%, transparent), transparent)\",\n          }}\n        />\n      ) : null}\n    </>\n  )\n\n  const shared = {\n    className: cn(\n      // No select-none on the static chip: a provenance label must travel with\n      // the text when a reader copies the paragraph it annotates.\n      \"relative isolate inline-flex w-fit shrink-0 items-center justify-center overflow-hidden rounded-full align-middle font-medium whitespace-nowrap\",\n      sizes.text,\n      labelVisible ? sizes.box : sizes.square,\n      overlay\n        ? cn(\n            \"absolute z-10 border border-border/70 bg-background/80 text-foreground shadow-sm backdrop-blur-sm\",\n            CORNERS[overlay],\n          )\n        : tone === \"outline\" && \"border\",\n      interactive &&\n        cn(\n          \"cursor-pointer transition-shadow duration-150 select-none motion-reduce:transition-none\",\n          \"hover:ring-2 hover:ring-ring/30 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n          // Popover reports open/closed, tooltip reports delayed-open/instant-open.\n          \"data-[state=open]:ring-2 data-[state=open]:ring-ring/40\",\n          \"data-[state=delayed-open]:ring-2 data-[state=delayed-open]:ring-ring/40\",\n          \"data-[state=instant-open]:ring-2 data-[state=instant-open]:ring-ring/40\",\n        ),\n      className,\n    ),\n    \"data-provenance\": provenance,\n    \"data-size\": size,\n    style: { ...surface, ...style },\n    ...props,\n  }\n\n  if (!interactive) {\n    return (\n      <span ref={ref as React.Ref<HTMLSpanElement>} {...shared}>\n        {content}\n      </span>\n    )\n  }\n\n  const trigger = (\n    <button ref={ref as React.Ref<HTMLButtonElement>} type=\"button\" {...shared}>\n      {content}\n    </button>\n  )\n\n  const body = (inverted: boolean) => (\n    <DetailsBody\n      accent={accent}\n      date={date}\n      description={blurb}\n      fields={fields}\n      footer={inverted ? undefined : footer}\n      formatDate={formatDate}\n      glyph={meta.glyph(\"size-3.5\")}\n      inverted={inverted}\n      label={text}\n      model={model}\n    />\n  )\n\n  if (mode === \"tooltip\") {\n    return (\n      <TooltipProvider delayDuration={delayDuration}>\n        <Tooltip>\n          <TooltipTrigger asChild>{trigger}</TooltipTrigger>\n          <TooltipContent align={align} className=\"max-w-72 items-start\" side={side} sideOffset={6}>\n            {body(true)}\n          </TooltipContent>\n        </Tooltip>\n      </TooltipProvider>\n    )\n  }\n\n  return (\n    <PopoverPrimitive.Root>\n      <PopoverPrimitive.Trigger asChild>{trigger}</PopoverPrimitive.Trigger>\n      {/* Portalled on purpose: an overlay chip lives inside an image wrapper\n          with overflow-hidden, which would clip an in-place panel. */}\n      <PopoverPrimitive.Portal>\n        <PopoverPrimitive.Content\n          align={align}\n          className={cn(\n            \"z-50 w-64 max-w-[calc(100vw-2rem)] rounded-lg border bg-popover p-3 text-popover-foreground shadow-md outline-none\",\n            \"motion-safe:data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95\",\n            \"motion-safe:data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95\",\n            \"motion-reduce:animate-none\",\n          )}\n          collisionPadding={8}\n          side={side}\n          sideOffset={6}\n        >\n          {body(false)}\n        </PopoverPrimitive.Content>\n      </PopoverPrimitive.Portal>\n    </PopoverPrimitive.Root>\n  )\n})\n\nAiBadge.displayName = \"AiBadge\"\n\nexport default AiBadge\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}