{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-disclaimer",
  "title": "AI Disclaimer",
  "description": "The 'AI can make mistakes. Check important info.' line — inline, banner and details-popover chromes, role=note so it never interrupts, and a one-shot dismissal that hands persistence to the app.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/registry/ui/ai-disclaimer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ExternalLink, Info, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/* -------------------------------------------------------------------------- *\n * AI Disclaimer\n *\n * The \"AI can make mistakes. Check important info.\" line. Three chromes for the\n * three places it lives — under a composer (inline), above a thread (banner),\n * and next to a disclosure that explains WHY plus where the data goes\n * (details).\n *\n * Two rules drive every decision below:\n *\n * 1. It is a standing statement, not an event. So the root is role=\"note\" and\n *    there is NO live region anywhere: a compliance line that re-announces\n *    itself on every render would talk over the answer the user asked for.\n * 2. It must stay out of the way. Muted foreground, no accent fill, no icon by\n *    default outside the banner — a disclaimer that competes with the product\n *    gets ignored (and then dismissed) faster than one that whispers.\n * -------------------------------------------------------------------------- */\n\nconst KEYFRAMES = `@keyframes aid-pop-below{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}\n@keyframes aid-pop-above{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}`\n\n/** useLayoutEffect measures before paint; on the server there is nothing to measure. */\nconst useIsomorphicLayoutEffect = typeof window === \"undefined\" ? React.useEffect : React.useLayoutEffect\n\nconst DEFAULT_MESSAGE = \"AI can make mistakes. Check important info.\"\n\nexport type AiDisclaimerVariant = \"inline\" | \"banner\" | \"details\"\n\nexport interface AiDisclaimerLink {\n  label: string\n  /** Consumer-owned destination — http(s) opens in a new tab, anything else navigates in place. */\n  href: string\n}\n\n/** Stable identities so a caller that omits them never re-triggers effects. */\nconst NO_REASONS: readonly string[] = []\nconst NO_LINKS: readonly AiDisclaimerLink[] = []\n\n/** Gap kept between the panel and the viewport edge when deciding where to open. */\nconst EDGE_GAP = 12\n\n/* -------------------------------------------------------------------------- *\n * Disclosure popover\n *\n * Hand-rolled rather than pulled from a popover library: the whole surface is\n * one trigger and one panel, and the interesting part — where it opens, who\n * gets focus back — is exactly the part a library would hide.\n * -------------------------------------------------------------------------- */\n\ninterface DetailsPopoverProps {\n  label: string\n  title: string\n  reasons: readonly string[]\n  links: readonly AiDisclaimerLink[]\n  onOpenChange?: (open: boolean) => void\n}\n\nfunction DetailsPopover({ label, title, reasons, links, onOpenChange }: DetailsPopoverProps) {\n  const [open, setOpen] = React.useState(false)\n  // Resolved from a measurement, never from a guess: the disclaimer usually\n  // sits at the very bottom of a chat panel, where \"below\" has no room.\n  const [side, setSide] = React.useState<\"above\" | \"below\">(\"below\")\n  const [align, setAlign] = React.useState<\"start\" | \"end\">(\"start\")\n\n  const wrapRef = React.useRef<HTMLSpanElement>(null)\n  const triggerRef = React.useRef<HTMLButtonElement>(null)\n  const panelRef = React.useRef<HTMLDivElement>(null)\n\n  // The callback is read through a ref so close() stays referentially stable and\n  // the document listeners below are attached exactly once per open.\n  const onOpenChangeRef = React.useRef(onOpenChange)\n  React.useEffect(() => {\n    onOpenChangeRef.current = onOpenChange\n  })\n\n  const id = React.useId()\n  const panelId = `${id}-panel`\n  const titleId = `${id}-title`\n\n  // close() is only ever reached from a state where the panel is open (every\n  // listener that can call it is attached under `if (!open) return`), so it can\n  // notify unconditionally without an \"is it really changing?\" guard.\n  const close = React.useCallback(() => {\n    setOpen(false)\n    onOpenChangeRef.current?.(false)\n  }, [])\n\n  const openPanel = React.useCallback(() => {\n    setOpen(true)\n    onOpenChangeRef.current?.(true)\n  }, [])\n\n  // Placement. Measured before paint so the panel never renders on the wrong\n  // side for a frame, and re-measured while it is open because an ancestor can\n  // scroll under it (capture phase catches scrolls on any container).\n  useIsomorphicLayoutEffect(() => {\n    if (!open) return\n    const trigger = triggerRef.current\n    const panel = panelRef.current\n    if (!trigger || !panel) return\n\n    const measure = () => {\n      const rect = trigger.getBoundingClientRect()\n      const height = panel.offsetHeight\n      const width = panel.offsetWidth\n      const noRoomBelow = rect.bottom + height + EDGE_GAP > window.innerHeight\n      const roomAbove = rect.top - height - EDGE_GAP > 0\n      const overflowsRight = rect.left + width + EDGE_GAP > window.innerWidth\n      const roomToTheLeft = rect.right - width - EDGE_GAP > 0\n      setSide(noRoomBelow && roomAbove ? \"above\" : \"below\")\n      setAlign(overflowsRight && roomToTheLeft ? \"end\" : \"start\")\n    }\n\n    measure()\n    window.addEventListener(\"resize\", measure)\n    window.addEventListener(\"scroll\", measure, true)\n    return () => {\n      window.removeEventListener(\"resize\", measure)\n      window.removeEventListener(\"scroll\", measure, true)\n    }\n  }, [open])\n\n  // Focus moves to the panel so the keyboard path continues inside it, but the\n  // panel is NOT modal: nothing is trapped and the page keeps scrolling.\n  React.useEffect(() => {\n    if (!open) return\n    panelRef.current?.focus({ preventScroll: true })\n  }, [open])\n\n  // Dismissal paths that live outside the subtree. Both listeners exist only\n  // while the panel is open and are removed by the same effect's cleanup, so an\n  // unmount mid-open leaves nothing behind.\n  React.useEffect(() => {\n    if (!open) return\n\n    const onKeyDown = (event: KeyboardEvent) => {\n      if (event.key !== \"Escape\") return\n      close()\n      // Escape is an explicit \"take me back\": focus returns to the trigger.\n      triggerRef.current?.focus()\n    }\n    const onPointerDown = (event: PointerEvent) => {\n      const wrap = wrapRef.current\n      if (wrap && event.target instanceof Node && !wrap.contains(event.target)) close()\n    }\n\n    document.addEventListener(\"keydown\", onKeyDown)\n    // Capture phase: a consumer's own stopPropagation must not strand the panel.\n    document.addEventListener(\"pointerdown\", onPointerDown, true)\n    return () => {\n      document.removeEventListener(\"keydown\", onKeyDown)\n      document.removeEventListener(\"pointerdown\", onPointerDown, true)\n    }\n  }, [close, open])\n\n  return (\n    <span\n      className=\"relative inline-flex\"\n      onBlur={event => {\n        if (!open) return\n        const next = event.relatedTarget\n        // Tabbing past the last link leaves the subtree: close, but leave focus\n        // where the user sent it — pulling it back would fight the Tab key.\n        if (next instanceof Node && event.currentTarget.contains(next)) return\n        close()\n      }}\n      ref={wrapRef}\n    >\n      <style href=\"zyeon-ai-disclaimer\" precedence=\"medium\">\n        {KEYFRAMES}\n      </style>\n\n      <button\n        aria-controls={open ? panelId : undefined}\n        aria-expanded={open}\n        aria-haspopup=\"dialog\"\n        // The accessible name starts with the visible label (so voice control\n        // still hears \"Why?\") and continues with what the panel is about.\n        aria-label={`${label} — ${title}`}\n        className={cn(\n          \"cursor-pointer rounded-sm underline decoration-dotted underline-offset-2\",\n          \"hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n          open && \"text-foreground\",\n        )}\n        data-state={open ? \"open\" : \"closed\"}\n        onClick={() => (open ? close() : openPanel())}\n        ref={triggerRef}\n        type=\"button\"\n      >\n        {label}\n      </button>\n\n      {open ? (\n        <div\n          aria-labelledby={titleId}\n          className={cn(\n            \"absolute z-50 w-72 max-w-[calc(100vw-2rem)] rounded-lg border bg-popover p-3 text-left shadow-md outline-none\",\n            side === \"above\"\n              ? \"bottom-full mb-2 [animation:aid-pop-above_120ms_ease-out]\"\n              : \"top-full mt-2 [animation:aid-pop-below_120ms_ease-out]\",\n            align === \"end\" ? \"right-0\" : \"left-0\",\n            \"motion-reduce:[animation:none]\",\n          )}\n          data-align={align}\n          data-side={side}\n          id={panelId}\n          ref={panelRef}\n          role=\"dialog\"\n          tabIndex={-1}\n        >\n          <p className=\"text-xs font-medium text-popover-foreground\" id={titleId}>\n            {title}\n          </p>\n\n          {reasons.length > 0 ? (\n            <ul className=\"mt-2 flex flex-col gap-1.5\">\n              {reasons.map((reason, i) => (\n                <li className=\"flex gap-2 text-xs leading-relaxed text-muted-foreground\" key={i}>\n                  <span aria-hidden=\"true\" className=\"mt-1.5 size-1 shrink-0 rounded-full bg-muted-foreground\" />\n                  <span className=\"min-w-0\">{reason}</span>\n                </li>\n              ))}\n            </ul>\n          ) : null}\n\n          {links.length > 0 ? (\n            <div className={cn(\"flex flex-col items-start gap-1\", reasons.length > 0 ? \"mt-3 border-t pt-2\" : \"mt-2\")}>\n              {links.map((link, i) => {\n                const external = /^https?:/i.test(link.href)\n                return (\n                  <a\n                    className=\"inline-flex items-center gap-1 rounded-sm text-xs font-medium text-primary underline-offset-2 hover:underline focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\"\n                    href={link.href}\n                    key={i}\n                    rel={external ? \"noopener noreferrer\" : undefined}\n                    target={external ? \"_blank\" : undefined}\n                  >\n                    {link.label}\n                    {external ? <ExternalLink aria-hidden=\"true\" className=\"size-3 shrink-0\" /> : null}\n                  </a>\n                )\n              })}\n            </div>\n          ) : null}\n        </div>\n      ) : null}\n    </span>\n  )\n}\n\n/* -------------------------------------------------------------------------- *\n * Component\n * -------------------------------------------------------------------------- */\n\nexport interface AiDisclaimerProps extends React.HTMLAttributes<HTMLDivElement> {\n  /**\n   * `inline` — one muted line, centred, made to sit under a composer.\n   * `banner` — a bordered strip for the top of a thread (the only variant with\n   * a default icon).\n   * `details` — the inline line plus a disclosure that opens `reasons` and\n   * `links` in a popover.\n   */\n  variant?: AiDisclaimerVariant\n  /**\n   * Overrides the per-variant default icon (only `banner` has one); pass `null`\n   * to render no icon at all.\n   */\n  icon?: React.ReactNode\n  /** Renders the dismiss button. Independent of `onDismiss` — a surface may allow hiding without persisting. */\n  dismissible?: boolean\n  /** Controlled visibility. When provided the component never hides itself; the consumer decides. */\n  dismissed?: boolean\n  /** Uncontrolled initial visibility — hydrate it from whatever store `onDismiss` wrote to. */\n  defaultDismissed?: boolean\n  /**\n   * Called at most once per visible lifetime, on the user's dismiss. This is the\n   * persistence hook: write it to localStorage / a preferences endpoint.\n   */\n  onDismiss?: () => void\n  /** Accessible name of the dismiss button. */\n  dismissLabel?: string\n  /** Visible text of the disclosure trigger. `details` only. */\n  detailsLabel?: string\n  /** Heading of the disclosure panel, and the tail of the trigger's accessible name. `details` only. */\n  detailsTitle?: string\n  /** Bullet list inside the panel — why the answer can be wrong. `details` only; other variants ignore it. */\n  reasons?: readonly string[]\n  /** Link slots inside the panel — data usage, privacy, model card. `details` only; other variants ignore it. */\n  links?: readonly AiDisclaimerLink[]\n  /** Fires when the disclosure opens or closes — useful for \"did anyone read it?\" analytics. `details` only. */\n  onDetailsOpenChange?: (open: boolean) => void\n}\n\nconst rootClass: Record<AiDisclaimerVariant, string> = {\n  inline: \"flex w-full items-center justify-center gap-1.5 px-2 py-1 text-center\",\n  details: \"flex w-full flex-wrap items-center justify-center gap-x-1.5 gap-y-1 px-2 py-1 text-center\",\n  banner: \"flex w-full items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2 text-left\",\n}\n\nexport const AiDisclaimer = React.forwardRef<HTMLDivElement, AiDisclaimerProps>(function AiDisclaimer(\n  {\n    variant = \"inline\",\n    icon,\n    dismissible = false,\n    dismissed,\n    defaultDismissed = false,\n    onDismiss,\n    dismissLabel = \"Dismiss\",\n    detailsLabel = \"Why?\",\n    detailsTitle = \"About this assistant\",\n    reasons = NO_REASONS,\n    links = NO_LINKS,\n    onDetailsOpenChange,\n    className,\n    children,\n    ...props\n  },\n  ref,\n) {\n  const [selfDismissed, setSelfDismissed] = React.useState(defaultDismissed)\n  const controlled = dismissed !== undefined\n  const hidden = dismissed ?? selfDismissed\n\n  // One-shot lock. A controlled consumer often persists asynchronously (network\n  // write, then flip the prop), which leaves the button clickable for a beat —\n  // without this, an impatient double-click writes the preference twice.\n  const persisted = React.useRef(false)\n  React.useEffect(() => {\n    // Brought back (preference reset, new session): the next dismiss counts again.\n    if (!hidden) persisted.current = false\n  }, [hidden])\n\n  const handleDismiss = React.useCallback(() => {\n    if (persisted.current) return\n    persisted.current = true\n    if (!controlled) setSelfDismissed(true)\n    onDismiss?.()\n  }, [controlled, onDismiss])\n\n  if (hidden) return null\n\n  // `undefined` means \"use the variant default\", `null` means \"no icon at all\".\n  const iconNode = icon === undefined ? (variant === \"banner\" ? <Info className=\"size-3.5\" /> : null) : icon\n  // An empty disclosure is worse than none: no trigger unless it has something to open.\n  const hasDetails = variant === \"details\" && (reasons.length > 0 || links.length > 0)\n\n  return (\n    <div\n      className={cn(\"text-xs leading-snug text-muted-foreground\", rootClass[variant], className)}\n      data-variant={variant}\n      // Standing statement, not an event: a note is reachable and describable\n      // but never interrupts, which role=\"status\" / \"alert\" would.\n      role=\"note\"\n      ref={ref}\n      {...props}\n    >\n      {iconNode !== null ? (\n        <span aria-hidden=\"true\" className=\"shrink-0\">\n          {iconNode}\n        </span>\n      ) : null}\n\n      <span className={cn(\"min-w-0\", variant === \"banner\" && \"flex-1\")}>{children ?? DEFAULT_MESSAGE}</span>\n\n      {hasDetails ? (\n        <DetailsPopover\n          label={detailsLabel}\n          links={links}\n          onOpenChange={onDetailsOpenChange}\n          reasons={reasons}\n          title={detailsTitle}\n        />\n      ) : null}\n\n      {dismissible ? (\n        <button\n          aria-label={dismissLabel}\n          className={cn(\n            \"shrink-0 cursor-pointer rounded-sm p-0.5 opacity-70 transition-opacity\",\n            \"hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none\",\n            variant === \"banner\" && \"-mr-1\",\n          )}\n          onClick={handleDismiss}\n          type=\"button\"\n        >\n          <X aria-hidden=\"true\" className=\"size-3.5\" />\n        </button>\n      ) : null}\n    </div>\n  )\n})\n\nexport default AiDisclaimer\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}