Skip to content

Toaster — live demo

This is the real @rozie-ui/toast-vue package running on this page (VitePress is itself a Vue app). Click a button to enqueue a toast — it appears in the corner, auto-dismisses after its duration (hover the stack to pause), and you can close it with its × button or clear them all. The same Toaster component, with the same API, ships for React, Vue, Svelte, Angular, Solid, and Lit. It's built on native DOM with no engine and no required CSS — the queue/timer behaviour and a tokenised skin all ship inside the component.

The host is mounted once and driven entirely through Vue's ref — there is no global toast() singleton; "call from anywhere" is your app's wiring concern.

show({ message, type, duration }) enqueues a toast and returns its id; dismiss(id) removes one and clear() removes them all. Pass duration: 0 for a sticky toast. Set position to any of the six corners (top-left, top-right, top-center, bottom-left, bottom-right, bottom-center), cap the stack with max, and opt out of hover-pause with disablePauseOnHover. See the full API for every prop, the handle, the #toast scoped slot, theming, and accessibility.

Custom chrome with the #toast slot

By default each toast renders its message plus a close button. The #toast scoped slot ({ toast, dismiss }) hands you the toast record and the dismiss function so you can render whatever chrome you like:

vue
<script setup>
import { ref } from 'vue';
import Toaster from '@rozie-ui/toast-vue';
const toaster = ref();
</script>

<template>
  <button @click="toaster.show({ message: 'Undo?', type: 'info' })">Delete</button>

  <Toaster ref="toaster" position="top-center">
    <template #toast="{ toast, dismiss }">
      <strong style="text-transform: capitalize">{{ toast.type }}</strong>
      <span>{{ toast.message }}</span>
      <button @click="dismiss(toast.id)">Undo</button>
    </template>
  </Toaster>
</template>

What ships for each framework

You author the component once as a .rozie file:

html
<!--
  Toaster.rozie — a headless, accessible toast / notification host.

  A pure-Rozie family (NO third-party engine). Cross-framework toast systems are
  re-implemented everywhere; Rozie ships ONE self-contained host. Deliberately
  NOT a global singleton + context system (the heavyweight shape): the <Toaster>
  owns the queue + auto-dismiss timers as internal state and exposes an imperative
  `show / dismiss / clear` handle the consumer drives via `ref`. "Call from
  anywhere" is then the consumer's app-wiring concern (stash the ref) — Rozie owns
  the component, not the app's global plumbing. This keeps it captcha-simple and
  side-steps the "$provide/$inject doesn't cross a portal" limitation.

  Timers: each non-sticky toast schedules a window.setTimeout to auto-dismiss;
  hovering the stack pauses them PRECISELY (the remainder is stored per-toast
  and resumed exactly on leave — a 1000ms toast hovered ~600ms in resumes to
  ~400ms, NOT a full restart), and $onUnmount clears them all. `timers` is a
  top-level `let` (mutable cross-render scratch → React hoists it to useRef;
  the engine-wrapper persistence guarantee).

  Exit lifecycle: every dismissal routes through the single funnel
  `dismissBegin(id, reason)` — idempotent via the entry's `exiting` flag,
  emits the family's first event `@dismissed { toast, reason }` (reason
  'timeout' | 'swipe' | 'close' | 'api'), then flips the entry to `exiting`
  (fresh-array patch) so the template applies the exit class/animation.
  Removal happens on the toast's inline `@animationend` (a plain DOM event,
  works ×6) OR a ~350ms setTimeout failsafe — whichever fires first; the
  `exiting` flag makes the second call a no-op. `clear()` stays bulk: full
  teardown, no per-toast emit.

  patch/promise: `patch(id, changes)` merges `{message,type,duration}` into
  the matching queue entry via a fresh-array map; a `duration` key
  clears+restarts the timer (0 → sticky, sticky → positive arms it), any
  other key leaves a running timer alone. `promise(p, {loading,success,error})`
  shows a `{type:'loading',duration:0}` toast synchronously, then patches the
  SAME entry to success/error on settle (the auto-dismiss timer starts AT
  settle) — guarded by `unmounted` + a live-queue check so a toast dismissed
  while pending is never resurrected. `promise()` never returns/derives a new
  promise; the consumer's own `.then`/`.catch` on `p` still fire normally.

  Swipe-to-dismiss (on by default, `disableSwipe` opts out): direction is
  derived from `position` (`*-right`→right, `*-left`→left, `top-center`→up,
  `bottom-center`→down), axis-locked, with the opposite direction rubber-
  banded ×0.15. The module-level `let swipeGesture` (script top) holds the
  ACTIVE gesture's non-visual bookkeeping (start coords/time/axis/sign/size,
  measured from `$event.currentTarget` — NEVER `$refs`); it is referenced
  ONLY from template `@pointer*`-bound handlers, hoisted to `useRef` on React
  by the Quick 260717-8zb Task 3 Item 6 fix (hoistModuleLet.ts's
  reachability analysis now treats a template-event-only helper the same as
  a $onMount/$onUnmount/$watch/$expose one). Never read directly in the
  template — script-only bookkeeping — so it never needs to trigger a
  re-render, unlike `$data.swipe` below.
  `$data.swipe = { id, d, axis, sign, size }` is the fresh object that drives
  the dragged toast's string-form `:style` transform+opacity. Past 45% of the
  toast's own size OR >0.11px/ms releases into `dismissBegin(id, 'swipe', {
  swipeExitSign })` — the sign rides a string-form `--rozie-toast-swipe-exit`
  custom property so the exit keyframe leaves the same way the finger moved;
  below threshold, clearing `$data.swipe` lets the CSS transition spring the
  toast back. A drag starting on a `button`/`a` (the close button) never
  swipes.

  Stacked mode (opt-in via `stacked`): a `.rozie-toaster--stacked` class marks
  the region; the ACTUAL collapse-vs-expand look is pure CSS
  (`:not(:hover):not(:focus-within)` for collapsed, hover/focus-within falls
  back to the plain flex column — no JS hover-tracking needed for this). Every
  toast ALWAYS carries its depth-from-newest as a string-form `:style` custom
  property `--rozie-toast-depth` (harmless when `stacked` is off); CSS turns
  that into `translateY`/`scale`/`opacity`/`z-index` in collapsed mode. `depth`
  is `$data.toasts.length - 1 - index` — corner-independent (newest = last in
  the array = depth 0), since the collapsed grid overlay ignores
  flex-direction entirely.

  Authoring notes (collision classes — see the authoring playbook):
    - Imperative verbs `show` / `dismiss` / `clear` are NOT inherited HTMLElement
      members (no ROZ137). The single `dismissed` event does not collide with
      the `dismiss` verb (ROZ121 — differently named). A per-toast close button
      calls the internal `dismissBegin(id,'close')`; consumers wanting an
      action button / custom chrome use the #toast scoped slot.
    - Handler params left UNTYPED (neutralize to `any`). All window.* calls are
      typeof-guarded for SSR.
    - $data.toasts is written via fresh arrays (concat/filter/map) — never
      in-place mutation (dropped on React/Solid/Lit/Angular change detection).
      The `exiting` flag is written the same way (a fresh per-entry object via
      map).

  Consumer example:

    <Toaster ref="toaster" position="top-right" :duration="4000" />
    // elsewhere: $refs.toaster.show({ message: 'Saved', type: 'success' })
-->

<rozie name="Toaster">

<props>
{
  // Stack corner: 'top-left' | 'top-right' | 'top-center' | 'bottom-left' |
  // 'bottom-right' | 'bottom-center'.
  position:             {
    type: String,
    default: 'bottom-right',
    docs: {
      description:
        "Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.",
    },
  },

  // Default auto-dismiss in ms. 0 (or a per-toast duration of 0) = sticky.
  duration:             {
    type: Number,
    default: 4000,
    docs: {
      description:
        'Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.',
    },
  },

  // Max visible toasts (0 = unlimited); when exceeded, the oldest drop.
  max:                  {
    type: Number,
    default: 0,
    docs: {
      description:
        'Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.',
    },
  },

  // Opt OUT of pausing auto-dismiss timers while the pointer is over the stack
  // (default: hovering pauses).
  disablePauseOnHover:  {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.',
    },
  },

  // Accessible name for the live region (default 'Notifications').
  ariaLabel:            {
    type: String,
    default: null,
    docs: {
      description:
        "Accessible name for the live region (`role=\"region\"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.",
    },
  },

  // Opt OUT of pointer swipe-to-dismiss (default: swipe is ON).
  disableSwipe:         {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Opt **out** of pointer swipe-to-dismiss. By default, dragging a toast past 45% of its own width/height (direction auto-derived from `position`) or a fast flick dismisses it with reason `\'swipe\'`; a short drag springs back. A drag starting on the close button (or any button/link) never swipes.',
    },
  },

  // Opt IN to the sonner-style collapsed stack (default: the plain flex
  // column, unchanged).
  stacked:              {
    type: Boolean,
    default: false,
    docs: {
      description:
        'Opt **in** to a sonner-style collapsed stack: a single-cell grid overlay with depth-driven transforms (toasts at depth 3+ fade to invisible), newest on top. Hovering the region or moving keyboard focus into it expands to the normal flex-column stack; leaving re-collapses. `false` (default) renders the plain flex column at all times.',
    },
  },
}
</props>

<data>
{
  // The live toast queue: [{ id, message, type, duration }].
  toasts: [],
  // Monotonic id counter. Kept in reactive $data (NOT a module-let) so it
  // persists across renders on React: a top-level `let` referenced ONLY inside an
  // $expose verb (useImperativeHandle) is not hoisted to useRef by the emitter, so
  // it would reset to 0 every render → duplicate toast ids. $data IS real state.
  seq: 0,
  // The ACTIVE pointer-drag's visual state: { id, d, axis, sign, size } | null.
  // A fresh object per @pointermove (never mutated in place) so it drives the
  // dragged toast's string-form `:style`.
  swipe: null,
}
</data>

<script lang="ts">
// Mutable cross-render scratch (NOT reactive): per-id timer bookkeeping. A
// top-level `let` → React useRef (it escapes into $onUnmount's effect, so the
// emitter hoists it). The id counter lives in $data.seq instead (see <data>).
//
// Shape: { [id]: { handle, startedAt, remaining } }. `pauseTimers` clears the
// live setTimeout handle but KEEPS the entry with a decremented `remaining` —
// the remainder IS the state (this is what makes the hover pause PRECISE
// instead of a full restart). `resumeTimers` re-arms with exactly that
// remainder. `clearTimer`/the full-teardown helper below are the only ways an
// entry is actually removed from the map.
let timers = {}

// Per-id handles for the ~350ms exit-removal failsafe (the fallback that
// removes a toast if its @animationend never fires). Tracked in a module map
// — NOT an anonymous window.setTimeout — so teardownTimers ($onUnmount /
// clear()) can cancel a pending failsafe (else it fires post-unmount and
// writes $data on a torn-down instance) and removeToast can cancel it
// first-wins when @animationend beats it. Escapes into $onUnmount's effect →
// React hoists it to useRef alongside `timers`.
let exitFailsafes = {}

// Set true in $onUnmount; read by promise()'s settle guard (never-resurrect
// a toast after the host itself is gone). A top-level `let` → React useRef
// (it escapes into $onUnmount's effect).
let unmounted = false

// Same-tick id-uniqueness guard for React. The id counter lives in reactive
// $data.seq (persists across renders), but React batches setState so within a
// SINGLE synchronous tick two show() calls read the SAME stale $data.seq →
// duplicate ids. `seqLocal` is a plain counter incremented SYNCHRONOUSLY in
// show(); it survives the same tick (and, because show() is an $expose verb,
// the emitter hoists it to a persistent useRef on React too — but the design
// does NOT depend on that: `Math.max($data.seq, seqLocal)` is correct whether
// seqLocal persists OR resets per render, since the monotonic $data.seq
// carries the high-water mark across any reset). On the other five targets
// $data.seq is synchronous, so the two simply stay in lockstep. Result:
// strictly-increasing, collision-free ids on all six with NO randomness.
let seqLocal = 0

// Hover-pause flag: true while the pointer is over the stack (set by
// pauseTimers, cleared by resumeTimers). Read by patch() so a duration change
// arriving mid-hover stores the new remainder WITHOUT arming a live timer
// (which would dismiss the toast while it is still hovered) — resume arms it
// on leave. A top-level `let` reachable from the $expose verbs (patch/show →
// startTimer) and the @mouseenter/@mouseleave handlers, so React hoists it to
// useRef (persistent) like `timers`.
let paused = false

// The ACTIVE pointer-drag gesture's non-visual bookkeeping: { id, axis, sign,
// size, startX, startY, startTime } | null (set on @pointerdown, read on
// @pointermove/@pointerup, cleared on @pointerup/@pointercancel). Referenced
// ONLY from the four onToastPointer* handlers below, which are bound ONLY via
// template `@pointerdown`/`@pointermove`/`@pointerup`/`@pointercancel` — the
// template-@event-handler reachability root (Quick 260717-8zb Task 3 Item 6,
// hoistModuleLet.ts) hoists this to useRef on React so it persists across the
// re-renders the sibling `$data.swipe` write triggers mid-gesture. Never read
// directly in the template — script-only bookkeeping.
let swipeGesture = null

// ---- timers ------------------------------------------------------------
const startTimer = (toast) => {
  if (!toast || !toast.duration || toast.duration <= 0) return
  if (typeof window === 'undefined') return
  // Belt-and-braces: clear any pre-existing live handle for this id before
  // overwriting the entry, so a re-arm never orphans a running timeout.
  const existing = timers[toast.id]
  if (existing && existing.handle != null) window.clearTimeout(existing.handle)
  const remaining = toast.duration
  const handle = window.setTimeout(() => dismissBegin(toast.id, 'timeout'), remaining)
  timers[toast.id] = { handle, startedAt: Date.now(), remaining }
}

const clearTimer = (id) => {
  const entry = timers[id]
  if (entry && entry.handle != null && typeof window !== 'undefined') window.clearTimeout(entry.handle)
  delete timers[id]
}

// Pauses every live timer WITHOUT losing the remainder: clears the handle,
// decrements `remaining` by the elapsed time, and KEEPS the entry (does NOT
// delete it — the old v1 shortcut deleted entries here, which is why leave
// had to do a full restart).
const pauseTimers = () => {
  paused = true
  if (typeof window === 'undefined') return
  for (const id in timers) {
    const entry = timers[id]
    // Idempotent: an entry already paused (handle cleared) keeps its stored
    // remainder. A second pause must NOT re-subtract elapsed against the
    // original startedAt — that drove `remaining` negative and stranded the
    // toast forever once resume saw the non-positive value.
    if (entry.handle == null) continue
    window.clearTimeout(entry.handle)
    const elapsed = Date.now() - entry.startedAt
    // Clamp so a late pause (e.g. a background-tab timer that overran) can
    // never store a negative remainder.
    const remaining = Math.max(0, entry.remaining - elapsed)
    timers[id] = { handle: null, startedAt: entry.startedAt, remaining }
  }
}

// Re-arms every paused timer with EXACTLY its stored remainder (called on
// mouse leave). An entry with a non-positive remainder is left un-armed
// (it will be cleaned up by the next dismiss/clear pass) rather than firing
// immediately from inside this loop.
const resumeTimers = () => {
  paused = false
  if (typeof window === 'undefined') return
  for (const id in timers) {
    const entry = timers[id]
    // Only re-arm entries that are actually paused (handle cleared). A live
    // handle is left alone — re-arming it would orphan the running timeout.
    if (entry.handle != null) continue
    if (entry.remaining == null || entry.remaining <= 0) {
      // Its deadline elapsed while paused (a background-tab overrun, or a
      // remainder clamped to 0): treat as EXPIRED and dismiss now — its time
      // is up — rather than leaving it un-armed and stranded forever.
      dismissBegin(id, 'timeout')
      continue
    }
    const remaining = entry.remaining
    const handle = window.setTimeout(() => dismissBegin(id, 'timeout'), remaining)
    timers[id] = { handle, startedAt: Date.now(), remaining }
  }
}

// FULL teardown: clears every live handle AND drops every entry (unlike
// pauseTimers, which deliberately keeps entries to hold their remainders).
// clear() and $onUnmount can no longer reuse pauseTimers for this reason.
const teardownTimers = () => {
  if (typeof window !== 'undefined') {
    for (const id in timers) {
      const entry = timers[id]
      if (entry.handle != null) window.clearTimeout(entry.handle)
    }
    // Also cancel every pending exit failsafe — otherwise a removal timeout
    // scheduled just before unmount/clear() fires afterward and writes $data.
    for (const id in exitFailsafes) {
      if (exitFailsafes[id] != null) window.clearTimeout(exitFailsafes[id])
    }
  }
  timers = {}
  exitFailsafes = {}
}

// ---- queue (imperative handle implementations) -------------------------
const show = (input) => {
  const t = input || {}
  let id
  if (t.id != null) {
    // Coerce a consumer-supplied id to a String once, at the single entry
    // point. Ids flow through the `timers` map (whose `for (const id in …)`
    // keys are ALWAYS strings) and every downstream `t.id === id` strict
    // comparison; a numeric consumer id (`show({ id: 42 })`) would otherwise
    // stop matching after a hover pause/resume re-arms with the string key.
    id = String(t.id)
  } else {
    // Take the high-water mark of the persistent-but-tick-stale $data.seq and
    // the synchronous-but-maybe-per-render seqLocal (see the <script> comment)
    // so same-tick multi-show yields DISTINCT ids on React too. Read both
    // BEFORE writing either (no read-after-write of $data.seq → ROZ138-safe).
    const s = Math.max($data.seq, seqLocal)
    id = 't' + s
    seqLocal = s + 1
    $data.seq = s + 1
  }
  const toast = {
    id,
    message: t.message != null ? t.message : '',
    type: t.type || 'info',
    duration: t.duration != null ? t.duration : $props.duration,
  }
  // ONE self-referential assignment so the React emitter lowers it to the
  // concurrent-safe functional updater `setToasts(prev => …)` (it only does so
  // when the RHS reads $data.toasts DIRECTLY — a via-a-local form lowered to a
  // stale-closure `setToasts(<value>)`, losing the first of two same-tick
  // toasts). slice() start: keep the newest `max` when over the cap
  // (Math.max(0, len+1-max)), else slice(0) = the whole fresh array.
  $data.toasts = $data.toasts.concat([toast]).slice($props.max > 0 ? Math.max(0, $data.toasts.length + 1 - $props.max) : 0)
  startTimer(toast)
  return id
}

// ---- exit lifecycle ------------------------------------------------------
// Deliberately exceeds the 200ms default --rozie-toast-exit-duration token
// comfortably; a consumer overriding the exit duration beyond ~350ms gets cut
// short by this failsafe (documented in docs/components/toast.md).
const EXIT_FAILSAFE_MS = 350

// Idempotent removal: filters the entry out of $data.toasts. Safe to call
// twice (from the inline @animationend binding AND the failsafe) — the
// second call is a harmless no-op filter over an already-absent id.
const removeToast = (id) => {
  // Cancel any pending exit failsafe for this id (first-wins: @animationend
  // beating the ~350ms timeout, or vice-versa — either way, only one removal).
  if (typeof window !== 'undefined' && exitFailsafes[id] != null) {
    window.clearTimeout(exitFailsafes[id])
  }
  delete exitFailsafes[id]
  $data.toasts = $data.toasts.filter((t) => t.id !== id)
}

// The single dismissal funnel every path routes through: the `dismiss(id)`
// verb ('api'), the built-in close button ('close'), a timer expiry
// ('timeout'), and a swipe past threshold ('swipe'). Idempotent via the
// entry's `exiting` flag — a second call on an id already exiting (or
// already gone) is a no-op, so a stray timeout firing mid-exit never
// double-emits. `extra` (swipe only) carries `{ swipeExitSign }` so the
// template can apply the direction-matched swipe-exit animation.
const dismissBegin = (id, reason, extra?: { swipeExitSign?: number }) => {
  const entry = $data.toasts.find((t) => t.id === id)
  if (!entry || entry.exiting) return
  clearTimer(id)
  $emit('dismissed', { toast: entry, reason })
  $data.toasts = $data.toasts.map((t) => (t.id === id ? { ...t, exiting: true, ...(extra || {}) } : t))
  if (typeof window === 'undefined') {
    removeToast(id)
  } else {
    exitFailsafes[id] = window.setTimeout(() => removeToast(id), EXIT_FAILSAFE_MS)
  }
}

const dismiss = (id) => {
  dismissBegin(id, 'api')
}

// clear() is bulk: immediate full teardown, NO per-toast exit animation and
// NO emit (documented — see docs/components/toast.md).
const clear = () => {
  teardownTimers()
  $data.toasts = []
}

// ---- patch / promise ------------------------------------------------------
// Update-in-place primitive: merges ONLY the present `{message,type,duration}`
// keys into the matching entry via a fresh-array map (never in-place
// mutation). Returns whether the id existed. A `duration` key clears+restarts
// the timer (0 → sticky/no-arm; positive → arm); any other key leaves a
// running timer untouched.
const patch = (id, changes) => {
  const c = changes || {}
  let existed = false
  const next = $data.toasts.map((t) => {
    if (t.id !== id) return t
    // Treat an EXITING entry as absent — never resurrect a toast whose
    // dismissal is already in flight (removal deferred to @animationend / the
    // failsafe). `existed` stays false → patch returns false, writes nothing,
    // arms no timer.
    if (t.exiting) return t
    existed = true
    const merged = { ...t }
    if (c.message !== undefined) merged.message = c.message
    if (c.type !== undefined) merged.type = c.type
    if (c.duration !== undefined) merged.duration = c.duration
    return merged
  })
  if (!existed) return false
  $data.toasts = next
  if (c.duration !== undefined) {
    clearTimer(id)
    const patched = next.find((t) => t.id === id)
    if (paused) {
      // Hovered: store the new duration as the pending remainder WITHOUT
      // arming a live timer (which would dismiss the toast while the pointer
      // is still over the stack). resumeTimers() arms it on leave.
      if (patched && patched.duration > 0 && typeof window !== 'undefined') {
        timers[id] = { handle: null, startedAt: Date.now(), remaining: patched.duration }
      }
    } else {
      startTimer(patched)
    }
  }
  return true
}

// The settle guard: a no-op if the host unmounted OR the toast was already
// dismissed while the promise was still pending (never-resurrect).
const settlePromise = (id, type, messageOrFn, value) => {
  if (unmounted) return
  // Never-resurrect: no-op if the toast is gone OR already exiting (its
  // dismissal is in flight — settling now would flip it back to a live
  // success/error toast and re-arm a timer).
  const entry = $data.toasts.find((t) => t.id === id)
  if (!entry || entry.exiting) return
  const message = typeof messageOrFn === 'function' ? messageOrFn(value) : messageOrFn
  patch(id, { type, message, duration: $props.duration })
}

// Sugar over show()+patch(): shows a sticky loading toast synchronously
// (returns its id immediately — the consumer already holds `p`), then patches
// the SAME entry to success/error on settle (the auto-dismiss timer starts AT
// SETTLE, via patch's duration-key restart). Never returns/derives a new
// promise — `p`'s own .then/.catch still fire for the consumer untouched.
const promise = (p, opts) => {
  const o = opts || {}
  const id = show({ type: 'loading', duration: 0, message: o.loading })
  if (p && typeof p.then === 'function') {
    p.then((value) => settlePromise(id, 'success', o.success, value)).catch((err) =>
      settlePromise(id, 'error', o.error, err),
    )
  }
  return id
}

// ---- swipe-to-dismiss ------------------------------------------------------
// Axis + dismiss-direction sign, purely derived from the corner (no per-
// gesture state needed for these two — they only depend on $props.position).
const swipeAxisFor = (position) => (position === 'top-center' || position === 'bottom-center' ? 'y' : 'x')
const swipeSignFor = (position) => {
  if (position === 'top-right' || position === 'bottom-right') return 1
  if (position === 'top-left' || position === 'bottom-left') return -1
  if (position === 'bottom-center') return 1
  return -1 // top-center
}

const onToastPointerDown = (t, event) => {
  if ($props.disableSwipe) return
  if (event.button != null && event.button !== 0) return
  // Ignore drags starting on the close button / any button-or-link chrome.
  const chrome = event.target && event.target.closest ? event.target.closest('button, a') : null
  if (chrome) return
  const axis = swipeAxisFor($props.position)
  const sign = swipeSignFor($props.position)
  const el = event.currentTarget
  const size = axis === 'x' ? el.offsetWidth : el.offsetHeight
  swipeGesture = {
    id: t.id,
    axis,
    sign,
    size,
    startX: event.clientX,
    startY: event.clientY,
    startTime: Date.now(),
  }
  if (el && el.setPointerCapture) {
    try {
      el.setPointerCapture(event.pointerId)
    } catch (e) {
      // Some embedded contexts throw on setPointerCapture — swipe still
      // works without capture (just loses "keeps tracking off-element").
    }
  }
}

const onToastPointerMove = (t, event) => {
  if ($props.disableSwipe) return
  const gesture = swipeGesture
  if (!gesture || gesture.id !== t.id) return
  const raw = gesture.axis === 'x' ? event.clientX - gesture.startX : event.clientY - gesture.startY
  const towardDismiss = raw * gesture.sign > 0
  const d = towardDismiss ? raw : raw * 0.15
  $data.swipe = { id: t.id, d, axis: gesture.axis, sign: gesture.sign, size: gesture.size }
}

const onToastPointerUp = (t, event) => {
  if ($props.disableSwipe) return
  const gesture = swipeGesture
  swipeGesture = null
  // Local named `dragState`, NOT `swipe` — a local `swipe` would shadow the
  // reactive `$data.swipe` key on Svelte 5 (top-level `let swipe = $state(…)`
  // self-shadow TDZ: `const swipe = swipe` then `swipe = null` throws
  // "Cannot assign to constant"). Same collision class as the documented
  // $refs/$props self-shadow, just for a $data key.
  const dragState = $data.swipe
  $data.swipe = null
  if (!gesture || gesture.id !== t.id || !dragState) return
  const elapsed = Math.max(1, Date.now() - gesture.startTime)
  const magnitude = dragState.d * gesture.sign
  const velocity = magnitude / elapsed
  if (magnitude > 0 && (magnitude > gesture.size * 0.45 || velocity > 0.11)) {
    dismissBegin(t.id, 'swipe', { swipeExitSign: gesture.sign })
  }
}

const onToastPointerCancel = (t) => {
  if ($props.disableSwipe) return
  if (swipeGesture && swipeGesture.id === t.id) swipeGesture = null
  if ($data.swipe && $data.swipe.id === t.id) $data.swipe = null
}

// ---- stacked mode ----------------------------------------------------------
// Depth from newest: the newest toast (last in the array — show() appends)
// is depth 0; each older toast is one deeper. Corner-independent — the
// collapsed grid overlay ignores flex-direction/column-reverse entirely, so
// this needs no position-aware math.
//
// quick 260716-npt Finding 3 (perf): depth USED to be a per-toast
// `$data.toasts.findIndex(...)` scan invoked from toastStyle() for every row
// — O(n) work × n toasts rendered = O(n^2) per render. The template's r-for
// already computes each row's array index for free (the r-for bare-comma
// index form, `t, ti in ...` — see TreeNode.rozie/Table.rozie precedent), so
// depth(ti) is now O(1) arithmetic off that index — no scan, and `t`'s id
// can never be "not found" via this call path (ti IS t's own index), so the
// old idx===-1→0 fallback collapses to unreachable-by-construction (same
// observable semantics: newest=depth 0, older=length-1-idx).
const depth = (ti) => $data.toasts.length - 1 - ti

// String-form `:style` for the toast row. ALWAYS carries `--rozie-toast-depth`
// (a no-op unless `stacked` is on — CSS reads it only inside
// `.rozie-toaster--stacked`), plus EITHER the active drag transform (while
// $data.swipe tracks this id) OR the swipe-exit sign custom property (once
// `dismissBegin('swipe')` flipped `t.swipeExitSign`). Drag/exit never overlap.
const toastStyle = (t, ti) => {
  const depthDecl = '--rozie-toast-depth: ' + depth(ti) + ';'
  if (t.exiting) {
    return t.swipeExitSign != null ? depthDecl + ' --rozie-toast-swipe-exit: ' + t.swipeExitSign + ';' : depthDecl
  }
  // Local named `dragState`, NOT `swipe` — see the onToastPointerUp comment
  // above (Svelte 5 $data-key self-shadow).
  const dragState = $data.swipe
  if (!dragState || dragState.id !== t.id) return depthDecl
  const translate = dragState.axis === 'x' ? 'translateX(' + dragState.d + 'px)' : 'translateY(' + dragState.d + 'px)'
  const magnitude = dragState.d * dragState.sign
  const opacity = magnitude > 0 && dragState.size > 0 ? Math.max(0.3, 1 - magnitude / dragState.size) : 1
  return depthDecl + ' transform: ' + translate + '; opacity: ' + opacity + '; transition: none;'
}

// ---- hover pause -------------------------------------------------------
const onMouseEnter = () => {
  if ($props.disablePauseOnHover) return
  pauseTimers()
}
const onMouseLeave = () => {
  if ($props.disablePauseOnHover) return
  resumeTimers()
}

// ---- helpers -----------------------------------------------------------
const regionLabel = () => ($props.ariaLabel != null ? $props.ariaLabel : 'Notifications')
// Type union: 'info' | 'success' | 'error' | 'warning' | 'loading'. Only
// error/warning interrupt (assertive); loading (like info/success) is polite.
const liveFor = (type) => (type === 'error' || type === 'warning' ? 'assertive' : 'polite')

// ---- lifecycle + handle ------------------------------------------------
$onUnmount(() => {
  unmounted = true
  teardownTimers()
})

$expose({ show, dismiss, clear, patch, promise })
</script>

<template>
<div
  class="rozie-toaster"
  :class="'rozie-toaster--' + $props.position + ($props.stacked ? ' rozie-toaster--stacked' : '')"
  role="region"
  :aria-label="regionLabel()"
  @mouseenter="onMouseEnter()"
  @mouseleave="onMouseLeave()"
>
  <!-- Loop var is `t`, NOT `toast`: a loop var named `toast` shadows the `#toast`
       slot snippet on Svelte (`{#each … as toast}` collides with the `toast`
       snippet → `{@render toast()}` renders a non-function → Svelte-only throw).
       Same collision class as slider's mark→tick / embla's slide→item. The slot
       PROP stays `:toast` (consumers still get `{ toast, dismiss }`). -->
  <div
    r-for="t, ti in $data.toasts"
    :key="t.id"
    class="rozie-toast"
    :class="'rozie-toast--' + t.type + (t.exiting ? ' rozie-toast--exiting' : '') + (t.swipeExitSign != null ? ' rozie-toast--swipe-exit' : '')"
    :style="toastStyle(t, ti)"
    role="status"
    :aria-live="liveFor(t.type)"
    @animationend="t.exiting && removeToast(t.id)"
    @pointerdown="onToastPointerDown(t, $event)"
    @pointermove="onToastPointerMove(t, $event)"
    @pointerup="onToastPointerUp(t, $event)"
    @pointercancel="onToastPointerCancel(t)"
  >
    <slot name="toast" :toast="t" :dismiss="dismiss">
      <span r-if="t.type === 'loading'" class="rozie-toast-spinner" aria-hidden="true"></span>
      <span class="rozie-toast-message">{{ t.message }}</span>
      <button type="button" class="rozie-toast-close" aria-label="Dismiss" @click="dismissBegin(t.id, 'close')">×</button>
    </slot>
  </div>
</div>
</template>

<style>
/*
  Token-driven (mirrors slider/otp themes): every visual value is a
  `var(--rozie-toast-*, <fallback>)`. The shipped themes/*.css presets map these
  onto shadcn/Radix, Material 3, Bootstrap 5.
*/
.rozie-toaster {
  position: fixed;
  z-index: var(--rozie-toast-z, 9999);
  display: flex;
  flex-direction: column;
  gap: var(--rozie-toast-gap, 0.5rem);
  padding: var(--rozie-toast-region-padding, 1rem);
  max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
  pointer-events: none;
  font: var(--rozie-toast-font, inherit);
}
.rozie-toaster > * {
  pointer-events: auto;
}

/* corners */
.rozie-toaster--top-left { top: 0; left: 0; align-items: flex-start; }
.rozie-toaster--top-right { top: 0; right: 0; align-items: flex-end; }
.rozie-toaster--top-center { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
.rozie-toaster--bottom-left { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
.rozie-toaster--bottom-right { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
.rozie-toaster--bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }

/*
  Stacked mode (opt-in `stacked` prop): `.rozie-toaster--stacked` is a pure
  ENABLEMENT marker — the actual collapsed-vs-expanded look is entirely
  `:hover`/`:focus-within`-driven CSS (no JS hover-tracking needed). Collapsed
  = a single-cell grid overlay (`grid-area: 1 / 1` on every toast — an
  overlay, no absolute-position math); each toast's `--rozie-toast-depth`
  custom property (always set — see toastStyle() in <script>) drives a
  depth-scaled translate/scale + an opacity that reaches 0 at depth>=3
  (`calc(1 - min(1, max(0, depth - 2)))`) + a depth-descending z-index
  (newest highest). Hover/focus-within simply stops matching the
  `:not(:hover):not(:focus-within)` selectors below, falling back to the
  plain flex column (the `.rozie-toast` `transition` — added alongside swipe
  above — animates the collapse/expand smoothly).
*/
.rozie-toaster--stacked .rozie-toast {
  grid-area: 1 / 1;
  z-index: calc(100 - var(--rozie-toast-depth, 0));
}
.rozie-toaster--stacked:not(:hover):not(:focus-within) {
  display: grid;
}
.rozie-toaster--stacked:not(:hover):not(:focus-within) .rozie-toast {
  transform:
    translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px)))
    scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
  opacity: calc(1 - min(1, max(0, var(--rozie-toast-depth, 0) - 2)));
}
.rozie-toaster--stacked.rozie-toaster--bottom-left:not(:hover):not(:focus-within) .rozie-toast,
.rozie-toaster--stacked.rozie-toaster--bottom-right:not(:hover):not(:focus-within) .rozie-toast,
.rozie-toaster--stacked.rozie-toaster--bottom-center:not(:hover):not(:focus-within) .rozie-toast {
  transform:
    translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px) * -1))
    scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
}

.rozie-toast {
  display: flex;
  align-items: center;
  gap: var(--rozie-toast-content-gap, 0.75rem);
  min-width: var(--rozie-toast-min-width, 16rem);
  max-width: var(--rozie-toast-toast-max-width, 24rem);
  padding: var(--rozie-toast-padding, 0.75rem 1rem);
  color: var(--rozie-toast-color, #fff);
  background: var(--rozie-toast-bg, #333);
  border-radius: var(--rozie-toast-radius, 0.5rem);
  box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
  /* Swipe: page scroll stays alive on touch along the axis the toast does
     NOT move on. The transition here drives the spring-back (the active-drag
     :style sets an inline `transition: none` to track the finger 1:1;
     releasing it without a further gesture falls back to this transition). */
  touch-action: pan-y;
  transition: transform 200ms ease, opacity 200ms ease;
}
.rozie-toaster--top-center .rozie-toast,
.rozie-toaster--bottom-center .rozie-toast {
  touch-action: pan-x;
}
.rozie-toast--success { background: var(--rozie-toast-success-bg, #16a34a); }
.rozie-toast--error { background: var(--rozie-toast-error-bg, #dc2626); }
.rozie-toast--warning { background: var(--rozie-toast-warning-bg, #ca8a04); }
.rozie-toast--info { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }

/*
  Enter/exit lifecycle. Enter plays on every mount (slide in from the
  corner's edge + fade); exit plays while `exiting` is true (the
  `rozie-toast--exiting` class), removal driven by the element's own
  `@animationend` OR the ~350ms JS failsafe — whichever fires first
  (state-driven, target-agnostic; see the <script> comment above).
  `--rozie-toast-enter-duration`/`--rozie-toast-exit-duration` default to
  200ms (documented in themes/base.css).
*/
@keyframes rozie-toast-enter {
  from { opacity: 0; transform: translateY(-0.5rem); }
  to { opacity: 1; transform: translateY(0); }
}
@keyframes rozie-toast-enter-from-bottom {
  from { opacity: 0; transform: translateY(0.5rem); }
  to { opacity: 1; transform: translateY(0); }
}
@keyframes rozie-toast-exit {
  from { opacity: 1; transform: translateY(0); }
  to { opacity: 0; transform: translateY(-0.5rem); }
}
@keyframes rozie-toast-exit-to-bottom {
  from { opacity: 1; transform: translateY(0); }
  to { opacity: 0; transform: translateY(0.5rem); }
}
.rozie-toast {
  animation: rozie-toast-enter var(--rozie-toast-enter-duration, 200ms) ease-out;
}
.rozie-toaster--bottom-left .rozie-toast,
.rozie-toaster--bottom-right .rozie-toast,
.rozie-toaster--bottom-center .rozie-toast {
  animation-name: rozie-toast-enter-from-bottom;
}
.rozie-toast--exiting {
  animation: rozie-toast-exit var(--rozie-toast-exit-duration, 200ms) ease-in forwards;
}
.rozie-toaster--bottom-left .rozie-toast--exiting,
.rozie-toaster--bottom-right .rozie-toast--exiting,
.rozie-toaster--bottom-center .rozie-toast--exiting {
  animation-name: rozie-toast-exit-to-bottom;
}
/* Collapse to a near-instant fade (still fires animationend — the exit
   lifecycle is unchanged) rather than disabling animation entirely. */
@media (prefers-reduced-motion: reduce) {
  .rozie-toast {
    animation-name: rozie-toast-fade-in;
    animation-duration: 1ms;
  }
  .rozie-toast--exiting {
    animation-name: rozie-toast-fade-out;
    animation-duration: 1ms;
  }
}
@keyframes rozie-toast-fade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}
@keyframes rozie-toast-fade-out {
  from { opacity: 1; }
  to { opacity: 0; }
}

/*
  Swipe exit: overrides the vertical default exit above with a slide along
  the swipe axis, signed by `--rozie-toast-swipe-exit` (the toastStyle()
  string-form `:style` above sets it only once `t.swipeExitSign` is set, i.e.
  exactly when reason === 'swipe'). `.rozie-toast--exiting.rozie-toast--swipe-exit`
  matches the corner-direction overrides' specificity (two classes) and wins
  by source order (declared after them).
*/
@keyframes rozie-toast-swipe-exit-x {
  from { opacity: 1; transform: translateX(0); }
  to { opacity: 0; transform: translateX(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
}
@keyframes rozie-toast-swipe-exit-y {
  from { opacity: 1; transform: translateY(0); }
  to { opacity: 0; transform: translateY(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
}
.rozie-toast--exiting.rozie-toast--swipe-exit {
  animation-name: rozie-toast-swipe-exit-x;
}
.rozie-toaster--top-center .rozie-toast--exiting.rozie-toast--swipe-exit,
.rozie-toaster--bottom-center .rozie-toast--exiting.rozie-toast--swipe-exit {
  animation-name: rozie-toast-swipe-exit-y;
}

/* Decorative — the message text carries the meaning (aria-hidden). */
.rozie-toast-spinner {
  flex: 0 0 auto;
  width: var(--rozie-toast-spinner-size, 1em);
  height: var(--rozie-toast-spinner-size, 1em);
  border: 2px solid color-mix(in srgb, var(--rozie-toast-spinner-color, currentColor) 25%, transparent);
  border-top-color: var(--rozie-toast-spinner-color, currentColor);
  border-radius: 50%;
  animation: rozie-toast-spin 0.75s linear infinite;
}
@keyframes rozie-toast-spin {
  to { transform: rotate(360deg); }
}

.rozie-toast-message {
  flex: 1 1 auto;
  font-size: var(--rozie-toast-font-size, 0.9rem);
}
.rozie-toast-close {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-toast-close-size, 1.25rem);
  height: var(--rozie-toast-close-size, 1.25rem);
  padding: 0;
  font-size: 1.1rem;
  line-height: 1;
  color: inherit;
  background: transparent;
  border: none;
  border-radius: 0.25rem;
  opacity: var(--rozie-toast-close-opacity, 0.75);
  cursor: pointer;
}
.rozie-toast-close:hover {
  opacity: 1;
}
</style>

</rozie>

…and Rozie compiles it to six framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/toast-{react,vue,svelte,angular,solid,lit}):

tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { clsx, parseInlineStyle, rozieAttr, rozieDisplay } from '@rozie/runtime-react';
import './Toaster.css';

interface ToastCtx { toast: any; dismiss: any; }

interface ToasterProps {
  /**
   * Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
   */
  position?: string;
  /**
   * Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
   */
  duration?: number;
  /**
   * Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
   */
  max?: number;
  /**
   * Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
   */
  disablePauseOnHover?: boolean;
  /**
   * Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
   */
  ariaLabel?: (string) | null;
  /**
   * Opt **out** of pointer swipe-to-dismiss. By default, dragging a toast past 45% of its own width/height (direction auto-derived from `position`) or a fast flick dismisses it with reason `'swipe'`; a short drag springs back. A drag starting on the close button (or any button/link) never swipes.
   */
  disableSwipe?: boolean;
  /**
   * Opt **in** to a sonner-style collapsed stack: a single-cell grid overlay with depth-driven transforms (toasts at depth 3+ fade to invisible), newest on top. Hovering the region or moving keyboard focus into it expands to the normal flex-column stack; leaving re-collapses. `false` (default) renders the plain flex column at all times.
   */
  stacked?: boolean;
  onDismissed?: (...args: any[]) => void;
  renderToast?: (ctx: ToastCtx) => ReactNode;
  slots?: Record<string, () => import('react').ReactNode>;
}

export interface ToasterHandle {
  show: (...args: any[]) => any;
  dismiss: (...args: any[]) => any;
  clear: (...args: any[]) => any;
  patch: (...args: any[]) => any;
  promise: (...args: any[]) => any;
}

const Toaster = forwardRef<ToasterHandle, ToasterProps>(function Toaster(_props: ToasterProps, ref): JSX.Element {
  const props: Omit<ToasterProps, 'position' | 'duration' | 'max' | 'disablePauseOnHover' | 'ariaLabel' | 'disableSwipe' | 'stacked'> & { position: string; duration: number; max: number; disablePauseOnHover: boolean; ariaLabel: (string) | null; disableSwipe: boolean; stacked: boolean } = {
    ..._props,
    position: _props.position ?? 'bottom-right',
    duration: _props.duration ?? 4000,
    max: _props.max ?? 0,
    disablePauseOnHover: _props.disablePauseOnHover ?? false,
    ariaLabel: _props.ariaLabel ?? null,
    disableSwipe: _props.disableSwipe ?? false,
    stacked: _props.stacked ?? false,
  };
  const attrs: Record<string, unknown> = (() => {
    const { position, duration, max, disablePauseOnHover, ariaLabel, disableSwipe, stacked, onDismissed, ...rest } = _props as ToasterProps & Record<string, unknown>;
    void position; void duration; void max; void disablePauseOnHover; void ariaLabel; void disableSwipe; void stacked; void onDismissed;
    return rest;
  })();
  const unmounted = useRef(false);
  const timers = useRef({});
  const exitFailsafes = useRef({});
  const seqLocal = useRef(0);
  const paused = useRef(false);
  const swipeGesture = useRef<any>(null);
  const [toasts, setToasts] = useState<any[]>([]);
  const [seq, setSeq] = useState(0);
  const [swipe, setSwipe] = useState<any>(null);

  function startTimer(toast: any) {
    if (!toast || !toast.duration || toast.duration <= 0) return;
    if (typeof window === 'undefined') return;
    // Belt-and-braces: clear any pre-existing live handle for this id before
    // overwriting the entry, so a re-arm never orphans a running timeout.
    const existing = timers.current[toast.id];
    if (existing && existing.handle != null) window.clearTimeout(existing.handle);
    const remaining = toast.duration;
    const handle = window.setTimeout(() => dismissBegin(toast.id, 'timeout'), remaining);
    timers.current[toast.id] = {
      handle,
      startedAt: Date.now(),
      remaining
    };
  }
  function clearTimer(id: any) {
    const entry = timers.current[id];
    if (entry && entry.handle != null && typeof window !== 'undefined') window.clearTimeout(entry.handle);
    delete timers.current[id];
  }
  function pauseTimers() {
    paused.current = true;
    if (typeof window === 'undefined') return;
    for (const id in timers.current) {
      const entry = timers.current[id];
      // Idempotent: an entry already paused (handle cleared) keeps its stored
      // remainder. A second pause must NOT re-subtract elapsed against the
      // original startedAt — that drove `remaining` negative and stranded the
      // toast forever once resume saw the non-positive value.
      if (entry.handle == null) continue;
      window.clearTimeout(entry.handle);
      const elapsed = Date.now() - entry.startedAt;
      // Clamp so a late pause (e.g. a background-tab timer that overran) can
      // never store a negative remainder.
      const remaining = Math.max(0, entry.remaining - elapsed);
      timers.current[id] = {
        handle: null,
        startedAt: entry.startedAt,
        remaining
      };
    }
  }
  function resumeTimers() {
    paused.current = false;
    if (typeof window === 'undefined') return;
    for (const id in timers.current) {
      const entry = timers.current[id];
      // Only re-arm entries that are actually paused (handle cleared). A live
      // handle is left alone — re-arming it would orphan the running timeout.
      if (entry.handle != null) continue;
      if (entry.remaining == null || entry.remaining <= 0) {
        // Its deadline elapsed while paused (a background-tab overrun, or a
        // remainder clamped to 0): treat as EXPIRED and dismiss now — its time
        // is up — rather than leaving it un-armed and stranded forever.
        dismissBegin(id, 'timeout');
        continue;
      }
      const remaining = entry.remaining;
      const handle = window.setTimeout(() => dismissBegin(id, 'timeout'), remaining);
      timers.current[id] = {
        handle,
        startedAt: Date.now(),
        remaining
      };
    }
  }
  const teardownTimers = useCallback(() => {
    if (typeof window !== 'undefined') {
      for (const id in timers.current) {
        const entry = timers.current[id];
        if (entry.handle != null) window.clearTimeout(entry.handle);
      }
      // Also cancel every pending exit failsafe — otherwise a removal timeout
      // scheduled just before unmount/clear() fires afterward and writes $data.
      for (const id in exitFailsafes.current) {
        if (exitFailsafes.current[id] != null) window.clearTimeout(exitFailsafes.current[id]);
      }
    }
    timers.current = {};
    exitFailsafes.current = {};
  }, []);
  function show(input: any) {
    const t = input || {};
    let id;
    if (t.id != null) {
      // Coerce a consumer-supplied id to a String once, at the single entry
      // point. Ids flow through the `timers` map (whose `for (const id in …)`
      // keys are ALWAYS strings) and every downstream `t.id === id` strict
      // comparison; a numeric consumer id (`show({ id: 42 })`) would otherwise
      // stop matching after a hover pause/resume re-arms with the string key.
      id = String(t.id);
    } else {
      // Take the high-water mark of the persistent-but-tick-stale $data.seq and
      // the synchronous-but-maybe-per-render seqLocal (see the <script> comment)
      // so same-tick multi-show yields DISTINCT ids on React too. Read both
      // BEFORE writing either (no read-after-write of $data.seq → ROZ138-safe).
      const s = Math.max(seq, seqLocal.current);
      id = 't' + s;
      seqLocal.current = s + 1;
      setSeq(s + 1);
    }
    const toast = {
      id,
      message: t.message != null ? t.message : '',
      type: t.type || 'info',
      duration: t.duration != null ? t.duration : props.duration
    };
    // ONE self-referential assignment so the React emitter lowers it to the
    // concurrent-safe functional updater `setToasts(prev => …)` (it only does so
    // when the RHS reads $data.toasts DIRECTLY — a via-a-local form lowered to a
    // stale-closure `setToasts(<value>)`, losing the first of two same-tick
    // toasts). slice() start: keep the newest `max` when over the cap
    // (Math.max(0, len+1-max)), else slice(0) = the whole fresh array.
    setToasts(prev => prev.concat([toast]).slice(props.max > 0 ? Math.max(0, prev.length + 1 - props.max) : 0));
    startTimer(toast);
    return id;
  }
  // ---- exit lifecycle ------------------------------------------------------
  // Deliberately exceeds the 200ms default --rozie-toast-exit-duration token
  // comfortably; a consumer overriding the exit duration beyond ~350ms gets cut
  // short by this failsafe (documented in docs/components/toast.md).
  const EXIT_FAILSAFE_MS = 350;

  // Idempotent removal: filters the entry out of $data.toasts. Safe to call
  // twice (from the inline @animationend binding AND the failsafe) — the
  // second call is a harmless no-op filter over an already-absent id.
  const removeToast = useCallback((id: any) => {
    // Cancel any pending exit failsafe for this id (first-wins: @animationend
    // beating the ~350ms timeout, or vice-versa — either way, only one removal).
    if (typeof window !== 'undefined' && exitFailsafes.current[id] != null) {
      window.clearTimeout(exitFailsafes.current[id]);
    }
    delete exitFailsafes.current[id];
    setToasts(prev => prev.filter((t: any) => t.id !== id));
  }, []);
  const { onDismissed: _rozieProp_onDismissed } = props;
    const dismissBegin = useCallback((id: any, reason: any, extra?: {
    swipeExitSign?: number;
  }) => {
    const entry = toasts.find((t: any) => t.id === id);
    if (!entry || entry.exiting) return;
    clearTimer(id);
    _rozieProp_onDismissed && _rozieProp_onDismissed({
      toast: entry,
      reason
    });
    setToasts(prev => prev.map((t: any) => t.id === id ? {
      ...t,
      exiting: true,
      ...(extra || {})
    } : t));
    if (typeof window === 'undefined') {
      removeToast(id);
    } else {
      exitFailsafes.current[id] = window.setTimeout(() => removeToast(id), EXIT_FAILSAFE_MS);
    }
  }, [_rozieProp_onDismissed, clearTimer, removeToast, toasts]);
  function dismiss(id: any) {
    dismissBegin(id, 'api');
  }
  function clear() {
    teardownTimers();
    setToasts([]);
  }
  function patch(id: any, changes: any) {
    const c = changes || {};
    let existed = false;
    const next = toasts.map((t: any) => {
      if (t.id !== id) return t;
      // Treat an EXITING entry as absent — never resurrect a toast whose
      // dismissal is already in flight (removal deferred to @animationend / the
      // failsafe). `existed` stays false → patch returns false, writes nothing,
      // arms no timer.
      if (t.exiting) return t;
      existed = true;
      const merged = {
        ...t
      };
      if (c.message !== undefined) merged.message = c.message;
      if (c.type !== undefined) merged.type = c.type;
      if (c.duration !== undefined) merged.duration = c.duration;
      return merged;
    });
    if (!existed) return false;
    setToasts(next);
    if (c.duration !== undefined) {
      clearTimer(id);
      const patched = next.find((t: any) => t.id === id);
      if (paused.current) {
        // Hovered: store the new duration as the pending remainder WITHOUT
        // arming a live timer (which would dismiss the toast while the pointer
        // is still over the stack). resumeTimers() arms it on leave.
        if (patched && patched.duration > 0 && typeof window !== 'undefined') {
          timers.current[id] = {
            handle: null,
            startedAt: Date.now(),
            remaining: patched.duration
          };
        }
      } else {
        startTimer(patched);
      }
    }
    return true;
  }
  function settlePromise(id: any, type: any, messageOrFn: any, value: any) {
    if (unmounted.current) return;
    // Never-resurrect: no-op if the toast is gone OR already exiting (its
    // dismissal is in flight — settling now would flip it back to a live
    // success/error toast and re-arm a timer).
    const entry = toasts.find((t: any) => t.id === id);
    if (!entry || entry.exiting) return;
    const message = typeof messageOrFn === 'function' ? messageOrFn(value) : messageOrFn;
    patch(id, {
      type,
      message,
      duration: props.duration
    });
  }
  function promise(p: any, opts: any) {
    const o = opts || {};
    const id = show({
      type: 'loading',
      duration: 0,
      message: o.loading
    });
    if (p && typeof p.then === 'function') {
      p.then((value: any) => settlePromise(id, 'success', o.success, value)).catch((err: any) => settlePromise(id, 'error', o.error, err));
    }
    return id;
  }
  function swipeAxisFor(position: any) {
    return position === 'top-center' || position === 'bottom-center' ? 'y' : 'x';
  }
  function swipeSignFor(position: any) {
    if (position === 'top-right' || position === 'bottom-right') return 1;
    if (position === 'top-left' || position === 'bottom-left') return -1;
    if (position === 'bottom-center') return 1;
    return -1; // top-center
  }
  const onToastPointerDown = useCallback((t: any, event: any) => {
    if (props.disableSwipe) return;
    if (event.button != null && event.button !== 0) return;
    // Ignore drags starting on the close button / any button-or-link chrome.
    const chrome = event.target && event.target.closest ? event.target.closest('button, a') : null;
    if (chrome) return;
    const axis = swipeAxisFor(props.position);
    const sign = swipeSignFor(props.position);
    const el = event.currentTarget;
    const size = axis === 'x' ? el.offsetWidth : el.offsetHeight;
    swipeGesture.current = {
      id: t.id,
      axis,
      sign,
      size,
      startX: event.clientX,
      startY: event.clientY,
      startTime: Date.now()
    };
    if (el && el.setPointerCapture) {
      try {
        el.setPointerCapture(event.pointerId);
      } catch (e: any) {
        // Some embedded contexts throw on setPointerCapture — swipe still
        // works without capture (just loses "keeps tracking off-element").
      }
    }
  }, [props.disableSwipe, props.position, swipeAxisFor, swipeSignFor]);
  const onToastPointerMove = useCallback((t: any, event: any) => {
    if (props.disableSwipe) return;
    const gesture = swipeGesture.current;
    if (!gesture || gesture.id !== t.id) return;
    const raw = gesture.axis === 'x' ? event.clientX - gesture.startX : event.clientY - gesture.startY;
    const towardDismiss = raw * gesture.sign > 0;
    const d = towardDismiss ? raw : raw * 0.15;
    setSwipe({
      id: t.id,
      d,
      axis: gesture.axis,
      sign: gesture.sign,
      size: gesture.size
    });
  }, [props.disableSwipe]);
  const onToastPointerUp = useCallback((t: any, event: any) => {
    if (props.disableSwipe) return;
    const gesture = swipeGesture.current;
    swipeGesture.current = null;
    // Local named `dragState`, NOT `swipe` — a local `swipe` would shadow the
    // reactive `$data.swipe` key on Svelte 5 (top-level `let swipe = $state(…)`
    // self-shadow TDZ: `const swipe = swipe` then `swipe = null` throws
    // "Cannot assign to constant"). Same collision class as the documented
    // $refs/$props self-shadow, just for a $data key.
    const dragState = swipe;
    setSwipe(null);
    if (!gesture || gesture.id !== t.id || !dragState) return;
    const elapsed = Math.max(1, Date.now() - gesture.startTime);
    const magnitude = dragState.d * gesture.sign;
    const velocity = magnitude / elapsed;
    if (magnitude > 0 && (magnitude > gesture.size * 0.45 || velocity > 0.11)) {
      dismissBegin(t.id, 'swipe', {
        swipeExitSign: gesture.sign
      });
    }
  }, [dismissBegin, props.disableSwipe, swipe]);
  const onToastPointerCancel = useCallback((t: any) => {
    if (props.disableSwipe) return;
    if (swipeGesture.current && swipeGesture.current.id === t.id) swipeGesture.current = null;
    if (swipe && swipe.id === t.id) setSwipe(null);
  }, [props.disableSwipe, swipe]);
  function depth(ti: any) {
    return toasts.length - 1 - ti;
  }
  function toastStyle(t: any, ti: any) {
    const depthDecl = '--rozie-toast-depth: ' + depth(ti) + ';';
    if (t.exiting) {
      return t.swipeExitSign != null ? depthDecl + ' --rozie-toast-swipe-exit: ' + t.swipeExitSign + ';' : depthDecl;
    }
    // Local named `dragState`, NOT `swipe` — see the onToastPointerUp comment
    // above (Svelte 5 $data-key self-shadow).
    const dragState = swipe;
    if (!dragState || dragState.id !== t.id) return depthDecl;
    const translate = dragState.axis === 'x' ? 'translateX(' + dragState.d + 'px)' : 'translateY(' + dragState.d + 'px)';
    const magnitude = dragState.d * dragState.sign;
    const opacity = magnitude > 0 && dragState.size > 0 ? Math.max(0.3, 1 - magnitude / dragState.size) : 1;
    return depthDecl + ' transform: ' + translate + '; opacity: ' + opacity + '; transition: none;';
  }
  const onMouseEnter = useCallback(() => {
    if (props.disablePauseOnHover) return;
    pauseTimers();
  }, [pauseTimers, props.disablePauseOnHover]);
  const onMouseLeave = useCallback(() => {
    if (props.disablePauseOnHover) return;
    resumeTimers();
  }, [props.disablePauseOnHover, resumeTimers]);
  function regionLabel() {
    return props.ariaLabel != null ? props.ariaLabel : 'Notifications';
  }
  function liveFor(type: any) {
    return type === 'error' || type === 'warning' ? 'assertive' : 'polite';
  }

  useEffect(() => {
    return () => {
      unmounted.current = true;
      teardownTimers();
    };
  }, []);

  const _rozieExposeRef = useRef({ show, dismiss, clear, patch, promise });
  _rozieExposeRef.current = { show, dismiss, clear, patch, promise };
  useImperativeHandle(ref, () => ({ show: (...args: Parameters<typeof show>): ReturnType<typeof show> => _rozieExposeRef.current.show(...args), dismiss: (...args: Parameters<typeof dismiss>): ReturnType<typeof dismiss> => _rozieExposeRef.current.dismiss(...args), clear: (...args: Parameters<typeof clear>): ReturnType<typeof clear> => _rozieExposeRef.current.clear(...args), patch: (...args: Parameters<typeof patch>): ReturnType<typeof patch> => _rozieExposeRef.current.patch(...args), promise: (...args: Parameters<typeof promise>): ReturnType<typeof promise> => _rozieExposeRef.current.promise(...args) }), []);

  return (
    <>
    <div role="region" aria-label={rozieAttr(regionLabel())} {...attrs} className={clsx(clsx("rozie-toaster", 'rozie-toaster--' + props.position + (props.stacked ? ' rozie-toaster--stacked' : '')), (attrs.className as string | undefined))} onMouseEnter={($event) => { onMouseEnter(); }} onMouseLeave={($event) => { onMouseLeave(); }} data-rozie-s-12d4265c="">
      
      {toasts.map((t, ti) => <div key={t.id} className={clsx("rozie-toast", 'rozie-toast--' + t.type + (t.exiting ? ' rozie-toast--exiting' : '') + (t.swipeExitSign != null ? ' rozie-toast--swipe-exit' : ''))} style={parseInlineStyle(toastStyle(t, ti))} role="status" aria-live={rozieAttr(liveFor(t.type))} onAnimationEnd={($event) => { t.exiting && removeToast(t.id); }} onPointerDown={($event) => { onToastPointerDown(t, $event); }} onPointerMove={($event) => { onToastPointerMove(t, $event); }} onPointerUp={($event) => { onToastPointerUp(t, $event); }} onPointerCancel={($event) => { onToastPointerCancel(t); }} data-rozie-s-12d4265c="">
        {(props.renderToast ?? props.slots?.['toast']) ? ((props.renderToast ?? props.slots?.['toast']) as Function)({ toast: t, dismiss }) : <>{!!(t.type === 'loading') && <span className={"rozie-toast-spinner"} aria-hidden="true" data-rozie-s-12d4265c="" />}<span className={"rozie-toast-message"} data-rozie-s-12d4265c="">{rozieDisplay(t.message)}</span><button type="button" className={"rozie-toast-close"} aria-label="Dismiss" onClick={($event) => { dismissBegin(t.id, 'close'); }} data-rozie-s-12d4265c="">×</button></>}
      </div>)}
    </div>
    </>
  );
});
export default Toaster;
vue
<template>

<div :class="['rozie-toaster', 'rozie-toaster--' + props.position + (props.stacked ? ' rozie-toaster--stacked' : '')]" role="region" :aria-label="regionLabel()" v-bind="$attrs" @mouseenter="onMouseEnter()" @mouseleave="onMouseLeave()">
  
  <div v-for="(t, ti) in toasts" :key="t.id" :class="['rozie-toast', 'rozie-toast--' + t.type + (t.exiting ? ' rozie-toast--exiting' : '') + (t.swipeExitSign != null ? ' rozie-toast--swipe-exit' : '')]" :style="toastStyle(t, ti)" role="status" :aria-live="liveFor(t.type)" @animationend="t.exiting && removeToast(t.id)" @pointerdown="onToastPointerDown(t, $event)" @pointermove="onToastPointerMove(t, $event)" @pointerup="onToastPointerUp(t, $event)" @pointercancel="onToastPointerCancel(t)">
    <slot name="toast" :toast="t" :dismiss="dismiss">
      <span v-if="t.type === 'loading'" class="rozie-toast-spinner" aria-hidden="true"></span><span class="rozie-toast-message">{{ t.message }}</span>
      <button type="button" class="rozie-toast-close" aria-label="Dismiss" @click="dismissBegin(t.id, 'close')">×</button>
    </slot>
  </div>
</div>

</template>

<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';

const props = withDefaults(
  defineProps<{
    /**
     * Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
     */
    position?: string;
    /**
     * Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
     */
    duration?: number;
    /**
     * Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
     */
    max?: number;
    /**
     * Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
     */
    disablePauseOnHover?: boolean;
    /**
     * Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
     */
    ariaLabel?: string | null;
    /**
     * Opt **out** of pointer swipe-to-dismiss. By default, dragging a toast past 45% of its own width/height (direction auto-derived from `position`) or a fast flick dismisses it with reason `'swipe'`; a short drag springs back. A drag starting on the close button (or any button/link) never swipes.
     */
    disableSwipe?: boolean;
    /**
     * Opt **in** to a sonner-style collapsed stack: a single-cell grid overlay with depth-driven transforms (toasts at depth 3+ fade to invisible), newest on top. Hovering the region or moving keyboard focus into it expands to the normal flex-column stack; leaving re-collapses. `false` (default) renders the plain flex column at all times.
     */
    stacked?: boolean;
  }>(),
  { position: 'bottom-right', duration: 4000, max: 0, disablePauseOnHover: false, ariaLabel: null, disableSwipe: false, stacked: false }
);

const emit = defineEmits<{
  dismissed: [...args: any[]];
}>();

defineSlots<{
  toast(props: { toast: any; dismiss: any }): any;
}>();

const toasts = ref<any[]>([]);
const seq = ref(0);
const swipe = ref<any>(null);

// Mutable cross-render scratch (NOT reactive): per-id timer bookkeeping. A
// top-level `let` → React useRef (it escapes into $onUnmount's effect, so the
// emitter hoists it). The id counter lives in $data.seq instead (see <data>).
//
// Shape: { [id]: { handle, startedAt, remaining } }. `pauseTimers` clears the
// live setTimeout handle but KEEPS the entry with a decremented `remaining` —
// the remainder IS the state (this is what makes the hover pause PRECISE
// instead of a full restart). `resumeTimers` re-arms with exactly that
// remainder. `clearTimer`/the full-teardown helper below are the only ways an
// entry is actually removed from the map.
let timers = {};
// Per-id handles for the ~350ms exit-removal failsafe (the fallback that
// removes a toast if its @animationend never fires). Tracked in a module map
// — NOT an anonymous window.setTimeout — so teardownTimers ($onUnmount /
// clear()) can cancel a pending failsafe (else it fires post-unmount and
// writes $data on a torn-down instance) and removeToast can cancel it
// first-wins when @animationend beats it. Escapes into $onUnmount's effect →
// React hoists it to useRef alongside `timers`.
let exitFailsafes = {};
// Set true in $onUnmount; read by promise()'s settle guard (never-resurrect
// a toast after the host itself is gone). A top-level `let` → React useRef
// (it escapes into $onUnmount's effect).
let unmounted = false;
// Same-tick id-uniqueness guard for React. The id counter lives in reactive
// $data.seq (persists across renders), but React batches setState so within a
// SINGLE synchronous tick two show() calls read the SAME stale $data.seq →
// duplicate ids. `seqLocal` is a plain counter incremented SYNCHRONOUSLY in
// show(); it survives the same tick (and, because show() is an $expose verb,
// the emitter hoists it to a persistent useRef on React too — but the design
// does NOT depend on that: `Math.max($data.seq, seqLocal)` is correct whether
// seqLocal persists OR resets per render, since the monotonic $data.seq
// carries the high-water mark across any reset). On the other five targets
// $data.seq is synchronous, so the two simply stay in lockstep. Result:
// strictly-increasing, collision-free ids on all six with NO randomness.
let seqLocal = 0;
// Hover-pause flag: true while the pointer is over the stack (set by
// pauseTimers, cleared by resumeTimers). Read by patch() so a duration change
// arriving mid-hover stores the new remainder WITHOUT arming a live timer
// (which would dismiss the toast while it is still hovered) — resume arms it
// on leave. A top-level `let` reachable from the $expose verbs (patch/show →
// startTimer) and the @mouseenter/@mouseleave handlers, so React hoists it to
// useRef (persistent) like `timers`.
let paused = false;
// The ACTIVE pointer-drag gesture's non-visual bookkeeping: { id, axis, sign,
// size, startX, startY, startTime } | null (set on @pointerdown, read on
// @pointermove/@pointerup, cleared on @pointerup/@pointercancel). Referenced
// ONLY from the four onToastPointer* handlers below, which are bound ONLY via
// template `@pointerdown`/`@pointermove`/`@pointerup`/`@pointercancel` — the
// template-@event-handler reachability root (Quick 260717-8zb Task 3 Item 6,
// hoistModuleLet.ts) hoists this to useRef on React so it persists across the
// re-renders the sibling `$data.swipe` write triggers mid-gesture. Never read
// directly in the template — script-only bookkeeping.
let swipeGesture: any = null;
// ---- timers ------------------------------------------------------------
const startTimer = (toast: any) => {
  if (!toast || !toast.duration || toast.duration <= 0) return;
  if (typeof window === 'undefined') return;
  // Belt-and-braces: clear any pre-existing live handle for this id before
  // overwriting the entry, so a re-arm never orphans a running timeout.
  const existing = timers[toast.id];
  if (existing && existing.handle != null) window.clearTimeout(existing.handle);
  const remaining = toast.duration;
  const handle = window.setTimeout(() => dismissBegin(toast.id, 'timeout'), remaining);
  timers[toast.id] = {
    handle,
    startedAt: Date.now(),
    remaining
  };
};
const clearTimer = (id: any) => {
  const entry = timers[id];
  if (entry && entry.handle != null && typeof window !== 'undefined') window.clearTimeout(entry.handle);
  delete timers[id];
};
// Pauses every live timer WITHOUT losing the remainder: clears the handle,
// decrements `remaining` by the elapsed time, and KEEPS the entry (does NOT
// delete it — the old v1 shortcut deleted entries here, which is why leave
// had to do a full restart).
const pauseTimers = () => {
  paused = true;
  if (typeof window === 'undefined') return;
  for (const id in timers) {
    const entry = timers[id];
    // Idempotent: an entry already paused (handle cleared) keeps its stored
    // remainder. A second pause must NOT re-subtract elapsed against the
    // original startedAt — that drove `remaining` negative and stranded the
    // toast forever once resume saw the non-positive value.
    if (entry.handle == null) continue;
    window.clearTimeout(entry.handle);
    const elapsed = Date.now() - entry.startedAt;
    // Clamp so a late pause (e.g. a background-tab timer that overran) can
    // never store a negative remainder.
    const remaining = Math.max(0, entry.remaining - elapsed);
    timers[id] = {
      handle: null,
      startedAt: entry.startedAt,
      remaining
    };
  }
};
// Re-arms every paused timer with EXACTLY its stored remainder (called on
// mouse leave). An entry with a non-positive remainder is left un-armed
// (it will be cleaned up by the next dismiss/clear pass) rather than firing
// immediately from inside this loop.
const resumeTimers = () => {
  paused = false;
  if (typeof window === 'undefined') return;
  for (const id in timers) {
    const entry = timers[id];
    // Only re-arm entries that are actually paused (handle cleared). A live
    // handle is left alone — re-arming it would orphan the running timeout.
    if (entry.handle != null) continue;
    if (entry.remaining == null || entry.remaining <= 0) {
      // Its deadline elapsed while paused (a background-tab overrun, or a
      // remainder clamped to 0): treat as EXPIRED and dismiss now — its time
      // is up — rather than leaving it un-armed and stranded forever.
      dismissBegin(id, 'timeout');
      continue;
    }
    const remaining = entry.remaining;
    const handle = window.setTimeout(() => dismissBegin(id, 'timeout'), remaining);
    timers[id] = {
      handle,
      startedAt: Date.now(),
      remaining
    };
  }
};
// FULL teardown: clears every live handle AND drops every entry (unlike
// pauseTimers, which deliberately keeps entries to hold their remainders).
// clear() and $onUnmount can no longer reuse pauseTimers for this reason.
const teardownTimers = () => {
  if (typeof window !== 'undefined') {
    for (const id in timers) {
      const entry = timers[id];
      if (entry.handle != null) window.clearTimeout(entry.handle);
    }
    // Also cancel every pending exit failsafe — otherwise a removal timeout
    // scheduled just before unmount/clear() fires afterward and writes $data.
    for (const id in exitFailsafes) {
      if (exitFailsafes[id] != null) window.clearTimeout(exitFailsafes[id]);
    }
  }
  timers = {};
  exitFailsafes = {};
};
// ---- queue (imperative handle implementations) -------------------------
const show = (input: any) => {
  const t = input || {};
  let id;
  if (t.id != null) {
    // Coerce a consumer-supplied id to a String once, at the single entry
    // point. Ids flow through the `timers` map (whose `for (const id in …)`
    // keys are ALWAYS strings) and every downstream `t.id === id` strict
    // comparison; a numeric consumer id (`show({ id: 42 })`) would otherwise
    // stop matching after a hover pause/resume re-arms with the string key.
    id = String(t.id);
  } else {
    // Take the high-water mark of the persistent-but-tick-stale $data.seq and
    // the synchronous-but-maybe-per-render seqLocal (see the <script> comment)
    // so same-tick multi-show yields DISTINCT ids on React too. Read both
    // BEFORE writing either (no read-after-write of $data.seq → ROZ138-safe).
    const s = Math.max(seq.value, seqLocal);
    id = 't' + s;
    seqLocal = s + 1;
    seq.value = s + 1;
  }
  const toast = {
    id,
    message: t.message != null ? t.message : '',
    type: t.type || 'info',
    duration: t.duration != null ? t.duration : props.duration
  };
  // ONE self-referential assignment so the React emitter lowers it to the
  // concurrent-safe functional updater `setToasts(prev => …)` (it only does so
  // when the RHS reads $data.toasts DIRECTLY — a via-a-local form lowered to a
  // stale-closure `setToasts(<value>)`, losing the first of two same-tick
  // toasts). slice() start: keep the newest `max` when over the cap
  // (Math.max(0, len+1-max)), else slice(0) = the whole fresh array.
  toasts.value = toasts.value.concat([toast]).slice(props.max > 0 ? Math.max(0, toasts.value.length + 1 - props.max) : 0);
  startTimer(toast);
  return id;
};
// ---- exit lifecycle ------------------------------------------------------
// Deliberately exceeds the 200ms default --rozie-toast-exit-duration token
// comfortably; a consumer overriding the exit duration beyond ~350ms gets cut
// short by this failsafe (documented in docs/components/toast.md).
const EXIT_FAILSAFE_MS = 350;
// Idempotent removal: filters the entry out of $data.toasts. Safe to call
// twice (from the inline @animationend binding AND the failsafe) — the
// second call is a harmless no-op filter over an already-absent id.
const removeToast = (id: any) => {
  // Cancel any pending exit failsafe for this id (first-wins: @animationend
  // beating the ~350ms timeout, or vice-versa — either way, only one removal).
  if (typeof window !== 'undefined' && exitFailsafes[id] != null) {
    window.clearTimeout(exitFailsafes[id]);
  }
  delete exitFailsafes[id];
  toasts.value = toasts.value.filter((t: any) => t.id !== id);
};
// The single dismissal funnel every path routes through: the `dismiss(id)`
// verb ('api'), the built-in close button ('close'), a timer expiry
// ('timeout'), and a swipe past threshold ('swipe'). Idempotent via the
// entry's `exiting` flag — a second call on an id already exiting (or
// already gone) is a no-op, so a stray timeout firing mid-exit never
// double-emits. `extra` (swipe only) carries `{ swipeExitSign }` so the
// template can apply the direction-matched swipe-exit animation.
const dismissBegin = (id: any, reason: any, extra?: {
  swipeExitSign?: number;
}) => {
  const entry = toasts.value.find((t: any) => t.id === id);
  if (!entry || entry.exiting) return;
  clearTimer(id);
  emit('dismissed', {
    toast: entry,
    reason
  });
  toasts.value = toasts.value.map((t: any) => t.id === id ? {
    ...t,
    exiting: true,
    ...(extra || {})
  } : t);
  if (typeof window === 'undefined') {
    removeToast(id);
  } else {
    exitFailsafes[id] = window.setTimeout(() => removeToast(id), EXIT_FAILSAFE_MS);
  }
};
const dismiss = (id: any) => {
  dismissBegin(id, 'api');
};
// clear() is bulk: immediate full teardown, NO per-toast exit animation and
// NO emit (documented — see docs/components/toast.md).
const clear = () => {
  teardownTimers();
  toasts.value = [];
};
// ---- patch / promise ------------------------------------------------------
// Update-in-place primitive: merges ONLY the present `{message,type,duration}`
// keys into the matching entry via a fresh-array map (never in-place
// mutation). Returns whether the id existed. A `duration` key clears+restarts
// the timer (0 → sticky/no-arm; positive → arm); any other key leaves a
// running timer untouched.
const patch = (id: any, changes: any) => {
  const c = changes || {};
  let existed = false;
  const next = toasts.value.map((t: any) => {
    if (t.id !== id) return t;
    // Treat an EXITING entry as absent — never resurrect a toast whose
    // dismissal is already in flight (removal deferred to @animationend / the
    // failsafe). `existed` stays false → patch returns false, writes nothing,
    // arms no timer.
    if (t.exiting) return t;
    existed = true;
    const merged = {
      ...t
    };
    if (c.message !== undefined) merged.message = c.message;
    if (c.type !== undefined) merged.type = c.type;
    if (c.duration !== undefined) merged.duration = c.duration;
    return merged;
  });
  if (!existed) return false;
  toasts.value = next;
  if (c.duration !== undefined) {
    clearTimer(id);
    const patched = next.find((t: any) => t.id === id);
    if (paused) {
      // Hovered: store the new duration as the pending remainder WITHOUT
      // arming a live timer (which would dismiss the toast while the pointer
      // is still over the stack). resumeTimers() arms it on leave.
      if (patched && patched.duration > 0 && typeof window !== 'undefined') {
        timers[id] = {
          handle: null,
          startedAt: Date.now(),
          remaining: patched.duration
        };
      }
    } else {
      startTimer(patched);
    }
  }
  return true;
};
// The settle guard: a no-op if the host unmounted OR the toast was already
// dismissed while the promise was still pending (never-resurrect).
const settlePromise = (id: any, type: any, messageOrFn: any, value: any) => {
  if (unmounted) return;
  // Never-resurrect: no-op if the toast is gone OR already exiting (its
  // dismissal is in flight — settling now would flip it back to a live
  // success/error toast and re-arm a timer).
  const entry = toasts.value.find((t: any) => t.id === id);
  if (!entry || entry.exiting) return;
  const message = typeof messageOrFn === 'function' ? messageOrFn(value) : messageOrFn;
  patch(id, {
    type,
    message,
    duration: props.duration
  });
};
// Sugar over show()+patch(): shows a sticky loading toast synchronously
// (returns its id immediately — the consumer already holds `p`), then patches
// the SAME entry to success/error on settle (the auto-dismiss timer starts AT
// SETTLE, via patch's duration-key restart). Never returns/derives a new
// promise — `p`'s own .then/.catch still fire for the consumer untouched.
const promise = (p: any, opts: any) => {
  const o = opts || {};
  const id = show({
    type: 'loading',
    duration: 0,
    message: o.loading
  });
  if (p && typeof p.then === 'function') {
    p.then((value: any) => settlePromise(id, 'success', o.success, value)).catch((err: any) => settlePromise(id, 'error', o.error, err));
  }
  return id;
};
// ---- swipe-to-dismiss ------------------------------------------------------
// Axis + dismiss-direction sign, purely derived from the corner (no per-
// gesture state needed for these two — they only depend on $props.position).
const swipeAxisFor = (position: any) => position === 'top-center' || position === 'bottom-center' ? 'y' : 'x';
const swipeSignFor = (position: any) => {
  if (position === 'top-right' || position === 'bottom-right') return 1;
  if (position === 'top-left' || position === 'bottom-left') return -1;
  if (position === 'bottom-center') return 1;
  return -1; // top-center
};
const onToastPointerDown = (t: any, event: any) => {
  if (props.disableSwipe) return;
  if (event.button != null && event.button !== 0) return;
  // Ignore drags starting on the close button / any button-or-link chrome.
  const chrome = event.target && event.target.closest ? event.target.closest('button, a') : null;
  if (chrome) return;
  const axis = swipeAxisFor(props.position);
  const sign = swipeSignFor(props.position);
  const el = event.currentTarget;
  const size = axis === 'x' ? el.offsetWidth : el.offsetHeight;
  swipeGesture = {
    id: t.id,
    axis,
    sign,
    size,
    startX: event.clientX,
    startY: event.clientY,
    startTime: Date.now()
  };
  if (el && el.setPointerCapture) {
    try {
      el.setPointerCapture(event.pointerId);
    } catch (e: any) {
      // Some embedded contexts throw on setPointerCapture — swipe still
      // works without capture (just loses "keeps tracking off-element").
    }
  }
};
const onToastPointerMove = (t: any, event: any) => {
  if (props.disableSwipe) return;
  const gesture = swipeGesture;
  if (!gesture || gesture.id !== t.id) return;
  const raw = gesture.axis === 'x' ? event.clientX - gesture.startX : event.clientY - gesture.startY;
  const towardDismiss = raw * gesture.sign > 0;
  const d = towardDismiss ? raw : raw * 0.15;
  swipe.value = {
    id: t.id,
    d,
    axis: gesture.axis,
    sign: gesture.sign,
    size: gesture.size
  };
};
const onToastPointerUp = (t: any, event: any) => {
  if (props.disableSwipe) return;
  const gesture = swipeGesture;
  swipeGesture = null;
  // Local named `dragState`, NOT `swipe` — a local `swipe` would shadow the
  // reactive `$data.swipe` key on Svelte 5 (top-level `let swipe = $state(…)`
  // self-shadow TDZ: `const swipe = swipe` then `swipe = null` throws
  // "Cannot assign to constant"). Same collision class as the documented
  // $refs/$props self-shadow, just for a $data key.
  const dragState = swipe.value;
  swipe.value = null;
  if (!gesture || gesture.id !== t.id || !dragState) return;
  const elapsed = Math.max(1, Date.now() - gesture.startTime);
  const magnitude = dragState.d * gesture.sign;
  const velocity = magnitude / elapsed;
  if (magnitude > 0 && (magnitude > gesture.size * 0.45 || velocity > 0.11)) {
    dismissBegin(t.id, 'swipe', {
      swipeExitSign: gesture.sign
    });
  }
};
const onToastPointerCancel = (t: any) => {
  if (props.disableSwipe) return;
  if (swipeGesture && swipeGesture.id === t.id) swipeGesture = null;
  if (swipe.value && swipe.value.id === t.id) swipe.value = null;
};
// ---- stacked mode ----------------------------------------------------------
// Depth from newest: the newest toast (last in the array — show() appends)
// is depth 0; each older toast is one deeper. Corner-independent — the
// collapsed grid overlay ignores flex-direction/column-reverse entirely, so
// this needs no position-aware math.
//
// quick 260716-npt Finding 3 (perf): depth USED to be a per-toast
// `$data.toasts.findIndex(...)` scan invoked from toastStyle() for every row
// — O(n) work × n toasts rendered = O(n^2) per render. The template's r-for
// already computes each row's array index for free (the r-for bare-comma
// index form, `t, ti in ...` — see TreeNode.rozie/Table.rozie precedent), so
// depth(ti) is now O(1) arithmetic off that index — no scan, and `t`'s id
// can never be "not found" via this call path (ti IS t's own index), so the
// old idx===-1→0 fallback collapses to unreachable-by-construction (same
// observable semantics: newest=depth 0, older=length-1-idx).
const depth = (ti: any) => toasts.value.length - 1 - ti;
// String-form `:style` for the toast row. ALWAYS carries `--rozie-toast-depth`
// (a no-op unless `stacked` is on — CSS reads it only inside
// `.rozie-toaster--stacked`), plus EITHER the active drag transform (while
// $data.swipe tracks this id) OR the swipe-exit sign custom property (once
// `dismissBegin('swipe')` flipped `t.swipeExitSign`). Drag/exit never overlap.
const toastStyle = (t: any, ti: any) => {
  const depthDecl = '--rozie-toast-depth: ' + depth(ti) + ';';
  if (t.exiting) {
    return t.swipeExitSign != null ? depthDecl + ' --rozie-toast-swipe-exit: ' + t.swipeExitSign + ';' : depthDecl;
  }
  // Local named `dragState`, NOT `swipe` — see the onToastPointerUp comment
  // above (Svelte 5 $data-key self-shadow).
  const dragState = swipe.value;
  if (!dragState || dragState.id !== t.id) return depthDecl;
  const translate = dragState.axis === 'x' ? 'translateX(' + dragState.d + 'px)' : 'translateY(' + dragState.d + 'px)';
  const magnitude = dragState.d * dragState.sign;
  const opacity = magnitude > 0 && dragState.size > 0 ? Math.max(0.3, 1 - magnitude / dragState.size) : 1;
  return depthDecl + ' transform: ' + translate + '; opacity: ' + opacity + '; transition: none;';
};
// ---- hover pause -------------------------------------------------------
const onMouseEnter = () => {
  if (props.disablePauseOnHover) return;
  pauseTimers();
};
const onMouseLeave = () => {
  if (props.disablePauseOnHover) return;
  resumeTimers();
};
// ---- helpers -----------------------------------------------------------
const regionLabel = () => props.ariaLabel != null ? props.ariaLabel : 'Notifications';
// Type union: 'info' | 'success' | 'error' | 'warning' | 'loading'. Only
// error/warning interrupt (assertive); loading (like info/success) is polite.
const liveFor = (type: any) => type === 'error' || type === 'warning' ? 'assertive' : 'polite';

// ---- lifecycle + handle ------------------------------------------------

onBeforeUnmount(() => {
  unmounted = true;
  teardownTimers();
});

defineExpose({ show, dismiss, clear, patch, promise });
</script>

<style scoped>
@media (prefers-reduced-motion: reduce) {
  .rozie-toast {
    animation-name: rozie-toast-fade-in;
    animation-duration: 1ms;
  }
  .rozie-toast--exiting {
    animation-name: rozie-toast-fade-out;
    animation-duration: 1ms;
  }
}
.rozie-toaster {
  position: fixed;
  z-index: var(--rozie-toast-z, 9999);
  display: flex;
  flex-direction: column;
  gap: var(--rozie-toast-gap, 0.5rem);
  padding: var(--rozie-toast-region-padding, 1rem);
  max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
  pointer-events: none;
  font: var(--rozie-toast-font, inherit);
}
.rozie-toaster > * {
  pointer-events: auto;
}
.rozie-toaster--top-left { top: 0; left: 0; align-items: flex-start; }
.rozie-toaster--top-right { top: 0; right: 0; align-items: flex-end; }
.rozie-toaster--top-center { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
.rozie-toaster--bottom-left { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
.rozie-toaster--bottom-right { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
.rozie-toaster--bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
.rozie-toaster--stacked .rozie-toast {
  grid-area: 1 / 1;
  z-index: calc(100 - var(--rozie-toast-depth, 0));
}
.rozie-toaster--stacked:not(:hover):not(:focus-within) {
  display: grid;
}
.rozie-toaster--stacked:not(:hover):not(:focus-within) .rozie-toast {
  transform:
    translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px)))
    scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
  opacity: calc(1 - min(1, max(0, var(--rozie-toast-depth, 0) - 2)));
}
.rozie-toaster--stacked.rozie-toaster--bottom-left:not(:hover):not(:focus-within) .rozie-toast,
.rozie-toaster--stacked.rozie-toaster--bottom-right:not(:hover):not(:focus-within) .rozie-toast,
.rozie-toaster--stacked.rozie-toaster--bottom-center:not(:hover):not(:focus-within) .rozie-toast {
  transform:
    translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px) * -1))
    scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
}
.rozie-toast {
  display: flex;
  align-items: center;
  gap: var(--rozie-toast-content-gap, 0.75rem);
  min-width: var(--rozie-toast-min-width, 16rem);
  max-width: var(--rozie-toast-toast-max-width, 24rem);
  padding: var(--rozie-toast-padding, 0.75rem 1rem);
  color: var(--rozie-toast-color, #fff);
  background: var(--rozie-toast-bg, #333);
  border-radius: var(--rozie-toast-radius, 0.5rem);
  box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
  /* Swipe: page scroll stays alive on touch along the axis the toast does
     NOT move on. The transition here drives the spring-back (the active-drag
     :style sets an inline `transition: none` to track the finger 1:1;
     releasing it without a further gesture falls back to this transition). */
  touch-action: pan-y;
  transition: transform 200ms ease, opacity 200ms ease;
}
.rozie-toaster--top-center .rozie-toast,
.rozie-toaster--bottom-center .rozie-toast {
  touch-action: pan-x;
}
.rozie-toast--success { background: var(--rozie-toast-success-bg, #16a34a); }
.rozie-toast--error { background: var(--rozie-toast-error-bg, #dc2626); }
.rozie-toast--warning { background: var(--rozie-toast-warning-bg, #ca8a04); }
.rozie-toast--info { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
from { opacity: 0; transform: translateY(-0.5rem); }
to { opacity: 1; transform: translateY(0); }
from { opacity: 0; transform: translateY(0.5rem); }
to { opacity: 1; transform: translateY(0); }
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-0.5rem); }
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(0.5rem); }
.rozie-toast {
  animation: rozie-toast-enter var(--rozie-toast-enter-duration, 200ms) ease-out;
}
.rozie-toaster--bottom-left .rozie-toast,
.rozie-toaster--bottom-right .rozie-toast,
.rozie-toaster--bottom-center .rozie-toast {
  animation-name: rozie-toast-enter-from-bottom;
}
.rozie-toast--exiting {
  animation: rozie-toast-exit var(--rozie-toast-exit-duration, 200ms) ease-in forwards;
}
.rozie-toaster--bottom-left .rozie-toast--exiting,
.rozie-toaster--bottom-right .rozie-toast--exiting,
.rozie-toaster--bottom-center .rozie-toast--exiting {
  animation-name: rozie-toast-exit-to-bottom;
}
from { opacity: 0; }
to { opacity: 1; }
from { opacity: 1; }
to { opacity: 0; }
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
.rozie-toast--exiting.rozie-toast--swipe-exit {
  animation-name: rozie-toast-swipe-exit-x;
}
.rozie-toaster--top-center .rozie-toast--exiting.rozie-toast--swipe-exit,
.rozie-toaster--bottom-center .rozie-toast--exiting.rozie-toast--swipe-exit {
  animation-name: rozie-toast-swipe-exit-y;
}
.rozie-toast-spinner {
  flex: 0 0 auto;
  width: var(--rozie-toast-spinner-size, 1em);
  height: var(--rozie-toast-spinner-size, 1em);
  border: 2px solid color-mix(in srgb, var(--rozie-toast-spinner-color, currentColor) 25%, transparent);
  border-top-color: var(--rozie-toast-spinner-color, currentColor);
  border-radius: 50%;
  animation: rozie-toast-spin 0.75s linear infinite;
}
to { transform: rotate(360deg); }
.rozie-toast-message {
  flex: 1 1 auto;
  font-size: var(--rozie-toast-font-size, 0.9rem);
}
.rozie-toast-close {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-toast-close-size, 1.25rem);
  height: var(--rozie-toast-close-size, 1.25rem);
  padding: 0;
  font-size: 1.1rem;
  line-height: 1;
  color: inherit;
  background: transparent;
  border: none;
  border-radius: 0.25rem;
  opacity: var(--rozie-toast-close-opacity, 0.75);
  cursor: pointer;
}
.rozie-toast-close:hover {
  opacity: 1;
}
</style>
svelte
<script lang="ts">
import { applyListeners, rozieAttr, rozieClass, rozieDisplay, rozieStyle } from '@rozie/runtime-svelte';

import type { Snippet } from 'svelte';
import { onDestroy } from 'svelte';

interface Props {
  /**
   * Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
   */
  position?: string;
  /**
   * Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
   */
  duration?: number;
  /**
   * Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
   */
  max?: number;
  /**
   * Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
   */
  disablePauseOnHover?: boolean;
  /**
   * Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
   */
  ariaLabel?: (string) | null;
  /**
   * Opt **out** of pointer swipe-to-dismiss. By default, dragging a toast past 45% of its own width/height (direction auto-derived from `position`) or a fast flick dismisses it with reason `'swipe'`; a short drag springs back. A drag starting on the close button (or any button/link) never swipes.
   */
  disableSwipe?: boolean;
  /**
   * Opt **in** to a sonner-style collapsed stack: a single-cell grid overlay with depth-driven transforms (toasts at depth 3+ fade to invisible), newest on top. Hovering the region or moving keyboard focus into it expands to the normal flex-column stack; leaving re-collapses. `false` (default) renders the plain flex column at all times.
   */
  stacked?: boolean;
  toast?: Snippet<[{ toast: any; dismiss: any }]>;
  snippets?: Record<string, any>;
  ondismissed?: (...args: unknown[]) => void;
  [key: string]: unknown;
}

let {
  position = 'bottom-right',
  duration = 4000,
  max = 0,
  disablePauseOnHover = false,
  ariaLabel = null,
  disableSwipe = false,
  stacked = false,
  toast: __toastProp,
  snippets,
  ondismissed,
  ...__rozieAttrs
}: Props = $props();

const toast = $derived(__toastProp ?? snippets?.toast);

let toasts: any[] = $state([]);
let seq = $state(0);
let swipe: any = $state(null);

// Mutable cross-render scratch (NOT reactive): per-id timer bookkeeping. A
// top-level `let` → React useRef (it escapes into $onUnmount's effect, so the
// emitter hoists it). The id counter lives in $data.seq instead (see <data>).
//
// Shape: { [id]: { handle, startedAt, remaining } }. `pauseTimers` clears the
// live setTimeout handle but KEEPS the entry with a decremented `remaining` —
// the remainder IS the state (this is what makes the hover pause PRECISE
// instead of a full restart). `resumeTimers` re-arms with exactly that
// remainder. `clearTimer`/the full-teardown helper below are the only ways an
// entry is actually removed from the map.
let timers = {};
// Per-id handles for the ~350ms exit-removal failsafe (the fallback that
// removes a toast if its @animationend never fires). Tracked in a module map
// — NOT an anonymous window.setTimeout — so teardownTimers ($onUnmount /
// clear()) can cancel a pending failsafe (else it fires post-unmount and
// writes $data on a torn-down instance) and removeToast can cancel it
// first-wins when @animationend beats it. Escapes into $onUnmount's effect →
// React hoists it to useRef alongside `timers`.
let exitFailsafes = {};
// Set true in $onUnmount; read by promise()'s settle guard (never-resurrect
// a toast after the host itself is gone). A top-level `let` → React useRef
// (it escapes into $onUnmount's effect).
let unmounted = false;
// Same-tick id-uniqueness guard for React. The id counter lives in reactive
// $data.seq (persists across renders), but React batches setState so within a
// SINGLE synchronous tick two show() calls read the SAME stale $data.seq →
// duplicate ids. `seqLocal` is a plain counter incremented SYNCHRONOUSLY in
// show(); it survives the same tick (and, because show() is an $expose verb,
// the emitter hoists it to a persistent useRef on React too — but the design
// does NOT depend on that: `Math.max($data.seq, seqLocal)` is correct whether
// seqLocal persists OR resets per render, since the monotonic $data.seq
// carries the high-water mark across any reset). On the other five targets
// $data.seq is synchronous, so the two simply stay in lockstep. Result:
// strictly-increasing, collision-free ids on all six with NO randomness.
let seqLocal = 0;
// Hover-pause flag: true while the pointer is over the stack (set by
// pauseTimers, cleared by resumeTimers). Read by patch() so a duration change
// arriving mid-hover stores the new remainder WITHOUT arming a live timer
// (which would dismiss the toast while it is still hovered) — resume arms it
// on leave. A top-level `let` reachable from the $expose verbs (patch/show →
// startTimer) and the @mouseenter/@mouseleave handlers, so React hoists it to
// useRef (persistent) like `timers`.
let paused = false;
// The ACTIVE pointer-drag gesture's non-visual bookkeeping: { id, axis, sign,
// size, startX, startY, startTime } | null (set on @pointerdown, read on
// @pointermove/@pointerup, cleared on @pointerup/@pointercancel). Referenced
// ONLY from the four onToastPointer* handlers below, which are bound ONLY via
// template `@pointerdown`/`@pointermove`/`@pointerup`/`@pointercancel` — the
// template-@event-handler reachability root (Quick 260717-8zb Task 3 Item 6,
// hoistModuleLet.ts) hoists this to useRef on React so it persists across the
// re-renders the sibling `$data.swipe` write triggers mid-gesture. Never read
// directly in the template — script-only bookkeeping.
let swipeGesture: any = null;
// ---- timers ------------------------------------------------------------
const startTimer = (toast: any) => {
  if (!toast || !toast.duration || toast.duration <= 0) return;
  if (typeof window === 'undefined') return;
  // Belt-and-braces: clear any pre-existing live handle for this id before
  // overwriting the entry, so a re-arm never orphans a running timeout.
  const existing = timers[toast.id];
  if (existing && existing.handle != null) window.clearTimeout(existing.handle);
  const remaining = toast.duration;
  const handle = window.setTimeout(() => dismissBegin(toast.id, 'timeout'), remaining);
  timers[toast.id] = {
    handle,
    startedAt: Date.now(),
    remaining
  };
};
const clearTimer = (id: any) => {
  const entry = timers[id];
  if (entry && entry.handle != null && typeof window !== 'undefined') window.clearTimeout(entry.handle);
  delete timers[id];
};
// Pauses every live timer WITHOUT losing the remainder: clears the handle,
// decrements `remaining` by the elapsed time, and KEEPS the entry (does NOT
// delete it — the old v1 shortcut deleted entries here, which is why leave
// had to do a full restart).
const pauseTimers = () => {
  paused = true;
  if (typeof window === 'undefined') return;
  for (const id in timers) {
    const entry = timers[id];
    // Idempotent: an entry already paused (handle cleared) keeps its stored
    // remainder. A second pause must NOT re-subtract elapsed against the
    // original startedAt — that drove `remaining` negative and stranded the
    // toast forever once resume saw the non-positive value.
    if (entry.handle == null) continue;
    window.clearTimeout(entry.handle);
    const elapsed = Date.now() - entry.startedAt;
    // Clamp so a late pause (e.g. a background-tab timer that overran) can
    // never store a negative remainder.
    const remaining = Math.max(0, entry.remaining - elapsed);
    timers[id] = {
      handle: null,
      startedAt: entry.startedAt,
      remaining
    };
  }
};
// Re-arms every paused timer with EXACTLY its stored remainder (called on
// mouse leave). An entry with a non-positive remainder is left un-armed
// (it will be cleaned up by the next dismiss/clear pass) rather than firing
// immediately from inside this loop.
const resumeTimers = () => {
  paused = false;
  if (typeof window === 'undefined') return;
  for (const id in timers) {
    const entry = timers[id];
    // Only re-arm entries that are actually paused (handle cleared). A live
    // handle is left alone — re-arming it would orphan the running timeout.
    if (entry.handle != null) continue;
    if (entry.remaining == null || entry.remaining <= 0) {
      // Its deadline elapsed while paused (a background-tab overrun, or a
      // remainder clamped to 0): treat as EXPIRED and dismiss now — its time
      // is up — rather than leaving it un-armed and stranded forever.
      dismissBegin(id, 'timeout');
      continue;
    }
    const remaining = entry.remaining;
    const handle = window.setTimeout(() => dismissBegin(id, 'timeout'), remaining);
    timers[id] = {
      handle,
      startedAt: Date.now(),
      remaining
    };
  }
};
// FULL teardown: clears every live handle AND drops every entry (unlike
// pauseTimers, which deliberately keeps entries to hold their remainders).
// clear() and $onUnmount can no longer reuse pauseTimers for this reason.
const teardownTimers = () => {
  if (typeof window !== 'undefined') {
    for (const id in timers) {
      const entry = timers[id];
      if (entry.handle != null) window.clearTimeout(entry.handle);
    }
    // Also cancel every pending exit failsafe — otherwise a removal timeout
    // scheduled just before unmount/clear() fires afterward and writes $data.
    for (const id in exitFailsafes) {
      if (exitFailsafes[id] != null) window.clearTimeout(exitFailsafes[id]);
    }
  }
  timers = {};
  exitFailsafes = {};
};
// ---- queue (imperative handle implementations) -------------------------
export const show = (input: any) => {
  const t = input || {};
  let id;
  if (t.id != null) {
    // Coerce a consumer-supplied id to a String once, at the single entry
    // point. Ids flow through the `timers` map (whose `for (const id in …)`
    // keys are ALWAYS strings) and every downstream `t.id === id` strict
    // comparison; a numeric consumer id (`show({ id: 42 })`) would otherwise
    // stop matching after a hover pause/resume re-arms with the string key.
    id = String(t.id);
  } else {
    // Take the high-water mark of the persistent-but-tick-stale $data.seq and
    // the synchronous-but-maybe-per-render seqLocal (see the <script> comment)
    // so same-tick multi-show yields DISTINCT ids on React too. Read both
    // BEFORE writing either (no read-after-write of $data.seq → ROZ138-safe).
    const s = Math.max(seq, seqLocal);
    id = 't' + s;
    seqLocal = s + 1;
    seq = s + 1;
  }
  const toast = {
    id,
    message: t.message != null ? t.message : '',
    type: t.type || 'info',
    duration: t.duration != null ? t.duration : duration
  };
  // ONE self-referential assignment so the React emitter lowers it to the
  // concurrent-safe functional updater `setToasts(prev => …)` (it only does so
  // when the RHS reads $data.toasts DIRECTLY — a via-a-local form lowered to a
  // stale-closure `setToasts(<value>)`, losing the first of two same-tick
  // toasts). slice() start: keep the newest `max` when over the cap
  // (Math.max(0, len+1-max)), else slice(0) = the whole fresh array.
  toasts = toasts.concat([toast]).slice(max > 0 ? Math.max(0, toasts.length + 1 - max) : 0);
  startTimer(toast);
  return id;
};
// ---- exit lifecycle ------------------------------------------------------
// Deliberately exceeds the 200ms default --rozie-toast-exit-duration token
// comfortably; a consumer overriding the exit duration beyond ~350ms gets cut
// short by this failsafe (documented in docs/components/toast.md).
const EXIT_FAILSAFE_MS = 350;
// Idempotent removal: filters the entry out of $data.toasts. Safe to call
// twice (from the inline @animationend binding AND the failsafe) — the
// second call is a harmless no-op filter over an already-absent id.
const removeToast = (id: any) => {
  // Cancel any pending exit failsafe for this id (first-wins: @animationend
  // beating the ~350ms timeout, or vice-versa — either way, only one removal).
  if (typeof window !== 'undefined' && exitFailsafes[id] != null) {
    window.clearTimeout(exitFailsafes[id]);
  }
  delete exitFailsafes[id];
  toasts = toasts.filter((t: any) => t.id !== id);
};
// The single dismissal funnel every path routes through: the `dismiss(id)`
// verb ('api'), the built-in close button ('close'), a timer expiry
// ('timeout'), and a swipe past threshold ('swipe'). Idempotent via the
// entry's `exiting` flag — a second call on an id already exiting (or
// already gone) is a no-op, so a stray timeout firing mid-exit never
// double-emits. `extra` (swipe only) carries `{ swipeExitSign }` so the
// template can apply the direction-matched swipe-exit animation.
const dismissBegin = (id: any, reason: any, extra?: {
  swipeExitSign?: number;
}) => {
  const entry = toasts.find((t: any) => t.id === id);
  if (!entry || entry.exiting) return;
  clearTimer(id);
  ondismissed?.({
    toast: entry,
    reason
  });
  toasts = toasts.map((t: any) => t.id === id ? {
    ...t,
    exiting: true,
    ...(extra || {})
  } : t);
  if (typeof window === 'undefined') {
    removeToast(id);
  } else {
    exitFailsafes[id] = window.setTimeout(() => removeToast(id), EXIT_FAILSAFE_MS);
  }
};
export const dismiss = (id: any) => {
  dismissBegin(id, 'api');
};
// clear() is bulk: immediate full teardown, NO per-toast exit animation and
// NO emit (documented — see docs/components/toast.md).
export const clear = () => {
  teardownTimers();
  toasts = [];
};
// ---- patch / promise ------------------------------------------------------
// Update-in-place primitive: merges ONLY the present `{message,type,duration}`
// keys into the matching entry via a fresh-array map (never in-place
// mutation). Returns whether the id existed. A `duration` key clears+restarts
// the timer (0 → sticky/no-arm; positive → arm); any other key leaves a
// running timer untouched.
export const patch = (id: any, changes: any) => {
  const c = changes || {};
  let existed = false;
  const next = toasts.map((t: any) => {
    if (t.id !== id) return t;
    // Treat an EXITING entry as absent — never resurrect a toast whose
    // dismissal is already in flight (removal deferred to @animationend / the
    // failsafe). `existed` stays false → patch returns false, writes nothing,
    // arms no timer.
    if (t.exiting) return t;
    existed = true;
    const merged = {
      ...t
    };
    if (c.message !== undefined) merged.message = c.message;
    if (c.type !== undefined) merged.type = c.type;
    if (c.duration !== undefined) merged.duration = c.duration;
    return merged;
  });
  if (!existed) return false;
  toasts = next;
  if (c.duration !== undefined) {
    clearTimer(id);
    const patched = next.find((t: any) => t.id === id);
    if (paused) {
      // Hovered: store the new duration as the pending remainder WITHOUT
      // arming a live timer (which would dismiss the toast while the pointer
      // is still over the stack). resumeTimers() arms it on leave.
      if (patched && patched.duration > 0 && typeof window !== 'undefined') {
        timers[id] = {
          handle: null,
          startedAt: Date.now(),
          remaining: patched.duration
        };
      }
    } else {
      startTimer(patched);
    }
  }
  return true;
};
// The settle guard: a no-op if the host unmounted OR the toast was already
// dismissed while the promise was still pending (never-resurrect).
const settlePromise = (id: any, type: any, messageOrFn: any, value: any) => {
  if (unmounted) return;
  // Never-resurrect: no-op if the toast is gone OR already exiting (its
  // dismissal is in flight — settling now would flip it back to a live
  // success/error toast and re-arm a timer).
  const entry = toasts.find((t: any) => t.id === id);
  if (!entry || entry.exiting) return;
  const message = typeof messageOrFn === 'function' ? messageOrFn(value) : messageOrFn;
  patch(id, {
    type,
    message,
    duration: duration
  });
};
// Sugar over show()+patch(): shows a sticky loading toast synchronously
// (returns its id immediately — the consumer already holds `p`), then patches
// the SAME entry to success/error on settle (the auto-dismiss timer starts AT
// SETTLE, via patch's duration-key restart). Never returns/derives a new
// promise — `p`'s own .then/.catch still fire for the consumer untouched.
export const promise = (p: any, opts: any) => {
  const o = opts || {};
  const id = show({
    type: 'loading',
    duration: 0,
    message: o.loading
  });
  if (p && typeof p.then === 'function') {
    p.then((value: any) => settlePromise(id, 'success', o.success, value)).catch((err: any) => settlePromise(id, 'error', o.error, err));
  }
  return id;
};
// ---- swipe-to-dismiss ------------------------------------------------------
// Axis + dismiss-direction sign, purely derived from the corner (no per-
// gesture state needed for these two — they only depend on $props.position).
const swipeAxisFor = (position: any) => position === 'top-center' || position === 'bottom-center' ? 'y' : 'x';
const swipeSignFor = (position: any) => {
  if (position === 'top-right' || position === 'bottom-right') return 1;
  if (position === 'top-left' || position === 'bottom-left') return -1;
  if (position === 'bottom-center') return 1;
  return -1; // top-center
};
const onToastPointerDown = (t: any, event: any) => {
  if (disableSwipe) return;
  if (event.button != null && event.button !== 0) return;
  // Ignore drags starting on the close button / any button-or-link chrome.
  const chrome = event.target && event.target.closest ? event.target.closest('button, a') : null;
  if (chrome) return;
  const axis = swipeAxisFor(position);
  const sign = swipeSignFor(position);
  const el = event.currentTarget;
  const size = axis === 'x' ? el.offsetWidth : el.offsetHeight;
  swipeGesture = {
    id: t.id,
    axis,
    sign,
    size,
    startX: event.clientX,
    startY: event.clientY,
    startTime: Date.now()
  };
  if (el && el.setPointerCapture) {
    try {
      el.setPointerCapture(event.pointerId);
    } catch (e: any) {
      // Some embedded contexts throw on setPointerCapture — swipe still
      // works without capture (just loses "keeps tracking off-element").
    }
  }
};
const onToastPointerMove = (t: any, event: any) => {
  if (disableSwipe) return;
  const gesture = swipeGesture;
  if (!gesture || gesture.id !== t.id) return;
  const raw = gesture.axis === 'x' ? event.clientX - gesture.startX : event.clientY - gesture.startY;
  const towardDismiss = raw * gesture.sign > 0;
  const d = towardDismiss ? raw : raw * 0.15;
  swipe = {
    id: t.id,
    d,
    axis: gesture.axis,
    sign: gesture.sign,
    size: gesture.size
  };
};
const onToastPointerUp = (t: any, event: any) => {
  if (disableSwipe) return;
  const gesture = swipeGesture;
  swipeGesture = null;
  // Local named `dragState`, NOT `swipe` — a local `swipe` would shadow the
  // reactive `$data.swipe` key on Svelte 5 (top-level `let swipe = $state(…)`
  // self-shadow TDZ: `const swipe = swipe` then `swipe = null` throws
  // "Cannot assign to constant"). Same collision class as the documented
  // $refs/$props self-shadow, just for a $data key.
  const dragState = swipe;
  swipe = null;
  if (!gesture || gesture.id !== t.id || !dragState) return;
  const elapsed = Math.max(1, Date.now() - gesture.startTime);
  const magnitude = dragState.d * gesture.sign;
  const velocity = magnitude / elapsed;
  if (magnitude > 0 && (magnitude > gesture.size * 0.45 || velocity > 0.11)) {
    dismissBegin(t.id, 'swipe', {
      swipeExitSign: gesture.sign
    });
  }
};
const onToastPointerCancel = (t: any) => {
  if (disableSwipe) return;
  if (swipeGesture && swipeGesture.id === t.id) swipeGesture = null;
  if (swipe && swipe.id === t.id) swipe = null;
};
// ---- stacked mode ----------------------------------------------------------
// Depth from newest: the newest toast (last in the array — show() appends)
// is depth 0; each older toast is one deeper. Corner-independent — the
// collapsed grid overlay ignores flex-direction/column-reverse entirely, so
// this needs no position-aware math.
//
// quick 260716-npt Finding 3 (perf): depth USED to be a per-toast
// `$data.toasts.findIndex(...)` scan invoked from toastStyle() for every row
// — O(n) work × n toasts rendered = O(n^2) per render. The template's r-for
// already computes each row's array index for free (the r-for bare-comma
// index form, `t, ti in ...` — see TreeNode.rozie/Table.rozie precedent), so
// depth(ti) is now O(1) arithmetic off that index — no scan, and `t`'s id
// can never be "not found" via this call path (ti IS t's own index), so the
// old idx===-1→0 fallback collapses to unreachable-by-construction (same
// observable semantics: newest=depth 0, older=length-1-idx).
const depth = (ti: any) => toasts.length - 1 - ti;
// String-form `:style` for the toast row. ALWAYS carries `--rozie-toast-depth`
// (a no-op unless `stacked` is on — CSS reads it only inside
// `.rozie-toaster--stacked`), plus EITHER the active drag transform (while
// $data.swipe tracks this id) OR the swipe-exit sign custom property (once
// `dismissBegin('swipe')` flipped `t.swipeExitSign`). Drag/exit never overlap.
const toastStyle = (t: any, ti: any) => {
  const depthDecl = '--rozie-toast-depth: ' + depth(ti) + ';';
  if (t.exiting) {
    return t.swipeExitSign != null ? depthDecl + ' --rozie-toast-swipe-exit: ' + t.swipeExitSign + ';' : depthDecl;
  }
  // Local named `dragState`, NOT `swipe` — see the onToastPointerUp comment
  // above (Svelte 5 $data-key self-shadow).
  const dragState = swipe;
  if (!dragState || dragState.id !== t.id) return depthDecl;
  const translate = dragState.axis === 'x' ? 'translateX(' + dragState.d + 'px)' : 'translateY(' + dragState.d + 'px)';
  const magnitude = dragState.d * dragState.sign;
  const opacity = magnitude > 0 && dragState.size > 0 ? Math.max(0.3, 1 - magnitude / dragState.size) : 1;
  return depthDecl + ' transform: ' + translate + '; opacity: ' + opacity + '; transition: none;';
};
// ---- hover pause -------------------------------------------------------
const onMouseEnter = () => {
  if (disablePauseOnHover) return;
  pauseTimers();
};
const onMouseLeave = () => {
  if (disablePauseOnHover) return;
  resumeTimers();
};
// ---- helpers -----------------------------------------------------------
const regionLabel = () => ariaLabel != null ? ariaLabel : 'Notifications';
// Type union: 'info' | 'success' | 'error' | 'warning' | 'loading'. Only
// error/warning interrupt (assertive); loading (like info/success) is polite.
const liveFor = (type: any) => type === 'error' || type === 'warning' ? 'assertive' : 'polite';

// ---- lifecycle + handle ------------------------------------------------

onDestroy(() => (() => {
  unmounted = true;
  teardownTimers();
})());
</script>

<div role="region" aria-label={rozieAttr(regionLabel())} {...__rozieAttrs} class={["rozie-toaster", rozieClass('rozie-toaster--' + position + (stacked ? ' rozie-toaster--stacked' : '')), (__rozieAttrs)?.class]} onmouseenter={($event) => { onMouseEnter(); }} onmouseleave={($event) => { onMouseLeave(); }} use:applyListeners={__rozieAttrs} data-rozie-s-12d4265c>{#each toasts as t, ti (t.id)}<div class={["rozie-toast", rozieClass('rozie-toast--' + t.type + (t.exiting ? ' rozie-toast--exiting' : '') + (t.swipeExitSign != null ? ' rozie-toast--swipe-exit' : ''))]} style={rozieStyle(toastStyle(t, ti))} role="status" aria-live={rozieAttr(liveFor(t.type))} onanimationend={($event) => { t.exiting && removeToast(t.id); }} onpointerdown={($event) => { onToastPointerDown(t, $event); }} onpointermove={($event) => { onToastPointerMove(t, $event); }} onpointerup={($event) => { onToastPointerUp(t, $event); }} onpointercancel={($event) => { onToastPointerCancel(t); }} data-rozie-s-12d4265c>{#if toast}{@render toast({ toast: t, dismiss })}{:else}{#if t.type === 'loading'}<span class="rozie-toast-spinner" aria-hidden="true" data-rozie-s-12d4265c></span>{/if}<span class="rozie-toast-message" data-rozie-s-12d4265c>{rozieDisplay(t.message)}</span><button type="button" class="rozie-toast-close" aria-label="Dismiss" onclick={($event) => { dismissBegin(t.id, 'close'); }} data-rozie-s-12d4265c>×</button>{/if}</div>{/each}</div>

<style>
:global {
  @media (prefers-reduced-motion: reduce) {
    .rozie-toast[data-rozie-s-12d4265c] {
      animation-name: rozie-toast-fade-in;
      animation-duration: 1ms;
    }
    .rozie-toast--exiting[data-rozie-s-12d4265c] {
      animation-name: rozie-toast-fade-out;
      animation-duration: 1ms;
    }
  }
  .rozie-toaster[data-rozie-s-12d4265c] {
    position: fixed;
    z-index: var(--rozie-toast-z, 9999);
    display: flex;
    flex-direction: column;
    gap: var(--rozie-toast-gap, 0.5rem);
    padding: var(--rozie-toast-region-padding, 1rem);
    max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
    pointer-events: none;
    font: var(--rozie-toast-font, inherit);
  }
  .rozie-toaster[data-rozie-s-12d4265c] > *[data-rozie-s-12d4265c] {
    pointer-events: auto;
  }
  .rozie-toaster--top-left[data-rozie-s-12d4265c] { top: 0; left: 0; align-items: flex-start; }
  .rozie-toaster--top-right[data-rozie-s-12d4265c] { top: 0; right: 0; align-items: flex-end; }
  .rozie-toaster--top-center[data-rozie-s-12d4265c] { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
  .rozie-toaster--bottom-left[data-rozie-s-12d4265c] { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
  .rozie-toaster--bottom-right[data-rozie-s-12d4265c] { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
  .rozie-toaster--bottom-center[data-rozie-s-12d4265c] { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
  .rozie-toaster--stacked[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
    grid-area: 1 / 1;
    z-index: calc(100 - var(--rozie-toast-depth, 0));
  }
  .rozie-toaster--stacked[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) {
    display: grid;
  }
  .rozie-toaster--stacked[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c] {
    transform:
      translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px)))
      scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
    opacity: calc(1 - min(1, max(0, var(--rozie-toast-depth, 0) - 2)));
  }
  .rozie-toaster--stacked.rozie-toaster--bottom-left[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c],
  .rozie-toaster--stacked.rozie-toaster--bottom-right[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c],
  .rozie-toaster--stacked.rozie-toaster--bottom-center[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c] {
    transform:
      translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px) * -1))
      scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
  }
  .rozie-toast[data-rozie-s-12d4265c] {
    display: flex;
    align-items: center;
    gap: var(--rozie-toast-content-gap, 0.75rem);
    min-width: var(--rozie-toast-min-width, 16rem);
    max-width: var(--rozie-toast-toast-max-width, 24rem);
    padding: var(--rozie-toast-padding, 0.75rem 1rem);
    color: var(--rozie-toast-color, #fff);
    background: var(--rozie-toast-bg, #333);
    border-radius: var(--rozie-toast-radius, 0.5rem);
    box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
    /* Swipe: page scroll stays alive on touch along the axis the toast does
       NOT move on. The transition here drives the spring-back (the active-drag
       :style sets an inline `transition: none` to track the finger 1:1;
       releasing it without a further gesture falls back to this transition). */
    touch-action: pan-y;
    transition: transform 200ms ease, opacity 200ms ease;
  }
  .rozie-toaster--top-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
  .rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
    touch-action: pan-x;
  }
  .rozie-toast--success[data-rozie-s-12d4265c] { background: var(--rozie-toast-success-bg, #16a34a); }
  .rozie-toast--error[data-rozie-s-12d4265c] { background: var(--rozie-toast-error-bg, #dc2626); }
  .rozie-toast--warning[data-rozie-s-12d4265c] { background: var(--rozie-toast-warning-bg, #ca8a04); }
  .rozie-toast--info[data-rozie-s-12d4265c] { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
  from[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(-0.5rem); }
  to[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
  from[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(0.5rem); }
  to[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
  from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
  to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(-0.5rem); }
  from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
  to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(0.5rem); }
  .rozie-toast[data-rozie-s-12d4265c] {
    animation: rozie-toast-enter var(--rozie-toast-enter-duration, 200ms) ease-out;
  }
  .rozie-toaster--bottom-left[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
  .rozie-toaster--bottom-right[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
  .rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
    animation-name: rozie-toast-enter-from-bottom;
  }
  .rozie-toast--exiting[data-rozie-s-12d4265c] {
    animation: rozie-toast-exit var(--rozie-toast-exit-duration, 200ms) ease-in forwards;
  }
  .rozie-toaster--bottom-left[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c],
  .rozie-toaster--bottom-right[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c],
  .rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c] {
    animation-name: rozie-toast-exit-to-bottom;
  }
  from[data-rozie-s-12d4265c] { opacity: 0; }
  to[data-rozie-s-12d4265c] { opacity: 1; }
  from[data-rozie-s-12d4265c] { opacity: 1; }
  to[data-rozie-s-12d4265c] { opacity: 0; }
  from[data-rozie-s-12d4265c] { opacity: 1; transform: translateX(0); }
  to[data-rozie-s-12d4265c] { opacity: 0; transform: translateX(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
  from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
  to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
  .rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c] {
    animation-name: rozie-toast-swipe-exit-x;
  }
  .rozie-toaster--top-center[data-rozie-s-12d4265c] .rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c],
  .rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c] {
    animation-name: rozie-toast-swipe-exit-y;
  }
  .rozie-toast-spinner[data-rozie-s-12d4265c] {
    flex: 0 0 auto;
    width: var(--rozie-toast-spinner-size, 1em);
    height: var(--rozie-toast-spinner-size, 1em);
    border: 2px solid color-mix(in srgb, var(--rozie-toast-spinner-color, currentColor) 25%, transparent);
    border-top-color: var(--rozie-toast-spinner-color, currentColor);
    border-radius: 50%;
    animation: rozie-toast-spin 0.75s linear infinite;
  }
  to[data-rozie-s-12d4265c] { transform: rotate(360deg); }
  .rozie-toast-message[data-rozie-s-12d4265c] {
    flex: 1 1 auto;
    font-size: var(--rozie-toast-font-size, 0.9rem);
  }
  .rozie-toast-close[data-rozie-s-12d4265c] {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--rozie-toast-close-size, 1.25rem);
    height: var(--rozie-toast-close-size, 1.25rem);
    padding: 0;
    font-size: 1.1rem;
    line-height: 1;
    color: inherit;
    background: transparent;
    border: none;
    border-radius: 0.25rem;
    opacity: var(--rozie-toast-close-opacity, 0.75);
    cursor: pointer;
  }
  .rozie-toast-close[data-rozie-s-12d4265c]:hover {
    opacity: 1;
  }
}
</style>
ts
import { Component, ContentChild, DestroyRef, ElementRef, Renderer2, TemplateRef, ViewEncapsulation, afterRenderEffect, computed, contentChildren, effect, inject, input, output, signal, viewChild } from '@angular/core';
import { NgClass, NgTemplateOutlet } from '@angular/common';
import { RozieSlot, createRozieAttrApplier, createRozieHostAttrsReader, rozieAttr as __rozieAttr, rozieDisplay as __rozieDisplay } from '@rozie/runtime-angular';

interface ToastCtx {
  $implicit: { toast: any; dismiss: any };
  toast: any;
  dismiss: any;
}

@Component({
  selector: 'rozie-toaster',
  standalone: true,
  imports: [NgTemplateOutlet, NgClass],
  template: `

    <div class="rozie-toaster" [ngClass]="'rozie-toaster--' + position() + (stacked() ? ' rozie-toaster--stacked' : '')" role="region" [attr.aria-label]="rozieAttr(regionLabel())" #rozieSpread_0 (mouseenter)="onMouseEnter()" (mouseleave)="onMouseLeave()" #rozieListenersTarget_1>
      
      @for (t of toasts(); track t.id; let ti = $index) {
    <div class="rozie-toast" [ngClass]="'rozie-toast--' + t.type + (t.exiting ? ' rozie-toast--exiting' : '') + (t.swipeExitSign != null ? ' rozie-toast--swipe-exit' : '')" [style]="toastStyle(t, ti)" role="status" [attr.aria-live]="rozieAttr(liveFor(t.type))" (animationend)="t.exiting && removeToast(t.id)" (pointerdown)="onToastPointerDown(t, $event)" (pointermove)="onToastPointerMove(t, $event)" (pointerup)="onToastPointerUp(t, $event)" (pointercancel)="onToastPointerCancel(t)">
        @if ((toastTpl ?? __rozieFillMap()['toast'] ?? templates()?.['toast'])) {
    <ng-container *ngTemplateOutlet="(toastTpl ?? __rozieFillMap()['toast'] ?? templates()?.['toast']); context: { $implicit: { toast: t, dismiss: dismiss }, toast: t, dismiss: dismiss }" />
    } @else {

          @if (t.type === 'loading') {
    <span class="rozie-toast-spinner" aria-hidden="true"></span>
    }<span class="rozie-toast-message">{{ rozieDisplay(t.message) }}</span>
          <button type="button" class="rozie-toast-close" aria-label="Dismiss" (click)="dismissBegin(t.id, 'close')">×</button>
        
    }
      </div>
    }
    </div>

  `,
  styles: [`
    :host(rozie-toaster) { display: contents; }
    @media (prefers-reduced-motion: reduce) {
      .rozie-toast {
        animation-name: rozie-toast-fade-in;
        animation-duration: 1ms;
      }
      .rozie-toast--exiting {
        animation-name: rozie-toast-fade-out;
        animation-duration: 1ms;
      }
    }
    .rozie-toaster {
      position: fixed;
      z-index: var(--rozie-toast-z, 9999);
      display: flex;
      flex-direction: column;
      gap: var(--rozie-toast-gap, 0.5rem);
      padding: var(--rozie-toast-region-padding, 1rem);
      max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
      pointer-events: none;
      font: var(--rozie-toast-font, inherit);
    }
    .rozie-toaster > * {
      pointer-events: auto;
    }
    .rozie-toaster--top-left { top: 0; left: 0; align-items: flex-start; }
    .rozie-toaster--top-right { top: 0; right: 0; align-items: flex-end; }
    .rozie-toaster--top-center { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
    .rozie-toaster--bottom-left { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
    .rozie-toaster--bottom-right { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
    .rozie-toaster--bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
    .rozie-toaster--stacked .rozie-toast {
      grid-area: 1 / 1;
      z-index: calc(100 - var(--rozie-toast-depth, 0));
    }
    .rozie-toaster--stacked:not(:hover):not(:focus-within) {
      display: grid;
    }
    .rozie-toaster--stacked:not(:hover):not(:focus-within) .rozie-toast {
      transform:
        translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px)))
        scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
      opacity: calc(1 - min(1, max(0, var(--rozie-toast-depth, 0) - 2)));
    }
    .rozie-toaster--stacked.rozie-toaster--bottom-left:not(:hover):not(:focus-within) .rozie-toast,
    .rozie-toaster--stacked.rozie-toaster--bottom-right:not(:hover):not(:focus-within) .rozie-toast,
    .rozie-toaster--stacked.rozie-toaster--bottom-center:not(:hover):not(:focus-within) .rozie-toast {
      transform:
        translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px) * -1))
        scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
    }
    .rozie-toast {
      display: flex;
      align-items: center;
      gap: var(--rozie-toast-content-gap, 0.75rem);
      min-width: var(--rozie-toast-min-width, 16rem);
      max-width: var(--rozie-toast-toast-max-width, 24rem);
      padding: var(--rozie-toast-padding, 0.75rem 1rem);
      color: var(--rozie-toast-color, #fff);
      background: var(--rozie-toast-bg, #333);
      border-radius: var(--rozie-toast-radius, 0.5rem);
      box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
      /* Swipe: page scroll stays alive on touch along the axis the toast does
         NOT move on. The transition here drives the spring-back (the active-drag
         :style sets an inline \`transition: none\` to track the finger 1:1;
         releasing it without a further gesture falls back to this transition). */
      touch-action: pan-y;
      transition: transform 200ms ease, opacity 200ms ease;
    }
    .rozie-toaster--top-center .rozie-toast,
    .rozie-toaster--bottom-center .rozie-toast {
      touch-action: pan-x;
    }
    .rozie-toast--success { background: var(--rozie-toast-success-bg, #16a34a); }
    .rozie-toast--error { background: var(--rozie-toast-error-bg, #dc2626); }
    .rozie-toast--warning { background: var(--rozie-toast-warning-bg, #ca8a04); }
    .rozie-toast--info { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
    from { opacity: 0; transform: translateY(-0.5rem); }
    to { opacity: 1; transform: translateY(0); }
    from { opacity: 0; transform: translateY(0.5rem); }
    to { opacity: 1; transform: translateY(0); }
    from { opacity: 1; transform: translateY(0); }
    to { opacity: 0; transform: translateY(-0.5rem); }
    from { opacity: 1; transform: translateY(0); }
    to { opacity: 0; transform: translateY(0.5rem); }
    .rozie-toast {
      animation: rozie-toast-enter var(--rozie-toast-enter-duration, 200ms) ease-out;
    }
    .rozie-toaster--bottom-left .rozie-toast,
    .rozie-toaster--bottom-right .rozie-toast,
    .rozie-toaster--bottom-center .rozie-toast {
      animation-name: rozie-toast-enter-from-bottom;
    }
    .rozie-toast--exiting {
      animation: rozie-toast-exit var(--rozie-toast-exit-duration, 200ms) ease-in forwards;
    }
    .rozie-toaster--bottom-left .rozie-toast--exiting,
    .rozie-toaster--bottom-right .rozie-toast--exiting,
    .rozie-toaster--bottom-center .rozie-toast--exiting {
      animation-name: rozie-toast-exit-to-bottom;
    }
    from { opacity: 0; }
    to { opacity: 1; }
    from { opacity: 1; }
    to { opacity: 0; }
    from { opacity: 1; transform: translateX(0); }
    to { opacity: 0; transform: translateX(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
    from { opacity: 1; transform: translateY(0); }
    to { opacity: 0; transform: translateY(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
    .rozie-toast--exiting.rozie-toast--swipe-exit {
      animation-name: rozie-toast-swipe-exit-x;
    }
    .rozie-toaster--top-center .rozie-toast--exiting.rozie-toast--swipe-exit,
    .rozie-toaster--bottom-center .rozie-toast--exiting.rozie-toast--swipe-exit {
      animation-name: rozie-toast-swipe-exit-y;
    }
    .rozie-toast-spinner {
      flex: 0 0 auto;
      width: var(--rozie-toast-spinner-size, 1em);
      height: var(--rozie-toast-spinner-size, 1em);
      border: 2px solid color-mix(in srgb, var(--rozie-toast-spinner-color, currentColor) 25%, transparent);
      border-top-color: var(--rozie-toast-spinner-color, currentColor);
      border-radius: 50%;
      animation: rozie-toast-spin 0.75s linear infinite;
    }
    to { transform: rotate(360deg); }
    .rozie-toast-message {
      flex: 1 1 auto;
      font-size: var(--rozie-toast-font-size, 0.9rem);
    }
    .rozie-toast-close {
      flex: 0 0 auto;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: var(--rozie-toast-close-size, 1.25rem);
      height: var(--rozie-toast-close-size, 1.25rem);
      padding: 0;
      font-size: 1.1rem;
      line-height: 1;
      color: inherit;
      background: transparent;
      border: none;
      border-radius: 0.25rem;
      opacity: var(--rozie-toast-close-opacity, 0.75);
      cursor: pointer;
    }
    .rozie-toast-close:hover {
      opacity: 1;
    }
  `],
})
export class Toaster {
  /**
   * Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
   */
  position = input<string>('bottom-right');
  /**
   * Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
   */
  duration = input<number>(4000);
  /**
   * Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
   */
  max = input<number>(0);
  /**
   * Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
   */
  disablePauseOnHover = input<boolean>(false);
  /**
   * Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
   */
  ariaLabel = input<(string) | null>(null);
  /**
   * Opt **out** of pointer swipe-to-dismiss. By default, dragging a toast past 45% of its own width/height (direction auto-derived from `position`) or a fast flick dismisses it with reason `'swipe'`; a short drag springs back. A drag starting on the close button (or any button/link) never swipes.
   */
  disableSwipe = input<boolean>(false);
  /**
   * Opt **in** to a sonner-style collapsed stack: a single-cell grid overlay with depth-driven transforms (toasts at depth 3+ fade to invisible), newest on top. Hovering the region or moving keyboard focus into it expands to the normal flex-column stack; leaving re-collapses. `false` (default) renders the plain flex column at all times.
   */
  stacked = input<boolean>(false);
  toasts = signal<any[]>([]);
  seq = signal(0);
  swipe = signal<any>(null);
  dismissed = output<unknown>();
  @ContentChild('toast', { read: TemplateRef }) toastTpl?: TemplateRef<ToastCtx>;
  templates = input<Record<string, TemplateRef<unknown>> | undefined>(undefined);
  __rozieFills = contentChildren(RozieSlot, { descendants: true });
  __rozieFillMap = computed(() => {
    const map = Object.create(null) as Record<string, TemplateRef<unknown>>;
    for (const f of this.__rozieFills()) {
      const k = f.rozieSlot();
      if (k == null) continue;
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      map[k === '' ? 'defaultSlot' : k] = f.templateRef;
    }
    return map;
  });

  constructor() {
    inject(DestroyRef).onDestroy(() => {
      this.unmounted = true;
      this.teardownTimers();
    });
  }

  timers = {};
  exitFailsafes = {};
  unmounted = false;
  seqLocal = 0;
  paused = false;
  swipeGesture: any = null;
  startTimer = (toast: any) => {
    if (!toast || !toast.duration || toast.duration <= 0) return;
    if (typeof window === 'undefined') return;
    // Belt-and-braces: clear any pre-existing live handle for this id before
    // overwriting the entry, so a re-arm never orphans a running timeout.
    const existing = this.timers[toast.id];
    if (existing && existing.handle != null) window.clearTimeout(existing.handle);
    const remaining = toast.duration;
    const handle = window.setTimeout(() => this.dismissBegin(toast.id, 'timeout'), remaining);
    this.timers[toast.id] = {
      handle,
      startedAt: Date.now(),
      remaining
    };
  };
  clearTimer = (id: any) => {
    const entry = this.timers[id];
    if (entry && entry.handle != null && typeof window !== 'undefined') window.clearTimeout(entry.handle);
    delete this.timers[id];
  };
  pauseTimers = () => {
    this.paused = true;
    if (typeof window === 'undefined') return;
    for (const id in this.timers) {
      const entry = this.timers[id];
      // Idempotent: an entry already paused (handle cleared) keeps its stored
      // remainder. A second pause must NOT re-subtract elapsed against the
      // original startedAt — that drove `remaining` negative and stranded the
      // toast forever once resume saw the non-positive value.
      if (entry.handle == null) continue;
      window.clearTimeout(entry.handle);
      const elapsed = Date.now() - entry.startedAt;
      // Clamp so a late pause (e.g. a background-tab timer that overran) can
      // never store a negative remainder.
      const remaining = Math.max(0, entry.remaining - elapsed);
      this.timers[id] = {
        handle: null,
        startedAt: entry.startedAt,
        remaining
      };
    }
  };
  resumeTimers = () => {
    this.paused = false;
    if (typeof window === 'undefined') return;
    for (const id in this.timers) {
      const entry = this.timers[id];
      // Only re-arm entries that are actually paused (handle cleared). A live
      // handle is left alone — re-arming it would orphan the running timeout.
      if (entry.handle != null) continue;
      if (entry.remaining == null || entry.remaining <= 0) {
        // Its deadline elapsed while paused (a background-tab overrun, or a
        // remainder clamped to 0): treat as EXPIRED and dismiss now — its time
        // is up — rather than leaving it un-armed and stranded forever.
        this.dismissBegin(id, 'timeout');
        continue;
      }
      const remaining = entry.remaining;
      const handle = window.setTimeout(() => this.dismissBegin(id, 'timeout'), remaining);
      this.timers[id] = {
        handle,
        startedAt: Date.now(),
        remaining
      };
    }
  };
  teardownTimers = () => {
    if (typeof window !== 'undefined') {
      for (const id in this.timers) {
        const entry = this.timers[id];
        if (entry.handle != null) window.clearTimeout(entry.handle);
      }
      // Also cancel every pending exit failsafe — otherwise a removal timeout
      // scheduled just before unmount/clear() fires afterward and writes $data.
      for (const id in this.exitFailsafes) {
        if (this.exitFailsafes[id] != null) window.clearTimeout(this.exitFailsafes[id]);
      }
    }
    this.timers = {};
    this.exitFailsafes = {};
  };
  show = (input: any) => {
    const __max = this.max();
    const t = input || {};
    let id;
    if (t.id != null) {
      // Coerce a consumer-supplied id to a String once, at the single entry
      // point. Ids flow through the `timers` map (whose `for (const id in …)`
      // keys are ALWAYS strings) and every downstream `t.id === id` strict
      // comparison; a numeric consumer id (`show({ id: 42 })`) would otherwise
      // stop matching after a hover pause/resume re-arms with the string key.
      id = String(t.id);
    } else {
      // Take the high-water mark of the persistent-but-tick-stale $data.seq and
      // the synchronous-but-maybe-per-render seqLocal (see the <script> comment)
      // so same-tick multi-show yields DISTINCT ids on React too. Read both
      // BEFORE writing either (no read-after-write of $data.seq → ROZ138-safe).
      const s = Math.max(this.seq(), this.seqLocal);
      id = 't' + s;
      this.seqLocal = s + 1;
      this.seq.set(s + 1);
    }
    const toast = {
      id,
      message: t.message != null ? t.message : '',
      type: t.type || 'info',
      duration: t.duration != null ? t.duration : this.duration()
    };
    // ONE self-referential assignment so the React emitter lowers it to the
    // concurrent-safe functional updater `setToasts(prev => …)` (it only does so
    // when the RHS reads $data.toasts DIRECTLY — a via-a-local form lowered to a
    // stale-closure `setToasts(<value>)`, losing the first of two same-tick
    // toasts). slice() start: keep the newest `max` when over the cap
    // (Math.max(0, len+1-max)), else slice(0) = the whole fresh array.
    this.toasts.set(this.toasts().concat([toast]).slice(__max > 0 ? Math.max(0, this.toasts().length + 1 - __max) : 0));
    this.startTimer(toast);
    return id;
  };
  EXIT_FAILSAFE_MS = 350;
  removeToast = (id: any) => {
    // Cancel any pending exit failsafe for this id (first-wins: @animationend
    // beating the ~350ms timeout, or vice-versa — either way, only one removal).
    if (typeof window !== 'undefined' && this.exitFailsafes[id] != null) {
      window.clearTimeout(this.exitFailsafes[id]);
    }
    delete this.exitFailsafes[id];
    this.toasts.set(this.toasts().filter((t: any) => t.id !== id));
  };
  dismissBegin = (id: any, reason: any, extra?: {
    swipeExitSign?: number;
  }) => {
    const entry = this.toasts().find((t: any) => t.id === id);
    if (!entry || entry.exiting) return;
    this.clearTimer(id);
    this.dismissed.emit({
      toast: entry,
      reason
    });
    this.toasts.set(this.toasts().map((t: any) => t.id === id ? {
      ...t,
      exiting: true,
      ...(extra || {})
    } : t));
    if (typeof window === 'undefined') {
      this.removeToast(id);
    } else {
      this.exitFailsafes[id] = window.setTimeout(() => this.removeToast(id), this.EXIT_FAILSAFE_MS);
    }
  };
  dismiss = (id: any) => {
    this.dismissBegin(id, 'api');
  };
  clear = () => {
    this.teardownTimers();
    this.toasts.set([]);
  };
  patch = (id: any, changes: any) => {
    const c = changes || {};
    let existed = false;
    const next = this.toasts().map((t: any) => {
      if (t.id !== id) return t;
      // Treat an EXITING entry as absent — never resurrect a toast whose
      // dismissal is already in flight (removal deferred to @animationend / the
      // failsafe). `existed` stays false → patch returns false, writes nothing,
      // arms no timer.
      if (t.exiting) return t;
      existed = true;
      const merged = {
        ...t
      };
      if (c.message !== undefined) merged.message = c.message;
      if (c.type !== undefined) merged.type = c.type;
      if (c.duration !== undefined) merged.duration = c.duration;
      return merged;
    });
    if (!existed) return false;
    this.toasts.set(next);
    if (c.duration !== undefined) {
      this.clearTimer(id);
      const patched = next.find((t: any) => t.id === id);
      if (this.paused) {
        // Hovered: store the new duration as the pending remainder WITHOUT
        // arming a live timer (which would dismiss the toast while the pointer
        // is still over the stack). resumeTimers() arms it on leave.
        if (patched && patched.duration > 0 && typeof window !== 'undefined') {
          this.timers[id] = {
            handle: null,
            startedAt: Date.now(),
            remaining: patched.duration
          };
        }
      } else {
        this.startTimer(patched);
      }
    }
    return true;
  };
  settlePromise = (id: any, type: any, messageOrFn: any, value: any) => {
    if (this.unmounted) return;
    // Never-resurrect: no-op if the toast is gone OR already exiting (its
    // dismissal is in flight — settling now would flip it back to a live
    // success/error toast and re-arm a timer).
    const entry = this.toasts().find((t: any) => t.id === id);
    if (!entry || entry.exiting) return;
    const message = typeof messageOrFn === 'function' ? messageOrFn(value) : messageOrFn;
    this.patch(id, {
      type,
      message,
      duration: this.duration()
    });
  };
  promise = (p: any, opts: any) => {
    const o = opts || {};
    const id = this.show({
      type: 'loading',
      duration: 0,
      message: o.loading
    });
    if (p && typeof p.then === 'function') {
      p.then((value: any) => this.settlePromise(id, 'success', o.success, value)).catch((err: any) => this.settlePromise(id, 'error', o.error, err));
    }
    return id;
  };
  swipeAxisFor = (position: any) => position === 'top-center' || position === 'bottom-center' ? 'y' : 'x';
  swipeSignFor = (position: any) => {
    if (position === 'top-right' || position === 'bottom-right') return 1;
    if (position === 'top-left' || position === 'bottom-left') return -1;
    if (position === 'bottom-center') return 1;
    return -1; // top-center
  };
  onToastPointerDown = (t: any, event: any) => {
    const __position = this.position();
    if (this.disableSwipe()) return;
    if (event.button != null && event.button !== 0) return;
    // Ignore drags starting on the close button / any button-or-link chrome.
    const chrome = event.target && event.target.closest ? event.target.closest('button, a') : null;
    if (chrome) return;
    const axis = this.swipeAxisFor(__position);
    const sign = this.swipeSignFor(__position);
    const el = event.currentTarget;
    const size = axis === 'x' ? el.offsetWidth : el.offsetHeight;
    this.swipeGesture = {
      id: t.id,
      axis,
      sign,
      size,
      startX: event.clientX,
      startY: event.clientY,
      startTime: Date.now()
    };
    if (el && el.setPointerCapture) {
      try {
        el.setPointerCapture(event.pointerId);
      } catch (e: any) {
        // Some embedded contexts throw on setPointerCapture — swipe still
        // works without capture (just loses "keeps tracking off-element").
      }
    }
  };
  onToastPointerMove = (t: any, event: any) => {
    if (this.disableSwipe()) return;
    const gesture = this.swipeGesture;
    if (!gesture || gesture.id !== t.id) return;
    const raw = gesture.axis === 'x' ? event.clientX - gesture.startX : event.clientY - gesture.startY;
    const towardDismiss = raw * gesture.sign > 0;
    const d = towardDismiss ? raw : raw * 0.15;
    this.swipe.set({
      id: t.id,
      d,
      axis: gesture.axis,
      sign: gesture.sign,
      size: gesture.size
    });
  };
  onToastPointerUp = (t: any, event: any) => {
    if (this.disableSwipe()) return;
    const gesture = this.swipeGesture;
    this.swipeGesture = null;
    // Local named `dragState`, NOT `swipe` — a local `swipe` would shadow the
    // reactive `$data.swipe` key on Svelte 5 (top-level `let swipe = $state(…)`
    // self-shadow TDZ: `const swipe = swipe` then `swipe = null` throws
    // "Cannot assign to constant"). Same collision class as the documented
    // $refs/$props self-shadow, just for a $data key.
    const dragState = this.swipe();
    this.swipe.set(null);
    if (!gesture || gesture.id !== t.id || !dragState) return;
    const elapsed = Math.max(1, Date.now() - gesture.startTime);
    const magnitude = dragState.d * gesture.sign;
    const velocity = magnitude / elapsed;
    if (magnitude > 0 && (magnitude > gesture.size * 0.45 || velocity > 0.11)) {
      this.dismissBegin(t.id, 'swipe', {
        swipeExitSign: gesture.sign
      });
    }
  };
  onToastPointerCancel = (t: any) => {
    if (this.disableSwipe()) return;
    if (this.swipeGesture && this.swipeGesture.id === t.id) this.swipeGesture = null;
    if (this.swipe() && this.swipe().id === t.id) this.swipe.set(null);
  };
  depth = (ti: any) => this.toasts().length - 1 - ti;
  toastStyle = (t: any, ti: any) => {
    const depthDecl = '--rozie-toast-depth: ' + this.depth(ti) + ';';
    if (t.exiting) {
      return t.swipeExitSign != null ? depthDecl + ' --rozie-toast-swipe-exit: ' + t.swipeExitSign + ';' : depthDecl;
    }
    // Local named `dragState`, NOT `swipe` — see the onToastPointerUp comment
    // above (Svelte 5 $data-key self-shadow).
    const dragState = this.swipe();
    if (!dragState || dragState.id !== t.id) return depthDecl;
    const translate = dragState.axis === 'x' ? 'translateX(' + dragState.d + 'px)' : 'translateY(' + dragState.d + 'px)';
    const magnitude = dragState.d * dragState.sign;
    const opacity = magnitude > 0 && dragState.size > 0 ? Math.max(0.3, 1 - magnitude / dragState.size) : 1;
    return depthDecl + ' transform: ' + translate + '; opacity: ' + opacity + '; transition: none;';
  };
  onMouseEnter = () => {
    if (this.disablePauseOnHover()) return;
    this.pauseTimers();
  };
  onMouseLeave = () => {
    if (this.disablePauseOnHover()) return;
    this.resumeTimers();
  };
  regionLabel = () => this.ariaLabel() != null ? this.ariaLabel() : 'Notifications';
  liveFor = (type: any) => type === 'error' || type === 'warning' ? 'assertive' : 'polite';

  static ngTemplateContextGuard(
    _dir: Toaster,
    _ctx: unknown,
  ): _ctx is ToastCtx {
    return true;
  }

  private __rozieDestroyRef = inject(DestroyRef);

  private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');

  private __rozieApplyAttrs = createRozieAttrApplier(inject(Renderer2));

  private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));

  private __rozieSpread_0_effect = afterRenderEffect(() => {
    const el = this.rozieSpread_0()?.nativeElement;
    if (!el) return;
    this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
  });

  private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');

  private __rozieListenersRenderer = inject(Renderer2);

  private __rozieListenersDisposers_1: Array<() => void> = [];

  private __rozieListenersDestroyRegistered_1 = false;

  private __rozieListenersEffect_1 = effect(() => {
    const el = this.rozieListenersTarget_1()?.nativeElement;
    if (!el) return;
    for (const off of this.__rozieListenersDisposers_1) off();
    this.__rozieListenersDisposers_1 = [];
    const obj: Record<string, unknown> = {};
    for (const [k, v] of Object.entries(obj)) {
      if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
      if (typeof v !== 'function') continue;
      const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
      const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
      this.__rozieListenersDisposers_1.push(dispose);
    }
    if (!this.__rozieListenersDestroyRegistered_1) {
      this.__rozieListenersDestroyRegistered_1 = true;
      this.__rozieDestroyRef.onDestroy(() => {
        for (const off of this.__rozieListenersDisposers_1) off();
        this.__rozieListenersDisposers_1 = [];
      });
    }
  });

  rozieDisplay(v: unknown): string { return __rozieDisplay(v); }

  rozieAttr(v: unknown): string | null { return __rozieAttr(v); }
}

export default Toaster;
tsx
import type { JSX } from 'solid-js';
import { Show, createSignal, mergeProps, onCleanup, onMount, splitProps } from 'solid-js';
import { Key } from '@solid-primitives/keyed';
import { __rozieInjectStyle, mergeListeners, parseInlineStyle, rozieAttr, rozieClass, rozieDisplay } from '@rozie/runtime-solid';

__rozieInjectStyle('Toaster-12d4265c', `@media (prefers-reduced-motion: reduce) {
  .rozie-toast[data-rozie-s-12d4265c] {
    animation-name: rozie-toast-fade-in;
    animation-duration: 1ms;
  }
  .rozie-toast--exiting[data-rozie-s-12d4265c] {
    animation-name: rozie-toast-fade-out;
    animation-duration: 1ms;
  }
}
.rozie-toaster[data-rozie-s-12d4265c] {
  position: fixed;
  z-index: var(--rozie-toast-z, 9999);
  display: flex;
  flex-direction: column;
  gap: var(--rozie-toast-gap, 0.5rem);
  padding: var(--rozie-toast-region-padding, 1rem);
  max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
  pointer-events: none;
  font: var(--rozie-toast-font, inherit);
}
.rozie-toaster[data-rozie-s-12d4265c] > *[data-rozie-s-12d4265c] {
  pointer-events: auto;
}
.rozie-toaster--top-left[data-rozie-s-12d4265c] { top: 0; left: 0; align-items: flex-start; }
.rozie-toaster--top-right[data-rozie-s-12d4265c] { top: 0; right: 0; align-items: flex-end; }
.rozie-toaster--top-center[data-rozie-s-12d4265c] { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
.rozie-toaster--bottom-left[data-rozie-s-12d4265c] { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
.rozie-toaster--bottom-right[data-rozie-s-12d4265c] { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
.rozie-toaster--stacked[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
  grid-area: 1 / 1;
  z-index: calc(100 - var(--rozie-toast-depth, 0));
}
.rozie-toaster--stacked[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) {
  display: grid;
}
.rozie-toaster--stacked[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c] {
  transform:
    translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px)))
    scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
  opacity: calc(1 - min(1, max(0, var(--rozie-toast-depth, 0) - 2)));
}
.rozie-toaster--stacked.rozie-toaster--bottom-left[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c],
.rozie-toaster--stacked.rozie-toaster--bottom-right[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c],
.rozie-toaster--stacked.rozie-toaster--bottom-center[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c] {
  transform:
    translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px) * -1))
    scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
}
.rozie-toast[data-rozie-s-12d4265c] {
  display: flex;
  align-items: center;
  gap: var(--rozie-toast-content-gap, 0.75rem);
  min-width: var(--rozie-toast-min-width, 16rem);
  max-width: var(--rozie-toast-toast-max-width, 24rem);
  padding: var(--rozie-toast-padding, 0.75rem 1rem);
  color: var(--rozie-toast-color, #fff);
  background: var(--rozie-toast-bg, #333);
  border-radius: var(--rozie-toast-radius, 0.5rem);
  box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
  /* Swipe: page scroll stays alive on touch along the axis the toast does
     NOT move on. The transition here drives the spring-back (the active-drag
     :style sets an inline \`transition: none\` to track the finger 1:1;
     releasing it without a further gesture falls back to this transition). */
  touch-action: pan-y;
  transition: transform 200ms ease, opacity 200ms ease;
}
.rozie-toaster--top-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
  touch-action: pan-x;
}
.rozie-toast--success[data-rozie-s-12d4265c] { background: var(--rozie-toast-success-bg, #16a34a); }
.rozie-toast--error[data-rozie-s-12d4265c] { background: var(--rozie-toast-error-bg, #dc2626); }
.rozie-toast--warning[data-rozie-s-12d4265c] { background: var(--rozie-toast-warning-bg, #ca8a04); }
.rozie-toast--info[data-rozie-s-12d4265c] { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
from[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(-0.5rem); }
to[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
from[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(0.5rem); }
to[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(-0.5rem); }
from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(0.5rem); }
.rozie-toast[data-rozie-s-12d4265c] {
  animation: rozie-toast-enter var(--rozie-toast-enter-duration, 200ms) ease-out;
}
.rozie-toaster--bottom-left[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
.rozie-toaster--bottom-right[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
  animation-name: rozie-toast-enter-from-bottom;
}
.rozie-toast--exiting[data-rozie-s-12d4265c] {
  animation: rozie-toast-exit var(--rozie-toast-exit-duration, 200ms) ease-in forwards;
}
.rozie-toaster--bottom-left[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c],
.rozie-toaster--bottom-right[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c],
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c] {
  animation-name: rozie-toast-exit-to-bottom;
}
from[data-rozie-s-12d4265c] { opacity: 0; }
to[data-rozie-s-12d4265c] { opacity: 1; }
from[data-rozie-s-12d4265c] { opacity: 1; }
to[data-rozie-s-12d4265c] { opacity: 0; }
from[data-rozie-s-12d4265c] { opacity: 1; transform: translateX(0); }
to[data-rozie-s-12d4265c] { opacity: 0; transform: translateX(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
.rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c] {
  animation-name: rozie-toast-swipe-exit-x;
}
.rozie-toaster--top-center[data-rozie-s-12d4265c] .rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c],
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c] {
  animation-name: rozie-toast-swipe-exit-y;
}
.rozie-toast-spinner[data-rozie-s-12d4265c] {
  flex: 0 0 auto;
  width: var(--rozie-toast-spinner-size, 1em);
  height: var(--rozie-toast-spinner-size, 1em);
  border: 2px solid color-mix(in srgb, var(--rozie-toast-spinner-color, currentColor) 25%, transparent);
  border-top-color: var(--rozie-toast-spinner-color, currentColor);
  border-radius: 50%;
  animation: rozie-toast-spin 0.75s linear infinite;
}
to[data-rozie-s-12d4265c] { transform: rotate(360deg); }
.rozie-toast-message[data-rozie-s-12d4265c] {
  flex: 1 1 auto;
  font-size: var(--rozie-toast-font-size, 0.9rem);
}
.rozie-toast-close[data-rozie-s-12d4265c] {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-toast-close-size, 1.25rem);
  height: var(--rozie-toast-close-size, 1.25rem);
  padding: 0;
  font-size: 1.1rem;
  line-height: 1;
  color: inherit;
  background: transparent;
  border: none;
  border-radius: 0.25rem;
  opacity: var(--rozie-toast-close-opacity, 0.75);
  cursor: pointer;
}
.rozie-toast-close[data-rozie-s-12d4265c]:hover {
  opacity: 1;
}`);

interface ToastSlotCtx { toast: any; dismiss: any; }

interface ToasterProps {
  /**
   * Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
   */
  position?: string;
  /**
   * Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
   */
  duration?: number;
  /**
   * Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
   */
  max?: number;
  /**
   * Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
   */
  disablePauseOnHover?: boolean;
  /**
   * Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
   */
  ariaLabel?: (string) | null;
  /**
   * Opt **out** of pointer swipe-to-dismiss. By default, dragging a toast past 45% of its own width/height (direction auto-derived from `position`) or a fast flick dismisses it with reason `'swipe'`; a short drag springs back. A drag starting on the close button (or any button/link) never swipes.
   */
  disableSwipe?: boolean;
  /**
   * Opt **in** to a sonner-style collapsed stack: a single-cell grid overlay with depth-driven transforms (toasts at depth 3+ fade to invisible), newest on top. Hovering the region or moving keyboard focus into it expands to the normal flex-column stack; leaving re-collapses. `false` (default) renders the plain flex column at all times.
   */
  stacked?: boolean;
  onDismissed?: (...args: unknown[]) => void;
  toastSlot?: (ctx: ToastSlotCtx) => JSX.Element;
  slots?: Record<string, (ctx: any) => JSX.Element>;
  ref?: (h: ToasterHandle) => void;
}

export interface ToasterHandle {
  show: (...args: any[]) => any;
  dismiss: (...args: any[]) => any;
  clear: (...args: any[]) => any;
  patch: (...args: any[]) => any;
  promise: (...args: any[]) => any;
}

export default function Toaster(_props: ToasterProps): JSX.Element {
  const _merged = mergeProps({ position: 'bottom-right', duration: 4000, max: 0, disablePauseOnHover: false, ariaLabel: null, disableSwipe: false, stacked: false }, _props);
  const [local, attrs] = splitProps(_merged, ['position', 'duration', 'max', 'disablePauseOnHover', 'ariaLabel', 'disableSwipe', 'stacked', 'ref', 'onDismissed']);
  onMount(() => { local.ref?.({ show, dismiss, clear, patch, promise }); });

  const [toasts, setToasts] = createSignal<any[]>([]);
  const [seq, setSeq] = createSignal(0);
  const [swipe, setSwipe] = createSignal<any>(null);
  onCleanup(() => {
    unmounted = true;
    teardownTimers();
  });

  // Mutable cross-render scratch (NOT reactive): per-id timer bookkeeping. A
  // top-level `let` → React useRef (it escapes into $onUnmount's effect, so the
  // emitter hoists it). The id counter lives in $data.seq instead (see <data>).
  //
  // Shape: { [id]: { handle, startedAt, remaining } }. `pauseTimers` clears the
  // live setTimeout handle but KEEPS the entry with a decremented `remaining` —
  // the remainder IS the state (this is what makes the hover pause PRECISE
  // instead of a full restart). `resumeTimers` re-arms with exactly that
  // remainder. `clearTimer`/the full-teardown helper below are the only ways an
  // entry is actually removed from the map.
  let timers = {};

  // Per-id handles for the ~350ms exit-removal failsafe (the fallback that
  // removes a toast if its @animationend never fires). Tracked in a module map
  // — NOT an anonymous window.setTimeout — so teardownTimers ($onUnmount /
  // clear()) can cancel a pending failsafe (else it fires post-unmount and
  // writes $data on a torn-down instance) and removeToast can cancel it
  // first-wins when @animationend beats it. Escapes into $onUnmount's effect →
  // React hoists it to useRef alongside `timers`.
  let exitFailsafes = {};

  // Set true in $onUnmount; read by promise()'s settle guard (never-resurrect
  // a toast after the host itself is gone). A top-level `let` → React useRef
  // (it escapes into $onUnmount's effect).
  let unmounted = false;

  // Same-tick id-uniqueness guard for React. The id counter lives in reactive
  // $data.seq (persists across renders), but React batches setState so within a
  // SINGLE synchronous tick two show() calls read the SAME stale $data.seq →
  // duplicate ids. `seqLocal` is a plain counter incremented SYNCHRONOUSLY in
  // show(); it survives the same tick (and, because show() is an $expose verb,
  // the emitter hoists it to a persistent useRef on React too — but the design
  // does NOT depend on that: `Math.max($data.seq, seqLocal)` is correct whether
  // seqLocal persists OR resets per render, since the monotonic $data.seq
  // carries the high-water mark across any reset). On the other five targets
  // $data.seq is synchronous, so the two simply stay in lockstep. Result:
  // strictly-increasing, collision-free ids on all six with NO randomness.
  let seqLocal = 0;

  // Hover-pause flag: true while the pointer is over the stack (set by
  // pauseTimers, cleared by resumeTimers). Read by patch() so a duration change
  // arriving mid-hover stores the new remainder WITHOUT arming a live timer
  // (which would dismiss the toast while it is still hovered) — resume arms it
  // on leave. A top-level `let` reachable from the $expose verbs (patch/show →
  // startTimer) and the @mouseenter/@mouseleave handlers, so React hoists it to
  // useRef (persistent) like `timers`.
  let paused = false;

  // The ACTIVE pointer-drag gesture's non-visual bookkeeping: { id, axis, sign,
  // size, startX, startY, startTime } | null (set on @pointerdown, read on
  // @pointermove/@pointerup, cleared on @pointerup/@pointercancel). Referenced
  // ONLY from the four onToastPointer* handlers below, which are bound ONLY via
  // template `@pointerdown`/`@pointermove`/`@pointerup`/`@pointercancel` — the
  // template-@event-handler reachability root (Quick 260717-8zb Task 3 Item 6,
  // hoistModuleLet.ts) hoists this to useRef on React so it persists across the
  // re-renders the sibling `$data.swipe` write triggers mid-gesture. Never read
  // directly in the template — script-only bookkeeping.
  let swipeGesture: any = null;

  // ---- timers ------------------------------------------------------------
  function startTimer(toast: any) {
    if (!toast || !toast.duration || toast.duration <= 0) return;
    if (typeof window === 'undefined') return;
    // Belt-and-braces: clear any pre-existing live handle for this id before
    // overwriting the entry, so a re-arm never orphans a running timeout.
    const existing = timers[toast.id];
    if (existing && existing.handle != null) window.clearTimeout(existing.handle);
    const remaining = toast.duration;
    const handle = window.setTimeout(() => dismissBegin(toast.id, 'timeout'), remaining);
    timers[toast.id] = {
      handle,
      startedAt: Date.now(),
      remaining
    };
  }
  function clearTimer(id: any) {
    const entry = timers[id];
    if (entry && entry.handle != null && typeof window !== 'undefined') window.clearTimeout(entry.handle);
    delete timers[id];
  }

  // Pauses every live timer WITHOUT losing the remainder: clears the handle,
  // decrements `remaining` by the elapsed time, and KEEPS the entry (does NOT
  // delete it — the old v1 shortcut deleted entries here, which is why leave
  // had to do a full restart).
  function pauseTimers() {
    paused = true;
    if (typeof window === 'undefined') return;
    for (const id in timers) {
      const entry = timers[id];
      // Idempotent: an entry already paused (handle cleared) keeps its stored
      // remainder. A second pause must NOT re-subtract elapsed against the
      // original startedAt — that drove `remaining` negative and stranded the
      // toast forever once resume saw the non-positive value.
      if (entry.handle == null) continue;
      window.clearTimeout(entry.handle);
      const elapsed = Date.now() - entry.startedAt;
      // Clamp so a late pause (e.g. a background-tab timer that overran) can
      // never store a negative remainder.
      const remaining = Math.max(0, entry.remaining - elapsed);
      timers[id] = {
        handle: null,
        startedAt: entry.startedAt,
        remaining
      };
    }
  }

  // Re-arms every paused timer with EXACTLY its stored remainder (called on
  // mouse leave). An entry with a non-positive remainder is left un-armed
  // (it will be cleaned up by the next dismiss/clear pass) rather than firing
  // immediately from inside this loop.
  function resumeTimers() {
    paused = false;
    if (typeof window === 'undefined') return;
    for (const id in timers) {
      const entry = timers[id];
      // Only re-arm entries that are actually paused (handle cleared). A live
      // handle is left alone — re-arming it would orphan the running timeout.
      if (entry.handle != null) continue;
      if (entry.remaining == null || entry.remaining <= 0) {
        // Its deadline elapsed while paused (a background-tab overrun, or a
        // remainder clamped to 0): treat as EXPIRED and dismiss now — its time
        // is up — rather than leaving it un-armed and stranded forever.
        dismissBegin(id, 'timeout');
        continue;
      }
      const remaining = entry.remaining;
      const handle = window.setTimeout(() => dismissBegin(id, 'timeout'), remaining);
      timers[id] = {
        handle,
        startedAt: Date.now(),
        remaining
      };
    }
  }

  // FULL teardown: clears every live handle AND drops every entry (unlike
  // pauseTimers, which deliberately keeps entries to hold their remainders).
  // clear() and $onUnmount can no longer reuse pauseTimers for this reason.
  function teardownTimers() {
    if (typeof window !== 'undefined') {
      for (const id in timers) {
        const entry = timers[id];
        if (entry.handle != null) window.clearTimeout(entry.handle);
      }
      // Also cancel every pending exit failsafe — otherwise a removal timeout
      // scheduled just before unmount/clear() fires afterward and writes $data.
      for (const id in exitFailsafes) {
        if (exitFailsafes[id] != null) window.clearTimeout(exitFailsafes[id]);
      }
    }
    timers = {};
    exitFailsafes = {};
  }

  // ---- queue (imperative handle implementations) -------------------------
  function show(input: any) {
    const t = input || {};
    let id;
    if (t.id != null) {
      // Coerce a consumer-supplied id to a String once, at the single entry
      // point. Ids flow through the `timers` map (whose `for (const id in …)`
      // keys are ALWAYS strings) and every downstream `t.id === id` strict
      // comparison; a numeric consumer id (`show({ id: 42 })`) would otherwise
      // stop matching after a hover pause/resume re-arms with the string key.
      id = String(t.id);
    } else {
      // Take the high-water mark of the persistent-but-tick-stale $data.seq and
      // the synchronous-but-maybe-per-render seqLocal (see the <script> comment)
      // so same-tick multi-show yields DISTINCT ids on React too. Read both
      // BEFORE writing either (no read-after-write of $data.seq → ROZ138-safe).
      const s = Math.max(seq(), seqLocal);
      id = 't' + s;
      seqLocal = s + 1;
      setSeq(s + 1);
    }
    const toast = {
      id,
      message: t.message != null ? t.message : '',
      type: t.type || 'info',
      duration: t.duration != null ? t.duration : local.duration
    };
    // ONE self-referential assignment so the React emitter lowers it to the
    // concurrent-safe functional updater `setToasts(prev => …)` (it only does so
    // when the RHS reads $data.toasts DIRECTLY — a via-a-local form lowered to a
    // stale-closure `setToasts(<value>)`, losing the first of two same-tick
    // toasts). slice() start: keep the newest `max` when over the cap
    // (Math.max(0, len+1-max)), else slice(0) = the whole fresh array.
    setToasts(toasts().concat([toast]).slice(local.max > 0 ? Math.max(0, toasts().length + 1 - local.max) : 0));
    startTimer(toast);
    return id;
  }

  // ---- exit lifecycle ------------------------------------------------------
  // Deliberately exceeds the 200ms default --rozie-toast-exit-duration token
  // comfortably; a consumer overriding the exit duration beyond ~350ms gets cut
  // short by this failsafe (documented in docs/components/toast.md).
  const EXIT_FAILSAFE_MS = 350;

  // Idempotent removal: filters the entry out of $data.toasts. Safe to call
  // twice (from the inline @animationend binding AND the failsafe) — the
  // second call is a harmless no-op filter over an already-absent id.
  function removeToast(id: any) {
    // Cancel any pending exit failsafe for this id (first-wins: @animationend
    // beating the ~350ms timeout, or vice-versa — either way, only one removal).
    if (typeof window !== 'undefined' && exitFailsafes[id] != null) {
      window.clearTimeout(exitFailsafes[id]);
    }
    delete exitFailsafes[id];
    setToasts(toasts().filter((t: any) => t.id !== id));
  }

  // The single dismissal funnel every path routes through: the `dismiss(id)`
  // verb ('api'), the built-in close button ('close'), a timer expiry
  // ('timeout'), and a swipe past threshold ('swipe'). Idempotent via the
  // entry's `exiting` flag — a second call on an id already exiting (or
  // already gone) is a no-op, so a stray timeout firing mid-exit never
  // double-emits. `extra` (swipe only) carries `{ swipeExitSign }` so the
  // template can apply the direction-matched swipe-exit animation.
  function dismissBegin(id: any, reason: any, extra?: {
    swipeExitSign?: number;
  }) {
    const entry = toasts().find((t: any) => t.id === id);
    if (!entry || entry.exiting) return;
    clearTimer(id);
    _props.onDismissed?.({
      toast: entry,
      reason
    });
    setToasts(toasts().map((t: any) => t.id === id ? {
      ...t,
      exiting: true,
      ...(extra || {})
    } : t));
    if (typeof window === 'undefined') {
      removeToast(id);
    } else {
      exitFailsafes[id] = window.setTimeout(() => removeToast(id), EXIT_FAILSAFE_MS);
    }
  }
  function dismiss(id: any) {
    dismissBegin(id, 'api');
  }

  // clear() is bulk: immediate full teardown, NO per-toast exit animation and
  // NO emit (documented — see docs/components/toast.md).
  function clear() {
    teardownTimers();
    setToasts([]);
  }

  // ---- patch / promise ------------------------------------------------------
  // Update-in-place primitive: merges ONLY the present `{message,type,duration}`
  // keys into the matching entry via a fresh-array map (never in-place
  // mutation). Returns whether the id existed. A `duration` key clears+restarts
  // the timer (0 → sticky/no-arm; positive → arm); any other key leaves a
  // running timer untouched.
  function patch(id: any, changes: any) {
    const c = changes || {};
    let existed = false;
    const next = toasts().map((t: any) => {
      if (t.id !== id) return t;
      // Treat an EXITING entry as absent — never resurrect a toast whose
      // dismissal is already in flight (removal deferred to @animationend / the
      // failsafe). `existed` stays false → patch returns false, writes nothing,
      // arms no timer.
      if (t.exiting) return t;
      existed = true;
      const merged = {
        ...t
      };
      if (c.message !== undefined) merged.message = c.message;
      if (c.type !== undefined) merged.type = c.type;
      if (c.duration !== undefined) merged.duration = c.duration;
      return merged;
    });
    if (!existed) return false;
    setToasts(next);
    if (c.duration !== undefined) {
      clearTimer(id);
      const patched = next.find((t: any) => t.id === id);
      if (paused) {
        // Hovered: store the new duration as the pending remainder WITHOUT
        // arming a live timer (which would dismiss the toast while the pointer
        // is still over the stack). resumeTimers() arms it on leave.
        if (patched && patched.duration > 0 && typeof window !== 'undefined') {
          timers[id] = {
            handle: null,
            startedAt: Date.now(),
            remaining: patched.duration
          };
        }
      } else {
        startTimer(patched);
      }
    }
    return true;
  }

  // The settle guard: a no-op if the host unmounted OR the toast was already
  // dismissed while the promise was still pending (never-resurrect).
  function settlePromise(id: any, type: any, messageOrFn: any, value: any) {
    if (unmounted) return;
    // Never-resurrect: no-op if the toast is gone OR already exiting (its
    // dismissal is in flight — settling now would flip it back to a live
    // success/error toast and re-arm a timer).
    const entry = toasts().find((t: any) => t.id === id);
    if (!entry || entry.exiting) return;
    const message = typeof messageOrFn === 'function' ? messageOrFn(value) : messageOrFn;
    patch(id, {
      type,
      message,
      duration: local.duration
    });
  }

  // Sugar over show()+patch(): shows a sticky loading toast synchronously
  // (returns its id immediately — the consumer already holds `p`), then patches
  // the SAME entry to success/error on settle (the auto-dismiss timer starts AT
  // SETTLE, via patch's duration-key restart). Never returns/derives a new
  // promise — `p`'s own .then/.catch still fire for the consumer untouched.
  function promise(p: any, opts: any) {
    const o = opts || {};
    const id = show({
      type: 'loading',
      duration: 0,
      message: o.loading
    });
    if (p && typeof p.then === 'function') {
      p.then((value: any) => settlePromise(id, 'success', o.success, value)).catch((err: any) => settlePromise(id, 'error', o.error, err));
    }
    return id;
  }

  // ---- swipe-to-dismiss ------------------------------------------------------
  // Axis + dismiss-direction sign, purely derived from the corner (no per-
  // gesture state needed for these two — they only depend on $props.position).
  function swipeAxisFor(position: any) {
    return position === 'top-center' || position === 'bottom-center' ? 'y' : 'x';
  }
  function swipeSignFor(position: any) {
    if (position === 'top-right' || position === 'bottom-right') return 1;
    if (position === 'top-left' || position === 'bottom-left') return -1;
    if (position === 'bottom-center') return 1;
    return -1; // top-center
  }
  function onToastPointerDown(t: any, event: any) {
    if (local.disableSwipe) return;
    if (event.button != null && event.button !== 0) return;
    // Ignore drags starting on the close button / any button-or-link chrome.
    const chrome = event.target && event.target.closest ? event.target.closest('button, a') : null;
    if (chrome) return;
    const axis = swipeAxisFor(local.position);
    const sign = swipeSignFor(local.position);
    const el = event.currentTarget;
    const size = axis === 'x' ? el.offsetWidth : el.offsetHeight;
    swipeGesture = {
      id: t.id,
      axis,
      sign,
      size,
      startX: event.clientX,
      startY: event.clientY,
      startTime: Date.now()
    };
    if (el && el.setPointerCapture) {
      try {
        el.setPointerCapture(event.pointerId);
      } catch (e: any) {
        // Some embedded contexts throw on setPointerCapture — swipe still
        // works without capture (just loses "keeps tracking off-element").
      }
    }
  }
  function onToastPointerMove(t: any, event: any) {
    if (local.disableSwipe) return;
    const gesture = swipeGesture;
    if (!gesture || gesture.id !== t.id) return;
    const raw = gesture.axis === 'x' ? event.clientX - gesture.startX : event.clientY - gesture.startY;
    const towardDismiss = raw * gesture.sign > 0;
    const d = towardDismiss ? raw : raw * 0.15;
    setSwipe({
      id: t.id,
      d,
      axis: gesture.axis,
      sign: gesture.sign,
      size: gesture.size
    });
  }
  function onToastPointerUp(t: any, event: any) {
    if (local.disableSwipe) return;
    const gesture = swipeGesture;
    swipeGesture = null;
    // Local named `dragState`, NOT `swipe` — a local `swipe` would shadow the
    // reactive `$data.swipe` key on Svelte 5 (top-level `let swipe = $state(…)`
    // self-shadow TDZ: `const swipe = swipe` then `swipe = null` throws
    // "Cannot assign to constant"). Same collision class as the documented
    // $refs/$props self-shadow, just for a $data key.
    const dragState = swipe();
    setSwipe(null);
    if (!gesture || gesture.id !== t.id || !dragState) return;
    const elapsed = Math.max(1, Date.now() - gesture.startTime);
    const magnitude = dragState.d * gesture.sign;
    const velocity = magnitude / elapsed;
    if (magnitude > 0 && (magnitude > gesture.size * 0.45 || velocity > 0.11)) {
      dismissBegin(t.id, 'swipe', {
        swipeExitSign: gesture.sign
      });
    }
  }
  function onToastPointerCancel(t: any) {
    if (local.disableSwipe) return;
    if (swipeGesture && swipeGesture.id === t.id) swipeGesture = null;
    if (swipe() && swipe().id === t.id) setSwipe(null);
  }

  // ---- stacked mode ----------------------------------------------------------
  // Depth from newest: the newest toast (last in the array — show() appends)
  // is depth 0; each older toast is one deeper. Corner-independent — the
  // collapsed grid overlay ignores flex-direction/column-reverse entirely, so
  // this needs no position-aware math.
  //
  // quick 260716-npt Finding 3 (perf): depth USED to be a per-toast
  // `$data.toasts.findIndex(...)` scan invoked from toastStyle() for every row
  // — O(n) work × n toasts rendered = O(n^2) per render. The template's r-for
  // already computes each row's array index for free (the r-for bare-comma
  // index form, `t, ti in ...` — see TreeNode.rozie/Table.rozie precedent), so
  // depth(ti) is now O(1) arithmetic off that index — no scan, and `t`'s id
  // can never be "not found" via this call path (ti IS t's own index), so the
  // old idx===-1→0 fallback collapses to unreachable-by-construction (same
  // observable semantics: newest=depth 0, older=length-1-idx).
  function depth(ti: any) {
    return toasts().length - 1 - ti;
  }

  // String-form `:style` for the toast row. ALWAYS carries `--rozie-toast-depth`
  // (a no-op unless `stacked` is on — CSS reads it only inside
  // `.rozie-toaster--stacked`), plus EITHER the active drag transform (while
  // $data.swipe tracks this id) OR the swipe-exit sign custom property (once
  // `dismissBegin('swipe')` flipped `t.swipeExitSign`). Drag/exit never overlap.
  function toastStyle(t: any, ti: any) {
    const depthDecl = '--rozie-toast-depth: ' + depth(ti) + ';';
    if (t.exiting) {
      return t.swipeExitSign != null ? depthDecl + ' --rozie-toast-swipe-exit: ' + t.swipeExitSign + ';' : depthDecl;
    }
    // Local named `dragState`, NOT `swipe` — see the onToastPointerUp comment
    // above (Svelte 5 $data-key self-shadow).
    const dragState = swipe();
    if (!dragState || dragState.id !== t.id) return depthDecl;
    const translate = dragState.axis === 'x' ? 'translateX(' + dragState.d + 'px)' : 'translateY(' + dragState.d + 'px)';
    const magnitude = dragState.d * dragState.sign;
    const opacity = magnitude > 0 && dragState.size > 0 ? Math.max(0.3, 1 - magnitude / dragState.size) : 1;
    return depthDecl + ' transform: ' + translate + '; opacity: ' + opacity + '; transition: none;';
  }

  // ---- hover pause -------------------------------------------------------
  function onMouseEnter() {
    if (local.disablePauseOnHover) return;
    pauseTimers();
  }
  function onMouseLeave() {
    if (local.disablePauseOnHover) return;
    resumeTimers();
  }

  // ---- helpers -----------------------------------------------------------
  function regionLabel() {
    return local.ariaLabel != null ? local.ariaLabel : 'Notifications';
  }
  // Type union: 'info' | 'success' | 'error' | 'warning' | 'loading'. Only
  // error/warning interrupt (assertive); loading (like info/success) is polite.
  function liveFor(type: any) {
    return type === 'error' || type === 'warning' ? 'assertive' : 'polite';
  }

  // ---- lifecycle + handle ------------------------------------------------

  return (
    <>
    <div role="region" aria-label={rozieAttr(regionLabel())} {...attrs} class={"rozie-toaster" + " " + rozieClass('rozie-toaster--' + local.position + (local.stacked ? ' rozie-toaster--stacked' : '')) + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} {...mergeListeners({ onMouseEnter: ($event: MouseEvent & { currentTarget: HTMLDivElement; target: Element }) => { onMouseEnter(); }, onMouseLeave: ($event: MouseEvent & { currentTarget: HTMLDivElement; target: Element }) => { onMouseLeave(); } }, attrs)} data-rozie-s-12d4265c="">
      
      <Key each={toasts() as readonly any[]} by={(t) => t.id}>{(t, ti) => <div role="status" aria-live={rozieAttr(liveFor(t().type))} class={"rozie-toast" + " " + rozieClass('rozie-toast--' + t().type + (t().exiting ? ' rozie-toast--exiting' : '') + (t().swipeExitSign != null ? ' rozie-toast--swipe-exit' : ''))} style={parseInlineStyle(toastStyle(t(), ti()))} onAnimationEnd={($event: AnimationEvent & { currentTarget: HTMLDivElement; target: Element }) => { t().exiting && removeToast(t().id); }} onPointerDown={($event: PointerEvent & { currentTarget: HTMLDivElement; target: Element }) => { onToastPointerDown(t(), $event); }} onPointerMove={($event: PointerEvent & { currentTarget: HTMLDivElement; target: Element }) => { onToastPointerMove(t(), $event); }} onPointerUp={($event: PointerEvent & { currentTarget: HTMLDivElement; target: Element }) => { onToastPointerUp(t(), $event); }} onPointerCancel={($event: PointerEvent & { currentTarget: HTMLDivElement; target: Element }) => { onToastPointerCancel(t()); }} data-rozie-s-12d4265c="">
        {(_props.toastSlot ?? _props.slots?.['toast'])?.({ toast: t(), dismiss }) ?? <>{<Show when={t().type === 'loading'}><span class={"rozie-toast-spinner"} aria-hidden="true" data-rozie-s-12d4265c="" /></Show>}<span class={"rozie-toast-message"} data-rozie-s-12d4265c="">{rozieDisplay(t().message)}</span><button type="button" aria-label="Dismiss" class={"rozie-toast-close"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { dismissBegin(t().id, 'close'); }} data-rozie-s-12d4265c="">×</button></>}
      </div>}</Key>
    </div>
    </>
  );
}
ts
import { LitElement, css, html, nothing } from 'lit';
import { customElement, property, queryAssignedElements, state } from 'lit/decorators.js';
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
import { RozieSlotDistributor, rozieAttr, rozieClass, rozieDisplay, rozieListeners, rozieSpread, rozieStyle } from '@rozie/runtime-lit';
import { repeat } from 'lit/directives/repeat.js';

interface RozieToastSlotCtx {
  toast: any;
  dismiss: any;
}

@customElement('rozie-toaster')
export default class Toaster extends SignalWatcher(LitElement) {
  static shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, slotAssignment: 'manual' };

  static styles = css`
:host{display:contents}
@media (prefers-reduced-motion: reduce) {
  .rozie-toast[data-rozie-s-12d4265c] {
    animation-name: rozie-toast-fade-in;
    animation-duration: 1ms;
  }
  .rozie-toast--exiting[data-rozie-s-12d4265c] {
    animation-name: rozie-toast-fade-out;
    animation-duration: 1ms;
  }
}
.rozie-toaster[data-rozie-s-12d4265c] {
  position: fixed;
  z-index: var(--rozie-toast-z, 9999);
  display: flex;
  flex-direction: column;
  gap: var(--rozie-toast-gap, 0.5rem);
  padding: var(--rozie-toast-region-padding, 1rem);
  max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
  pointer-events: none;
  font: var(--rozie-toast-font, inherit);
}
.rozie-toaster[data-rozie-s-12d4265c] > *[data-rozie-s-12d4265c] {
  pointer-events: auto;
}
.rozie-toaster--top-left[data-rozie-s-12d4265c] { top: 0; left: 0; align-items: flex-start; }
.rozie-toaster--top-right[data-rozie-s-12d4265c] { top: 0; right: 0; align-items: flex-end; }
.rozie-toaster--top-center[data-rozie-s-12d4265c] { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
.rozie-toaster--bottom-left[data-rozie-s-12d4265c] { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
.rozie-toaster--bottom-right[data-rozie-s-12d4265c] { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
.rozie-toaster--stacked[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
  grid-area: 1 / 1;
  z-index: calc(100 - var(--rozie-toast-depth, 0));
}
.rozie-toaster--stacked[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) {
  display: grid;
}
.rozie-toaster--stacked[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c] {
  transform:
    translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px)))
    scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
  opacity: calc(1 - min(1, max(0, var(--rozie-toast-depth, 0) - 2)));
}
.rozie-toaster--stacked.rozie-toaster--bottom-left[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c],
.rozie-toaster--stacked.rozie-toaster--bottom-right[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c],
.rozie-toaster--stacked.rozie-toaster--bottom-center[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c] {
  transform:
    translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px) * -1))
    scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
}
.rozie-toast[data-rozie-s-12d4265c] {
  display: flex;
  align-items: center;
  gap: var(--rozie-toast-content-gap, 0.75rem);
  min-width: var(--rozie-toast-min-width, 16rem);
  max-width: var(--rozie-toast-toast-max-width, 24rem);
  padding: var(--rozie-toast-padding, 0.75rem 1rem);
  color: var(--rozie-toast-color, #fff);
  background: var(--rozie-toast-bg, #333);
  border-radius: var(--rozie-toast-radius, 0.5rem);
  box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
  /* Swipe: page scroll stays alive on touch along the axis the toast does
     NOT move on. The transition here drives the spring-back (the active-drag
     :style sets an inline \`transition: none\` to track the finger 1:1;
     releasing it without a further gesture falls back to this transition). */
  touch-action: pan-y;
  transition: transform 200ms ease, opacity 200ms ease;
}
.rozie-toaster--top-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
  touch-action: pan-x;
}
.rozie-toast--success[data-rozie-s-12d4265c] { background: var(--rozie-toast-success-bg, #16a34a); }
.rozie-toast--error[data-rozie-s-12d4265c] { background: var(--rozie-toast-error-bg, #dc2626); }
.rozie-toast--warning[data-rozie-s-12d4265c] { background: var(--rozie-toast-warning-bg, #ca8a04); }
.rozie-toast--info[data-rozie-s-12d4265c] { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
from[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(-0.5rem); }
to[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
from[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(0.5rem); }
to[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(-0.5rem); }
from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(0.5rem); }
.rozie-toast[data-rozie-s-12d4265c] {
  animation: rozie-toast-enter var(--rozie-toast-enter-duration, 200ms) ease-out;
}
.rozie-toaster--bottom-left[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
.rozie-toaster--bottom-right[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
  animation-name: rozie-toast-enter-from-bottom;
}
.rozie-toast--exiting[data-rozie-s-12d4265c] {
  animation: rozie-toast-exit var(--rozie-toast-exit-duration, 200ms) ease-in forwards;
}
.rozie-toaster--bottom-left[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c],
.rozie-toaster--bottom-right[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c],
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c] {
  animation-name: rozie-toast-exit-to-bottom;
}
from[data-rozie-s-12d4265c] { opacity: 0; }
to[data-rozie-s-12d4265c] { opacity: 1; }
from[data-rozie-s-12d4265c] { opacity: 1; }
to[data-rozie-s-12d4265c] { opacity: 0; }
from[data-rozie-s-12d4265c] { opacity: 1; transform: translateX(0); }
to[data-rozie-s-12d4265c] { opacity: 0; transform: translateX(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
.rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c] {
  animation-name: rozie-toast-swipe-exit-x;
}
.rozie-toaster--top-center[data-rozie-s-12d4265c] .rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c],
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c] {
  animation-name: rozie-toast-swipe-exit-y;
}
.rozie-toast-spinner[data-rozie-s-12d4265c] {
  flex: 0 0 auto;
  width: var(--rozie-toast-spinner-size, 1em);
  height: var(--rozie-toast-spinner-size, 1em);
  border: 2px solid color-mix(in srgb, var(--rozie-toast-spinner-color, currentColor) 25%, transparent);
  border-top-color: var(--rozie-toast-spinner-color, currentColor);
  border-radius: 50%;
  animation: rozie-toast-spin 0.75s linear infinite;
}
to[data-rozie-s-12d4265c] { transform: rotate(360deg); }
.rozie-toast-message[data-rozie-s-12d4265c] {
  flex: 1 1 auto;
  font-size: var(--rozie-toast-font-size, 0.9rem);
}
.rozie-toast-close[data-rozie-s-12d4265c] {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--rozie-toast-close-size, 1.25rem);
  height: var(--rozie-toast-close-size, 1.25rem);
  padding: 0;
  font-size: 1.1rem;
  line-height: 1;
  color: inherit;
  background: transparent;
  border: none;
  border-radius: 0.25rem;
  opacity: var(--rozie-toast-close-opacity, 0.75);
  cursor: pointer;
}
.rozie-toast-close[data-rozie-s-12d4265c]:hover {
  opacity: 1;
}
`;

  /**
   * Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
   */
  @property({ type: String, reflect: true }) position: string = 'bottom-right';
  /**
   * Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
   */
  @property({ type: Number, reflect: true }) duration: number = 4000;
  /**
   * Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
   */
  @property({ type: Number, reflect: true }) max: number = 0;
  /**
   * Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
   */
  @property({ type: Boolean, reflect: true }) disablePauseOnHover: boolean = false;
  /**
   * Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
   */
  @property({ type: String, reflect: true }) ariaLabel: string | null = null;
  /**
   * Opt **out** of pointer swipe-to-dismiss. By default, dragging a toast past 45% of its own width/height (direction auto-derived from `position`) or a fast flick dismisses it with reason `'swipe'`; a short drag springs back. A drag starting on the close button (or any button/link) never swipes.
   */
  @property({ type: Boolean, reflect: true }) disableSwipe: boolean = false;
  /**
   * Opt **in** to a sonner-style collapsed stack: a single-cell grid overlay with depth-driven transforms (toasts at depth 3+ fade to invisible), newest on top. Hovering the region or moving keyboard focus into it expands to the normal flex-column stack; leaving re-collapses. `false` (default) renders the plain flex column at all times.
   */
  @property({ type: Boolean, reflect: true }) stacked: boolean = false;
  private _toasts = signal<any[]>([]);
  private _seq = signal(0);
  private _swipe = signal<any>(null);

  private _rozieSlotDistributor = new RozieSlotDistributor(this);

  @state() private _hasSlotToast = false;
  @queryAssignedElements({ slot: 'toast', flatten: true }) private _slotToastElements!: Element[];
  @property({ attribute: false }) toast?: (scope: { toast: any; dismiss: any }) => unknown;
  // Phase 79 Plan 08 (R4) contract for 79-09: the record intake for
  // record-routed slot fills. 79-09's consumer-side emitSlotFiller
  // accumulates an object literal onto the SAME `.rozieSlots=${{ ... }}`
  // open-tag binding; the KEY is the fill's authored (possibly
  // non-identifier) name and the VALUE is a scope-taking render
  // function. `rozieSlots?.[name]` must be checked BEFORE the legacy
  // named function-prop / <slot> fallback (AC-9). Attribute
  // deserialization is disabled — this is a function-valued record,
  // never reflected to/from an HTML attribute.
  @property({ attribute: false }) rozieSlots?: Record<string, (scope: any) => unknown>;

  private _disconnectCleanups: Array<() => void> = [];
  // Re-parenting guard: set true once the deferred teardown has actually
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
  private _rozieTornDown = false;

  private _armListeners(): void {
    {
      const slotEl = this.shadowRoot?.querySelector('slot[name="toast"]');
      if (slotEl !== null && slotEl !== undefined) {
        const update = () => { this._hasSlotToast = this._slotToastElements.length > 0; };
        slotEl.addEventListener('slotchange', update);
        // CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
        this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
        update();
      }
    }
  }

  connectedCallback(): void {
    // Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
    this._hasSlotToast = Array.from(this.children).some((el) => el.getAttribute('slot') === 'toast');
    super.connectedCallback();
    if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
  }

  firstUpdated(): void {
    this._armListeners();
  }

  disconnectedCallback(): void {
    super.disconnectedCallback();
    queueMicrotask(() => {
      if (this.isConnected || this._rozieTornDown) return;
      this._rozieTornDown = true;
      () => {
        this.unmounted = true;
        this.teardownTimers();
      };
      for (const fn of this._disconnectCleanups) fn();
      this._disconnectCleanups = [];
    });
  }

  render() {
    return html`
<div class="rozie-toaster ${(rozieClass('rozie-toaster--' + this.position + (this.stacked ? ' rozie-toaster--stacked' : '')))}" role="region" aria-label=${rozieAttr(this.regionLabel())} ${rozieSpread(this.$attrs)} @mouseenter=${($event: MouseEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.onMouseEnter(); }} @mouseleave=${($event: MouseEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.onMouseLeave(); }} ${rozieListeners(this.$listeners)} data-rozie-s-12d4265c>
  
  ${repeat<any>(this._toasts.value, (t, ti) => t.id, (t, ti) => html`<div class="rozie-toast ${(rozieClass('rozie-toast--' + t.type + (t.exiting ? ' rozie-toast--exiting' : '') + (t.swipeExitSign != null ? ' rozie-toast--swipe-exit' : '')))}" style=${rozieStyle(this.toastStyle(t, ti))} role="status" aria-live=${rozieAttr(this.liveFor(t.type))} @animationend=${($event: Event & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { t.exiting && this.removeToast(t.id); }} @pointerdown=${($event: PointerEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.onToastPointerDown(t, $event); }} @pointermove=${($event: PointerEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.onToastPointerMove(t, $event); }} @pointerup=${($event: PointerEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.onToastPointerUp(t, $event); }} @pointercancel=${($event: PointerEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.onToastPointerCancel(t); }} data-rozie-s-12d4265c>
    ${this.toast !== undefined ? this.toast({toast: t, dismiss: this.dismiss}) : html`<slot name="toast" data-rozie-params=${(() => { try { return JSON.stringify({toast: t}); } catch { return '{}'; } })()} @rozie-toast-dismiss=${($event: CustomEvent) => ((this.dismiss) as (...args: any[]) => any)($event.detail)}>
      ${t.type === 'loading' ? html`<span class="rozie-toast-spinner" aria-hidden="true" data-rozie-s-12d4265c></span>` : nothing}<span class="rozie-toast-message" data-rozie-s-12d4265c>${rozieDisplay(t.message)}</span>
      <button class="rozie-toast-close" type="button" aria-label="Dismiss" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.dismissBegin(t.id, 'close'); }} data-rozie-s-12d4265c>×</button>
    </slot>`}
  </div>`)}
</div>
`;
  }

  timers = {};

  exitFailsafes = {};

  unmounted = false;

  seqLocal = 0;

  paused = false;

  swipeGesture: any = null;

  startTimer = (toast: any) => {
  if (!toast || !toast.duration || toast.duration <= 0) return;
  if (typeof window === 'undefined') return;
  // Belt-and-braces: clear any pre-existing live handle for this id before
  // overwriting the entry, so a re-arm never orphans a running timeout.
  const existing = this.timers[toast.id];
  if (existing && existing.handle != null) window.clearTimeout(existing.handle);
  const remaining = toast.duration;
  const handle = window.setTimeout(() => this.dismissBegin(toast.id, 'timeout'), remaining);
  this.timers[toast.id] = {
    handle,
    startedAt: Date.now(),
    remaining
  };
};

  clearTimer = (id: any) => {
  const entry = this.timers[id];
  if (entry && entry.handle != null && typeof window !== 'undefined') window.clearTimeout(entry.handle);
  delete this.timers[id];
};

  pauseTimers = () => {
  this.paused = true;
  if (typeof window === 'undefined') return;
  for (const id in this.timers) {
    const entry = this.timers[id];
    // Idempotent: an entry already paused (handle cleared) keeps its stored
    // remainder. A second pause must NOT re-subtract elapsed against the
    // original startedAt — that drove `remaining` negative and stranded the
    // toast forever once resume saw the non-positive value.
    if (entry.handle == null) continue;
    window.clearTimeout(entry.handle);
    const elapsed = Date.now() - entry.startedAt;
    // Clamp so a late pause (e.g. a background-tab timer that overran) can
    // never store a negative remainder.
    const remaining = Math.max(0, entry.remaining - elapsed);
    this.timers[id] = {
      handle: null,
      startedAt: entry.startedAt,
      remaining
    };
  }
};

  resumeTimers = () => {
  this.paused = false;
  if (typeof window === 'undefined') return;
  for (const id in this.timers) {
    const entry = this.timers[id];
    // Only re-arm entries that are actually paused (handle cleared). A live
    // handle is left alone — re-arming it would orphan the running timeout.
    if (entry.handle != null) continue;
    if (entry.remaining == null || entry.remaining <= 0) {
      // Its deadline elapsed while paused (a background-tab overrun, or a
      // remainder clamped to 0): treat as EXPIRED and dismiss now — its time
      // is up — rather than leaving it un-armed and stranded forever.
      this.dismissBegin(id, 'timeout');
      continue;
    }
    const remaining = entry.remaining;
    const handle = window.setTimeout(() => this.dismissBegin(id, 'timeout'), remaining);
    this.timers[id] = {
      handle,
      startedAt: Date.now(),
      remaining
    };
  }
};

  teardownTimers = () => {
  if (typeof window !== 'undefined') {
    for (const id in this.timers) {
      const entry = this.timers[id];
      if (entry.handle != null) window.clearTimeout(entry.handle);
    }
    // Also cancel every pending exit failsafe — otherwise a removal timeout
    // scheduled just before unmount/clear() fires afterward and writes $data.
    for (const id in this.exitFailsafes) {
      if (this.exitFailsafes[id] != null) window.clearTimeout(this.exitFailsafes[id]);
    }
  }
  this.timers = {};
  this.exitFailsafes = {};
};

  show = (input: any) => {
  const t = input || {};
  let id;
  if (t.id != null) {
    // Coerce a consumer-supplied id to a String once, at the single entry
    // point. Ids flow through the `timers` map (whose `for (const id in …)`
    // keys are ALWAYS strings) and every downstream `t.id === id` strict
    // comparison; a numeric consumer id (`show({ id: 42 })`) would otherwise
    // stop matching after a hover pause/resume re-arms with the string key.
    id = String(t.id);
  } else {
    // Take the high-water mark of the persistent-but-tick-stale $data.seq and
    // the synchronous-but-maybe-per-render seqLocal (see the <script> comment)
    // so same-tick multi-show yields DISTINCT ids on React too. Read both
    // BEFORE writing either (no read-after-write of $data.seq → ROZ138-safe).
    const s = Math.max(this._seq.value, this.seqLocal);
    id = 't' + s;
    this.seqLocal = s + 1;
    this._seq.value = s + 1;
  }
  const toast = {
    id,
    message: t.message != null ? t.message : '',
    type: t.type || 'info',
    duration: t.duration != null ? t.duration : this.duration
  };
  // ONE self-referential assignment so the React emitter lowers it to the
  // concurrent-safe functional updater `setToasts(prev => …)` (it only does so
  // when the RHS reads $data.toasts DIRECTLY — a via-a-local form lowered to a
  // stale-closure `setToasts(<value>)`, losing the first of two same-tick
  // toasts). slice() start: keep the newest `max` when over the cap
  // (Math.max(0, len+1-max)), else slice(0) = the whole fresh array.
  this._toasts.value = this._toasts.value.concat([toast]).slice(this.max > 0 ? Math.max(0, this._toasts.value.length + 1 - this.max) : 0);
  this.startTimer(toast);
  return id;
};

  EXIT_FAILSAFE_MS = 350;

  removeToast = (id: any) => {
  // Cancel any pending exit failsafe for this id (first-wins: @animationend
  // beating the ~350ms timeout, or vice-versa — either way, only one removal).
  if (typeof window !== 'undefined' && this.exitFailsafes[id] != null) {
    window.clearTimeout(this.exitFailsafes[id]);
  }
  delete this.exitFailsafes[id];
  this._toasts.value = this._toasts.value.filter((t: any) => t.id !== id);
};

  dismissBegin = (id: any, reason: any, extra?: {
  swipeExitSign?: number;
}) => {
  const entry = this._toasts.value.find((t: any) => t.id === id);
  if (!entry || entry.exiting) return;
  this.clearTimer(id);
  this.dispatchEvent(new CustomEvent("dismissed", {
    detail: {
      toast: entry,
      reason
    },
    bubbles: true,
    composed: true
  }));
  this._toasts.value = this._toasts.value.map((t: any) => t.id === id ? {
    ...t,
    exiting: true,
    ...(extra || {})
  } : t);
  if (typeof window === 'undefined') {
    this.removeToast(id);
  } else {
    this.exitFailsafes[id] = window.setTimeout(() => this.removeToast(id), this.EXIT_FAILSAFE_MS);
  }
};

  dismiss = (id: any) => {
  this.dismissBegin(id, 'api');
};

  clear = () => {
  this.teardownTimers();
  this._toasts.value = [];
};

  patch = (id: any, changes: any) => {
  const c = changes || {};
  let existed = false;
  const next = this._toasts.value.map((t: any) => {
    if (t.id !== id) return t;
    // Treat an EXITING entry as absent — never resurrect a toast whose
    // dismissal is already in flight (removal deferred to @animationend / the
    // failsafe). `existed` stays false → patch returns false, writes nothing,
    // arms no timer.
    if (t.exiting) return t;
    existed = true;
    const merged = {
      ...t
    };
    if (c.message !== undefined) merged.message = c.message;
    if (c.type !== undefined) merged.type = c.type;
    if (c.duration !== undefined) merged.duration = c.duration;
    return merged;
  });
  if (!existed) return false;
  this._toasts.value = next;
  if (c.duration !== undefined) {
    this.clearTimer(id);
    const patched = next.find((t: any) => t.id === id);
    if (this.paused) {
      // Hovered: store the new duration as the pending remainder WITHOUT
      // arming a live timer (which would dismiss the toast while the pointer
      // is still over the stack). resumeTimers() arms it on leave.
      if (patched && patched.duration > 0 && typeof window !== 'undefined') {
        this.timers[id] = {
          handle: null,
          startedAt: Date.now(),
          remaining: patched.duration
        };
      }
    } else {
      this.startTimer(patched);
    }
  }
  return true;
};

  settlePromise = (id: any, type: any, messageOrFn: any, value: any) => {
  if (this.unmounted) return;
  // Never-resurrect: no-op if the toast is gone OR already exiting (its
  // dismissal is in flight — settling now would flip it back to a live
  // success/error toast and re-arm a timer).
  const entry = this._toasts.value.find((t: any) => t.id === id);
  if (!entry || entry.exiting) return;
  const message = typeof messageOrFn === 'function' ? messageOrFn(value) : messageOrFn;
  this.patch(id, {
    type,
    message,
    duration: this.duration
  });
};

  promise = (p: any, opts: any) => {
  const o = opts || {};
  const id = this.show({
    type: 'loading',
    duration: 0,
    message: o.loading
  });
  if (p && typeof p.then === 'function') {
    p.then((value: any) => this.settlePromise(id, 'success', o.success, value)).catch((err: any) => this.settlePromise(id, 'error', o.error, err));
  }
  return id;
};

  swipeAxisFor = (position: any) => position === 'top-center' || position === 'bottom-center' ? 'y' : 'x';

  swipeSignFor = (position: any) => {
  if (position === 'top-right' || position === 'bottom-right') return 1;
  if (position === 'top-left' || position === 'bottom-left') return -1;
  if (position === 'bottom-center') return 1;
  return -1; // top-center
};

  onToastPointerDown = (t: any, event: any) => {
  if (this.disableSwipe) return;
  if (event.button != null && event.button !== 0) return;
  // Ignore drags starting on the close button / any button-or-link chrome.
  const chrome = event.target && event.target.closest ? event.target.closest('button, a') : null;
  if (chrome) return;
  const axis = this.swipeAxisFor(this.position);
  const sign = this.swipeSignFor(this.position);
  const el = event.currentTarget;
  const size = axis === 'x' ? el.offsetWidth : el.offsetHeight;
  this.swipeGesture = {
    id: t.id,
    axis,
    sign,
    size,
    startX: event.clientX,
    startY: event.clientY,
    startTime: Date.now()
  };
  if (el && el.setPointerCapture) {
    try {
      el.setPointerCapture(event.pointerId);
    } catch (e: any) {
      // Some embedded contexts throw on setPointerCapture — swipe still
      // works without capture (just loses "keeps tracking off-element").
    }
  }
};

  onToastPointerMove = (t: any, event: any) => {
  if (this.disableSwipe) return;
  const gesture = this.swipeGesture;
  if (!gesture || gesture.id !== t.id) return;
  const raw = gesture.axis === 'x' ? event.clientX - gesture.startX : event.clientY - gesture.startY;
  const towardDismiss = raw * gesture.sign > 0;
  const d = towardDismiss ? raw : raw * 0.15;
  this._swipe.value = {
    id: t.id,
    d,
    axis: gesture.axis,
    sign: gesture.sign,
    size: gesture.size
  };
};

  onToastPointerUp = (t: any, event: any) => {
  if (this.disableSwipe) return;
  const gesture = this.swipeGesture;
  this.swipeGesture = null;
  // Local named `dragState`, NOT `swipe` — a local `swipe` would shadow the
  // reactive `$data.swipe` key on Svelte 5 (top-level `let swipe = $state(…)`
  // self-shadow TDZ: `const swipe = swipe` then `swipe = null` throws
  // "Cannot assign to constant"). Same collision class as the documented
  // $refs/$props self-shadow, just for a $data key.
  const dragState = this._swipe.value;
  this._swipe.value = null;
  if (!gesture || gesture.id !== t.id || !dragState) return;
  const elapsed = Math.max(1, Date.now() - gesture.startTime);
  const magnitude = dragState.d * gesture.sign;
  const velocity = magnitude / elapsed;
  if (magnitude > 0 && (magnitude > gesture.size * 0.45 || velocity > 0.11)) {
    this.dismissBegin(t.id, 'swipe', {
      swipeExitSign: gesture.sign
    });
  }
};

  onToastPointerCancel = (t: any) => {
  if (this.disableSwipe) return;
  if (this.swipeGesture && this.swipeGesture.id === t.id) this.swipeGesture = null;
  if (this._swipe.value && this._swipe.value.id === t.id) this._swipe.value = null;
};

  depth = (ti: any) => this._toasts.value.length - 1 - ti;

  toastStyle = (t: any, ti: any) => {
  const depthDecl = '--rozie-toast-depth: ' + this.depth(ti) + ';';
  if (t.exiting) {
    return t.swipeExitSign != null ? depthDecl + ' --rozie-toast-swipe-exit: ' + t.swipeExitSign + ';' : depthDecl;
  }
  // Local named `dragState`, NOT `swipe` — see the onToastPointerUp comment
  // above (Svelte 5 $data-key self-shadow).
  const dragState = this._swipe.value;
  if (!dragState || dragState.id !== t.id) return depthDecl;
  const translate = dragState.axis === 'x' ? 'translateX(' + dragState.d + 'px)' : 'translateY(' + dragState.d + 'px)';
  const magnitude = dragState.d * dragState.sign;
  const opacity = magnitude > 0 && dragState.size > 0 ? Math.max(0.3, 1 - magnitude / dragState.size) : 1;
  return depthDecl + ' transform: ' + translate + '; opacity: ' + opacity + '; transition: none;';
};

  onMouseEnter = () => {
  if (this.disablePauseOnHover) return;
  this.pauseTimers();
};

  onMouseLeave = () => {
  if (this.disablePauseOnHover) return;
  this.resumeTimers();
};

  regionLabel = () => this.ariaLabel != null ? this.ariaLabel : 'Notifications';

  liveFor = (type: any) => type === 'error' || type === 'warning' ? 'assertive' : 'polite';

  /**
   * Plan 14-05 — cross-framework attribute fallthrough source. Reads the
   * host custom element's attributes on each call so a consumer-side bound
   * attribute flows through on every render. The `rozieSpread` directive
   * (D-02) does the cross-render diff downstream.
   *
   * Phase 15 follow-up Bug A — declared-prop attribute names are filtered
   * out so `$attrs` returns "rest after declared props" (semantic parity
   * with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
   * forms are folded into the skip set: kebab-case for model props
   * (explicit `attribute:`) AND lowercased property name (Lit's default).
   *
   * command-palette-per-level-virtual / portal-through-portal cluster —
   * `data-rozie-ref` is ALWAYS skipped too (a reserved compiler bookkeeping
   * attribute, never a consumer prop) so a parent-assigned `ref=` on this
   * component's own host tag can never clobber this component's OWN
   * internal `data-rozie-ref` ref markers via fallthrough re-application.
   */
  private get $attrs(): Record<string, string> {
    const __skip = new Set<string>(['data-rozie-ref', 'position', 'duration', 'max', 'disable-pause-on-hover', 'disablepauseonhover', 'aria-label', 'arialabel', 'disable-swipe', 'disableswipe', 'stacked']);
    const out: Record<string, string> = {};
    for (const a of Array.from(this.attributes)) {
      if (__skip.has(a.name)) continue;
      out[a.name] = a.value;
    }
    return out;
  }

  /**
   * Phase 15 D-19 — consumer-passed listener cluster placeholder.
   * Lit attaches event listeners directly on the host element via
   * `addEventListener` (no per-instance prop rest binding), so the
   * runtime value is undefined; the `rozieListeners` directive's
   * nullish coercion (`obj ?? {}`) handles the no-op cleanly.
   * The declaration exists to satisfy `tsc --noEmit` on consumer
   * projects with strict mode — bare `$listeners` in `render()`
   * would otherwise raise TS2304 (Cannot find name).
   */
  private get $listeners(): Record<string, EventListener> | undefined {
    return undefined;
  }
}

Each is a real component for its framework — React forwardRef + hooks, Vue <script setup>, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same show / dismiss / clear handle, same #toast scoped slot — identical on every target, with no third-party engine behind it.

See also

Pre-1.0 — APIs may change between minor versions.