{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-centre",
  "type": "registry:component",
  "title": "Notification Centre",
  "description": "A bell whose panel grows out of it: tabs for all, mentions and unread, grouped by day, approve requests inline, archive with undo and a badge that rolls as you read.",
  "categories": [
    "component",
    "Workspace"
  ],
  "dependencies": [
    "@base-ui/react",
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/button.json",
    "https://coss.com/ui/r/tabs.json"
  ],
  "files": [
    {
      "path": "registry/devl/notification-centre.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { Popover } from \"@base-ui/react/popover\";\nimport {\n  ArchiveIcon,\n  AtSignIcon,\n  BellIcon,\n  BellOffIcon,\n  CheckCheckIcon,\n  CheckIcon,\n  CircleIcon,\n  KeyRoundIcon,\n  MessageSquareIcon,\n  RocketIcon,\n  SettingsIcon,\n  UserRoundPlusIcon,\n  XIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ReactNode, useEffect, useMemo, useRef, useState } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { Tabs, TabsList, TabsTab } from \"@/components/ui/tabs\";\nimport { cn } from \"@/lib/utils\";\n\nexport type NotificationKind = \"mention\" | \"comment\" | \"assign\" | \"access\" | \"deploy\";\nexport type NotificationTone = \"violet\" | \"sky\" | \"emerald\" | \"amber\" | \"rose\" | \"teal\";\n\nexport interface NotificationItem {\n  id: string;\n  kind: NotificationKind;\n  actor: { name: string; tone?: NotificationTone };\n  action: string;\n  target?: string;\n  snippet?: string;\n  time: string;\n  place?: string;\n  group: string;\n  read?: boolean;\n  request?: boolean;\n}\n\nexport interface NotificationCentreProps {\n  notifications: NotificationItem[];\n  defaultOpen?: boolean;\n  onRequest?: (item: NotificationItem, approved: boolean) => void;\n  onSettings?: () => void;\n  className?: string;\n}\n\nconst TONES: Record<NotificationTone, string> = {\n  violet: \"bg-violet-500/15 text-violet-700 dark:text-violet-300\",\n  sky: \"bg-sky-500/15 text-sky-700 dark:text-sky-300\",\n  emerald: \"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300\",\n  amber: \"bg-amber-500/15 text-amber-700 dark:text-amber-300\",\n  rose: \"bg-rose-500/15 text-rose-700 dark:text-rose-300\",\n  teal: \"bg-teal-500/15 text-teal-700 dark:text-teal-300\",\n};\n\nconst KIND_ICON: Record<NotificationKind, ReactNode> = {\n  mention: <AtSignIcon />,\n  comment: <MessageSquareIcon />,\n  assign: <UserRoundPlusIcon />,\n  access: <KeyRoundIcon />,\n  deploy: <RocketIcon />,\n};\n\ntype Tab = \"all\" | \"mentions\" | \"unread\";\n\nconst initials = (name: string) =>\n  name\n    .split(/\\s+/)\n    .map((w) => w[0])\n    .slice(0, 2)\n    .join(\"\")\n    .toUpperCase();\n\nexport function NotificationCentre({ notifications, defaultOpen = false, onRequest, onSettings, className }: NotificationCentreProps) {\n  const reduce = useReducedMotion();\n  const [items, setItems] = useState(notifications);\n  const [tab, setTab] = useState<Tab>(\"all\");\n  const [answered, setAnswered] = useState<Record<string, boolean>>({});\n  const [archived, setArchived] = useState<{ item: NotificationItem; index: number } | null>(null);\n  const undoTimer = useRef<number>(undefined);\n  const popupRef = useRef<HTMLDivElement>(null);\n  useEffect(() => () => clearTimeout(undoTimer.current), []);\n\n  const unread = items.filter((n) => !n.read).length;\n  const counts = {\n    all: items.length,\n    mentions: items.filter((n) => n.kind === \"mention\").length,\n    unread,\n  };\n\n  const visible = useMemo(() => {\n    const list = items.filter((n) => (tab === \"mentions\" ? n.kind === \"mention\" : tab === \"unread\" ? !n.read : true));\n    const groups: { title: string; items: NotificationItem[] }[] = [];\n    for (const n of list) {\n      const g = groups.find((x) => x.title === n.group);\n      if (g) g.items.push(n);\n      else groups.push({ title: n.group, items: [n] });\n    }\n    return groups;\n  }, [items, tab]);\n\n  const setRead = (id: string, read: boolean) => setItems((list) => list.map((n) => (n.id === id ? { ...n, read } : n)));\n  const markAll = () => setItems((list) => list.map((n) => ({ ...n, read: true })));\n  const archive = (id: string) => {\n    const index = items.findIndex((n) => n.id === id);\n    if (index < 0) return;\n    setArchived({ item: items[index], index });\n    setItems((list) => list.filter((n) => n.id !== id));\n    clearTimeout(undoTimer.current);\n    undoTimer.current = window.setTimeout(() => setArchived(null), 5000);\n  };\n  const undo = () => {\n    if (!archived) return;\n    setItems((list) => {\n      const next = [...list];\n      next.splice(Math.min(archived.index, next.length), 0, archived.item);\n      return next;\n    });\n    setArchived(null);\n    clearTimeout(undoTimer.current);\n  };\n  const answer = (item: NotificationItem, approved: boolean) => {\n    setAnswered((a) => ({ ...a, [item.id]: approved }));\n    setRead(item.id, true);\n    onRequest?.(item, approved);\n  };\n\n  const ease = [0.23, 1, 0.32, 1] as const;\n\n  return (\n    <Popover.Root defaultOpen={defaultOpen}>\n      <Popover.Trigger\n        aria-label={unread ? `Notifications, ${unread} unread` : \"Notifications\"}\n        className={cn(\n          \"relative inline-flex size-9 cursor-pointer items-center justify-center rounded-lg border bg-popover text-foreground shadow-xs/5 outline-none transition-[background-color,scale] duration-150 hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background active:scale-[0.97] data-popup-open:bg-accent sm:size-8 dark:bg-input/32\",\n          className,\n        )}\n      >\n        <BellIcon className=\"size-4\" />\n        <AnimatePresence initial={false}>\n          {unread > 0 && (\n            <motion.span\n              animate={{ opacity: 1, scale: 1 }}\n              className=\"absolute -top-1.5 -right-1.5 flex h-4 min-w-4 items-center justify-center overflow-hidden rounded-full bg-destructive px-1 font-semibold text-[10px] text-white tabular-nums shadow-xs ring-2 ring-background\"\n              exit={{ opacity: 0, scale: 0.6 }}\n              initial={{ opacity: 0, scale: 0.6 }}\n              transition={{ duration: 0.18, ease }}\n            >\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  animate={{ y: 0, opacity: 1 }}\n                  exit={{ y: -8, opacity: 0 }}\n                  initial={reduce ? false : { y: 8, opacity: 0 }}\n                  key={unread}\n                  transition={{ duration: 0.18, ease }}\n                >\n                  {unread > 9 ? \"9+\" : unread}\n                </motion.span>\n              </AnimatePresence>\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </Popover.Trigger>\n      <Popover.Portal>\n        <Popover.Positioner align=\"end\" className=\"z-50\" collisionPadding={16} sideOffset={8}>\n          <Popover.Popup\n            className=\"flex w-[min(380px,calc(100vw-2rem))] origin-(--transform-origin) flex-col overflow-hidden rounded-2xl border bg-popover text-popover-foreground shadow-lg/5 outline-none transition-[scale,opacity] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] data-ending-style:scale-[0.97] data-starting-style:scale-[0.96] data-ending-style:opacity-0 data-starting-style:opacity-0 data-ending-style:duration-150 motion-reduce:data-starting-style:scale-100\"\n            initialFocus={(type) => (type === \"keyboard\" ? true : type === \"\" ? false : popupRef.current)}\n            ref={popupRef}\n          >\n            <div className=\"flex items-center justify-between gap-2 px-4 pt-3.5 pb-2\">\n              <Popover.Title className=\"font-semibold text-sm\">Notifications</Popover.Title>\n              <div className=\"flex items-center gap-0.5\">\n                <Button className=\"text-muted-foreground\" disabled={!unread} onClick={markAll} size=\"xs\" variant=\"ghost\">\n                  <CheckCheckIcon />\n                  Mark all read\n                </Button>\n                <Button aria-label=\"Notification settings\" className=\"text-muted-foreground\" onClick={onSettings} size=\"icon-xs\" variant=\"ghost\">\n                  <SettingsIcon />\n                </Button>\n              </div>\n            </div>\n            <Tabs onValueChange={(v) => setTab(v as Tab)} value={tab}>\n              <TabsList className=\"w-full justify-start gap-1 border-b px-2.5 py-0! *:data-[slot=tabs-tab]:grow-0\" variant=\"underline\">\n                {([\"all\", \"mentions\", \"unread\"] as const).map((t) => (\n                  <TabsTab className=\"h-9 gap-1.5 px-2 text-[13px] capitalize sm:h-9 sm:text-[13px]\" key={t} value={t}>\n                    {t}\n                    <span className=\"rounded-full bg-muted px-1.5 py-px font-medium text-[10px] text-muted-foreground tabular-nums\">{counts[t]}</span>\n                  </TabsTab>\n                ))}\n              </TabsList>\n            </Tabs>\n            <div className=\"relative h-[316px] overflow-y-auto overscroll-contain\" role=\"tabpanel\">\n              {visible.length === 0 ? (\n                <div className=\"flex h-full flex-col items-center justify-center gap-1 px-8 text-center\">\n                  <span className=\"mb-2 flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground\">\n                    <BellOffIcon className=\"size-4.5\" />\n                  </span>\n                  <p className=\"font-medium text-sm\">You’re all caught up</p>\n                  <p className=\"text-pretty text-muted-foreground text-xs\">\n                    {tab === \"mentions\" ? \"When someone @mentions you, it lands here.\" : \"New replies, requests and deploys will show up here.\"}\n                  </p>\n                </div>\n              ) : (\n                <AnimatePresence initial={false}>\n                  {visible.map((group) => (\n                    <motion.section\n                      exit={{ opacity: 0 }}\n                      key={group.title}\n                      layout={reduce ? false : \"position\"}\n                      transition={{ duration: 0.2, ease }}\n                    >\n                      <h3 className=\"sticky top-0 z-20 bg-popover/95 px-4 pt-2.5 pb-1 font-medium text-[11px] text-muted-foreground uppercase tracking-wide backdrop-blur-sm\">\n                        {group.title}\n                      </h3>\n                      <AnimatePresence initial={false}>\n                        {group.items.map((n) => (\n                          <Row\n                            answered={answered[n.id]}\n                            item={n}\n                            key={n.id}\n                            onAnswer={(approved) => answer(n, approved)}\n                            onArchive={() => archive(n.id)}\n                            onOpen={() => setRead(n.id, true)}\n                            onToggleRead={() => setRead(n.id, !n.read)}\n                            reduce={!!reduce}\n                          />\n                        ))}\n                      </AnimatePresence>\n                    </motion.section>\n                  ))}\n                </AnimatePresence>\n              )}\n            </div>\n            <div className=\"relative flex h-10 items-center border-t px-4 text-xs\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {archived ? (\n                  <motion.div\n                    animate={{ opacity: 1, y: 0 }}\n                    className=\"flex w-full items-center justify-between gap-2\"\n                    exit={{ opacity: 0, y: -6 }}\n                    initial={reduce ? false : { opacity: 0, y: 6 }}\n                    key=\"undo\"\n                    transition={{ duration: 0.18, ease }}\n                  >\n                    <span className=\"truncate text-muted-foreground\">\n                      Archived <span className=\"text-foreground\">{archived.item.actor.name}</span>’s notification\n                    </span>\n                    <Button className=\"-me-2\" onClick={undo} size=\"xs\" variant=\"ghost\">\n                      Undo\n                    </Button>\n                  </motion.div>\n                ) : (\n                  <motion.p\n                    animate={{ opacity: 1, y: 0 }}\n                    className=\"text-muted-foreground\"\n                    exit={{ opacity: 0, y: -6 }}\n                    initial={reduce ? false : { opacity: 0, y: 6 }}\n                    key=\"hint\"\n                    transition={{ duration: 0.18, ease }}\n                  >\n                    {unread ? `${unread} unread` : \"Nothing unread\"}\n                  </motion.p>\n                )}\n              </AnimatePresence>\n            </div>\n          </Popover.Popup>\n        </Popover.Positioner>\n      </Popover.Portal>\n    </Popover.Root>\n  );\n}\n\ninterface RowProps {\n  item: NotificationItem;\n  answered?: boolean;\n  reduce: boolean;\n  onOpen: () => void;\n  onToggleRead: () => void;\n  onArchive: () => void;\n  onAnswer: (approved: boolean) => void;\n}\n\nfunction Row({ item, answered, reduce, onOpen, onToggleRead, onArchive, onAnswer }: RowProps) {\n  const ease = [0.23, 1, 0.32, 1] as const;\n  const tone = TONES[item.actor.tone ?? \"sky\"];\n  return (\n    <motion.div\n      className=\"overflow-hidden\"\n      exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n      layout={reduce ? false : \"position\"}\n      transition={{ duration: 0.2, ease }}\n    >\n      <div className=\"group/row relative flex gap-3 px-4 py-2.5 transition-colors duration-150 has-[[data-open]:focus-visible]:bg-accent [@media(hover:hover)]:hover:bg-accent/60\">\n        <button\n          aria-label={`${item.actor.name} ${item.action} ${item.target ?? \"\"}`.trim()}\n          className=\"absolute inset-0 z-0 cursor-pointer outline-none\"\n          data-open=\"\"\n          onClick={onOpen}\n          type=\"button\"\n        />\n        <span className=\"pointer-events-none relative mt-0.5 size-8 shrink-0\">\n          <span className={cn(\"flex size-8 items-center justify-center rounded-full font-semibold text-[11px]\", tone)}>{initials(item.actor.name)}</span>\n          <span className=\"absolute -right-1 -bottom-1 flex size-4 items-center justify-center rounded-full bg-popover text-muted-foreground ring-2 ring-popover [&_svg]:size-2.5\">\n            {KIND_ICON[item.kind]}\n          </span>\n        </span>\n        <div className=\"pointer-events-none relative min-w-0 flex-1 pe-4\">\n          <p className={cn(\"text-pretty text-[13px] leading-snug transition-colors duration-200\", item.read ? \"text-muted-foreground\" : \"text-foreground\")}>\n            <span className=\"font-medium text-foreground\">{item.actor.name}</span> {item.action}\n            {item.target && (\n              <>\n                {\" \"}\n                <span className=\"font-medium text-foreground\">{item.target}</span>\n              </>\n            )}\n          </p>\n          {item.snippet && (\n            <p className=\"mt-1 line-clamp-2 border-s-2 ps-2 text-muted-foreground text-xs leading-relaxed\">{item.snippet}</p>\n          )}\n          {item.request && (\n            <div className=\"pointer-events-auto relative z-10 mt-2 flex h-7 items-center gap-1.5 sm:h-6\">\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                {answered === undefined ? (\n                  <motion.div className=\"flex gap-1.5\" exit={{ opacity: 0, scale: 0.96 }} key=\"ask\" transition={{ duration: 0.15 }}>\n                    <Button onClick={() => onAnswer(true)} size=\"xs\">\n                      Approve\n                    </Button>\n                    <Button onClick={() => onAnswer(false)} size=\"xs\" variant=\"outline\">\n                      Decline\n                    </Button>\n                  </motion.div>\n                ) : (\n                  <motion.span\n                    animate={{ opacity: 1, x: 0 }}\n                    className=\"flex items-center gap-1 font-medium text-muted-foreground text-xs\"\n                    initial={reduce ? false : { opacity: 0, x: -4 }}\n                    key=\"done\"\n                    transition={{ duration: 0.2, ease }}\n                  >\n                    {answered ? <CheckIcon className=\"size-3.5 text-success\" /> : <XIcon className=\"size-3.5\" />}\n                    {answered ? \"Access granted\" : \"Request declined\"}\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </div>\n          )}\n          <p className=\"mt-1 text-muted-foreground text-xs tabular-nums\">\n            {item.time}\n            {item.place && <> · {item.place}</>}\n          </p>\n        </div>\n        <motion.span\n          animate={{ opacity: item.read ? 0 : 1, scale: item.read ? 0.4 : 1 }}\n          aria-hidden\n          className=\"pointer-events-none absolute top-4 right-4 size-2 rounded-full bg-info\"\n          initial={false}\n          transition={{ duration: 0.18, ease }}\n        />\n        <div className=\"absolute top-1.5 right-2 z-10 flex items-center gap-0.5 rounded-lg border bg-popover p-0.5 opacity-100 shadow-xs/5 transition-opacity duration-150 group-focus-within/row:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/row:opacity-100\">\n          <Button aria-label={item.read ? \"Mark as unread\" : \"Mark as read\"} onClick={onToggleRead} size=\"icon-xs\" title={item.read ? \"Mark as unread\" : \"Mark as read\"} variant=\"ghost\">\n            {item.read ? <CircleIcon /> : <CheckIcon />}\n          </Button>\n          <Button aria-label=\"Archive\" onClick={onArchive} size=\"icon-xs\" title=\"Archive\" variant=\"ghost\">\n            <ArchiveIcon />\n          </Button>\n        </div>\n      </div>\n    </motion.div>\n  );\n}\n"
    }
  ]
}