{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-inbox",
  "type": "registry:ui",
  "title": "Notification Inbox",
  "description": "A bell that rings on arrival and opens a grouped, keyboard-driven inbox with undo.",
  "categories": [
    "notifications"
  ],
  "dependencies": [
    "@base-ui/react",
    "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",
    "https://www.stealth.pm/r/count-badge.json"
  ],
  "files": [
    {
      "path": "components/ui/notification-inbox.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { Tabs } from \"@base-ui/react/tabs\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Bell } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\nimport { CountBadge } from \"@/components/ui/count-badge\";\n\nexport type InboxNotification = {\n  id: string;\n  /** What happened. Bold the actor and the object yourself: <><b>Maya</b> commented on <b>Q3 forecast</b></>. */\n  title: React.ReactNode;\n  /** A line of the comment or change, shown quieter under the title. */\n  preview?: string;\n  time: Date | number | string;\n  /** Initials are drawn from this. */\n  actor?: string;\n  /** For system notifications with no person behind them. Replaces the initials. */\n  icon?: React.ReactNode;\n  read?: boolean;\n  /** Also listed under Mentions. */\n  mention?: boolean;\n};\n\ntype Tab = \"all\" | \"mentions\";\n\nconst toMs = (t: InboxNotification[\"time\"]) => (t instanceof Date ? t.getTime() : typeof t === \"number\" ? t : Date.parse(t));\nconst initials = (name: string) =>\n  name\n    .split(/\\s+/)\n    .map((p) => p[0])\n    .join(\"\")\n    .slice(0, 2)\n    .toUpperCase();\n\n// One shared minute clock for every open list, so relative times (\"4m\") stay honest.\nlet clockNow = 0;\nconst subscribeClock = (notify: () => void) => {\n  const tick = () => {\n    clockNow = Date.now();\n    notify();\n  };\n  tick();\n  const id = window.setInterval(tick, 60_000);\n  return () => window.clearInterval(id);\n};\n\n/** Wall-clock time that refreshes once a minute while mounted. 0 on the server. */\nfunction useNow() {\n  return useSyncExternalStore(\n    subscribeClock,\n    () => clockNow,\n    () => 0,\n  );\n}\n\nconst startOfDay = (ms: number) => {\n  const d = new Date(ms);\n  d.setHours(0, 0, 0, 0);\n  return d.getTime();\n};\n\n/** \"now\", \"4m\", \"3h\", \"1d\", \"Mon\", \"12 Sep\": short enough to sit in a row. The full date is on hover. */\nfunction shortTime(ms: number, now: number) {\n  const diff = Math.max(0, now - ms);\n  const today = startOfDay(now);\n  if (diff < 60_000) return \"now\";\n  if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;\n  if (ms >= today) return `${Math.floor(diff / 3_600_000)}h`;\n  if (ms >= today - 86_400_000) return \"1d\";\n  if (ms >= today - 6 * 86_400_000) return new Intl.DateTimeFormat(undefined, { weekday: \"short\" }).format(ms);\n  return new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\" }).format(ms);\n}\nconst fullTime = (ms: number) => new Intl.DateTimeFormat(undefined, { dateStyle: \"medium\", timeStyle: \"short\" }).format(ms);\n\nexport type NotificationInboxProps = Omit<React.ComponentProps<\"button\">, \"children\" | \"onClick\"> & {\n  items?: InboxNotification[];\n  defaultItems?: InboxNotification[];\n  onItemsChange?: (items: InboxNotification[]) => void;\n  /** Called when a notification is opened. It is marked read first. */\n  onOpenItem?: (item: InboxNotification) => void;\n  /** Called after an archive is final, once its undo window has passed. */\n  onArchive?: (item: InboxNotification) => void;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Skeleton rows in place of the list, for the first load. */\n  loading?: boolean;\n  /** The bell's name and the panel's title. */\n  label?: string;\n  /** A link or button at the bottom right of the panel, e.g. notification settings. */\n  footer?: React.ReactNode;\n};\n\nexport function NotificationInbox({\n  items: itemsProp,\n  defaultItems = [],\n  onItemsChange,\n  onOpenItem,\n  onArchive,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  loading = false,\n  label = \"Notifications\",\n  footer,\n  className,\n  ...rest\n}: NotificationInboxProps) {\n  const reduce = useReducedMotion();\n  const [items, setItems] = useControllableState({ value: itemsProp, defaultValue: defaultItems, onChange: onItemsChange });\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const [tab, setTab] = useState<Tab>(\"all\");\n  const [undo, setUndo] = useState<{ item: InboxNotification; index: number } | null>(null);\n  const undoTimer = useRef(0);\n  const tabRef = useRef<HTMLButtonElement>(null);\n  const [bell, ring] = useAnimate<HTMLSpanElement>();\n  const unread = items.filter((i) => !i.read).length;\n  const mentionsUnread = items.filter((i) => i.mention && !i.read).length;\n\n  // The bell swings once when something new lands, never on load or when the count drops.\n  const previous = useRef(unread);\n  useEffect(() => {\n    const was = previous.current;\n    previous.current = unread;\n    if (reduce || unread <= was || !bell.current) return;\n    ring(bell.current, { rotate: [0, 14, -11, 7, -4, 0] }, { duration: 0.6, ease: \"easeOut\" });\n  }, [unread, reduce, ring, bell]);\n\n  // An archive becomes final when its undo window closes, the panel closes, or another archive replaces it.\n  const pendingUndo = useRef<{ item: InboxNotification; index: number } | null>(null);\n  const finalize = useCallback(() => {\n    window.clearTimeout(undoTimer.current);\n    const done = pendingUndo.current;\n    pendingUndo.current = null;\n    if (done) onArchive?.(done.item);\n    setUndo(null);\n  }, [onArchive]);\n  useEffect(() => () => window.clearTimeout(undoTimer.current), []);\n\n  const archive = (item: InboxNotification) => {\n    const index = items.findIndex((i) => i.id === item.id);\n    if (index < 0) return;\n    if (pendingUndo.current) onArchive?.(pendingUndo.current.item);\n    pendingUndo.current = { item, index };\n    setItems(items.filter((i) => i.id !== item.id));\n    setUndo({ item, index });\n    window.clearTimeout(undoTimer.current);\n    undoTimer.current = window.setTimeout(finalize, 5000);\n  };\n  const restore = () => {\n    if (!undo) return;\n    window.clearTimeout(undoTimer.current);\n    pendingUndo.current = null;\n    const next = [...items];\n    next.splice(Math.min(undo.index, next.length), 0, undo.item);\n    setItems(next);\n    setUndo(null);\n  };\n  const markAllRead = () => setItems(items.map((i) => (i.read ? i : { ...i, read: true })));\n  const openItem = (item: InboxNotification) => {\n    if (!item.read) setItems(items.map((i) => (i.id === item.id ? { ...i, read: true } : i)));\n    onOpenItem?.({ ...item, read: true });\n  };\n\n  return (\n    <Popover.Root\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(next);\n        if (!next) finalize();\n      }}\n    >\n      <Popover.Trigger\n        data-slot=\"notification-inbox\"\n        className={cn(\n          \"group/bell relative grid size-8 shrink-0 place-items-center rounded-lg text-fg-2 outline-none\",\n          \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.94] active:duration-75 data-popup-open:bg-hover data-popup-open:text-fg\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n          className,\n        )}\n        {...rest}\n      >\n        {/* Named by text, not aria-label, so the badge's \"3 unread\" joins the name. */}\n        <span className=\"sr-only\">{label}</span>\n        <span ref={bell} className=\"grid origin-[50%_2px] place-items-center\">\n          <Bell />\n        </span>\n        <CountBadge count={unread} size=\"sm\" position=\"top-right\" className=\"right-1! top-1!\" label={(n) => `${n} unread`} announce />\n      </Popover.Trigger>\n      <Popover.Portal>\n        <Popover.Positioner side=\"bottom\" align=\"end\" sideOffset={8} collisionPadding={12} className=\"z-(--z-popover)\">\n          <Popover.Popup\n            initialFocus={tabRef}\n            className={cn(\n              \"flex max-h-[min(520px,var(--available-height))] w-[380px] max-w-[calc(100vw-24px)] flex-col overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n              \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-200 ease-out-expo\",\n              \"data-starting-style:-translate-y-1 data-starting-style:scale-[0.96] data-starting-style:opacity-0\",\n              \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-120 data-ending-style:ease-out\",\n              \"motion-reduce:data-starting-style:translate-y-0 motion-reduce:data-starting-style:scale-100\",\n            )}\n          >\n            <Tabs.Root value={tab} onValueChange={(v) => setTab(v as Tab)} className=\"flex min-h-0 flex-1 flex-col\">\n              <div className=\"flex items-center justify-between gap-3 px-3.5 pt-3\">\n                <Popover.Title className=\"text-[14px] font-medium tracking-[-0.015em] text-fg\">{label}</Popover.Title>\n                <button\n                  type=\"button\"\n                  onClick={markAllRead}\n                  disabled={unread === 0}\n                  className=\"-mr-1.5 h-7 rounded-md px-2 text-[12px] font-medium text-fg-2 outline-none transition-[background-color,color,opacity,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.96] disabled:pointer-events-none disabled:opacity-40 focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\"\n                >\n                  Mark all read\n                </button>\n              </div>\n              <Tabs.List className=\"relative mt-1.5 flex gap-4 border-b border-line px-3.5\">\n                {(\n                  [\n                    [\"all\", \"All\", unread],\n                    [\"mentions\", \"Mentions\", mentionsUnread],\n                  ] as const\n                ).map(([value, text, count], i) => (\n                  <Tabs.Tab\n                    key={value}\n                    value={value}\n                    ref={i === 0 ? tabRef : undefined}\n                    className=\"flex h-9 items-center gap-1.5 text-[12.5px] text-fg-3 outline-none transition-colors duration-150 hover:text-fg-2 data-active:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-[-6px] focus-visible:outline-fg-3\"\n                  >\n                    {text}\n                    <CountBadge count={count} tone=\"muted\" size=\"sm\" label={(n) => `${n} unread`} />\n                  </Tabs.Tab>\n                ))}\n                <Tabs.Indicator className=\"absolute bottom-[-1px] left-0 h-px w-(--active-tab-width) translate-x-(--active-tab-left) bg-fg transition-[translate,width] duration-200 ease-in-out-quart\" />\n              </Tabs.List>\n\n              {([\"all\", \"mentions\"] as const).map((value) => (\n                <Tabs.Panel key={value} value={value} tabIndex={-1} className=\"flex min-h-0 flex-1 flex-col outline-none\">\n                  {loading ? (\n                    <Skeleton />\n                  ) : (\n                    <InboxList\n                      items={value === \"all\" ? items : items.filter((i) => i.mention)}\n                      empty={value === \"all\" ? [\"You’re all caught up\", \"New activity shows up here.\"] : [\"No mentions\", \"When someone @mentions you, it lands here.\"]}\n                      onOpen={openItem}\n                      onArchive={archive}\n                    />\n                  )}\n                </Tabs.Panel>\n              ))}\n            </Tabs.Root>\n\n            <div className=\"relative flex h-10 shrink-0 items-center justify-between gap-3 overflow-hidden border-t border-line px-3.5 text-[12px] text-fg-3\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {undo ? (\n                  <motion.div\n                    key=\"undo\"\n                    role=\"status\"\n                    className=\"flex min-w-0 flex-1 items-center justify-between gap-3\"\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={reduce ? { opacity: 0 } : { opacity: 0, y: 12, transition: { duration: 0.14, ease: ease.in } }}\n                    transition={reduce ? { duration: 0.12 } : spring.snappy}\n                  >\n                    <span className=\"min-w-0 truncate text-fg-2\">Archived</span>\n                    <button\n                      type=\"button\"\n                      onClick={restore}\n                      className=\"-mr-1.5 h-7 shrink-0 rounded-md px-2 font-medium text-fg outline-none transition-[background-color,scale] duration-150 hover:bg-hover active:scale-[0.96] focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-fg-3\"\n                    >\n                      Undo\n                    </button>\n                  </motion.div>\n                ) : (\n                  <motion.div\n                    key=\"hints\"\n                    className=\"flex min-w-0 flex-1 items-center justify-between gap-3\"\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, y: -12 }}\n                    animate={{ opacity: 1, y: 0 }}\n                    exit={reduce ? { opacity: 0 } : { opacity: 0, y: -12, transition: { duration: 0.14, ease: ease.in } }}\n                    transition={reduce ? { duration: 0.12 } : spring.snappy}\n                  >\n                    <span className=\"flex min-w-0 items-center gap-1.5 truncate pointer-coarse:invisible\">\n                      <Kbd>↑</Kbd>\n                      <Kbd>↓</Kbd>\n                      <span className=\"mr-1.5\">move</span>\n                      <Kbd>E</Kbd>\n                      <span>archive</span>\n                    </span>\n                    {footer}\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n\nfunction Kbd({ children }: { children: React.ReactNode }) {\n  return <kbd className=\"grid h-[18px] min-w-[18px] place-items-center rounded border border-line-2 bg-frame px-1 font-mono text-2xs text-fg-3\">{children}</kbd>;\n}\n\nfunction InboxList({\n  items,\n  empty,\n  onOpen,\n  onArchive,\n}: {\n  items: InboxNotification[];\n  empty: [string, string];\n  onOpen: (item: InboxNotification) => void;\n  onArchive: (item: InboxNotification) => void;\n}) {\n  const reduce = useReducedMotion();\n  const now = useNow();\n  const list = useRef<HTMLUListElement>(null);\n  const [active, setActive] = useState<string | null>(null);\n  const sorted = [...items].sort((a, b) => toMs(b.time) - toMs(a.time));\n  const today = now ? startOfDay(now) : 0;\n  const groups = [\n    { key: \"today\", title: \"Today\", rows: sorted.filter((i) => toMs(i.time) >= today) },\n    { key: \"earlier\", title: \"Earlier\", rows: sorted.filter((i) => toMs(i.time) < today) },\n  ].filter((g) => g.rows.length);\n  const order = groups.flatMap((g) => g.rows);\n  // Roving focus: one row is in the tab order; arrows move between rows.\n  const current = order.some((i) => i.id === active) ? active : (order[0]?.id ?? null);\n\n  const focusRow = (id: string | undefined) => {\n    if (!id) return;\n    setActive(id);\n    list.current?.querySelector<HTMLElement>(`[data-row=\"${CSS.escape(id)}\"]`)?.focus();\n  };\n  const onKeyDown = (e: React.KeyboardEvent, item: InboxNotification) => {\n    const at = order.findIndex((i) => i.id === item.id);\n    const key = e.key.toLowerCase();\n    if (key === \"arrowdown\" || key === \"j\") focusRow(order[Math.min(order.length - 1, at + 1)]?.id);\n    else if (key === \"arrowup\" || key === \"k\") focusRow(order[Math.max(0, at - 1)]?.id);\n    else if (key === \"home\") focusRow(order[0]?.id);\n    else if (key === \"end\") focusRow(order[order.length - 1]?.id);\n    else if (key === \"e\" || key === \"delete\" || key === \"backspace\") {\n      const next = order[at + 1] ?? order[at - 1];\n      const tab = list.current?.closest(\"[role=dialog]\")?.querySelector<HTMLElement>(\"[role=tab][aria-selected=true]\");\n      onArchive(item);\n      // Keep the keyboard where the list was: on the row that slides into place, or the tab once the list is empty.\n      requestAnimationFrame(() => (next ? focusRow(next.id) : tab?.focus()));\n    } else return;\n    e.preventDefault();\n  };\n\n  if (!order.length) return <Empty title={empty[0]} body={empty[1]} />;\n\n  let row = 0;\n  return (\n    <ul ref={list} aria-label=\"Notifications\" className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain pb-1.5\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {groups.map((group) => [\n          <motion.li\n            key={`h-${group.key}`}\n            layout={reduce ? false : \"position\"}\n            exit={{ opacity: 0, transition: { duration: 0.12 } }}\n            className=\"sticky top-0 z-[1] bg-raised/95 px-3.5 pb-1 pt-3 font-mono text-2xs uppercase tracking-[0.08em] text-fg-4 backdrop-blur-[2px]\"\n            aria-hidden\n          >\n            {group.title}\n          </motion.li>,\n          ...group.rows.map((item) => {\n            const index = row++;\n            const ms = toMs(item.time);\n            return (\n              <motion.li\n                key={item.id}\n                layout={reduce ? false : \"position\"}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}\n                animate={{ opacity: 1, y: 0, x: 0 }}\n                exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, x: 24, transition: { duration: 0.18, ease: ease.in } }}\n                transition={reduce ? { duration: 0.15 } : spring.soft}\n                className=\"group/row relative px-1.5\"\n              >\n                <button\n                  type=\"button\"\n                  data-row={item.id}\n                  tabIndex={item.id === current ? 0 : -1}\n                  onFocus={() => setActive(item.id)}\n                  onClick={() => onOpen(item)}\n                  onKeyDown={(e) => onKeyDown(e, item)}\n                  title={now ? fullTime(ms) : undefined}\n                  className={cn(\n                    \"flex w-full items-start gap-2.5 rounded-lg py-2.5 pl-2 pr-12 text-left outline-none transition-colors duration-150\",\n                    \"hover:bg-hover focus-visible:bg-hover focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n                  )}\n                >\n                  {/* The unread dot lives in a fixed gutter, so reading a row changes nothing but the dot and the text color. */}\n                  <span className=\"relative mt-[11px] grid size-1.5 shrink-0 place-items-center\">\n                    <AnimatePresence initial={false}>\n                      {!item.read && (\n                        <motion.span\n                          key=\"dot\"\n                          className=\"absolute inset-0 rounded-full bg-fg\"\n                          initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.3 }}\n                          animate={{ opacity: 1, scale: 1 }}\n                          exit={\n                            reduce\n                              ? { opacity: 0, transition: { duration: 0.12 } }\n                              : { opacity: 0, scale: 0.3, transition: { duration: 0.2, ease: ease.out, delay: Math.min(index, 8) * 0.025 } }\n                          }\n                          transition={spring.pop}\n                        />\n                      )}\n                    </AnimatePresence>\n                  </span>\n                  <span\n                    aria-hidden\n                    className=\"grid size-7 shrink-0 place-items-center rounded-full bg-hover text-[10.5px] font-medium text-fg-2 shadow-[inset_0_0_0_1px_var(--line-2)] [&_svg]:size-3.5\"\n                  >\n                    {item.icon ?? (item.actor ? initials(item.actor) : <Bell />)}\n                  </span>\n                  <span className=\"min-w-0 flex-1\">\n                    <span className={cn(\"block text-[13px] leading-[1.4] transition-colors duration-300 [&_b]:font-medium\", item.read ? \"text-fg-2 [&_b]:text-fg-2\" : \"text-fg-2 [&_b]:text-fg\")}>\n                      {item.title}\n                    </span>\n                    {item.preview && <span className=\"mt-0.5 block truncate text-[12.5px] text-fg-3\">{item.preview}</span>}\n                    <span className=\"mt-0.5 hidden text-[12px] text-fg-3 tabular pointer-coarse:block\">{now ? shortTime(ms, now) : \"\"}</span>\n                    {!item.read && <span className=\"sr-only\">, unread</span>}\n                  </span>\n                </button>\n                {/* Time and archive share one box on the right: hover or focus swaps one for the other. */}\n                <span className=\"pointer-events-none absolute right-3 top-2.5 grid h-7 w-8 place-items-center\">\n                  <time\n                    dateTime={now ? new Date(ms).toISOString() : undefined}\n                    className=\"col-start-1 row-start-1 text-[12px] text-fg-3 tabular transition-opacity duration-150 group-focus-within/row:opacity-0 group-hover/row:opacity-0 pointer-coarse:hidden\"\n                  >\n                    {now ? shortTime(ms, now) : \"\"}\n                  </time>\n                  <button\n                    type=\"button\"\n                    tabIndex={-1}\n                    aria-label=\"Archive\"\n                    title=\"Archive (E)\"\n                    onClick={() => onArchive(item)}\n                    className={cn(\n                      \"pointer-events-auto col-start-1 row-start-1 grid size-7 place-items-center rounded-md text-fg-3 opacity-0 outline-none transition-[opacity,background-color,color,scale] duration-150\",\n                      \"hover:bg-line hover:text-fg active:scale-[0.9] group-focus-within/row:opacity-100 group-hover/row:opacity-100 pointer-coarse:opacity-100\",\n                      \"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n                    )}\n                  >\n                    <ArchiveIcon />\n                  </button>\n                </span>\n              </motion.li>\n            );\n          }),\n        ])}\n      </AnimatePresence>\n    </ul>\n  );\n}\n\nfunction Empty({ title, body }: { title: string; body: string }) {\n  const reduce = useReducedMotion();\n  return (\n    <motion.div\n      className=\"flex flex-1 flex-col items-center justify-center gap-1 px-6 py-12 text-center\"\n      initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.24, ease: ease.out, delay: 0.1 }}\n    >\n      <span className=\"mb-2 grid size-9 place-items-center rounded-full bg-hover text-fg-3 shadow-[inset_0_0_0_1px_var(--line-2)]\">\n        <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n          <path d=\"M4 11V7a4 4 0 0 1 8 0v4l1.25 1.25H2.75zM6.5 13.75a1.6 1.6 0 0 0 3 0\" />\n          <motion.path d=\"m6.1 7.6 1.3 1.3 2.5-2.7\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.4, ease: ease.out, delay: 0.25 }} />\n        </svg>\n      </span>\n      <p className=\"text-[13px] font-medium text-fg\">{title}</p>\n      <p className=\"max-w-[28ch] text-[12.5px] text-fg-3 [text-wrap:balance]\">{body}</p>\n    </motion.div>\n  );\n}\n\nfunction Skeleton() {\n  return (\n    <div aria-busy className=\"flex flex-col gap-1 px-3.5 py-3\" aria-label=\"Loading notifications\">\n      {[0.72, 0.9, 0.6].map((w, i) => (\n        <div key={i} className=\"flex items-start gap-2.5 py-2 pl-3.5\">\n          <span className=\"size-7 shrink-0 rounded-full bg-hover\" />\n          <span className=\"flex flex-1 flex-col gap-1.5 pt-1\">\n            <span className=\"h-2.5 rounded bg-hover\" style={{ width: `${w * 100}%` }} />\n            <span className=\"h-2.5 w-2/5 rounded bg-hover\" />\n          </span>\n        </div>\n      ))}\n    </div>\n  );\n}\n\nfunction ArchiveIcon() {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <rect x=\"2.25\" y=\"3\" width=\"11.5\" height=\"3\" rx=\".75\" />\n      <path d=\"M3.25 6v6.25c0 .4.35.75.75.75h8c.4 0 .75-.35.75-.75V6M6.5 8.75h3\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/notification-inbox"
}
