{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "toast",
  "title": "Toast",
  "description": "Toasts as a deck at the bottom end: hover or focus fans out the history, ↩ undoes (⌘Z), a flick dismisses.",
  "dependencies": [
    "@base-ui/react@^1.9.0"
  ],
  "registryDependencies": [
    "https://sproutboat.com/ikada/r/theme.json",
    "https://sproutboat.com/ikada/r/button.json",
    "https://sproutboat.com/ikada/r/cn.json",
    "https://sproutboat.com/ikada/r/crossfade.json",
    "https://sproutboat.com/ikada/r/icons.json",
    "https://sproutboat.com/ikada/r/status.json",
    "https://sproutboat.com/ikada/r/status-glyph.json",
    "https://sproutboat.com/ikada/r/toast-api.json",
    "https://sproutboat.com/ikada/r/tooltip.json"
  ],
  "files": [
    {
      "path": "registry/ikada/ui/toast.tsx",
      "content": "\"use client\";\n\n/**\n * Toasts as a deck at the bottom end: hover or focus fans out the history, ↩ undoes (⌘Z), a flick\n * dismisses. They arrive on the snappy spring, leave in 110ms, and success sprouts as it lands.\n */\nimport { Toast } from \"@base-ui/react/toast\";\nimport { useEffect, useRef, useState } from \"react\";\nimport type { PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { cn, focusRing, pressMotion } from \"@/components/ikada/lib/cn\";\nimport { Crossfade } from \"@/components/ikada/lib/crossfade\";\nimport { CloseIcon, UndoIcon } from \"@/components/ikada/lib/icons\";\nimport { buttonStyles } from \"@/components/ikada/button\";\nimport { dotColor } from \"@/components/ikada/status\";\nimport { StatusGlyph } from \"@/components/ikada/status-glyph\";\nimport { Tooltip } from \"@/components/ikada/tooltip\";\nimport { toast } from \"@/components/ikada/toast-api\";\nimport type { ToastAction, ToastData, ToastEvent, ToastTone } from \"@/components/ikada/toast-api\";\n\nexport { deployToast, toast } from \"@/components/ikada/toast-api\";\nexport type {\n  DeployToast,\n  DeployToastOptions,\n  ToastAction,\n  ToastActionContext,\n  ToastEvent,\n  ToastListener,\n  ToastOptions,\n  ToastPatch,\n  ToastRecord,\n  ToastTone,\n  UndoOptions,\n} from \"@/components/ikada/toast-api\";\n\n/** The Base UI toast manager for the nearest ToastProvider. Prefer the `toast` object for app code. */\nexport function useToast() {\n  return Toast.useToastManager<ToastData>();\n}\n\ntype BaseToast = Toast.Root.ToastObject<ToastData>;\n\n/** A flick is a fast, short horizontal release: 0.4px per ms, at least 16px. A slow drag needs 40px (Base UI). */\nconst FLICK_SPEED = 0.4;\nconst FLICK_MIN = 16;\n\nconst rootBase = [\n  \"absolute inset-x-0 bottom-0 origin-top touch-pan-y select-none rounded-xl\",\n  \"[--peek:10px] [--gap:8px]\",\n  \"[--x:var(--toast-swipe-movement-x)]\",\n  \"[transform:translate3d(calc(var(--x)+var(--ex)),calc(var(--y)+var(--ey)),0)_scale(var(--s))]\",\n  \"[opacity:calc(var(--o-deck)*var(--o-life))]\",\n  // Arriving and every deck move ride the snappy spring; opacity and the height of a morph ease out.\n  \"[transition:transform_var(--sb-duration-spring-snappy)_var(--sb-spring-snappy),opacity_var(--sb-duration-toast-in)_var(--ease-out),height_var(--sb-duration-toast-in)_var(--ease-out)]\",\n  // Reduced motion: the deck stays where it is and only fades.\n  \"motion-reduce:[transition:opacity_var(--sb-duration-toast-in)_linear,height_var(--sb-duration-toast-in)_linear]\",\n  // A drag let go short of dismissal returns on the bob spring.\n  \"data-released:[transition:transform_var(--sb-duration-spring-bob)_var(--sb-spring-bob),opacity_var(--sb-duration-toast-in)_var(--ease-out),height_var(--sb-duration-toast-in)_var(--ease-out)]\",\n  \"data-swiping:cursor-grabbing\",\n  focusRing,\n  // Invisible bridge across the gap so the pointer can cross from one fanned-out toast to the next.\n  \"after:absolute after:inset-x-0 after:top-full after:h-[calc(var(--gap)+1px)] after:content-['']\",\n].join(\" \");\n\nfunction rootState(state: Toast.Root.State, flick: string | undefined): string {\n  const leaving = state.transitionStatus === \"ending\";\n  const entering = state.transitionStatus === \"starting\";\n  const swiped = leaving && (state.swipeDirection !== undefined || flick !== undefined);\n  return cn(\n    state.expanded\n      ? \"h-(--toast-height) [--s:1] [--y:calc(var(--toast-swipe-movement-y)-var(--toast-offset-y)-var(--toast-index)*var(--gap))] [--o-deck:1]\"\n      : [\n          \"h-[var(--toast-frontmost-height,var(--toast-height))]\",\n          \"[--s:max(0,calc(1-var(--toast-index)*0.05))]\",\n          \"[--y:calc(var(--toast-swipe-movement-y)-var(--toast-index)*var(--peek))]\",\n          // The front three show; the rest are fully faded (3 minus index, clamped to 0..1).\n          \"[--o-deck:clamp(0,calc(3-var(--toast-index)),1)]\",\n        ],\n    // Entering and leaving: slide from below by a full height, or fly sideways after a swipe or flick.\n    entering || (leaving && !swiped) ? \"[--ey:calc(100%*var(--sb-move))]\" : \"[--ey:0px]\",\n    swiped ? \"[--ex:120%]\" : \"[--ex:0px]\",\n    swiped && (state.swipeDirection === \"left\" || flick === \"left\") && \"[--ex:-120%]\",\n    entering || leaving || state.limited ? \"[--o-life:0]\" : \"[--o-life:1]\",\n    // Leaving is quick and plain: 110ms ease-out, no spring.\n    leaving &&\n      \"[transition:transform_var(--sb-duration-toast-out)_var(--ease-out),opacity_var(--sb-duration-toast-out)_var(--ease-out),height_var(--sb-duration-toast-out)_var(--ease-out)]\",\n    state.swiping && \"transition-none!\",\n  );\n}\n\nfunction Glyph({ tone, busy }: { tone: ToastTone; busy: boolean }) {\n  return (\n    <Crossfade\n      kind=\"slot\"\n      layerKey={`${tone}:${busy}`}\n      value={{ tone, busy }}\n      settleMs={300}\n      render={(v) =>\n        v.tone === \"info\" && !v.busy ? (\n          // Info at rest is a full ring; the open ring is the one that spins while work is in progress.\n          <span\n            aria-hidden=\"true\"\n            data-glyph=\"info\"\n            className=\"inline-block size-2.5 rounded-full border-2 border-info-dot\"\n          />\n        ) : (\n          // Success sprouts as it lands (on arrival, or when a busy toast turns into a success).\n          <StatusGlyph tone={v.tone} pulse={v.busy} sprout={v.tone === \"success\" && !v.busy} />\n        )\n      }\n    />\n  );\n}\n\ninterface Face {\n  title: string;\n  description?: string;\n  count: number;\n  actions: ToastData[\"actions\"];\n}\n\nfunction CountBadge({ count }: { count: number }) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const first = useRef(true);\n  useEffect(() => {\n    if (first.current) {\n      first.current = false;\n      return;\n    }\n    const el = ref.current;\n    if (!el || !(\"animate\" in el)) return;\n    if (matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n    el.animate([{ scale: 1 }, { scale: 1.25 }, { scale: 1 }], {\n      duration: 240,\n      easing: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n    });\n  }, [count]);\n  if (count < 2) return null;\n  return (\n    <span\n      ref={ref}\n      className=\"inline-flex min-h-[18px] shrink-0 items-center rounded-full bg-well px-1.5 text-sb-xs font-semibold tabular-nums text-fg shadow-button\"\n    >\n      <span aria-hidden=\"true\">×{count}</span>\n      <span className=\"sr-only\">, {count} times</span>\n    </span>\n  );\n}\n\n/** ⌘Z on Apple platforms, Ctrl Z elsewhere. */\nfunction undoShortcut(): { text: string; keys: string } {\n  const mac = /Mac|iPhone|iPad/.test(globalThis.navigator?.userAgent ?? \"\");\n  return mac ? { text: \"⌘Z\", keys: \"Meta+Z\" } : { text: \"Ctrl Z\", keys: \"Control+Z\" };\n}\n\nfunction UndoButton({ undo, id }: { undo: ToastAction; id: string }) {\n  const shortcut = undoShortcut();\n  return (\n    <Tooltip\n      content={\n        <span className=\"inline-flex items-center gap-2\">\n          {undo.label}\n          <kbd aria-hidden=\"true\">{shortcut.text}</kbd>\n        </span>\n      }\n    >\n      <button\n        type=\"button\"\n        aria-label={undo.label}\n        aria-keyshortcuts={shortcut.keys}\n        data-sb-undo=\"\"\n        onClick={() => toast.runUndo(id)}\n        className={cn(\n          \"relative -mt-0.5 inline-grid size-7 shrink-0 place-items-center self-start rounded-full text-fg-muted hover:bg-hover hover:text-fg\",\n          pressMotion,\n          // 28px visual, 40px hit area.\n          \"after:absolute after:-inset-1.5 after:rounded-full after:content-['']\",\n          focusRing,\n        )}\n      >\n        {/* Optical centring: the hook's bounding box sits 0.75px low of centre, so nudge it up. */}\n        <UndoIcon className=\"translate-x-[0.25px] -translate-y-[0.5px]\" />\n      </button>\n    </Tooltip>\n  );\n}\n\nfunction ToastActions({ face, id, live }: { face: Face; id: string; live: boolean }) {\n  if (face.actions.length === 0) return null;\n  return (\n    <div className=\"mt-2 flex flex-wrap gap-1.5\">\n      {face.actions.map((action) => {\n        const ghost = (action.variant ?? \"ghost\") === \"ghost\";\n        return (\n          <button\n            key={action.label}\n            type=\"button\"\n            tabIndex={live ? undefined : -1}\n            onClick={() =>\n              action.onClick({\n                id,\n                update: (patch) => toast.update(id, patch),\n                close: () => toast.close(id),\n              })\n            }\n            className={cn(\n              buttonStyles({ variant: ghost ? \"ghost\" : \"danger\", size: \"sm\" }),\n              \"after:absolute after:-inset-x-0.5 after:-inset-y-1.5 after:content-['']\",\n              ghost && \"-ms-2 px-2 text-fg underline decoration-fg-muted decoration-1 underline-offset-3 hover:text-fg\",\n            )}\n          >\n            {action.label}\n          </button>\n        );\n      })}\n    </div>\n  );\n}\n\nfunction Face({ face, id, live }: { face: Face; id: string; live: boolean }) {\n  return (\n    <div className=\"py-px\">\n      <div className=\"flex min-h-6 items-center gap-1.5 text-sb-base font-semibold\">\n        {live ? (\n          <Toast.Title className=\"min-w-0 text-balance wrap-anywhere\" />\n        ) : (\n          <div className=\"min-w-0 text-balance wrap-anywhere\">{face.title}</div>\n        )}\n        <CountBadge count={face.count} />\n      </div>\n      {face.description ? (\n        live ? (\n          <Toast.Description className=\"mt-px text-sb-sm text-fg-muted wrap-anywhere\" />\n        ) : (\n          <div className=\"mt-px text-sb-sm text-fg-muted wrap-anywhere\">{face.description}</div>\n        )\n      ) : null}\n      <ToastActions face={face} id={id} live={live} />\n    </div>\n  );\n}\n\n/** Only while progress is a number. The bar keeps its last value while it fades, so it never shrinks on the way out. */\nfunction ProgressLine({ progress, tone }: { progress: number | undefined; tone: ToastTone }) {\n  const shown = progress !== undefined;\n  const [last, setLast] = useState(progress ?? 0);\n  if (progress !== undefined && progress !== last) setLast(progress);\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-progress={shown ? \"\" : undefined}\n      className=\"pointer-events-none absolute inset-x-0 bottom-0 h-[3px]\"\n    >\n      <span\n        className={cn(\n          \"absolute inset-0 bg-hairline transition-opacity duration-300 ease-out\",\n          shown ? \"opacity-80\" : \"opacity-0\",\n        )}\n      />\n      <span\n        className={cn(\n          \"absolute inset-0 origin-left transition-[scale,opacity] duration-600 ease-out\",\n          dotColor[tone],\n          shown ? \"opacity-100\" : \"opacity-0\",\n        )}\n        style={{ scale: `${last} 1` }}\n      />\n    </span>\n  );\n}\n\nfunction ToastItem({ item }: { item: BaseToast }) {\n  const data: ToastData = item.data ?? { title: \"\", tone: \"info\", busy: false, actions: [], count: 1 };\n  const [flick, setFlick] = useState<\"left\" | \"right\" | undefined>(undefined);\n  const [released, setReleased] = useState(false);\n  const releaseTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  useEffect(() => () => clearTimeout(releaseTimer.current), []);\n\n  /** Base UI dismisses a slow drag past 40px; this adds the flick, and a spring on the way back. */\n  function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {\n    if (event.button !== 0 || (event.target instanceof Element && event.target.closest(\"button, a, input, textarea\")))\n      return;\n    clearTimeout(releaseTimer.current);\n    setReleased(false);\n    const doc = event.currentTarget.ownerDocument;\n    const startX = event.clientX;\n    const samples = [{ x: event.clientX, t: event.timeStamp }];\n    const move = (e: PointerEvent) => {\n      samples.push({ x: e.clientX, t: e.timeStamp });\n      while (samples.length > 2 && e.timeStamp - (samples[0]?.t ?? 0) > 100) samples.shift();\n    };\n    const stop = () => {\n      doc.removeEventListener(\"pointermove\", move);\n      doc.removeEventListener(\"pointerup\", up);\n      doc.removeEventListener(\"pointercancel\", stop);\n    };\n    const up = (e: PointerEvent) => {\n      stop();\n      const dx = e.clientX - startX;\n      const a = samples[0];\n      const b = samples[samples.length - 1];\n      const speed = a && b && b.t > a.t ? (b.x - a.x) / (b.t - a.t) : 0;\n      if (Math.abs(speed) >= FLICK_SPEED && Math.abs(dx) >= FLICK_MIN && Math.sign(speed) === Math.sign(dx)) {\n        setFlick(dx > 0 ? \"right\" : \"left\");\n        toast.close(item.id);\n      } else if (Math.abs(dx) > 1) {\n        setReleased(true);\n        releaseTimer.current = setTimeout(() => setReleased(false), 600);\n      }\n    };\n    doc.addEventListener(\"pointermove\", move);\n    doc.addEventListener(\"pointerup\", up);\n    doc.addEventListener(\"pointercancel\", stop);\n  }\n\n  const face: Face = {\n    title: data.title,\n    description: data.description,\n    count: data.count,\n    actions: data.actions,\n  };\n  const faceKey = [data.tone, face.title, face.description ?? \"\", data.actions.map((a) => a.label).join(\"/\")].join(\n    \"\\u0001\",\n  );\n\n  return (\n    <Toast.Root\n      data-slot=\"toast\"\n      toast={item}\n      swipeDirection={[\"left\", \"right\"]}\n      data-sb-toast=\"\"\n      data-tone={data.tone}\n      data-flick={flick}\n      data-released={released ? \"\" : undefined}\n      onPointerDown={onPointerDown}\n      className={(state) => cn(rootBase, rootState(state, flick))}\n      style={{ zIndex: `calc(100 - var(--toast-index))` }}\n    >\n      <Toast.Content\n        className={(state) =>\n          cn(\n            \"relative size-full overflow-hidden rounded-xl bg-raised text-fg shadow-float\",\n            state.behind && !state.expanded ? \"[--c-op:0]\" : \"[--c-op:1]\",\n          )\n        }\n      >\n        <div\n          className={cn(\n            \"grid gap-2.5 py-3 ps-3 pe-2.5 opacity-(--c-op) transition-opacity duration-(--sb-duration-toast-in) ease-out\",\n            data.undo\n              ? \"grid-cols-[1.5rem_minmax(0,1fr)_1.75rem_1.5rem] gap-x-2\"\n              : \"grid-cols-[1.5rem_minmax(0,1fr)_1.5rem]\",\n          )}\n        >\n          <Glyph tone={data.tone} busy={data.busy} />\n          <Crossfade layerKey={faceKey} value={face} render={(f, live) => <Face face={f} id={item.id} live={live} />} />\n          {data.undo ? <UndoButton undo={data.undo} id={item.id} /> : null}\n          <Toast.Close\n            aria-label=\"Dismiss notification\"\n            className={cn(\n              \"relative inline-grid size-6 shrink-0 place-items-center self-start rounded-md text-fg-muted hover:bg-hover hover:text-fg\",\n              pressMotion,\n              \"after:absolute after:-inset-2 after:content-[''] pointer-coarse:after:-inset-3\",\n              focusRing,\n            )}\n          >\n            <CloseIcon className=\"size-3.5\" />\n          </Toast.Close>\n        </div>\n        <ProgressLine progress={data.progress} tone={data.tone} />\n      </Toast.Content>\n    </Toast.Root>\n  );\n}\n\nfunction ToastList() {\n  const { toasts } = Toast.useToastManager<ToastData>();\n  return toasts.map((item) => <ToastItem key={item.id} item={item} />);\n}\n\nexport interface ToastProviderProps {\n  children?: ReactNode;\n  /** The history hook: called for every add, merge, update and close. For an Activity feed or a bell. */\n  onToast?: (event: ToastEvent) => void;\n  /** Toasts kept on screen at once; older ones are dropped. Default 5. */\n  limit?: number;\n  /** Accessible name of the region. */\n  label?: string;\n}\n\n/**\n * Mount once near the app root. The viewport is a labelled region at the bottom end; Alt+T (or F6) moves\n * focus to the newest toast, Escape dismisses the focused one. Hover or focus fans the deck out, and timers\n * stop while it is out or the tab is hidden.\n */\nexport function ToastProvider({ children, onToast, limit = 5, label = \"Notifications (Alt+T)\" }: ToastProviderProps) {\n  const [viewport, setViewport] = useState<HTMLDivElement | null>(null);\n  const onToastRef = useRef(onToast);\n  useEffect(() => {\n    onToastRef.current = onToast;\n  });\n\n  useEffect(() => toast.subscribe((event) => onToastRef.current?.(event)), []);\n\n  useEffect(() => {\n    const hidden = () => toast.setPaused(\"hidden\", document.hidden);\n    hidden();\n    document.addEventListener(\"visibilitychange\", hidden);\n    // Alt+T: Base UI already moves focus into the region on F6, so reuse that, then land on the newest toast.\n    const onKey = (event: KeyboardEvent) => {\n      if (!(event.altKey && event.code === \"KeyT\")) return;\n      const front = viewport?.querySelector<HTMLElement>(\"[data-sb-toast]:not([data-ending-style])\");\n      if (!front) return;\n      event.preventDefault();\n      window.dispatchEvent(new KeyboardEvent(\"keydown\", { key: \"F6\", bubbles: true, cancelable: true }));\n      front.focus({ preventScroll: true });\n    };\n    window.addEventListener(\"keydown\", onKey);\n    // ⌘Z / Ctrl Z undoes the newest undoable toast, but never steals the browser undo from a text field.\n    const onUndoKey = (event: KeyboardEvent) => {\n      if (event.defaultPrevented || event.repeat) return;\n      if (!(event.metaKey || event.ctrlKey) || event.shiftKey || event.altKey || event.key.toLowerCase() !== \"z\")\n        return;\n      const target = event.target;\n      if (\n        target instanceof Element &&\n        target.closest(\"input, textarea, select, [contenteditable]:not([contenteditable=false])\")\n      )\n        return;\n      if (toast.undoNewest()) event.preventDefault();\n    };\n    window.addEventListener(\"keydown\", onUndoKey);\n    return () => {\n      document.removeEventListener(\"visibilitychange\", hidden);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"keydown\", onUndoKey);\n    };\n  }, [viewport]);\n\n  // The viewport carries data-expanded while hovered or focused; mirror it into the timers.\n  useEffect(() => {\n    const el = viewport;\n    if (!el) return undefined;\n    const sync = () => toast.setPaused(\"expanded\", el.hasAttribute(\"data-expanded\"));\n    const observer = new MutationObserver(sync);\n    observer.observe(el, { attributes: true, attributeFilter: [\"data-expanded\"] });\n    sync();\n    return () => observer.disconnect();\n  }, [viewport]);\n\n  return (\n    <Toast.Provider toastManager={toast.manager} timeout={0} limit={limit}>\n      {children}\n      <Toast.Portal>\n        <Toast.Viewport\n          data-slot=\"toast-viewport\"\n          ref={setViewport}\n          aria-label={label}\n          aria-keyshortcuts=\"Alt+T F6\"\n          className=\"fixed inset-x-3 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-(--sb-z-toast) h-0 w-auto outline-hidden sm:start-auto sm:end-4 sm:bottom-[max(1rem,env(safe-area-inset-bottom))] sm:w-90\"\n        >\n          <ToastList />\n        </Toast.Viewport>\n      </Toast.Portal>\n    </Toast.Provider>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/ikada/toast.tsx"
    }
  ],
  "docs": "Mount <ToastProvider> once near the root of your app (and <TooltipProvider> for tooltips), then call toast() from anywhere.",
  "categories": [
    "feedback"
  ],
  "type": "registry:ui"
}