{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "notification-stack-01",
  "type": "registry:block",
  "title": "Layered Notification Inbox",
  "description": "A layered notification stack with expand, dismiss, swipe and keyboard interactions, adapted from Kokonut UI.",
  "author": "kokonutUI",
  "registryDependencies": [],
  "dependencies": [
    "cn@0.2.6",
    "lucide-react@0.577.0"
  ],
  "files": [
    {
      "path": "licenses/notification-stack-01/LICENSE.txt",
      "type": "registry:file",
      "target": "@components/uiblocks/notification-stack-01/LICENSE.txt",
      "content": "Includes source from Blocks.so and its bundled primitives.\nSource: https://github.com/ephraimduncan/blocks\nRevision: 3f759d5171c2391253ec4c772e9e5b3db2bc7c7f\nUIBlocks modifications (block v0.1.0):\n- Added safe controlled defaults, real-time notification props and host-owned dismissal.\n- Separated deterministic, labeled local demo data from actual product notifications.\n- Retained upstream interaction patterns and reduced-motion handling while adapting local utilities.\n\nMIT License\n\nCopyright (c) 2025 Ephraim Duncan\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n\nThird-party design: Kokonut UI\nBlock: notification-stack-01 (v0.1.0)\nSource: https://github.com/kokonut-labs/kokonutui\nAuthor: kokonutUI\nRevision: 340d5741a1a48114b2765c2abf03d50225d52602\nOriginal files:\n- components/kokonutui/notification-stack.tsx\nUIBlocks modifications:\n- Added safe controlled defaults, real-time notification props and host-owned dismissal.\n- Separated deterministic, labeled local demo data from actual product notifications.\n- Retained upstream interaction patterns and reduced-motion handling while adapting local utilities.\n\nMIT License\n\nCopyright (c) 2025 kokonutUI\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
    },
    {
      "path": "content/components/notifications/notification-stack-01.tsx",
      "type": "registry:component",
      "target": "@components/uiblocks/notification-stack-01/notification-stack-01.tsx",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Notification Stack\n * @version: 1.1.0\n * @date: 2026-10-14\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n * @source: components/kokonutui/notification-stack.tsx\n * @revision: 340d5741a1a48114b2765c2abf03d50225d52602\n * Adapted for UIBlocks: explicit demo mode and host-owned live notifications.\n */\n\nimport { CalendarDays, CreditCard, Mail, MessageCircle, X } from \"lucide-react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  type ReactNode,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"./shared/lib/utils\";\n\nexport interface StackNotification {\n  id: string;\n  app: string;\n  title: string;\n  body: string;\n  time: string;\n  icon?: ReactNode;\n}\n\nconst DEFAULT_NOTIFICATIONS: StackNotification[] = [\n  {\n    id: \"n-1\",\n    app: \"Messages\",\n    title: \"Maya Chen\",\n    body: \"Pushed the fix. Can you take a look before standup?\",\n    time: \"now\",\n    icon: (\n      <span className=\"flex size-8 items-center justify-center rounded-lg bg-emerald-500 text-white\">\n        <MessageCircle className=\"size-4\" />\n      </span>\n    ),\n  },\n  {\n    id: \"n-2\",\n    app: \"Calendar\",\n    title: \"Design review in 10 min\",\n    body: \"Room 4B · Kokonut UI roadmap\",\n    time: \"9:50\",\n    icon: (\n      <span className=\"flex size-8 items-center justify-center rounded-lg bg-red-500 text-white\">\n        <CalendarDays className=\"size-4\" />\n      </span>\n    ),\n  },\n  {\n    id: \"n-3\",\n    app: \"Mail\",\n    title: \"Invoice #2041 paid\",\n    body: \"Acme Inc. paid $1,280.00. The receipt is attached.\",\n    time: \"9:12\",\n    icon: (\n      <span className=\"flex size-8 items-center justify-center rounded-lg bg-sky-500 text-white\">\n        <Mail className=\"size-4\" />\n      </span>\n    ),\n  },\n  {\n    id: \"n-4\",\n    app: \"Wallet\",\n    title: \"Card payment\",\n    body: \"$4.50 at Blue Bottle Coffee\",\n    time: \"8:31\",\n    icon: (\n      <span className=\"flex size-8 items-center justify-center rounded-lg bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900\">\n        <CreditCard className=\"size-4\" />\n      </span>\n    ),\n  },\n];\n\n// Collapsed: how much of each card behind the front one peeks out.\nconst PEEK = 10;\nconst VISIBLE_BEHIND = 2;\nconst GAP = 8;\n// Used until a card has been measured, so server and client agree.\nconst FALLBACK_HEIGHT = 76;\n\n// A swipe dismisses when it travels this far, or when it is a quick flick\n// (px per ms), whichever comes first. A flick shouldn't need to go far.\nconst DISMISS_DISTANCE = 100;\nconst DISMISS_VELOCITY = 0.11;\n// Movement before we decide whether the gesture is a horizontal swipe or a\n// vertical scroll.\nconst AXIS_LOCK = 6;\n// Snap-back spring (critically damped, so it never wobbles past rest but\n// still carries the release momentum a little further before it turns).\nconst SPRING_STIFFNESS = 520;\nconst SPRING_DAMPING = 2 * Math.sqrt(SPRING_STIFFNESS);\n// Dismiss flight: a released card keeps its own speed (px/s), never slower\n// than this, and picks up a little speed as it goes.\nconst FLIGHT_MIN_SPEED = 1400;\nconst FLIGHT_ACCEL = 4200;\n// Velocity is read from the last stretch of the gesture, not the average.\nconst VELOCITY_WINDOW_MS = 80;\nconst EXIT_MS = 200;\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\ninterface Drag {\n  pointerId: number;\n  startX: number;\n  startY: number;\n  /** Where the card already was when grabbed (mid snap-back). */\n  originX: number;\n  dx: number;\n  axis: \"x\" | \"y\" | null;\n  samples: { t: number; x: number }[];\n}\n\nconst opacityFor = (x: number) => Math.max(1 - Math.abs(x) / 240, 0.3);\n\n/** Release velocity in px/ms from the most recent pointer samples. */\nfunction releaseVelocity(samples: { t: number; x: number }[]) {\n  const last = samples.at(-1);\n  if (!last) {\n    return 0;\n  }\n  let first = last;\n  for (let i = samples.length - 1; i >= 0; i--) {\n    const sample = samples[i];\n    if (last.t - sample.t > VELOCITY_WINDOW_MS) {\n      break;\n    }\n    first = sample;\n  }\n  const dt = last.t - first.t;\n  return dt > 0 ? (last.x - first.x) / dt : 0;\n}\n\n/**\n * Which way a released card leaves, or 0 to spring back. A flick counts\n * only when it moves away from rest; flicking a dragged card back toward\n * the middle returns it.\n */\nfunction dismissDirection(dx: number, velocity: number): 1 | -1 | 0 {\n  const flick =\n    Math.abs(velocity) > DISMISS_VELOCITY &&\n    Math.sign(velocity) === Math.sign(dx);\n  if (Math.abs(dx) < DISMISS_DISTANCE && !flick) {\n    return 0;\n  }\n  return (dx === 0 ? velocity : dx) > 0 ? 1 : -1;\n}\n\nconst prefersReducedMotion = () =>\n  typeof window !== \"undefined\" &&\n  window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\nconst EMPTY_NOTIFICATIONS: readonly StackNotification[] = [];\n\nexport interface NotificationStackProps {\n  mode?: \"controlled\" | \"demo\";\n  title?: string;\n  /** Authoritative live notifications, newest first; the host owns removal. */\n  notifications?: readonly StackNotification[];\n  /** Requests dismissal. The host removes the record after its service succeeds. */\n  onDismiss?: (id: string) => void | Promise<void>;\n  className?: string;\n}\n\nexport default function NotificationStack({\n  mode = \"controlled\",\n  title = \"Notifications\",\n  notifications,\n  onDismiss,\n  className,\n}: NotificationStackProps) {\n  const [demoItems, setDemoItems] = useState<readonly StackNotification[]>(\n    DEFAULT_NOTIFICATIONS\n  );\n  const locallyManaged = mode === \"demo\" && notifications === undefined;\n  const items = locallyManaged\n    ? demoItems\n    : (notifications ?? EMPTY_NOTIFICATIONS);\n  const canDismiss = locallyManaged || Boolean(onDismiss);\n  const [pending, setPending] = useState<Set<string>>(() => new Set());\n  const requested = useRef(new Set<string>());\n  const [actionError, setActionError] = useState(\"\");\n  const mounted = useRef(true);\n  const [heights, setHeights] = useState<Record<string, number>>({});\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [pinned, setPinned] = useState(false);\n  const [leaving, setLeaving] = useState<Set<string>>(() => new Set());\n\n  const listRef = useRef<HTMLOListElement>(null);\n  const swipeRefs = useRef(new Map<string, HTMLDivElement>());\n  const focusedCard = useRef<string | null>(null);\n  const previousIds = useRef(items.map((item) => item.id));\n  const drag = useRef<Drag | null>(null);\n  const suppressClick = useRef(false);\n  const timers = useRef<number[]>([]);\n  // Where each card last sat, so a leaving card slides out from there.\n  const lastY = useRef(new Map<string, number>());\n  // Live horizontal offset of each card, and its running animation frame.\n  const swipeX = useRef(new Map<string, number>());\n  const frames = useRef(new Map<string, number>());\n  // Cards already on their way out, so a repeated Delete or a double click\n  // can't dismiss (and report) the same card twice.\n  const dismissed = useRef(new Set<string>());\n  // Ends a gesture whose pointer was released off the card before it was\n  // captured (a vertical drag that left the card).\n  const stopListening = useRef<(() => void) | null>(null);\n\n  useEffect(() => {\n    const pending = timers.current;\n    const running = frames.current;\n    mounted.current = true;\n    return () => {\n      mounted.current = false;\n      for (const id of pending) {\n        window.clearTimeout(id);\n      }\n      for (const frame of running.values()) {\n        cancelAnimationFrame(frame);\n      }\n      stopListening.current?.();\n    };\n  }, []);\n\n  // Live feeds can replace records indefinitely; release measurements and flights\n  // for removed IDs instead of retaining the entire feed's history.\n  useEffect(() => {\n    const ids = new Set(items.map((item) => item.id));\n    for (const [id, frame] of frames.current) {\n      if (!ids.has(id)) {\n        cancelAnimationFrame(frame);\n        frames.current.delete(id);\n      }\n    }\n    for (const map of [lastY.current, swipeX.current]) {\n      for (const id of map.keys()) if (!ids.has(id)) map.delete(id);\n    }\n    setHeights((previous) => {\n      if (Object.keys(previous).every((id) => ids.has(id))) return previous;\n      return Object.fromEntries(\n        Object.entries(previous).filter(([id]) => ids.has(id))\n      );\n    });\n  }, [items]);\n\n  // A controlled dismissal only moves focus once the host actually removes\n  // the focused record. This also covers records removed by a live feed.\n  useLayoutEffect(() => {\n    const ids = items.map((item) => item.id);\n    const removed = focusedCard.current;\n    if (removed && !ids.includes(removed)) {\n      focusedCard.current = null;\n      const active = document.activeElement;\n      if (\n        document.hasFocus() &&\n        (!active ||\n          active === document.body ||\n          active === document.documentElement)\n      ) {\n        const oldIndex = previousIds.current.indexOf(removed);\n        const nextId =\n          previousIds.current\n            .slice(oldIndex + 1)\n            .find((id) => ids.includes(id)) ??\n          previousIds.current\n            .slice(0, oldIndex)\n            .reverse()\n            .find((id) => ids.includes(id)) ??\n          ids[0];\n        const target = nextId\n          ? swipeRefs.current.get(nextId)?.querySelector(\"article\")\n          : listRef.current;\n        (target ?? listRef.current)?.focus({ preventScroll: true });\n      }\n    }\n    previousIds.current = ids;\n  }, [items]);\n\n  // Re-observe when host records change; ResizeObserver handles text rewrapping.\n  useLayoutEffect(() => {\n    const list = listRef.current;\n    if (!list) {\n      return;\n    }\n    const measure = (entries: { id?: string; height: number }[]) => {\n      setHeights((prev) => {\n        let next = prev;\n        for (const { id, height } of entries) {\n          if (id && height && prev[id] !== height) {\n            next = next === prev ? { ...prev } : next;\n            next[id] = height;\n          }\n        }\n        return next;\n      });\n    };\n    const cards = Array.from(list.children) as HTMLElement[];\n    measure(\n      cards.map((card) => ({ id: card.dataset.id, height: card.offsetHeight }))\n    );\n    const observer = new ResizeObserver((entries) => {\n      measure(\n        entries.map((entry) => ({\n          id: (entry.target as HTMLElement).dataset.id,\n          height: Math.round(entry.borderBoxSize[0]?.blockSize ?? 0),\n        }))\n      );\n    });\n    for (const card of cards) {\n      observer.observe(card);\n    }\n    return () => observer.disconnect();\n  }, [items]);\n\n  const expanded = (hovered || focused || pinned) && items.length > 1;\n  const heightOf = (id: string) => heights[id] ?? FALLBACK_HEIGHT;\n\n  // Cards that are on their way out no longer take up room, so the rest\n  // close the gap while the leaving card slides away.\n  const laidOut = items.filter((item) => !leaving.has(item.id));\n  const frontHeight = laidOut[0] ? heightOf(laidOut[0].id) : 0;\n  const stackHeight = expanded\n    ? laidOut.reduce((sum, item) => sum + heightOf(item.id), 0) +\n      Math.max(laidOut.length - 1, 0) * GAP\n    : frontHeight +\n      Math.min(Math.max(laidOut.length - 1, 0), VISIBLE_BEHIND) * PEEK;\n\n  const offsets: number[] = [];\n  let running = 0;\n  for (const item of laidOut) {\n    offsets.push(running);\n    running += heightOf(item.id) + GAP;\n  }\n\n  const stopFrame = (id: string) => {\n    const frame = frames.current.get(id);\n    if (frame !== undefined) {\n      cancelAnimationFrame(frame);\n      frames.current.delete(id);\n    }\n  };\n\n  const paint = (id: string, x: number) => {\n    swipeX.current.set(id, x);\n    const node = swipeRefs.current.get(id);\n    if (node) {\n      node.style.transition = \"none\";\n      node.style.transform = x === 0 ? \"\" : `translateX(${x}px)`;\n      node.style.opacity = x === 0 ? \"\" : `${opacityFor(x)}`;\n    }\n  };\n\n  /** Step a per-card simulation on rAF until it reports it is done. */\n  const run = (\n    id: string,\n    step: (dt: number) => boolean,\n    onDone?: () => void\n  ) => {\n    stopFrame(id);\n    let last = performance.now();\n    const tick = (now: number) => {\n      // Clamp the step so a dropped frame or a background tab can't fling\n      // the simulation.\n      const dt = Math.min((now - last) / 1000, 1 / 30);\n      last = now;\n      if (step(dt)) {\n        frames.current.delete(id);\n        onDone?.();\n        return;\n      }\n      frames.current.set(id, requestAnimationFrame(tick));\n    };\n    frames.current.set(id, requestAnimationFrame(tick));\n  };\n\n  /** Spring back to rest, starting with the speed the finger let go at. */\n  const snapBack = (id: string, velocity: number) => {\n    let x = swipeX.current.get(id) ?? 0;\n    let v = velocity * 1000;\n    if (prefersReducedMotion()) {\n      paint(id, 0);\n      return;\n    }\n    run(id, (dt) => {\n      const force = -SPRING_STIFFNESS * x - SPRING_DAMPING * v;\n      v += force * dt;\n      x += v * dt;\n      if (Math.abs(x) < 0.5 && Math.abs(v) < 10) {\n        paint(id, 0);\n        return true;\n      }\n      paint(id, x);\n      return false;\n    });\n  };\n\n  const requestDismiss = async (id: string) => {\n    if (!onDismiss || requested.current.has(id)) return;\n    requested.current.add(id);\n    setActionError(\"\");\n    setPending((previous) => new Set(previous).add(id));\n    try {\n      await onDismiss(id);\n    } catch (error) {\n      if (mounted.current)\n        setActionError(\n          error instanceof Error\n            ? error.message\n            : \"Notification dismissal failed. Try again.\"\n        );\n    } finally {\n      requested.current.delete(id);\n      if (mounted.current)\n        setPending((previous) => {\n          const next = new Set(previous);\n          next.delete(id);\n          return next;\n        });\n    }\n  };\n\n  const finishDismiss = (id: string) => {\n    setDemoItems((prev) => prev.filter((item) => item.id !== id));\n    lastY.current.delete(id);\n    swipeX.current.delete(id);\n    setLeaving((prev) => {\n      const next = new Set(prev);\n      next.delete(id);\n      return next;\n    });\n    if (onDismiss) void requestDismiss(id);\n  };\n\n  /**\n   * Throw the card off the side. A swipe keeps the speed it was released\n   * at (so a hard flick leaves faster than a slow drag past the threshold);\n   * a button or keyboard dismiss starts from rest at the minimum speed.\n   */\n  const dismiss = (id: string, direction: 1 | -1, velocity = 0) => {\n    if (!canDismiss || pending.has(id)) {\n      snapBack(id, 0);\n      return;\n    }\n    if (!locallyManaged) {\n      snapBack(id, 0);\n      void requestDismiss(id);\n      return;\n    }\n    if (dismissed.current.has(id)) {\n      return;\n    }\n    dismissed.current.add(id);\n    setLeaving((prev) => new Set(prev).add(id));\n    const node = swipeRefs.current.get(id);\n\n    // Keyboard and button dismissals keep focus in the stack: it moves to\n    // the next card (or the previous one, or the list once it is empty)\n    // instead of dropping to the page when this card unmounts.\n    if (node?.contains(document.activeElement)) {\n      const at = laidOut.findIndex((item) => item.id === id);\n      const next = laidOut[at + 1] ?? laidOut[at - 1];\n      const target = next\n        ? swipeRefs.current.get(next.id)?.querySelector(\"article\")\n        : listRef.current;\n      target?.focus();\n    }\n\n    if (!node || prefersReducedMotion()) {\n      stopFrame(id);\n      if (node) {\n        node.style.transition = `opacity ${EXIT_MS}ms ${EASE_OUT}`;\n        node.style.opacity = \"0\";\n      }\n      const timer = window.setTimeout(() => finishDismiss(id), EXIT_MS);\n      timers.current.push(timer);\n      return;\n    }\n\n    const distance = node.offsetWidth * 1.1;\n    let x = swipeX.current.get(id) ?? 0;\n    let speed = Math.max(Math.abs(velocity) * 1000, FLIGHT_MIN_SPEED);\n    run(\n      id,\n      (dt) => {\n        speed += FLIGHT_ACCEL * dt;\n        x += direction * speed * dt;\n        const done = Math.abs(x) >= distance;\n        paint(id, done ? direction * distance : x);\n        node.style.opacity = `${Math.max(1 - Math.abs(x) / distance, 0)}`;\n        return done;\n      },\n      () => finishDismiss(id)\n    );\n  };\n\n  const handlePointerDown = (\n    event: PointerEvent<HTMLDivElement>,\n    index: number,\n    id: string\n  ) => {\n    // Only the front card can be swiped while the stack is collapsed, and a\n    // second finger never takes over a drag that is already running.\n    if (\n      !canDismiss ||\n      pending.has(id) ||\n      drag.current ||\n      leaving.has(id) ||\n      (!expanded && index > 0)\n    ) {\n      return;\n    }\n    suppressClick.current = false;\n    if (event.pointerType === \"mouse\" && event.button !== 0) {\n      return;\n    }\n    if ((event.target as HTMLElement).closest(\"button\")) {\n      return;\n    }\n    // Catch a card that is still springing back from the last swipe.\n    stopFrame(id);\n    const { pointerId } = event;\n    const release = (up: globalThis.PointerEvent) => {\n      if (up.pointerId !== pointerId) {\n        return;\n      }\n      stopListening.current?.();\n      // The card's own handlers run first; this only clears a gesture that\n      // ended off the card, which would otherwise block the next one.\n      if (drag.current?.pointerId === pointerId) {\n        drag.current = null;\n        if (swipeX.current.get(id)) {\n          snapBack(id, 0);\n        }\n      }\n    };\n    stopListening.current?.();\n    window.addEventListener(\"pointerup\", release);\n    window.addEventListener(\"pointercancel\", release);\n    stopListening.current = () => {\n      window.removeEventListener(\"pointerup\", release);\n      window.removeEventListener(\"pointercancel\", release);\n      stopListening.current = null;\n    };\n    drag.current = {\n      pointerId: event.pointerId,\n      startX: event.clientX,\n      startY: event.clientY,\n      originX: swipeX.current.get(id) ?? 0,\n      dx: 0,\n      axis: null,\n      samples: [],\n    };\n  };\n\n  const handlePointerMove = (\n    event: PointerEvent<HTMLDivElement>,\n    id: string\n  ) => {\n    const current = drag.current;\n    if (!current || current.pointerId !== event.pointerId) {\n      return;\n    }\n    const dx = event.clientX - current.startX;\n    const dy = event.clientY - current.startY;\n\n    if (current.axis === null) {\n      if (Math.abs(dx) < AXIS_LOCK && Math.abs(dy) < AXIS_LOCK) {\n        return;\n      }\n      current.axis = Math.abs(dx) > Math.abs(dy) ? \"x\" : \"y\";\n      if (current.axis === \"x\") {\n        // Keep the swipe alive when the pointer leaves the card.\n        event.currentTarget.setPointerCapture(event.pointerId);\n      }\n    }\n    if (current.axis !== \"x\") {\n      return;\n    }\n\n    current.dx = current.originX + dx;\n    current.samples.push({ t: event.timeStamp, x: current.dx });\n    if (current.samples.length > 12) {\n      current.samples.shift();\n    }\n    // Written straight to the element: no re-render per pointer move.\n    paint(id, current.dx);\n  };\n\n  const handlePointerUp = (event: PointerEvent<HTMLDivElement>, id: string) => {\n    const current = drag.current;\n    if (!current || current.pointerId !== event.pointerId) {\n      return;\n    }\n    drag.current = null;\n    if (current.axis !== \"x\") {\n      // A tap or scroll that caught a card mid spring lets it finish.\n      if (swipeX.current.get(id)) {\n        snapBack(id, 0);\n      }\n      return;\n    }\n    suppressClick.current = true;\n\n    const velocity = releaseVelocity(current.samples);\n    const direction = dismissDirection(current.dx, velocity);\n    if (direction === 0) {\n      snapBack(id, velocity);\n      return;\n    }\n    // Never carry speed that points back toward the stack into the flight.\n    dismiss(id, direction, Math.sign(velocity) === direction ? velocity : 0);\n  };\n\n  const handlePointerCancel = (\n    event: PointerEvent<HTMLDivElement>,\n    id: string\n  ) => {\n    const current = drag.current;\n    if (!current || current.pointerId !== event.pointerId) return;\n    drag.current = null;\n    stopListening.current?.();\n    if (current.axis === \"x\") suppressClick.current = true;\n    snapBack(id, 0);\n  };\n\n  const handleCardKeyDown = (event: KeyboardEvent<HTMLElement>, id: string) => {\n    if (canDismiss && (event.key === \"Delete\" || event.key === \"Backspace\")) {\n      event.preventDefault();\n      // A held key would clear the next card too, now that focus moves to it.\n      if (event.repeat) {\n        return;\n      }\n      dismiss(id, 1);\n    }\n  };\n\n  return (\n    // biome-ignore lint/a11y: hover and focus only widen the stack; every action has its own button.\n    <div\n      className={cn(\"mx-auto w-full max-w-sm\", className)}\n      data-notifications-mode={mode}\n      onBlur={(event) => {\n        if (!event.currentTarget.contains(event.relatedTarget as Node)) {\n          setFocused(false);\n        }\n      }}\n      onFocus={() => setFocused(true)}\n      onPointerEnter={(event) => {\n        // Touch screens fire pointerenter on tap; only a real hover expands.\n        if (event.pointerType === \"mouse\") {\n          setHovered(true);\n        }\n      }}\n      onPointerLeave={(event) => {\n        if (event.pointerType === \"mouse\") {\n          setHovered(false);\n        }\n      }}\n    >\n      <div className=\"mb-3 flex h-7 items-center justify-between px-1\">\n        <p className=\"font-medium text-sm text-zinc-900 dark:text-zinc-100\">\n          {title}\n          <span className=\"ml-2 text-zinc-500 tabular-nums dark:text-zinc-400\">\n            {laidOut.length}\n          </span>\n        </p>\n        {laidOut.length > 1 && (\n          <button\n            aria-expanded={expanded}\n            className=\"rounded-md px-2 py-1 text-sm text-zinc-500 transition-colors duration-150 hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100\"\n            onClick={() => setPinned((prev) => !prev)}\n            type=\"button\"\n          >\n            {expanded ? \"Show less\" : \"Show all\"}\n          </button>\n        )}\n      </div>\n\n      {mode === \"demo\" && (\n        <p className=\"mb-3 text-xs text-zinc-500 dark:text-zinc-400\">\n          Demo data · Local notification interactions only.\n        </p>\n      )}\n      {actionError && (\n        <p\n          role=\"alert\"\n          className=\"mb-3 break-words rounded-lg bg-red-50 p-3 text-xs text-red-700 dark:bg-red-950/30 dark:text-red-300\"\n        >\n          {actionError}\n        </p>\n      )}\n      <ol\n        aria-label={title}\n        className=\"relative outline-none transition-[height] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\"\n        ref={listRef}\n        style={{ height: stackHeight }}\n        // Focus lands here when the last card is dismissed from the keyboard.\n        tabIndex={-1}\n      >\n        {items.map((item) => {\n          const index = laidOut.findIndex((card) => card.id === item.id);\n          const isLeaving = index === -1;\n          const depth = Math.max(index, 0);\n          const hiddenBehind = !(expanded || depth <= VISIBLE_BEHIND);\n          const scale = expanded || isLeaving ? 1 : 1 - depth * 0.05;\n          // Bottom-align the cards behind so a strip of each peeks out below\n          // the front card, whatever their own heights.\n          const restingY = expanded\n            ? offsets[depth]\n            : frontHeight + depth * PEEK - heightOf(item.id) * scale;\n          const y = isLeaving ? (lastY.current.get(item.id) ?? 0) : restingY;\n          if (!isLeaving) {\n            lastY.current.set(item.id, restingY);\n          }\n\n          return (\n            <li\n              className=\"absolute inset-x-0 top-0 origin-top transition-[transform,opacity] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-opacity\"\n              data-id={item.id}\n              key={item.id}\n              style={{\n                transform: `translateY(${y}px) scale(${scale})`,\n                opacity: hiddenBehind ? 0 : 1,\n                zIndex: items.length - depth,\n                pointerEvents: hiddenBehind ? \"none\" : undefined,\n              }}\n            >\n              <div\n                className=\"touch-pan-y\"\n                onLostPointerCapture={(event) => {\n                  // Touch starts with an implicit capture on the element\n                  // under the finger; moving it to this card fires a\n                  // bubbling lostpointercapture that is not the end of the\n                  // swipe.\n                  if (event.target === event.currentTarget) {\n                    handlePointerCancel(event, item.id);\n                  }\n                }}\n                onPointerCancel={(event) => handlePointerCancel(event, item.id)}\n                onPointerDown={(event) =>\n                  handlePointerDown(event, depth, item.id)\n                }\n                onPointerMove={(event) => handlePointerMove(event, item.id)}\n                onPointerUp={(event) => handlePointerUp(event, item.id)}\n                ref={(node) => {\n                  if (node) {\n                    swipeRefs.current.set(item.id, node);\n                  } else {\n                    swipeRefs.current.delete(item.id);\n                  }\n                }}\n              >\n                {/* biome-ignore lint/a11y: the card is focusable so Delete can dismiss it, and tapping it fans out the stack; both also have buttons. */}\n                <article\n                  aria-label={`${item.app}: ${item.title}. ${item.body}`}\n                  className={cn(\n                    \"group/card relative flex select-none gap-3 rounded-2xl border border-zinc-200 bg-white p-3.5 shadow-sm outline-none dark:border-zinc-800 dark:bg-zinc-900\",\n                    \"focus-visible:ring-2 focus-visible:ring-zinc-400\",\n                    !expanded &&\n                      depth === 0 &&\n                      laidOut.length > 1 &&\n                      \"cursor-pointer\"\n                  )}\n                  onFocusCapture={() => {\n                    focusedCard.current = item.id;\n                  }}\n                  onBlurCapture={(event) => {\n                    if (\n                      !event.currentTarget.contains(event.relatedTarget as Node)\n                    ) {\n                      focusedCard.current = null;\n                    }\n                  }}\n                  onClick={() => {\n                    if (suppressClick.current) {\n                      suppressClick.current = false;\n                      return;\n                    }\n                    if (!expanded) {\n                      setPinned(true);\n                    }\n                  }}\n                  onKeyDown={(event) => handleCardKeyDown(event, item.id)}\n                  tabIndex={hiddenBehind ? -1 : 0}\n                >\n                  <NotificationContent\n                    item={item}\n                    muted={!expanded && depth > 0}\n                    onDismiss={() => dismiss(item.id, 1)}\n                    tabbable={!hiddenBehind}\n                    disabled={!canDismiss}\n                    busy={pending.has(item.id)}\n                  />\n                </article>\n              </div>\n            </li>\n          );\n        })}\n      </ol>\n\n      {laidOut.length === 0 && (\n        <p className=\"py-6 text-center text-sm text-zinc-500 dark:text-zinc-400\">\n          {notifications === undefined && mode !== \"demo\"\n            ? \"No notifications supplied. Connect your notification feed.\"\n            : \"You're all caught up.\"}\n        </p>\n      )}\n    </div>\n  );\n}\n\nfunction NotificationContent({\n  item,\n  muted,\n  tabbable,\n  onDismiss,\n  disabled,\n  busy,\n}: {\n  item: StackNotification;\n  /** Cards behind the front one show only their edge, so their content\n   * fades out instead of poking through. */\n  muted: boolean;\n  tabbable: boolean;\n  onDismiss: () => void;\n  disabled: boolean;\n  busy: boolean;\n}) {\n  return (\n    <>\n      <div\n        className={cn(\n          \"flex min-w-0 flex-1 gap-3 transition-opacity duration-200\",\n          muted && \"opacity-0\"\n        )}\n      >\n        <div className=\"shrink-0\">{item.icon}</div>\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex items-baseline justify-between gap-2\">\n            <p className=\"truncate font-medium text-sm text-zinc-900 dark:text-zinc-100\">\n              {item.title}\n            </p>\n            <span className=\"max-w-[35%] shrink-0 truncate text-xs text-zinc-400 tabular-nums\">\n              {item.time}\n            </span>\n          </div>\n          <p className=\"line-clamp-2 text-sm text-zinc-500 dark:text-zinc-400\">\n            {item.body}\n          </p>\n        </div>\n      </div>\n      <button\n        aria-label={`Dismiss ${item.title}`}\n        aria-busy={busy || undefined}\n        aria-disabled={busy || undefined}\n        disabled={disabled}\n        className={cn(\n          \"disabled:cursor-not-allowed disabled:opacity-40 absolute -top-2 -left-2 flex size-6 items-center justify-center rounded-full border border-zinc-200 bg-white text-zinc-500 opacity-0 transition-opacity duration-150 hover:text-zinc-900 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 group-hover/card:opacity-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100\",\n          muted && \"hidden\"\n        )}\n        onClick={(event) => {\n          event.stopPropagation();\n          if (!busy) onDismiss();\n        }}\n        tabIndex={tabbable ? 0 : -1}\n        type=\"button\"\n      >\n        <X className=\"size-3\" />\n      </button>\n    </>\n  );\n}\n"
    },
    {
      "path": "lib/utils.ts",
      "type": "registry:component",
      "target": "@components/uiblocks/notification-stack-01/shared/lib/utils.ts",
      "content": "export { cn } from \"cn\";\n"
    }
  ],
  "categories": [
    "notifications",
    "application",
    "clean-saas"
  ],
  "docs": "Import the default component from @components/uiblocks/notification-stack-01/notification-stack-01.tsx. React 19, Tailwind CSS 4, and the standard shadcn CSS theme variables are required. This block is framework-independent React and works in TanStack Start, Vite, and Next.js projects. Connect links or navigation callbacks to your host application. The live preview opts into mode='demo'; the installed component defaults to controlled data and actions. Supply the host's records and callbacks before enabling service actions. Original design: Kokonut UI by kokonutUI, commit 340d5741a1a48114b2765c2abf03d50225d52602, source https://github.com/kokonut-labs/kokonutui. Original paths and local adaptations are recorded in meta.source. The complete upstream MIT notice is bundled in LICENSE.txt; retain it when adapting the component. See https://uiblocks.org/notifications/notification-stack-01 for content guidelines and source attribution.",
  "meta": {
    "kind": "block",
    "previewProps": {
      "mode": "demo"
    },
    "defaultMode": "controlled",
    "demoMode": "explicit",
    "includesBackend": false,
    "version": "0.1.0",
    "source": {
      "name": "Kokonut UI",
      "url": "https://github.com/kokonut-labs/kokonutui",
      "license": "MIT",
      "author": "kokonutUI",
      "revision": "340d5741a1a48114b2765c2abf03d50225d52602",
      "licenseFile": "licenses/third-party/kokonut-ui-MIT.txt",
      "files": [
        "components/kokonutui/notification-stack.tsx"
      ],
      "modifications": [
        "Added safe controlled defaults, real-time notification props and host-owned dismissal.",
        "Separated deterministic, labeled local demo data from actual product notifications.",
        "Retained upstream interaction patterns and reduced-motion handling while adapting local utilities."
      ]
    },
    "license": "MIT",
    "style": "clean-saas",
    "pageType": "application",
    "featured": true,
    "tags": [
      "notification",
      "notifications",
      "stack",
      "inbox",
      "alerts",
      "dismiss",
      "通知",
      "消息",
      "通知中心",
      "提醒"
    ],
    "contentGuidelines": [
      "Supply notifications and update them when your service state changes.",
      "Use onDismiss to request host removal; controlled mode never fabricates a successful dismissal.",
      "Use explicit demo mode for sample notifications. Verify long titles, swipe alternatives and keyboard dismissal."
    ],
    "recommendedWith": [
      "workspace-header-01",
      "task-queue-01",
      "uptime-01"
    ],
    "entry": "@components/uiblocks/notification-stack-01/notification-stack-01.tsx",
    "exportName": "default",
    "frameworkIndependent": true,
    "frameworkDependencies": [],
    "compatibleHosts": [
      "react",
      "vite",
      "tanstack-start",
      "nextjs"
    ],
    "requirements": {
      "react": ">=19",
      "tailwind": ">=4"
    },
    "previewUrl": "https://uiblocks.org/preview/notification-stack-01"
  }
}
