//
// Toaster -- the notification host. Mount it once (usually in a layout) and
// raise notifications from anywhere with the runtime global:
//
// toast("Saved")
// toast.success("Invite sent to " + email)
// toast.error("Could not save", { title: "Network error", duration: 8000 })
// toast({ message: "Uploading", tone: "info", duration: 0 })   // 0 = sticky
//
// Each tone ships a built-in icon tinted with that tone colour -- green for
// success, red for danger, amber for warning, blue for info. Swap in your own
// per toast, or per tone on the host:
//
//   toast.success("Shipped", { icon: "icon-[lucide--rocket]" })
//   toast("Quiet one", { icon: false })          // suppress the icon
//   <Toaster successIcon="icon-[lucide--party-popper]" />
//   <Toaster showIcon={false} />                 // never show icons
//
// Actions run your own code. The handler is passed straight through, so it
// closes over whatever the calling function can see:
//
//   toast("Note deleted", {
//     actionLabel: "Undo",
//     onAction: function () { restoreNote(id) }
//   })
//
//   toast.warning("Two versions of this file", {
//     actions: [
//       { label: "Keep mine", onClick: keepMine },
//       { label: "Use theirs", onClick: useTheirs, tone: "danger", dismiss: false }
//     ]
//   })
//
// The toast dismisses itself after an action; pass dismiss: false on the
// action to keep it open (for a step that has to report back). At most two
// actions render -- see normalizeActions. A declarative @action on the tag
// still fires for every click, so a page can log or route centrally.
//
// IMPORTANT -- what a callback may do. It runs long after the function that
// created it returned, and a client function only flushes its state when its
// body ends, so assigning your own component state from inside a callback
// writes to a dead local and is lost. Calling toast(), fetch, navigation and
// anything else that is not a state assignment works normally. To change
// state, dispatch an event and handle it declaratively, which re-enters with
// live state:
//
//   onAction: function () {
//     window.dispatchEvent(new CustomEvent("app:undo-delete"))
//   }
//   ...
//   <div @window:app:undo-delete="restoreItem()">
//
// An icon prop is a CSS class, never markup, so any icon system works
// (iconify, an icon font, your own sprite). NOTE the class has to appear in
// YOUR source for a scanner like Tailwind to emit it -- that is exactly why
// the defaults are inline SVG rather than classes from this package.
//
// The runtime never imports this component: toast() dispatches a
// `wrnexus:toast` window event and this listens for it, so an app can supply
// its own host by listening for the same event.
//
// NOTE: apostrophes are avoided in the style block on purpose -- the .wrn
// block scanner treats a quote as a string delimiter while counting braces,
// so a stray one breaks parsing of the whole component.
component Toaster {
  outputs {
    show(payload: { id: number; message: string; tone: string })
    dismiss(payload: { id: number; reason: string })
    action(payload: { id: number; sourceEvent: Event })
  }

  props {
    // Default tone for toasts raised without one of their own. Every WrNexus UI
    // component takes color and size; here they set the stack defaults.
    color: string = "info"
    // default | sm | lg -- controls toast width and density.
    size: string = "default"
    // top-left | top-center | top-right | bottom-left | bottom-center | bottom-right
    position: string = "bottom-right"
    // Auto-dismiss delay in ms. 0 keeps a toast until it is dismissed.
    duration: number = 4500
    // Oldest toasts beyond this are retired as new ones arrive.
    max: number = 4
    pauseOnHover: boolean = true
    // Icons: a built-in glyph per tone, tinted with that tone colour.
    // Override any of them with an icon class of your own (iconify, an icon
    // font, whatever your app already uses) -- the built-in SVG is only a
    // fallback so the component needs no icon dependency. Per toast:
    //   toast.success("Saved", { icon: "icon-[lucide--party-popper]" })
    //   toast("Quiet", { icon: false })   // no icon on this one
    showIcon: boolean = true
    successIcon: string = ""
    dangerIcon: string = ""
    warningIcon: string = ""
    infoIcon: string = ""
    closable: boolean = true
    showProgress: boolean = true
    closeLabel: string = "Dismiss notification"
    class: string = ""
  }

  state toasts = []
  state sequence = 0
  // Timer bookkeeping, deliberately outside `toasts`: the view never reads
  // this, so pausing and resuming rebuilds no DOM. See the note in functions.
  state timers = {}

  functions {
    // TIMER DESIGN -- read before changing.
    //
    // 1. No callback here touches state. A client function gets state as a
    //    local snapshot and flushes it back when the body returns, so a write
    //    from a setTimeout callback lands in a dead local and is lost; calling
    //    a peer function from one re-flushes the stale snapshot over live
    //    state. Timers therefore only dispatch a window event, and the
    //    declarative @window handlers re-enter with live state.
    //
    // 2. Timer bookkeeping lives in `timers`, NOT on the toast entries.
    //    The view renders `toasts` through data-for, so touching a toast
    //    object rebuilds every row -- which restarted each progress bar from
    //    zero and made the bar look permanently full. Hover has to be free of
    //    that: pausing must leave the DOM completely alone. `timers` is never
    //    read by the view, so writing it re-renders nothing.
    //
    // 3. Everything that DOES change `toasts` (add, dismiss, remove) returns
    //    untouched items by reference for the rows it is not changing, so the
    //    keyed loop reuses those nodes and their bars keep running.
    client function scheduleEvent(name, id, delay) {
      return setTimeout(function () {
        window.dispatchEvent(new CustomEvent(name, { detail: { id: id } }))
      }, delay)
    }

    // kind is either "dismiss" (the toast lifetime, which hover pauses) or
    // "remove" (the exit-animation cleanup, which hover must NOT touch --
    // see pauseAll).
    client function trackTimer(id, handle, life, kind) {
      var next = {}
      Object.keys(timers).forEach(function (key) { next[key] = timers[key] })
      next[id] = {
        handle: handle,
        expiresAt: Date.now() + life,
        remaining: life,
        kind: kind || "dismiss"
      }
      timers = next
    }

    client function forgetTimer(id) {
      var next = {}
      Object.keys(timers).forEach(function (key) {
        if (String(key) !== String(id)) {
          next[key] = timers[key]
        }
      })
      timers = next
    }

    // An explicit per-toast icon wins; otherwise the tone default prop; and if
    // that is empty the built-in SVG for the tone renders instead. Returns a
    // CSS class name, never markup, so an app can hand us any icon system.
    client function resolveIcon(tone, requested) {
      if (requested) {
        return String(requested)
      }
      if (tone === "success") {
        return successIcon
      }
      if (tone === "danger") {
        return dangerIcon
      }
      if (tone === "warning") {
        return warningIcon
      }
      if (tone === "info") {
        return infoIcon
      }
      return ""
    }

    // Actions arrive either as a single actionLabel/onAction pair or as an
    // actions array. At most two are rendered: the view has two fixed slots
    // because a data-for inside a data-for is not expanded by the runtime, so
    // an arbitrary list cannot be rendered per row. Two covers the real cases
    // (Undo, Retry, View / Dismiss); anything beyond that is dropped loudly
    // rather than silently.
    client function normalizeActions(detail) {
      var list = []
      if (Array.isArray(detail.actions)) {
        list = detail.actions.filter(function (action) { return action && action.label })
      } else if (detail.actionLabel) {
        list = [{ label: detail.actionLabel, onClick: detail.onAction, tone: detail.actionTone }]
      }
      if (list.length > 2) {
        console.warn(
          "[wrnexus] Toaster renders at most 2 actions per toast; ignoring " +
            (list.length - 2) + " extra."
        )
      }
      return list.slice(0, 2)
    }

    // Is the pointer resting on the stack right now?
    //
    // mouseenter only fires when the pointer MOVES. A toast raised while the
    // cursor is already parked over the stack -- which is exactly what an
    // action handler does -- therefore gets no enter event, is never paused,
    // and counts down and disappears while the user is still reaching for its
    // button. Its progress bar meanwhile IS paused, because CSS :hover does
    // apply, so the bar sat still while the toast quietly expired. Asking the
    // DOM for the live :hover state closes that gap.
    client function stackHovered() {
      var list = refs.list
      return !!(pauseOnHover && list && list.matches(":hover"))
    }

    client function receiveToast(sourceEvent) {
      var detail = sourceEvent.detail || {}

      // One toast per raise, however many hosts are mounted.
      //
      // toast() dispatches a window event, so EVERY mounted Toaster hears it
      // and a page with two hosts showed the message twice (the component
      // showcase mounts five and showed five). Claiming the event on the
      // detail object lets the first host win and the rest stand down, so an
      // accidental second host is harmless instead of multiplying every
      // notification.
      if (detail.__wrnClaimed) {
        return
      }
      detail.__wrnClaimed = true
      sequence = sequence + 1

      var id = sequence
      var life = detail.duration === 0 ? 0 : (detail.duration || duration)
      var tone = detail.tone || color

      var entry = {
        id: id,
        title: detail.title || "",
        message: detail.message === undefined ? "" : String(detail.message),
        tone: tone,
        icon: resolveIcon(tone, detail.icon),
        showIcon: showIcon && detail.icon !== false,
        actions: normalizeActions(detail),
        duration: life,
        leaving: false
      }

      // Retire the oldest live toasts in the same pass that appends the new
      // one, so a burst can never leave the stack over max. Untouched rows are
      // returned by reference so their nodes (and bars) survive.
      var live = toasts.filter(function (item) { return !item.leaving })
      var retire = live.length + 1 > max ? live.slice(0, live.length + 1 - max) : []

      toasts = toasts
        .map(function (item) {
          var doomed = retire.some(function (old) { return old.id === item.id })
          return doomed ? Object.assign({}, item, { leaving: true }) : item
        })
        .concat([entry])

      retire.forEach(function (item) {
        clearTimeout((timers[item.id] || {}).handle)
        trackTimer(item.id, scheduleEvent("wrnexus:toast:remove", item.id, 240), 240, "remove")
      })

      if (life) {
        // Born paused when the stack is already hovered: handle 0 parks it,
        // and resumeAll starts the clock when the pointer finally leaves.
        if (stackHovered()) {
          trackTimer(id, 0, life, "dismiss")
        } else {
          trackTimer(id, scheduleEvent("wrnexus:toast:dismiss", id, life), life, "dismiss")
        }
      }
      output.show({ id: id, message: entry.message, tone: entry.tone })
    }

    client function dismissToast(id, reason) {
      var found = false
      toasts = toasts.map(function (item) {
        if (item.id !== id || item.leaving) {
          return item
        }
        found = true
        return Object.assign({}, item, { leaving: true })
      })
      if (!found) {
        return
      }
      clearTimeout((timers[id] || {}).handle)
      // Give the exit animation time to play, then drop the entry.
      trackTimer(id, scheduleEvent("wrnexus:toast:remove", id, 240), 240, "remove")
      output.dismiss({ id: id, reason: reason || "auto" })
    }

    client function removeToast(id) {
      clearTimeout((timers[id] || {}).handle)
      forgetTimer(id)
      toasts = toasts.filter(function (item) { return item.id !== id })
    }

    client function dismissById(sourceEvent) {
      var detail = sourceEvent.detail || {}
      dismissToast(detail.id, detail.reason || "timeout")
    }

    client function removeById(sourceEvent) {
      var detail = sourceEvent.detail || {}
      removeToast(detail.id)
    }

    client function clearAll() {
      toasts.slice().forEach(function (item) {
        if (!item.leaving) {
          dismissToast(item.id, "clear")
        }
      })
    }

    // Hovering must not run the clock down while a toast is being read, so
    // the remaining time is banked and the timers restart on the way out.
    // Only `timers` is written, so not a single DOM node is rebuilt -- the
    // progress bars simply stop where they are (CSS pauses them on :hover)
    // and carry on from there.
    client function pauseAll() {
      if (!pauseOnHover) {
        return
      }
      var now = Date.now()
      var next = {}
      Object.keys(timers).forEach(function (key) {
        var entry = timers[key]
        // A "remove" timer finishes an exit animation -- pausing it strands
        // the toast: invisible, still taking up space in the stack, and still
        // able to swallow clicks, forever. Only lifetimes pause.
        if (!entry || !entry.handle || entry.kind !== "dismiss") {
          next[key] = entry
          return
        }
        clearTimeout(entry.handle)
        var left = entry.expiresAt - now
        next[key] = {
          handle: 0,
          expiresAt: entry.expiresAt,
          remaining: left > 0 ? left : 1,
          kind: "dismiss"
        }
      })
      timers = next
    }

    client function resumeAll() {
      if (!pauseOnHover) {
        return
      }
      var now = Date.now()
      var next = {}
      Object.keys(timers).forEach(function (key) {
        var entry = timers[key]
        if (!entry || entry.handle || entry.kind !== "dismiss") {
          next[key] = entry
          return
        }
        var left = entry.remaining > 0 ? entry.remaining : 1
        // Number(key): object keys come back as strings, and the dismiss
        // handler matches ids with !==, so a string id silently matches no
        // toast and the resumed timer would fire into the void.
        next[key] = {
          handle: scheduleEvent("wrnexus:toast:dismiss", Number(key), left),
          expiresAt: now + left,
          remaining: left,
          kind: "dismiss"
        }
      })
      timers = next
    }

    // The click handler for an action. Runs as a fresh invocation from the
    // DOM, so state here is live and calling peer functions is safe.
    client function runAction(id, index, sourceEvent) {
      var toast = null
      toasts.forEach(function (item) {
        if (item.id === id) {
          toast = item
        }
      })
      if (!toast) {
        return
      }

      var action = toast.actions[index]
      if (!action) {
        return
      }

      // Declarative listeners on the <Toaster> tag see every action too.
      output.action({
        id: id,
        index: index,
        label: action.label,
        sourceEvent: sourceEvent
      })

      // The callback is application code: a throw here must not take the
      // toaster down with it, or the toast would be stuck on screen forever.
      if (typeof action.onClick === "function") {
        try {
          action.onClick(sourceEvent)
        } catch (error) {
          console.error("[wrnexus] toast action handler failed", error)
        }
      }

      // NOTHING may call a peer function past this point.
      //
      // The callback is application code and is re-entrant: a handler that
      // raises its own toast runs receiveToast in a fresh invocation, which
      // appends to live state. Calling a peer from here would first flush the
      // snapshot this function captured on entry -- taken BEFORE the callback
      // ran -- straight over that live state, silently erasing the toast the
      // handler just raised. So the dismissal is dispatched inline instead,
      // and handled on the next tick with state that is actually current.
      // This function never assigns to state itself, so it flushes nothing.
      if (action.dismiss !== false) {
        setTimeout(function () {
          window.dispatchEvent(
            new CustomEvent("wrnexus:toast:dismiss", {
              detail: { id: id, reason: "action" }
            })
          )
        }, 0)
      }
    }
  }

  view {
    <div
      {...attrs}
      data-ui-component="Toaster"
      data-toaster="true"
      data-position='{position}'
      data-size='{size}'
      class='wrn-toaster {class}'
      role="region"
      aria-label="Notifications"
      @window:wrnexus:toast='receiveToast(event)'
      @window:wrnexus:toast:dismiss='dismissById(event)'
      @window:wrnexus:toast:clear='clearAll()'
      @window:wrnexus:toast:remove='removeById(event)'
    >
      <!-- Hover is bound to the list, not to each toast, and pauses the whole
           stack. A row is a data-for clone that is recreated whenever state
           changes, so a per-row mouseleave would be bound to a node the
           browser has already discarded: the pointer would never "leave", the
           toast would stay paused, and it would hang on screen forever.
           The list element is stable for the lifetime of the page. Pausing
           the whole stack is also what people expect -- reading one toast
           should not let its neighbours expire underneath it. -->
      <ol
        class="wrn-toaster__list"
        data-ref="list"
        aria-live="polite"
        aria-relevant="additions text"
        @mouseenter='pauseAll()'
        @mouseleave='resumeAll()'
        @focusin='pauseAll()'
        @focusout='resumeAll()'
      >
        <li
          class="wrn-toast"
          data-for="item in toasts"
          data-key="item.id"
          data-tone='{item.tone}'
          data-leaving='{item.leaving}'
        >
          <span
            class="wrn-toast__indicator"
            aria-hidden="true"
          >
          </span>

          <!-- An app-supplied icon class (iconify or otherwise). -->
          <span
            class='wrn-toast__icon {item.icon}'
            data-show="item.showIcon && item.icon"
            aria-hidden="true"
          >
          </span>

          <!-- Built-in fallback. Inline SVG on purpose: the package cannot
               assume the host app has an icon set installed, and a class from
               here would not be in the app Tailwind content globs anyway, so
               it would generate no CSS and render nothing. Each tone shows its
               own glyph via data-show; all of them inherit --toast-accent, so
               the icon is green / red / amber / blue with the tone. -->
          <svg
            class="wrn-toast__icon wrn-toast__icon--default"
            data-show="item.showIcon && !item.icon"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            stroke-width="2"
            stroke-linecap="round"
            stroke-linejoin="round"
            aria-hidden="true"
          >
            <g data-show="item.tone === 'success'">
              <circle cx="12" cy="12" r="10" />
              <path d="m9 12 2 2 4-4" />
            </g>
            <g data-show="item.tone === 'danger'">
              <circle cx="12" cy="12" r="10" />
              <path d="M12 8v4" />
              <path d="M12 16h.01" />
            </g>
            <g data-show="item.tone === 'warning'">
              <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
              <path d="M12 9v4" />
              <path d="M12 17h.01" />
            </g>
            <g data-show="item.tone !== 'success' && item.tone !== 'danger' && item.tone !== 'warning'">
              <circle cx="12" cy="12" r="10" />
              <path d="M12 16v-4" />
              <path d="M12 8h.01" />
            </g>
          </svg>

          <div
            class="wrn-toast__body"
          >
            <p
              class="wrn-toast__title"
              data-show="item.title"
            >
              {item.title}
            </p>
            <p
              class="wrn-toast__message"
            >
              {item.message}
            </p>
          </div>

          <!-- Two fixed action slots. A data-for inside a data-for is not
               expanded by the runtime, so an arbitrary list cannot be looped
               per row; normalizeActions caps the array at two and warns. -->
          <div
            class="wrn-toast__actions"
            data-show="item.actions.length"
          >
            <button
              type="button"
              class="wrn-toast__action"
              data-show="item.actions.length > 0"
              data-tone='{item.actions[0].tone}'
              @click='runAction(item.id, 0, event)'
            >
              {item.actions[0].label}
            </button>

            <button
              type="button"
              class="wrn-toast__action"
              data-show="item.actions.length > 1"
              data-tone='{item.actions[1].tone}'
              @click='runAction(item.id, 1, event)'
            >
              {item.actions[1].label}
            </button>
          </div>

          <button
            type="button"
            class="wrn-toast__close"
            data-show="closable"
            aria-label='{closeLabel}'
            @click='dismissToast(item.id, "close-button")'
          >
            <svg
              viewBox="0 0 24 24"
              width="14"
              height="14"
              fill="none"
              stroke="currentColor"
              stroke-width="2.2"
              stroke-linecap="round"
              aria-hidden="true"
            >
              <path d="M18 6 6 18" />
              <path d="M6 6 18 18" />
            </svg>
          </button>

          <!-- Decoration only: the timer above owns dismissal. Shown when the
               toast has a lifetime, and paused in step with it on hover. -->
          <span
            class="wrn-toast__progress"
            data-show="showProgress && item.duration"
            style="--toast-duration: {item.duration}ms"
            aria-hidden="true"
          >
          </span>
        </li>
      </ol>
    </div>
  }

  style {
    .wrn-toaster {
      position: fixed;
      inset: 0;
      z-index: 1400;
      display: flex;
      padding: clamp(0.75rem, 2vw, 1.25rem);
      /* The host covers the viewport so it can align the stack in any corner;
         it must never swallow clicks meant for the page underneath. */
      pointer-events: none;
    }

    /*
     * pointer-events MUST be re-enabled here, not only on .wrn-toast.
     *
     * The host is pointer-events: none so the page underneath stays clickable
     * through the empty overlay, and the list inherits that. An element with
     * pointer-events: none is never a hit-test target, so it never matches
     * :hover AND never receives mouseenter/mouseleave -- and those two do not
     * bubble up from the rows either. The result was that hover-to-pause did
     * nothing at all for a real user: toasts kept counting down and vanished
     * from under the cursor as they reached for the action button. (Synthetic
     * dispatchEvent bypasses hit-testing, so it hid this in testing.)
     *
     * The list box wraps the stack exactly -- its height is the toasts plus
     * their gaps -- so making it interactive costs the page nothing.
     */
    .wrn-toaster__list {
      display: flex;
      flex-direction: column;
      gap: 0.6rem;
      width: min(23rem, 100%);
      margin: 0;
      padding: 0;
      list-style: none;
      pointer-events: auto;
    }

    /* Nothing to hover when the stack is empty. */
    .wrn-toaster__list:empty {
      pointer-events: none;
    }

    .wrn-toaster[data-size="sm"] .wrn-toaster__list {
      width: min(18rem, 100%);
    }

    .wrn-toaster[data-size="lg"] .wrn-toaster__list {
      width: min(28rem, 100%);
    }

    .wrn-toaster[data-size="sm"] .wrn-toast {
      padding: 0.6rem 0.7rem;
      font-size: 0.78rem;
    }

    .wrn-toaster[data-size="lg"] .wrn-toast {
      padding: 1.05rem 1.1rem;
    }

    .wrn-toaster[data-position^="top"] {
      align-items: flex-start;
    }

    .wrn-toaster[data-position^="bottom"] {
      align-items: flex-end;
    }

    /* Newest nearest the screen edge: at the bottom that means visually last,
       so the column is reversed rather than the state array. */
    .wrn-toaster[data-position^="bottom"] .wrn-toaster__list {
      flex-direction: column-reverse;
    }

    .wrn-toaster[data-position$="left"] {
      justify-content: flex-start;
    }

    .wrn-toaster[data-position$="center"] {
      justify-content: center;
    }

    .wrn-toaster[data-position$="right"] {
      justify-content: flex-end;
    }

    .wrn-toast {
      position: relative;
      display: flex;
      align-items: flex-start;
      gap: 0.7rem;
      overflow: hidden;
      padding: 0.85rem 0.9rem;
      color: var(--wrn-color-text);
      background: var(--wrn-color-surface-raised);
      border: 1px solid var(--wrn-color-border);
      border-radius: 0.9rem;
      box-shadow: 0 18px 40px color-mix(in srgb, black 28%, transparent);
      pointer-events: auto;
      animation: wrn-toast-in 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
    }

    .wrn-toaster[data-position$="left"] .wrn-toast {
      animation-name: wrn-toast-in-left;
    }

    /*
     * Belt and braces: a toast on its way out is faded to nothing but still
     * occupies its box until it is dropped from the list, so without this it
     * can swallow clicks aimed at whatever is under it.
     */
    .wrn-toast[data-leaving="true"] {
      animation: wrn-toast-out 200ms ease forwards;
      pointer-events: none;
    }

    .wrn-toast__indicator {
      flex: 0 0 auto;
      width: 0.4rem;
      align-self: stretch;
      border-radius: 999px;
      background: var(--toast-accent, var(--wrn-color-primary));
    }

    .wrn-toast[data-tone="success"] {
      --toast-accent: var(--wrn-color-success);
    }

    .wrn-toast[data-tone="danger"] {
      --toast-accent: var(--wrn-color-danger);
    }

    .wrn-toast[data-tone="warning"] {
      --toast-accent: var(--wrn-color-warning);
    }

    .wrn-toast[data-tone="info"] {
      --toast-accent: var(--wrn-color-info);
    }

    /*
     * Centred like the trailing controls. The row is top-aligned so long text
     * starts at the top, but on a one-line toast the 1.75rem close button is
     * taller than the text, so a top-aligned body left the text sitting a few
     * pixels above the button it is supposed to line up with.
     */
    /*
     * Tinted with the tone accent, so the glyph reads as the status at a
     * glance -- green for success, red for danger, amber for warning, and the
     * info colour otherwise. The built-in SVG strokes with currentColor, and
     * an app icon class that uses currentColor (iconify does) picks up the
     * same value for free.
     */
    .wrn-toast__icon {
      flex: 0 0 auto;
      align-self: center;
      width: 1.15rem;
      height: 1.15rem;
      color: var(--toast-accent, var(--wrn-color-primary));
    }

    .wrn-toast__body {
      flex: 1 1 auto;
      min-width: 0;
      align-self: center;
      display: grid;
      gap: 0.15rem;
    }

    .wrn-toast__title,
    .wrn-toast__message {
      margin: 0;
      overflow-wrap: anywhere;
    }

    .wrn-toast__title {
      color: var(--wrn-color-text);
      font-size: 0.85rem;
      font-weight: 650;
      line-height: 1.35;
    }

    .wrn-toast__message {
      color: var(--wrn-color-text-muted);
      font-size: 0.82rem;
      line-height: 1.5;
    }

    /*
     * The trailing controls share one alignment. The close button used to be
     * pinned to the top with a negative margin while the action sat centred,
     * so the two sat on different lines and read as misaligned. Both are
     * centred against the toast body now, and both are the same height, so
     * their centres line up whether the toast is one line or three.
     */
    .wrn-toast__actions {
      display: flex;
      align-items: center;
      flex: 0 0 auto;
      align-self: center;
      gap: 0.35rem;
    }

    /* A destructive action reads in the danger colour regardless of tone. */
    .wrn-toast__action[data-tone="danger"] {
      color: var(--wrn-color-danger);
      border-color: color-mix(in srgb, var(--wrn-color-danger) 45%, transparent);
    }

    .wrn-toast__action {
      appearance: none;
      flex: 0 0 auto;
      min-height: 1.75rem;
      display: inline-flex;
      align-items: center;
      padding: 0 0.6rem;
      color: var(--toast-accent, var(--wrn-color-primary));
      background: transparent;
      border: 1px solid color-mix(in srgb, var(--toast-accent, var(--wrn-color-primary)) 40%, transparent);
      border-radius: 0.55rem;
      font: inherit;
      font-size: 0.78rem;
      font-weight: 650;
      cursor: pointer;
    }

    /*
     * padding is reset explicitly: an app-level button padding rule beats the
     * browser default and collapses the icon to a sliver inside this
     * fixed-size button.
     */
    .wrn-toast__close {
      appearance: none;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      flex: 0 0 auto;
      align-self: center;
      padding: 0;
      width: 1.75rem;
      height: 1.75rem;
      margin: 0;
      color: var(--wrn-color-text-muted);
      background: transparent;
      border: 0;
      border-radius: 999px;
      cursor: pointer;
      transition: color 150ms ease, background 150ms ease;
    }

    .wrn-toast__close:hover {
      color: var(--wrn-color-text);
      background: var(--wrn-color-surface-soft);
    }

    .wrn-toast__close svg {
      flex: 0 0 auto;
      width: 0.875rem;
      height: 0.875rem;
    }

    /*
     * Sweeps left to right as the toast lives out its delay, reaching full
     * width as it is dismissed, so the remaining time is readable at a glance.
     * (Flip the keyframes below to run 1 -> 0 if you would rather it drain.)
     */
    .wrn-toast__progress {
      position: absolute;
      left: 0;
      bottom: 0;
      height: 2px;
      width: 100%;
      transform-origin: left center;
      background: var(--toast-accent, var(--wrn-color-primary));
      animation: wrn-toast-progress var(--toast-duration, 4500ms) linear forwards;
    }

    /* Hover pauses the clock, so the bar must pause with it or it would lie
       about how much time is left. */
    /*
     * Paused straight from :hover rather than from a state flag. A flag would
     * mean writing state on every hover, and that rebuilds the rows -- which
     * restarts the bars from zero and is exactly what made them look stuck.
     * CSS pauses the animation in place and touches no DOM, and the JS timer
     * is banked on the same mouseenter, so the two stay in step.
     */
    .wrn-toaster__list:hover .wrn-toast__progress,
    .wrn-toaster__list:focus-within .wrn-toast__progress {
      animation-play-state: paused;
    }

    @keyframes wrn-toast-in {
      from {
        opacity: 0;
        transform: translateX(18px) scale(0.98);
      }
      to {
        opacity: 1;
        transform: none;
      }
    }

    @keyframes wrn-toast-in-left {
      from {
        opacity: 0;
        transform: translateX(-18px) scale(0.98);
      }
      to {
        opacity: 1;
        transform: none;
      }
    }

    @keyframes wrn-toast-out {
      to {
        opacity: 0;
        transform: translateX(12px) scale(0.97);
      }
    }

    @keyframes wrn-toast-progress {
      from {
        transform: scaleX(0);
      }
      to {
        transform: scaleX(1);
      }
    }

    @media (max-width: 639px) {
      .wrn-toaster {
        justify-content: stretch;
      }

      .wrn-toaster__list {
        width: 100%;
      }
    }

    /*
     * Reduced motion drops the entry/exit movement but KEEPS the progress
     * sweep: it is a clock, not decoration, and a frozen bar would both
     * misreport the time left and look like the bug it used to be. A linear
     * 2px bar carries no vestibular risk.
     */
    @media (prefers-reduced-motion: reduce) {
      .wrn-toast,
      .wrn-toast[data-leaving="true"] {
        animation: none;
      }
    }
  }
}