{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast-api",
  "title": "Toast API",
  "description": "The toast store behind the toast component: toast(), success and error helpers, undo, merging repeats and mutable deploy toasts.",
  "dependencies": [
    "@base-ui/react@^1.9.0"
  ],
  "registryDependencies": [
    "https://sproutboat.com/ikada/r/theme.json",
    "https://sproutboat.com/ikada/r/status.json"
  ],
  "files": [
    {
      "path": "registry/ikada/ui/toast-api.ts",
      "content": "/**\n * The toast store behind ToastProvider: `toast()`, `toast.success()`, undo, merging repeats and\n * mutable deploy toasts, plus the history hook. Usable outside React.\n */\nimport { Toast } from \"@base-ui/react/toast\";\nimport type { Status } from \"@/components/ikada/status\";\n\n/** Tone is the status vocabulary: it picks the glyph, the progress colour and the live-region priority. */\nexport type ToastTone = Status;\n\nexport interface ToastActionContext {\n  id: string;\n  /** Morph this toast in place, e.g. from \"Rolled back\" to \"Undone\". */\n  update: (patch: ToastPatch) => void;\n  close: () => void;\n}\n\nexport interface ToastAction {\n  label: string;\n  onClick: (ctx: ToastActionContext) => void;\n  /** `ghost` is plain underlined text (View build log); `danger` is a red button (Retry). Default ghost. */\n  variant?: \"ghost\" | \"danger\";\n}\n\nexport interface ToastOptions {\n  /** Reuse an id to update that toast instead of adding a new one. */\n  id?: string;\n  title: string;\n  description?: string;\n  /** Default `info`. `danger` is announced assertively, everything else politely. */\n  tone?: ToastTone;\n  /** The glyph spins while work is in progress. */\n  busy?: boolean;\n  /** 0 to 1. The progress line shows only while this is a number. */\n  progress?: number | null;\n  actions?: ToastAction[];\n  /**\n   * An icon-only Undo button (the ↩ hook arrow) before the dismiss ×, also bound to ⌘Z / Ctrl Z for the newest\n   * toast that has one. `label` is its accessible name. Pass `null` in an update to remove it.\n   */\n  undo?: ToastAction | null;\n  /** Milliseconds before it leaves; 0 keeps it until dismissed. Default by tone: 5s, warning 7s, danger 9s. */\n  duration?: number;\n  /** Identical toasts merge into one with a ×N counter. Default true; ignored when `id` is set. */\n  merge?: boolean;\n  /** What counts as identical. Default is tone, title and description. */\n  key?: string;\n  /** Marks a step the activity feed should skip (Queued, Building). Subscribers decide. */\n  silent?: boolean;\n  /** Free-form data for history subscribers: project, kind. */\n  meta?: ToastMeta;\n}\n\n/** Free-form facts for history subscribers: project, kind. */\nexport type ToastMeta = Record<string, string | number | boolean | undefined>;\n\nexport type ToastPatch = Partial<Omit<ToastOptions, \"id\" | \"merge\" | \"key\">>;\n\n/** What history subscribers receive: a plain snapshot, safe to keep. */\nexport interface ToastRecord {\n  id: string;\n  tone: ToastTone;\n  title: string;\n  description?: string;\n  count: number;\n  busy: boolean;\n  progress?: number;\n  silent: boolean;\n  meta?: ToastMeta;\n}\n\nexport interface ToastEvent {\n  /** `add` a new toast, `merge` another identical one arrived (count went up), `update` it morphed, `close` it left. */\n  type: \"add\" | \"merge\" | \"update\" | \"close\";\n  at: number;\n  toast: ToastRecord;\n}\n\nexport type ToastListener = (event: ToastEvent) => void;\n\n/** Stored on the Base UI toast; the renderer reads it. */\nexport interface ToastData {\n  title: string;\n  description?: string;\n  tone: ToastTone;\n  busy: boolean;\n  progress?: number;\n  actions: ToastAction[];\n  undo?: ToastAction;\n  count: number;\n}\n\ninterface Entry extends ToastRecord {\n  key: string;\n  merge: boolean;\n  actions: ToastAction[];\n  undo?: ToastAction;\n  duration: number;\n  remaining: number;\n  startedAt: number | null;\n  handle: ReturnType<typeof setTimeout> | undefined;\n}\n\nconst defaultDuration = {\n  info: 5000,\n  success: 5000,\n  neutral: 5000,\n  warning: 7000,\n  danger: 9000,\n} satisfies Record<ToastTone, number>;\n\nconst manager = Toast.createToastManager<ToastData>();\nconst entries = new Map<string, Entry>();\n/** Oldest first; the last id is the toast in front. */\nconst order: string[] = [];\nconst listeners = new Set<ToastListener>();\nconst paused = { expanded: false, hidden: false };\n\nconst keyOf = (tone: ToastTone, title: string, description?: string) => `${tone}|${title}|${description ?? \"\"}`;\n\nfunction record(e: Entry): ToastRecord {\n  return {\n    id: e.id,\n    tone: e.tone,\n    title: e.title,\n    description: e.description,\n    count: e.count,\n    busy: e.busy,\n    progress: e.progress,\n    silent: e.silent,\n    meta: e.meta,\n  };\n}\n\nfunction emit(type: ToastEvent[\"type\"], e: Entry): void {\n  const event: ToastEvent = { type, at: Date.now(), toast: record(e) };\n  for (const listener of listeners) listener(event);\n}\n\nconst dataOf = (e: Entry): ToastData => ({\n  title: e.title,\n  description: e.description,\n  tone: e.tone,\n  busy: e.busy,\n  progress: e.progress,\n  actions: e.actions,\n  undo: e.undo,\n  count: e.count,\n});\n\nfunction baseOptions(e: Entry) {\n  return {\n    id: e.id,\n    title: e.title,\n    description: e.description,\n    type: e.tone,\n    priority: e.tone === \"danger\" ? (\"high\" as const) : (\"low\" as const),\n    // Timers belong to this module: Base UI's own would not pause for a hidden tab.\n    timeout: 0,\n    data: dataOf(e),\n    onClose: () => forget(e),\n  };\n}\n\n/* ---------- timers: paused while the deck is expanded or the tab is hidden ---------- */\n\nconst isPaused = () => paused.expanded || paused.hidden;\n\nfunction resume(e: Entry): void {\n  if (e.remaining <= 0 || e.startedAt !== null || isPaused()) return;\n  e.startedAt = Date.now();\n  e.handle = setTimeout(() => manager.close(e.id), e.remaining);\n}\n\nfunction pause(e: Entry): void {\n  if (e.startedAt === null) return;\n  clearTimeout(e.handle);\n  e.remaining = Math.max(1, e.remaining - (Date.now() - e.startedAt));\n  e.startedAt = null;\n}\n\nfunction arm(e: Entry, ms: number): void {\n  clearTimeout(e.handle);\n  e.handle = undefined;\n  e.startedAt = null;\n  e.duration = ms;\n  e.remaining = ms;\n  resume(e);\n}\n\n/** Called by the provider: `expanded` follows hover and focus, `hidden` follows the page visibility. */\nfunction setPaused(reason: \"expanded\" | \"hidden\", value: boolean): void {\n  if (paused[reason] === value) return;\n  paused[reason] = value;\n  for (const e of entries.values()) (isPaused() ? pause : resume)(e);\n}\n\nfunction forget(e: Entry): void {\n  clearTimeout(e.handle);\n  if (entries.get(e.id) !== e) return;\n  entries.delete(e.id);\n  const i = order.indexOf(e.id);\n  if (i > -1) order.splice(i, 1);\n  emit(\"close\", e);\n}\n\n/* ---------- public API ---------- */\n\nfunction add(o: ToastOptions): string {\n  const tone = o.tone ?? \"info\";\n  if (o.id !== undefined && entries.has(o.id)) {\n    update(o.id, o);\n    return o.id;\n  }\n  const key = o.key ?? keyOf(tone, o.title, o.description);\n  const merge = o.merge !== false && o.id === undefined;\n  if (merge) {\n    const twin = [...entries.values()].find((e) => e.merge && e.key === key);\n    if (twin) return mergeInto(twin, o.duration);\n  }\n  const e: Entry = {\n    id: o.id ?? `sb-toast-${Math.random().toString(36).slice(2, 10)}`,\n    key,\n    merge,\n    tone,\n    title: o.title,\n    description: o.description,\n    busy: o.busy ?? false,\n    progress: o.progress ?? undefined,\n    actions: o.actions ?? [],\n    undo: o.undo ?? undefined,\n    count: 1,\n    silent: o.silent ?? false,\n    meta: o.meta,\n    duration: 0,\n    remaining: 0,\n    startedAt: null,\n    handle: undefined,\n  };\n  entries.set(e.id, e);\n  order.push(e.id);\n  manager.add(baseOptions(e));\n  arm(e, o.duration ?? defaultDuration[tone]);\n  emit(\"add\", e);\n  return e.id;\n}\n\nfunction mergeInto(e: Entry, duration?: number): string {\n  e.count += 1;\n  if (order[order.length - 1] === e.id) {\n    manager.update(e.id, { data: dataOf(e) });\n  } else {\n    // Behind others: bring it to the front by replacing it (Base UI swaps a closing toast of the same id).\n    manager.close(e.id);\n    entries.set(e.id, e);\n    order.push(e.id);\n    manager.add(baseOptions(e));\n  }\n  arm(e, duration ?? e.duration);\n  emit(\"merge\", e);\n  return e.id;\n}\n\nfunction update(id: string, patch: ToastPatch): void {\n  const e = entries.get(id);\n  if (!e) return;\n  if (patch.tone !== undefined) e.tone = patch.tone;\n  if (patch.title !== undefined) e.title = patch.title;\n  if (\"description\" in patch) e.description = patch.description;\n  if (patch.busy !== undefined) e.busy = patch.busy;\n  if (\"progress\" in patch) e.progress = patch.progress ?? undefined;\n  if (patch.actions !== undefined) e.actions = patch.actions;\n  if (\"undo\" in patch) e.undo = patch.undo ?? undefined;\n  if (patch.silent !== undefined) e.silent = patch.silent;\n  if (patch.meta !== undefined) e.meta = patch.meta;\n  if (e.merge) e.key = keyOf(e.tone, e.title, e.description);\n  const { id: _id, onClose: _onClose, ...next } = baseOptions(e);\n  manager.update(id, next);\n  // New information restarts the clock (a sticky toast stays sticky).\n  arm(e, patch.duration ?? e.duration);\n  emit(\"update\", e);\n}\n\nfunction close(id?: string): void {\n  manager.close(id);\n}\n\nexport interface UndoOptions extends Omit<ToastOptions, \"actions\" | \"undo\" | \"tone\" | \"merge\"> {\n  onUndo: () => void;\n  /** The accessible name of the icon button. Default \"Undo\". */\n  label?: string;\n  /** What the toast becomes after Undo. Default \"Undone\". */\n  confirmTitle?: string;\n  confirmDescription?: string;\n}\n\n/**\n * A success toast with an icon-only Undo button. Clicking it, or pressing ⌘Z / Ctrl Z while this is the newest\n * undoable toast, runs `onUndo` and swaps the toast in place to a confirmation.\n */\nfunction undo({ onUndo, label = \"Undo\", confirmTitle = \"Undone\", confirmDescription, ...rest }: UndoOptions): string {\n  return add({\n    duration: 8000,\n    ...rest,\n    tone: \"success\",\n    merge: false,\n    undo: {\n      label,\n      onClick: (ctx) => {\n        onUndo();\n        ctx.update({\n          title: confirmTitle,\n          description: confirmDescription,\n          tone: \"success\",\n          undo: null,\n          duration: 3500,\n          meta: { ...rest.meta, undone: true },\n        });\n      },\n    },\n  });\n}\n\n/** Run the Undo of one toast, as its button does. Returns false when it has none. */\nfunction runUndo(id: string): boolean {\n  const e = entries.get(id);\n  if (!e?.undo) return false;\n  e.undo.onClick({ id, update: (patch) => update(id, patch), close: () => close(id) });\n  return true;\n}\n\n/** ⌘Z / Ctrl Z: undo the newest toast still on screen that has an Undo. */\nfunction undoNewest(): boolean {\n  for (let i = order.length - 1; i >= 0; i--) {\n    const id = order[i];\n    if (id !== undefined && entries.get(id)?.undo) return runUndo(id);\n  }\n  return false;\n}\n\nfunction subscribe(listener: ToastListener): () => void {\n  listeners.add(listener);\n  return () => {\n    listeners.delete(listener);\n  };\n}\n\n/**\n * Call from anywhere, inside or outside React. Mount `ToastProvider` once.\n * `add` returns the id; `update(id, patch)` morphs that toast in place.\n */\nexport const toast = {\n  add,\n  update,\n  close,\n  undo,\n  /** @internal Used by the toast's Undo button and the provider's ⌘Z handler. */\n  runUndo,\n  /** @internal */\n  undoNewest,\n  subscribe,\n  /** Whether a toast with this id is still on screen. */\n  has: (id: string): boolean => entries.has(id),\n  /** @internal The Base UI manager the provider renders. */\n  manager,\n  /** @internal Provider hooks for the timers. */\n  setPaused,\n};\n\n/* ---------- deploy recipe ---------- */\n\nexport interface DeployToastOptions {\n  /** Shown as the description, e.g. \"field-notes v48\". */\n  name: string;\n  meta?: ToastMeta;\n  /** Adds a red Retry action to the failed state. */\n  onRetry?: () => void;\n}\n\nexport interface DeployToast {\n  /** The toast's id once the first step has run. */\n  readonly id: string | undefined;\n  /** The artifact reached the box and is being activated. */\n  uploaded: (progress?: number) => void;\n  /** Move the progress line without changing the step. */\n  progress: (value: number) => void;\n  live: () => void;\n  /** Red, with a Retry action when `onRetry` is given. */\n  failed: (reason?: string) => void;\n}\n\n/**\n * Uploaded, then Live, as one toast that morphs in place. The progress line shows only while the deploy\n * is in flight; Live and failed end it. Only those two end states are not `silent`.\n */\nexport function deployToast(options: DeployToastOptions): DeployToast {\n  const { name, onRetry } = options;\n  let id: string | undefined;\n  const meta = (kind: string, extra?: ToastMeta) => ({ ...options.meta, kind, name, ...extra });\n\n  function show(patch: ToastPatch & { title: string }): void {\n    const next: ToastOptions = { description: name, duration: 0, ...patch };\n    if (id !== undefined && toast.has(id)) toast.update(id, next);\n    else id = toast.add({ ...next, merge: false });\n  }\n\n  const flight: ToastPatch = { silent: true, busy: false, actions: [] };\n  return {\n    get id() {\n      return id;\n    },\n    uploaded: (progress = 0.6) =>\n      show({ ...flight, title: \"Uploaded\", tone: \"info\", busy: true, progress, meta: meta(\"deploy\") }),\n    progress: (value) => {\n      if (id !== undefined && toast.has(id)) toast.update(id, { progress: value });\n    },\n    live: () =>\n      show({\n        title: \"Live\",\n        tone: \"success\",\n        busy: false,\n        progress: null,\n        actions: [],\n        silent: false,\n        duration: 6000,\n        meta: meta(\"deploy\"),\n      }),\n    failed: (reason = \"see the build log\") =>\n      show({\n        title: \"Deploy failed\",\n        description: `${name} · ${reason}`,\n        tone: \"danger\",\n        busy: false,\n        progress: null,\n        silent: false,\n        duration: 12000,\n        actions: onRetry ? [{ label: \"Retry\", variant: \"danger\", onClick: () => onRetry() }] : [],\n        meta: meta(\"failure\", { reason }),\n      }),\n  };\n}\n",
      "type": "registry:ui",
      "target": "@components/ikada/toast-api.ts"
    }
  ],
  "docs": "Mount <ToastProvider> from the toast item once near the root; toast() works from anywhere after that.",
  "categories": [
    "feedback",
    "utilities"
  ],
  "type": "registry:ui"
}