{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"toast-stack","type":"registry:component","title":"Toast Notification Stack","description":"A Sonner-style toast system with no dependencies: toasts fold into a deck, fan out on hover, focus or tap, and pause their progress lines while open. Drag to throw one away, success, error and info types, an action button, and a loading toast that becomes a success or error in place.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/toast-stack.tsx","content":"\"use client\";\n\n/*\n * Toast stack: a Sonner-style notification system in one file, no dependencies.\n *\n * useToasts() returns pushToast, dismiss, update, promise and a <ToastStack />\n * to render once. Toasts pile up as a deck (older ones peek out behind the\n * newest), fan out into a list on hover, focus or tap, and every one carries a\n * progress line that pauses while the stack is open. Drag a toast sideways, or\n * toward the screen edge, to throw it away. A loading toast turns into a\n * success or an error in place.\n */\n\nimport {\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type FocusEvent as ReactFocusEvent,\n  type KeyboardEvent as ReactKeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\n\n/* ---- Types -------------------------------------------------------------- */\n\nexport type ToastType = \"default\" | \"success\" | \"error\" | \"info\" | \"warning\" | \"loading\";\n\nexport type ToastAction = { label: string; onClick?: () => void };\n\nexport type ToastOptions = {\n  type?: ToastType;\n  /** A second, quieter line under the message. */\n  description?: string;\n  /** A button on the toast. Clicking it runs onClick and dismisses the toast. */\n  action?: ToastAction;\n  /** ms before this toast dismisses itself. 0 keeps it until dismissed. Overrides the hook's duration. */\n  duration?: number;\n  /** Reuse an id to update a toast that is still showing instead of adding another. */\n  id?: number;\n};\n\ntype ToastPosition = \"bottom\" | \"top\";\ntype ToastAlign = \"center\" | \"left\" | \"right\";\n\ntype Config = {\n  duration: number;\n  position: ToastPosition;\n  align: ToastAlign;\n  container: \"viewport\" | \"parent\";\n  visibleToasts: number;\n  limit: number;\n  width: number;\n};\n\ntype ToastRecord = {\n  id: number;\n  seq: number;\n  message: string;\n  description?: string;\n  type: ToastType;\n  action?: ToastAction;\n  /** Lifetime in ms. Infinity means it stays until dismissed. */\n  total: number;\n  /** Time left. Counted down by the viewport's frame loop, never rendered directly. */\n  remaining: number;\n  removed: boolean;\n  exit?: { x: number; y: number };\n};\n\ntype Snapshot = { toasts: ToastRecord[]; config: Config };\n\ntype ResultText = string | { message: string; description?: string };\n\n/* ---- Constants ---------------------------------------------------------- */\n\nconst EXIT_MS = 340;\nconst GAP = 10;\nconst PEEK = 14;\nconst FALLBACK_H = 64;\nconst EASE_OUT = \"cubic-bezier(0.22, 1, 0.36, 1)\";\nconst EASE_SPRING = \"cubic-bezier(0.22, 1.12, 0.36, 1)\";\n\nconst TONE: Record<ToastType, { fg: string; rgb: string }> = {\n  default: { fg: \"#e4e4e7\", rgb: \"228,228,231\" },\n  success: { fg: \"#4ade80\", rgb: \"74,222,128\" },\n  error: { fg: \"#f87171\", rgb: \"248,113,113\" },\n  info: { fg: \"#8ab0ff\", rgb: \"138,176,255\" },\n  warning: { fg: \"#fbbf24\", rgb: \"251,191,36\" },\n  loading: { fg: \"#c4c4cc\", rgb: \"196,196,204\" },\n};\n\nlet toastId = 0;\nlet toastSeq = 0;\n\n/* ---- Store -------------------------------------------------------------- */\n\nfunction lifetime(type: ToastType, duration: number | undefined, fallback: number): number {\n  if (duration !== undefined) return duration > 0 ? duration : Infinity;\n  return type === \"loading\" ? Infinity : fallback;\n}\n\nfunction text(r: ResultText): { message: string; description?: string } {\n  return typeof r === \"string\" ? { message: r } : r;\n}\n\nfunction createToastStore(initial: Config) {\n  let snap: Snapshot = { toasts: [], config: initial };\n  const serverSnap: Snapshot = snap;\n  const subs = new Set<() => void>();\n\n  function commit(next: Partial<Snapshot>) {\n    snap = { ...snap, ...next };\n    subs.forEach((f) => f());\n  }\n\n  function dismiss(id?: number, exit?: { x: number; y: number }) {\n    const gone: number[] = [];\n    const toasts = snap.toasts.map((t) => {\n      if (t.removed || (id !== undefined && t.id !== id)) return t;\n      gone.push(t.id);\n      return { ...t, removed: true, exit };\n    });\n    if (gone.length === 0) return;\n    commit({ toasts });\n    window.setTimeout(() => {\n      commit({ toasts: snap.toasts.filter((t) => !gone.includes(t.id)) });\n    }, EXIT_MS);\n  }\n\n  function update(id: number, patch: ToastOptions & { message?: string }) {\n    const prev = snap.toasts.find((t) => t.id === id && !t.removed);\n    if (!prev) return;\n    const type = patch.type ?? prev.type;\n    const total = lifetime(type, patch.duration, snap.config.duration);\n    const next: ToastRecord = {\n      ...prev,\n      message: patch.message ?? prev.message,\n      description: \"description\" in patch ? patch.description : prev.description,\n      action: \"action\" in patch ? patch.action : prev.action,\n      type,\n      total,\n      remaining: total,\n    };\n    commit({ toasts: snap.toasts.map((t) => (t === prev ? next : t)) });\n  }\n\n  function push(message: string, options: ToastOptions = {}): number {\n    const id = options.id ?? toastId++;\n    if (snap.toasts.some((t) => t.id === id && !t.removed)) {\n      update(id, { ...options, message });\n      return id;\n    }\n    const type = options.type ?? \"default\";\n    const total = lifetime(type, options.duration, snap.config.duration);\n    const rec: ToastRecord = {\n      id,\n      seq: toastSeq++,\n      message,\n      description: options.description,\n      type,\n      action: options.action,\n      total,\n      remaining: total,\n      removed: false,\n    };\n    commit({ toasts: [...snap.toasts, rec] });\n    const live = snap.toasts.filter((t) => !t.removed);\n    for (let k = 0; k < live.length - snap.config.limit; k++) {\n      const old = live[k];\n      if (old) dismiss(old.id);\n    }\n    return id;\n  }\n\n  function promise<T>(\n    work: Promise<T> | (() => Promise<T>),\n    msgs: {\n      loading: ResultText;\n      success: ResultText | ((value: T) => ResultText);\n      error: ResultText | ((error: unknown) => ResultText);\n    },\n    options: Omit<ToastOptions, \"type\" | \"id\"> = {},\n  ): number {\n    const first = text(msgs.loading);\n    const id = push(first.message, { ...options, type: \"loading\", description: first.description });\n    const run = typeof work === \"function\" ? work() : work;\n    run.then(\n      (value) => {\n        const r = text(typeof msgs.success === \"function\" ? msgs.success(value) : msgs.success);\n        update(id, { type: \"success\", message: r.message, description: r.description });\n      },\n      (err: unknown) => {\n        const r = text(typeof msgs.error === \"function\" ? msgs.error(err) : msgs.error);\n        update(id, { type: \"error\", message: r.message, description: r.description });\n      },\n    );\n    return id;\n  }\n\n  /** Count every running timer down by dt ms; toasts that reach zero dismiss themselves. */\n  function tick(dt: number) {\n    const expired: number[] = [];\n    for (const t of snap.toasts) {\n      if (t.removed || !Number.isFinite(t.total)) continue;\n      t.remaining -= dt;\n      if (t.remaining <= 0) expired.push(t.id);\n    }\n    expired.forEach((id) => dismiss(id));\n  }\n\n  return {\n    subscribe(cb: () => void) {\n      subs.add(cb);\n      return () => {\n        subs.delete(cb);\n      };\n    },\n    getSnapshot: () => snap,\n    getServerSnapshot: () => serverSnap,\n    configure(config: Config) {\n      const c = snap.config;\n      if (\n        c.duration === config.duration &&\n        c.position === config.position &&\n        c.align === config.align &&\n        c.container === config.container &&\n        c.visibleToasts === config.visibleToasts &&\n        c.limit === config.limit &&\n        c.width === config.width\n      )\n        return;\n      commit({ config });\n    },\n    push,\n    update,\n    dismiss,\n    promise,\n    tick,\n  };\n}\n\ntype Store = ReturnType<typeof createToastStore>;\n\n/* ---- Hook --------------------------------------------------------------- */\n\nexport function useToasts({\n  duration = 2200,\n  position = \"bottom\",\n  align = \"center\",\n  container = \"viewport\",\n  visibleToasts = 3,\n  limit = 8,\n  width = 360,\n}: {\n  /** ms before a toast auto-dismisses. */\n  duration?: number;\n  /** Which edge the stack grows from. */\n  position?: \"bottom\" | \"top\";\n  /** Where the stack sits along that edge. */\n  align?: \"center\" | \"left\" | \"right\";\n  /** \"viewport\" pins the stack to the window; \"parent\" pins it inside the nearest positioned ancestor. */\n  container?: \"viewport\" | \"parent\";\n  /** How many toasts show in the folded deck. */\n  visibleToasts?: number;\n  /** The most toasts kept at once; the oldest is dismissed past this. */\n  limit?: number;\n  /** Toast width in px (never wider than the screen). */\n  width?: number;\n} = {}) {\n  const [store] = useState(() =>\n    createToastStore({ duration, position, align, container, visibleToasts, limit, width }),\n  );\n  useEffect(() => {\n    store.configure({ duration, position, align, container, visibleToasts, limit, width });\n  }, [store, duration, position, align, container, visibleToasts, limit, width]);\n  const [ToastStack] = useState(() => {\n    const Stack = () => <Viewport store={store} />;\n    return Stack;\n  });\n  return {\n    pushToast: store.push,\n    dismiss: store.dismiss,\n    update: store.update,\n    promise: store.promise,\n    ToastStack,\n  };\n}\n\n/* ---- Styles ------------------------------------------------------------- */\n\nconst CSS = [\n  \".ts-root{position:absolute;height:0;z-index:60;pointer-events:none;outline:none;font-family:inherit}\",\n  \".ts-root[data-fixed='true']{position:fixed}\",\n  \".ts-item{position:absolute;left:0;right:0;margin-inline:auto;list-style:none;pointer-events:auto;touch-action:none;user-select:none;-webkit-user-select:none;will-change:transform,opacity}\",\n  \".ts-root[data-align='left'] .ts-item{margin-left:0;margin-right:auto}\",\n  \".ts-root[data-align='right'] .ts-item{margin-left:auto;margin-right:0}\",\n  \".ts-item[data-dead='true']{pointer-events:none}\",\n  \".ts-item::after{content:'';position:absolute;left:0;right:0;height:\" + (GAP + 20) + \"px}\",\n  \".ts-root[data-pos='bottom'] .ts-item::after{bottom:100%}\",\n  \".ts-root[data-pos='top'] .ts-item::after{top:100%}\",\n  \".ts-card{position:relative;height:100%;overflow:hidden;border-radius:16px;border:1px solid rgba(255,255,255,.1);color:#f5f5f6;background:linear-gradient(180deg,rgba(32,33,38,.94),rgba(17,18,21,.95));-webkit-backdrop-filter:blur(18px) saturate(1.5);backdrop-filter:blur(18px) saturate(1.5);box-shadow:inset 0 1px 0 rgba(255,255,255,.07),0 1px 2px rgba(0,0,0,.4),0 14px 34px -10px rgba(0,0,0,.65),0 30px 60px -24px rgba(0,0,0,.5)}\",\n  \".ts-tint{position:absolute;inset:0;pointer-events:none;border-radius:inherit}\",\n  \".ts-glow{position:absolute;inset:0;pointer-events:none;opacity:0;transition:opacity .3s ease;background:radial-gradient(200px circle at var(--mx,50%) var(--my,0%),rgba(255,255,255,.075),transparent 70%)}\",\n  \".ts-item:hover .ts-glow{opacity:1}\",\n  \".ts-inner{position:relative;display:flex;align-items:center;gap:12px;padding:13px 12px 13px 14px;transition:opacity .3s ease}\",\n  \".ts-inner[data-hide='true']{opacity:0}\",\n  \".ts-icon{flex:none;display:grid;place-items:center;width:24px;height:24px;border-radius:999px;align-self:flex-start;margin-top:-1px}\",\n  \".ts-icon svg{width:15px;height:15px;overflow:visible}\",\n  \".ts-draw{stroke-dasharray:1;stroke-dashoffset:1;animation:ts-draw .5s .12s cubic-bezier(.3,.7,.3,1) forwards}\",\n  \".ts-pop{transform-origin:center;animation:ts-pop .5s cubic-bezier(.3,1.6,.5,1) both}\",\n  \".ts-spin{transform-origin:10px 10px;animation:ts-spin .8s linear infinite}\",\n  \".ts-body{min-width:0;flex:1}\",\n  \".ts-msg{font-size:13.5px;line-height:18px;font-weight:560;letter-spacing:-.006em;color:#f5f5f6;animation:ts-text .32s \" + EASE_OUT + \" both;overflow-wrap:anywhere}\",\n  \".ts-desc{margin-top:2px;font-size:12.5px;line-height:17px;font-weight:400;color:#a0a2ab;animation:ts-text .32s .05s \" + EASE_OUT + \" both;overflow-wrap:anywhere}\",\n  \".ts-btn{flex:none;appearance:none;cursor:pointer;display:grid;place-items:center;border:0;font:inherit;color:#f5f5f6;transition:background-color .18s ease,color .18s ease,opacity .18s ease,transform .18s ease}\",\n  \".ts-btn:active{transform:scale(.94)}\",\n  \".ts-act{height:28px;padding:0 12px;border-radius:999px;font-size:12px;font-weight:600;background:rgba(255,255,255,.11);box-shadow:inset 0 0 0 1px rgba(255,255,255,.08)}\",\n  \".ts-act:hover{background:rgba(255,255,255,.2)}\",\n  \".ts-close{width:26px;height:26px;border-radius:8px;background:transparent;color:#8d8f98;opacity:0}\",\n  \".ts-close:hover{background:rgba(255,255,255,.1);color:#f5f5f6}\",\n  \".ts-item:hover .ts-close,.ts-item:focus-within .ts-close{opacity:1}\",\n  \".ts-btn:focus-visible{outline:2px solid #fff!important;outline-offset:2px!important;opacity:1}\",\n  \".ts-act:focus-visible{border-radius:999px!important}\",\n  \".ts-close:focus-visible{border-radius:8px!important}\",\n  \".ts-bar{position:absolute;left:0;right:0;bottom:0;height:2px;transform-origin:0 50%;opacity:.5;transition:opacity .25s ease}\",\n  \".ts-root[data-paused='true'] .ts-bar{opacity:1}\",\n  \"@media (pointer:coarse){.ts-close{opacity:1}.ts-act{height:40px;padding:0 16px}.ts-close{width:40px;height:40px}.ts-inner{padding-block:10px}}\",\n  \"@keyframes ts-draw{to{stroke-dashoffset:0}}\",\n  \"@keyframes ts-pop{from{transform:scale(.4);opacity:0}to{transform:scale(1);opacity:1}}\",\n  \"@keyframes ts-spin{to{transform:rotate(360deg)}}\",\n  \"@keyframes ts-text{from{opacity:0;transform:translateY(4px);filter:blur(3px)}to{opacity:1;transform:none;filter:none}}\",\n  \"@media (prefers-reduced-motion:reduce){.ts-item{transition-property:opacity!important;transition-duration:.2s!important}.ts-msg,.ts-desc,.ts-pop{animation:none}.ts-draw{animation:none;stroke-dashoffset:0}.ts-spin{animation-duration:2.4s}}\",\n].join(\"\\n\");\n\n/* ---- Pieces ------------------------------------------------------------- */\n\nfunction Glyph({ type }: { type: ToastType }) {\n  const tone = TONE[type];\n  return (\n    <span\n      className=\"ts-icon ts-pop\"\n      key={type}\n      style={{ color: tone.fg, background: \"rgba(\" + tone.rgb + \",.14)\", boxShadow: \"inset 0 0 0 1px rgba(\" + tone.rgb + \",.3)\" }}\n      aria-hidden=\"true\"\n    >\n      <svg viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        {type === \"success\" && <path className=\"ts-draw\" pathLength={1} d=\"M5.5 10.4l3 3 6-6.4\" />}\n        {type === \"error\" && <path className=\"ts-draw\" pathLength={1} d=\"M6.5 6.5l7 7M13.5 6.5l-7 7\" />}\n        {type === \"info\" && (\n          <>\n            <path d=\"M10 9v5\" />\n            <circle cx=\"10\" cy=\"5.8\" r=\"0.6\" fill=\"currentColor\" />\n          </>\n        )}\n        {type === \"warning\" && (\n          <>\n            <path d=\"M10 5.5v5.4\" />\n            <circle cx=\"10\" cy=\"14.4\" r=\"0.6\" fill=\"currentColor\" />\n          </>\n        )}\n        {type === \"loading\" && (\n          <g className=\"ts-spin\">\n            <circle cx=\"10\" cy=\"10\" r=\"6.5\" strokeOpacity=\"0.25\" />\n            <path d=\"M10 3.5a6.5 6.5 0 0 1 6.5 6.5\" />\n          </g>\n        )}\n        {type === \"default\" && <circle cx=\"10\" cy=\"10\" r=\"2.4\" fill=\"currentColor\" stroke=\"none\" />}\n      </svg>\n    </span>\n  );\n}\n\ntype ItemProps = {\n  t: ToastRecord;\n  store: Store;\n  dir: 1 | -1;\n  offset: number;\n  scale: number;\n  height: number | undefined;\n  foldedHeight: number | null;\n  folded: boolean;\n  beyond: boolean;\n  width: number;\n  onHeight: (id: number, h: number) => void;\n  registerBar: (id: number, el: HTMLElement | null) => void;\n  onDragActive: (id: number | null) => void;\n  onTap: () => void;\n  refocus: () => void;\n};\n\nfunction ToastItem({\n  t,\n  store,\n  dir,\n  offset,\n  scale,\n  height,\n  foldedHeight,\n  folded,\n  beyond,\n  width,\n  onHeight,\n  registerBar,\n  onDragActive,\n  onTap,\n  refocus,\n}: ItemProps) {\n  const liRef = useRef<HTMLLIElement>(null);\n  const innerRef = useRef<HTMLDivElement>(null);\n  const [mounted, setMounted] = useState(false);\n  const [drag, setDrag] = useState({ x: 0, y: 0, active: false });\n  const gesture = useRef<{\n    x0: number;\n    y0: number;\n    k: number;\n    lastX: number;\n    lastT: number;\n    vx: number;\n    moved: boolean;\n    touch: boolean;\n  } | null>(null);\n  const outward = -dir;\n\n  useEffect(() => {\n    let b = 0;\n    const a = requestAnimationFrame(() => {\n      b = requestAnimationFrame(() => setMounted(true));\n    });\n    return () => {\n      cancelAnimationFrame(a);\n      cancelAnimationFrame(b);\n    };\n  }, []);\n\n  useEffect(() => {\n    const el = innerRef.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => onHeight(t.id, el.offsetHeight + 2));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [t.id, onHeight]);\n\n  useEffect(() => {\n    liRef.current?.toggleAttribute(\"inert\", folded || t.removed);\n  }, [folded, t.removed]);\n\n  function leave(exit: { x: number; y: number }) {\n    store.dismiss(t.id, exit);\n    refocus();\n  }\n\n  function onPointerDown(e: ReactPointerEvent<HTMLLIElement>) {\n    if (e.button !== 0 || t.removed) return;\n    if ((e.target as HTMLElement).closest(\"button\")) return;\n    const li = e.currentTarget;\n    const rect = li.getBoundingClientRect();\n    gesture.current = {\n      x0: e.clientX,\n      y0: e.clientY,\n      k: rect.width > 0 ? li.offsetWidth / rect.width : 1,\n      lastX: e.clientX,\n      lastT: e.timeStamp,\n      vx: 0,\n      moved: false,\n      touch: e.pointerType === \"touch\",\n    };\n    li.setPointerCapture(e.pointerId);\n  }\n\n  function onPointerMove(e: ReactPointerEvent<HTMLLIElement>) {\n    const g = gesture.current;\n    const li = e.currentTarget;\n    if (!g) {\n      if (e.pointerType === \"mouse\") {\n        const r = li.getBoundingClientRect();\n        const k = r.width > 0 ? li.offsetWidth / r.width : 1;\n        li.style.setProperty(\"--mx\", (e.clientX - r.left) * k + \"px\");\n        li.style.setProperty(\"--my\", (e.clientY - r.top) * k + \"px\");\n      }\n      return;\n    }\n    const dx = (e.clientX - g.x0) * g.k;\n    const dy = (e.clientY - g.y0) * g.k;\n    if (!g.moved && Math.hypot(dx, dy) < 5) return;\n    if (!g.moved) {\n      g.moved = true;\n      onDragActive(t.id);\n    }\n    const dt = Math.max(1, e.timeStamp - g.lastT);\n    g.vx = g.vx * 0.6 + (((e.clientX - g.lastX) * g.k) / dt) * 0.4;\n    g.lastX = e.clientX;\n    g.lastT = e.timeStamp;\n    const out = dy * outward;\n    setDrag({ x: dx, y: out > 0 ? dy : dy * 0.12, active: true });\n  }\n\n  function end(e: ReactPointerEvent<HTMLLIElement>, cancelled: boolean) {\n    const g = gesture.current;\n    gesture.current = null;\n    if (!g) return;\n    if (!g.moved) {\n      if (!cancelled && g.touch) onTap();\n      return;\n    }\n    onDragActive(null);\n    const dx = (e.clientX - g.x0) * g.k;\n    const out = (e.clientY - g.y0) * g.k * outward;\n    const fast = Math.abs(g.vx) > 0.55 && Math.abs(dx) > 30;\n    if (!cancelled && (Math.abs(dx) > 96 || out > 56 || fast)) {\n      if (Math.abs(dx) >= out) leave({ x: Math.sign(dx || 1) * (width + 60), y: drag.y });\n      else leave({ x: dx, y: outward * 90 });\n      setDrag({ x: 0, y: 0, active: false });\n      return;\n    }\n    setDrag({ x: 0, y: 0, active: false });\n  }\n\n  function onKeyDown(e: ReactKeyboardEvent<HTMLLIElement>) {\n    if (e.key === \"Escape\") {\n      e.stopPropagation();\n      leave({ x: 0, y: outward * 24 });\n    }\n  }\n\n  const y = dir * offset;\n  const hh = height ?? FALLBACK_H;\n  let transform: string;\n  let opacity = 1;\n  let transition: string;\n  if (t.removed) {\n    const ex = t.exit ?? { x: 0, y: outward * 24 };\n    transform = \"translate3d(\" + ex.x + \"px,\" + (y + ex.y) + \"px,0) scale(0.95)\";\n    opacity = 0;\n    transition = \"transform \" + EXIT_MS + \"ms cubic-bezier(0.4,0,0.9,0.5), opacity 260ms ease, height 0s\";\n  } else if (!mounted) {\n    transform = \"translate3d(0,\" + (y + outward * (hh * 0.7 + 10)) + \"px,0) scale(0.94)\";\n    opacity = 0;\n    transition = \"none\";\n  } else if (drag.active) {\n    transform = \"translate3d(\" + drag.x + \"px,\" + (y + drag.y) + \"px,0) scale(\" + scale + \")\";\n    opacity = 1 - Math.min(0.55, Math.abs(drag.x) / 340);\n    transition = \"height 0.45s \" + EASE_OUT;\n  } else {\n    transform = \"translate3d(0,\" + y + \"px,0) scale(\" + scale + \")\";\n    opacity = beyond ? 0 : 1;\n    transition =\n      \"transform 0.55s \" + EASE_SPRING + \", opacity 0.35s ease, height 0.45s \" + EASE_OUT;\n  }\n  const tone = TONE[t.type];\n  const finite = Number.isFinite(t.total);\n  const shownH = folded && foldedHeight !== null ? foldedHeight : height;\n\n  return (\n    <li\n      ref={liRef}\n      className=\"ts-item\"\n      data-dead={t.removed || beyond ? \"true\" : \"false\"}\n      role={t.type === \"error\" ? \"alert\" : undefined}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={(e) => end(e, false)}\n      onPointerCancel={(e) => end(e, true)}\n      onKeyDown={onKeyDown}\n      style={{\n        [dir === -1 ? \"bottom\" : \"top\"]: 0,\n        width: \"min(\" + width + \"px, 100%)\",\n        height: shownH,\n        transform,\n        opacity,\n        transition,\n        transformOrigin: dir === -1 ? \"50% 100%\" : \"50% 0%\",\n        zIndex: 1000 - Math.round(offset),\n        cursor: drag.active ? \"grabbing\" : undefined,\n      }}\n    >\n      <div className=\"ts-card\">\n        <div\n          className=\"ts-tint\"\n          style={{ background: \"radial-gradient(240px 110px at 0% 0%, rgba(\" + tone.rgb + \",.13), transparent 72%)\" }}\n        />\n        <div className=\"ts-glow\" />\n        <div ref={innerRef} className=\"ts-inner\" data-hide={folded ? \"true\" : \"false\"}>\n          <Glyph type={t.type} />\n          <div className=\"ts-body\">\n            <div className=\"ts-msg\" key={t.type + t.message}>\n              {t.message}\n            </div>\n            {t.description ? (\n              <div className=\"ts-desc\" key={t.type + t.description}>\n                {t.description}\n              </div>\n            ) : null}\n          </div>\n          {t.action ? (\n            <button\n              type=\"button\"\n              className=\"ts-btn ts-act\"\n              onClick={() => {\n                t.action?.onClick?.();\n                leave({ x: 0, y: outward * 24 });\n              }}\n            >\n              {t.action.label}\n            </button>\n          ) : null}\n          <button\n            type=\"button\"\n            className=\"ts-btn ts-close\"\n            aria-label={\"Dismiss notification: \" + t.message}\n            onClick={() => leave({ x: 0, y: outward * 24 })}\n          >\n            <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\" aria-hidden=\"true\">\n              <path d=\"M2.5 2.5l7 7M9.5 2.5l-7 7\" />\n            </svg>\n          </button>\n        </div>\n        {finite ? (\n          <div\n            ref={(el) => registerBar(t.id, el)}\n            className=\"ts-bar\"\n            style={{ background: \"linear-gradient(90deg, rgba(\" + tone.rgb + \",.9), rgba(\" + tone.rgb + \",.35))\" }}\n          />\n        ) : null}\n      </div>\n    </li>\n  );\n}\n\n/* ---- Viewport ----------------------------------------------------------- */\n\nfunction Viewport({ store }: { store: Store }) {\n  const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getServerSnapshot);\n  const { toasts, config } = snap;\n  const rootRef = useRef<HTMLOListElement>(null);\n  const bars = useRef(new Map<number, HTMLElement>());\n  const pausedRef = useRef(false);\n  const [hovered, setHovered] = useState(false);\n  const [touchOpen, setTouchOpen] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const [dragId, setDragId] = useState<number | null>(null);\n  const [heights, setHeights] = useState<Record<number, number>>({});\n\n  const live = toasts.filter((t) => !t.removed);\n  const liveCount = live.length;\n  if (liveCount === 0 && (hovered || touchOpen || focused)) {\n    setHovered(false);\n    setTouchOpen(false);\n    setFocused(false);\n  }\n  const expanded = liveCount > 0 && (hovered || touchOpen || focused);\n  const paused = expanded || dragId !== null;\n  const timed = live.some((t) => Number.isFinite(t.total));\n\n  useEffect(() => {\n    pausedRef.current = paused;\n  }, [paused]);\n\n  useEffect(() => {\n    if (!timed) return;\n    let raf = 0;\n    let last = -1;\n    const frame = (now: number) => {\n      if (last >= 0) {\n        const dt = Math.min(64, now - last);\n        if (!pausedRef.current) store.tick(dt);\n      }\n      last = now;\n      for (const t of store.getSnapshot().toasts) {\n        const el = bars.current.get(t.id);\n        if (el && Number.isFinite(t.total)) {\n          el.style.transform = \"scaleX(\" + Math.max(0, Math.min(1, t.remaining / t.total)) + \")\";\n        }\n      }\n      raf = requestAnimationFrame(frame);\n    };\n    raf = requestAnimationFrame(frame);\n    return () => cancelAnimationFrame(raf);\n  }, [timed, store]);\n\n  useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if (e.altKey && e.code === \"KeyT\") {\n        e.preventDefault();\n        rootRef.current?.focus();\n      }\n    };\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, []);\n\n  useEffect(() => {\n    if (!touchOpen) return;\n    const onDown = (e: PointerEvent) => {\n      if (!rootRef.current?.contains(e.target as Node)) setTouchOpen(false);\n    };\n    document.addEventListener(\"pointerdown\", onDown);\n    return () => document.removeEventListener(\"pointerdown\", onDown);\n  }, [touchOpen]);\n\n  const onHeight = useCallback((id: number, h: number) => {\n    setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));\n  }, []);\n  const registerBar = useCallback((id: number, el: HTMLElement | null) => {\n    if (el) bars.current.set(id, el);\n    else bars.current.delete(id);\n  }, []);\n  const refocus = useCallback(() => {\n    const root = rootRef.current;\n    if (root && root.contains(document.activeElement)) root.focus();\n  }, []);\n  const onTap = useCallback(() => setTouchOpen((v) => !v), []);\n\n  const dir: 1 | -1 = config.position === \"bottom\" ? -1 : 1;\n  const hOf = (id: number) => heights[id] ?? FALLBACK_H;\n  const front = live[live.length - 1];\n  const frontH = front ? hOf(front.id) : FALLBACK_H;\n  const ordered = [...toasts].sort((a, b) => b.seq - a.seq);\n  const viewport = config.container === \"viewport\";\n  const edge = \"16px\";\n  const vEdge = viewport ? \"calc(\" + edge + \" + env(safe-area-inset-\" + (dir === -1 ? \"bottom\" : \"top\") + \", 0px))\" : \"max(\" + edge + \", 9%)\";\n\n  return (\n    <>\n      <style>{CSS}</style>\n      <ol\n        ref={rootRef}\n        className=\"ts-root\"\n        aria-label=\"Notifications (Alt+T)\"\n        aria-live=\"polite\"\n        aria-relevant=\"additions text\"\n        tabIndex={-1}\n        data-fixed={viewport ? \"true\" : \"false\"}\n        data-pos={config.position}\n        data-align={config.align}\n        data-paused={paused ? \"true\" : \"false\"}\n        onPointerEnter={(e) => {\n          if (e.pointerType !== \"touch\") setHovered(true);\n        }}\n        onPointerLeave={(e) => {\n          if (e.pointerType !== \"touch\") setHovered(false);\n        }}\n        onFocus={(e: ReactFocusEvent<HTMLOListElement>) => {\n          setFocused(e.target.matches(\":focus-visible\"));\n        }}\n        onBlur={(e: ReactFocusEvent<HTMLOListElement>) => {\n          if (!e.currentTarget.contains(e.relatedTarget)) setFocused(false);\n        }}\n        style={{\n          left: edge,\n          right: edge,\n          [dir === -1 ? \"bottom\" : \"top\"]:\n            vEdge,\n        }}\n      >\n        {ordered.map((t) => {\n          const newer = live.filter((x) => x.seq > t.seq);\n          const i = newer.length;\n          const expandedOffset = newer.reduce((s, x) => s + hOf(x.id) + GAP, 0);\n          const offset = expanded ? expandedOffset : i * PEEK;\n          const scale = expanded ? 1 : Math.max(0.8, 1 - i * 0.05);\n          const folded = !expanded && i > 0 && !t.removed;\n          return (\n            <ToastItem\n              key={t.id}\n              t={t}\n              store={store}\n              dir={dir}\n              offset={offset}\n              scale={scale}\n              height={heights[t.id]}\n              foldedHeight={folded ? frontH : null}\n              folded={folded}\n              beyond={!expanded && i >= config.visibleToasts && !t.removed}\n              width={config.width}\n              onHeight={onHeight}\n              registerBar={registerBar}\n              onDragActive={setDragId}\n              onTap={onTap}\n              refocus={refocus}\n            />\n          );\n        })}\n      </ol>\n    </>\n  );\n}\n","type":"registry:component","target":"components/viberdy/toast-stack.tsx"}],"meta":{"tier":"free","tags":["toast","notification","stack","feedback","swipe","promise"],"href":"https://viberdy.dev/library/toast-stack","aiPrompt":"Build a dependency-free React toast system as one \"use client\" file (plain CSS in a style tag is fine).\n\nAPI: export useToasts({ duration = 2200, position = \"bottom\" | \"top\", align = \"center\" | \"left\" | \"right\", container = \"viewport\" | \"parent\", visibleToasts = 3, limit = 8, width = 360 }) returning { pushToast, dismiss, update, promise, ToastStack }. pushToast(message: string, { type?: \"default\"|\"success\"|\"error\"|\"info\"|\"warning\"|\"loading\", description?, action?: { label, onClick? }, duration?, id? }) returns a numeric id (module counter). Reusing a live id updates that toast. duration 0 or type loading means it never auto-dismisses. promise(promiseOrFn, { loading, success, error }) pushes a loading toast and updates it in place to success/error (each message a string, { message, description } or a function of the result). ToastStack must be a stable component (render it once); state lives in a store created per hook call and read with useSyncExternalStore.\n\nVisuals: each toast is a dark glass card, width min(360px, 100%), radius 16, 1px border rgba(255,255,255,.1), gradient rgba(32,33,38,.94) to rgba(17,18,21,.95), backdrop blur 18, inset top highlight and a deep layered shadow. 13.5px/560 message, optional 12.5px #a0a2ab description, a 24px tinted circular icon (success #4ade80 with a drawn check, error #f87171 with a drawn cross, info #8ab0ff, warning #fbbf24, loading a spinning arc), a faint tinted radial wash from the top-left, a pointer-following white spotlight on hover, a 2px progress line along the bottom edge in the type colour that pauses (brightens) while the stack is open, an optional pill action button, and a close button revealed on hover or focus (always visible on coarse pointers).\n\nStack: an ol anchored to the bottom or top edge 16px in. Newest toast in front; folded state offsets each older toast 14px away from the edge, scales it 1 - 0.05 per level (min 0.8), gives it the front toast's height, hides its content and makes it inert; toasts past visibleToasts fade out. Expanded state (pointer hover, keyboard focus-visible inside, or a tap on a touch device which toggles and closes on outside tap) lays them out in a column with a 10px gap using measured heights (ResizeObserver on each toast's content). A transparent ::after on each toast bridges the gaps so hover does not flicker. Transitions: transform .55s cubic-bezier(.22,1.12,.36,1), opacity .35s, height .45s cubic-bezier(.22,1,.36,1). Enter: mount offscreen toward the edge (0.7 x height + 10px, scale .94, opacity 0) and transition in on the second frame. Exit: 340ms slide toward the edge or in the throw direction, fade, scale .95, then remove from the store.\n\nTimers: one rAF loop inside a mount effect (only while any finite toast is live) counts every live toast's remaining time down by the frame delta (capped at 64ms), skipping frames while the stack is expanded or a toast is being dragged, and writes scaleX(remaining/total) to each progress line. Updating a toast restores its full time.\n\nGestures: pointer events with capture on the toast (not on its buttons), touch-action none. Past 5px it follows the pointer horizontally and, toward the edge only, vertically (away from the edge at 0.12 resistance), fading as |dx| grows to 55% at 340px. Release with |dx| > 96, edge pull > 56, or flick speed > 0.55px/ms with |dx| > 30 dismisses in that direction; else it springs back. Map pointer deltas to layout pixels using offsetWidth / getBoundingClientRect().width.\n\nA11y: the ol has aria-live polite, aria-relevant additions text, aria-label \"Notifications (Alt+T)\", tabIndex -1; Alt+T focuses it. Error toasts get role alert. Escape on a focused toast dismisses it; after a button dismiss, refocus the ol so focus is not lost. Focus rings are white 2px with offset. Buttons are 40px on coarse pointers. prefers-reduced-motion: opacity-only transitions, no icon or text animation, slower spinner.","license":"https://viberdy.dev/license"}}