{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "banner",
  "type": "registry:ui",
  "title": "Banner",
  "description": "Region-wide notice that collapses smoothly when dismissed and can remember it.",
  "categories": [
    "notifications"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/banner.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useRef, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, ArrowRight, CircleCheck, Info, Warning, X } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type BannerTone = \"neutral\" | \"info\" | \"success\" | \"warning\" | \"danger\";\n\n// ---------------------------------------------------------------------------\n// Remembered dismissal. One localStorage key per banner, shared across tabs,\n// read after hydration so the server and the first client render agree.\n\nconst PREFIX = \"stealth-banner:\";\nconst EVENT = \"stealth-banner-change\";\n\nfunction read(key: string) {\n  try {\n    return window.localStorage.getItem(PREFIX + key) === \"1\";\n  } catch {\n    return false;\n  }\n}\n\nfunction write(key: string, dismissed: boolean) {\n  try {\n    if (dismissed) window.localStorage.setItem(PREFIX + key, \"1\");\n    else window.localStorage.removeItem(PREFIX + key);\n  } catch {\n    // Private mode or storage disabled: the banner still hides for this visit.\n  }\n  window.dispatchEvent(new Event(EVENT));\n}\n\nfunction subscribe(onChange: () => void) {\n  window.addEventListener(\"storage\", onChange);\n  window.addEventListener(EVENT, onChange);\n  return () => {\n    window.removeEventListener(\"storage\", onChange);\n    window.removeEventListener(EVENT, onChange);\n  };\n}\n\n/** Whether a banner with this key was dismissed on this device, and ways to change it. */\nexport function useBannerDismissal(key: string | undefined) {\n  const dismissed = useSyncExternalStore(\n    subscribe,\n    () => (key ? read(key) : false),\n    () => false,\n  );\n  const dismiss = useCallback(() => key && write(key, true), [key]);\n  const restore = useCallback(() => key && write(key, false), [key]);\n  return { dismissed, dismiss, restore };\n}\n\n// When the dismissed banner held focus, hand it to the next thing on the page\n// rather than dropping it on <body>.\nconst FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex=\"-1\"])';\nfunction focusAfter(node: HTMLElement) {\n  if (!node.contains(document.activeElement)) return;\n  const all = Array.from(document.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) => !node.contains(el) && el.getClientRects().length > 0);\n  const next = all.find((el) => node.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) ?? all.findLast((el) => node.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING);\n  next?.focus({ preventScroll: true });\n}\n\n// ---------------------------------------------------------------------------\n\nconst tones: Record<BannerTone, { icon: React.ReactNode; surface: string; bar: string; glyph: string; label: string }> = {\n  neutral: { icon: <Info />, surface: \"bg-raised\", bar: \"border-line\", glyph: \"text-fg-3\", label: \"Note\" },\n  info: { icon: <Info />, surface: \"bg-info-soft\", bar: \"border-info/20\", glyph: \"text-info\", label: \"Info\" },\n  success: { icon: <CircleCheck />, surface: \"bg-success-soft\", bar: \"border-success/20\", glyph: \"text-success\", label: \"Success\" },\n  warning: { icon: <Warning />, surface: \"bg-warning-soft\", bar: \"border-warning/25\", glyph: \"text-warning\", label: \"Warning\" },\n  danger: { icon: <Alert />, surface: \"bg-danger-soft\", bar: \"border-danger/25\", glyph: \"text-danger\", label: \"Error\" },\n};\n\nexport type BannerProps = Omit<React.ComponentProps<\"div\">, \"title\" | \"children\"> & {\n  tone?: BannerTone;\n  /** Full-bleed strip across a region, or a rounded block inside a content column. */\n  variant?: \"bar\" | \"inset\";\n  /** Replace the tone's icon, or `false` for none. */\n  icon?: React.ReactNode | false;\n  /** A few words, set in the primary color ahead of the message. */\n  title?: React.ReactNode;\n  children?: React.ReactNode;\n  /** Usually a `BannerAction`. */\n  action?: React.ReactNode;\n  /** Show a close button. */\n  dismissible?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Remember the dismissal on this device under this key. Change the key to show a new message again. */\n  storageKey?: string;\n  /** Accessible name for the close button. */\n  dismissLabel?: string;\n};\n\nexport function Banner({\n  tone = \"neutral\",\n  variant = \"bar\",\n  icon,\n  title,\n  children,\n  action,\n  dismissible = false,\n  open: openProp,\n  defaultOpen = true,\n  onOpenChange,\n  storageKey,\n  dismissLabel = \"Dismiss\",\n  className,\n  ...rest\n}: BannerProps) {\n  const reduce = useReducedMotion();\n  const ref = useRef<HTMLDivElement>(null);\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const remembered = useBannerDismissal(storageKey);\n  const t = tones[tone];\n  const shown = open && !remembered.dismissed;\n  const glyph = icon === false ? null : (icon ?? t.icon);\n\n  const dismiss = () => {\n    if (ref.current) focusAfter(ref.current);\n    remembered.dismiss();\n    setOpen(false);\n  };\n\n  return (\n    <AnimatePresence initial={false}>\n      {shown && (\n        // The outer box animates height so what sits below slides up as the banner closes.\n        <motion.div\n          key=\"banner\"\n          className={cn(\"shrink-0 overflow-hidden\", variant === \"inset\" && \"-m-1 p-1\")}\n          initial={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n          animate={{ height: \"auto\", opacity: 1 }}\n          exit={reduce ? { opacity: 0 } : { height: 0, opacity: 0 }}\n          transition={\n            reduce\n              ? { duration: 0.15 }\n              : { height: { duration: 0.28, ease: ease.inOut }, opacity: { duration: 0.16, ease: ease.out } }\n          }\n        >\n          <div\n            ref={ref}\n            role={tone === \"danger\" || tone === \"warning\" ? \"alert\" : \"status\"}\n            data-tone={tone}\n            data-variant={variant}\n            className={cn(\n              \"@container relative text-[13px] leading-5\",\n              t.surface,\n              variant === \"bar\" ? cn(\"border-b\", t.bar) : cn(\"rounded-xl border\", t.bar),\n              className,\n            )}\n            {...rest}\n          >\n            <div\n              className={cn(\n                \"grid grid-cols-[auto_minmax(0,1fr)_auto_auto] items-start py-2.5\",\n                variant === \"bar\" ? \"px-4\" : \"px-3\",\n              )}\n            >\n              {glyph && <span className={cn(\"col-start-1 row-start-1 mr-2.5 mt-1.5 grid size-4 place-items-center\", t.glyph)}>{glyph}</span>}\n              <p className=\"col-start-2 row-start-1 min-w-0 py-1 text-fg-2 text-pretty\">\n                <span className=\"sr-only\">{t.label}: </span>\n                {title != null && <span className=\"font-medium text-fg\">{title} </span>}\n                {children}\n              </p>\n              {action != null && (\n                <div className=\"col-start-3 row-start-1 ml-3 flex items-center gap-1.5 @max-[480px]:col-start-2 @max-[480px]:row-start-2 @max-[480px]:-ml-2 @max-[480px]:mt-1\">\n                  {action}\n                </div>\n              )}\n              {dismissible && (\n                <button\n                  type=\"button\"\n                  aria-label={dismissLabel}\n                  onClick={dismiss}\n                  className={cn(\n                    \"relative col-start-4 row-start-1 ml-1.5 grid size-7 place-items-center rounded-md text-fg-3\",\n                    \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n                    \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-fg/[0.06] hover:text-fg active:scale-[0.9] active:duration-75\",\n                    \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n                    variant === \"bar\" ? \"-mr-2\" : \"-mr-1\",\n                  )}\n                >\n                  <X size={14} />\n                </button>\n              )}\n            </div>\n          </div>\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n}\n\nexport type BannerActionProps = (Omit<React.ComponentProps<\"button\">, \"type\"> & { href?: undefined }) | (React.ComponentProps<\"a\"> & { href: string });\n\n/** A text action with an arrow that leans forward on hover. Renders a link when given `href`. */\nexport function BannerAction({ className, children, ...rest }: BannerActionProps) {\n  const cls = cn(\n    \"group/action inline-flex h-7 shrink-0 items-center gap-1 rounded-md px-2 text-[12.5px] font-medium text-fg\",\n    \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-0 focus-visible:outline-fg-3\",\n    \"transition-[background-color,scale] duration-150 ease-out hover:bg-fg/[0.06] active:scale-[0.97] active:duration-75\",\n    className,\n  );\n  const inner = (\n    <>\n      {children}\n      <ArrowRight size={14} className=\"text-fg-3 transition-[translate,color] duration-200 ease-out-expo group-hover/action:translate-x-0.5 group-hover/action:text-fg\" />\n    </>\n  );\n  if (rest.href !== undefined) {\n    return (\n      <a className={cls} {...(rest as React.ComponentProps<\"a\">)}>\n        {inner}\n      </a>\n    );\n  }\n  return (\n    <button type=\"button\" className={cls} {...(rest as React.ComponentProps<\"button\">)}>\n      {inner}\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/banner"
}
