Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Toast Notification Reference

teksilo_widgets::toast ships the Teksilo notification system: stackable, action-rich, severity-aware floating notifications backed by a persistent archive with a log + bell-button + dialog UI.

Mental model in one line:

TeksiloAppBuilder.install_toast_default() → ctx.show_toast(Toast::…) from any handler

Distinct from siblings:

WidgetShapeLifetimeStackableArchive
SnackbarBottom-center, single-instanceAuto-dismiss (4s default)No (calls dismiss_all_except_hosts)No
BannerInline persistent stripUntil user dismissesNo (inline)No
MessageBoxModal dialogUntil user picks a buttonNo (one modal at a time)No
ToastCorner-anchored, stackableAuto-dismiss (10s default) or persistentYesYes (persistent across restarts)

End-to-end demo: cargo run -p toast-demo. Source: examples/toast_demo/src/main.rs.


Quickstart

use teksilo::prelude::*;
use teksilo::settings::AppPaths;

fn main() {
    TeksiloAppBuilder::new()
        .theme(intui::light())
        .app_paths(AppPaths::new("eu", "FernTech", "MyApp").unwrap())
        .install_toast_default()                    // ← one-line install
        .initial_window(WindowConfig::new()
            .id("main")
            .root(|tree, _state| tree.add(MyRoot::new())))
        .run();
}

Anywhere inside a handler:

#![allow(unused)]
fn main() {
fn save_handler(ctx: &mut EventContext) {
    save_to_disk();
    ctx.show_toast(
        Toast::success(tr!(saved()))
            .body(tr!(save_details(path = path.display())))
    );
}
}

For errors with replayable actions:

#![allow(unused)]
fn main() {
ctx.show_toast(
    Toast::error(tr!(build_failed()))
        .body(tr!(build_error_count(n = 3)))
        .action(ToastAction::primary(tr!(retry()), |c| c.send_intent(AppIntent::BuildRetry)))
        .action(ToastAction::new(tr!(show_log()), |c| c.send_intent(AppIntent::ShowLog)))
);
}

For long-running operations that update in place:

#![allow(unused)]
fn main() {
let h = ctx.show_toast(Toast::loading(tr!(uploading_start())).id("upload"));
// later, with the same id:
ctx.show_toast(Toast::loading(tr!(uploading_progress(n = 4))).id("upload"));
// completion replaces the same entry:
ctx.show_toast(
    Toast::success(tr!(upload_complete()))
        .id("upload")
        .auto_dismiss_after(Duration::from_secs(5))
);
}

Installing the toast system

One line at the builder. The TeksiloAppBuilderToastExt extension trait is re-exported from the umbrella prelude (teksilo::prelude::*) so install_toast(...) / install_toast_default() are callable without an extra import:

#![allow(unused)]
fn main() {
.install_toast_default()                          // BottomTrailing, persistent
.install_toast(ToastInstallOptions {              // explicit override
    corner: Corner::TopTrailing,
    archive: Some(NotificationArchive::in_memory()),
    ..ToastInstallOptions::default()
})
}

The toast subsystem ships behind the umbrella's toast feature (default-on). To drop it (and the bell-icon SVG + archive code), depend on teksilo with default-features = false and re-add only the features you need.

What install_toast does, internally:

  1. Opens the NotificationArchiveModelInMemory or Persistent. Persistent requires app_paths(…) to be set on the builder first; the install panics with a helpful message otherwise.
  2. Constructs a shared ToastRegistry bound to the archive (or naked if no archive is configured).
  3. Registers a DefaultPostRoot closure that wraps every window's root with ZStack { user_root, ToastHost::new(registry, options) }. The DefaultPostRoot fires for every window the app opens — initial AND runtime-opened — so the host installs everywhere automatically.
  4. Registers the ToastRegistry + Rc<NotificationArchiveModel> into app_state so NotificationLog / NotificationCenterButton / NotificationLogDialog can look them up.

Install options

#![allow(unused)]
fn main() {
pub struct ToastInstallOptions {
    pub corner: Corner,                       // BottomTrailing (default)
    pub margin: Vec2,                         // (24.0, 24.0)
    pub gap: f32,                             // 8.0 between stacked toasts
    pub max_visible: usize,                   // 5
    pub entry_width: f32,                     // 380.0 (matches IntelliJ)
    pub pause_on_hover_group: bool,           // true (any hover pauses all)
    pub archive: Option<NotificationArchive>, // Persistent("notifications") (default)
}
}
FieldNotes
cornerRTL-aware: Trailing flips to the physical left edge. Top corners stack downward, bottom corners upward.
max_visibleNormal-priority overflow drops with cause SlotPoolFull. High / Urgent priority evicts the oldest Normal to make room.
pause_on_hover_group: trueHovering ANY live toast pauses every timer. false pauses only the hovered toast (libadwaita behaviour).
archive: NoneToasts work; just not archived. NotificationLog / bell button still load but show empty state.
archive: Some(InMemory)Session-only ring buffer. Cleared on app exit.
archive: Some(Persistent)TOML file at <config>/notifications.toml via PersistedListModel. Survives app restarts.

The Toast request

Toast is NOT a Widget — it's a present-able request, like MessageBox::present(ctx). Construct with a severity-named constructor, configure via builder methods, then call ctx.show_toast(toast) (or toast.present(ctx)).

Constructors

ConstructorSeverityDefault behaviour
Toast::info(title)InfoStatus / confirmation
Toast::success(title)Success"Saved", "Deploy complete"
Toast::warning(title)WarningNon-fatal issue
Toast::error(title)ErrorFailure (forces Role::Alert + Live::Assertive)
Toast::loading(title)InfoPersistent, with a Spinner leading widget

Each has a _literal #[doc(hidden)] shim (info_literal, etc.) for untranslated strings. Use during scaffolding; the grep marker keeps "find me before localizing" findable.

Builder methods

#![allow(unused)]
fn main() {
Toast::warning(tr!(unsaved()))
    .body(tr!(close_anyway()))                          // optional second line
    .leading(MyAppIcon::new())                          // override severity glyph
    .action(ToastAction::primary(tr!(save()), on_save)) // 0..N actions
    .primary_action(tr!(retry()), on_retry)             // shorthand for ToastAction::primary
    .auto_dismiss_after(Duration::from_secs(5))         // override the 10s default
    .persistent()                                       // disable auto-dismiss
    .priority(ToastPriority::High)                      // Normal | High | Urgent
    .id("unique-key")                                   // update-in-place key (see below)
    .on_click(|ctx| ctx.send_intent(AppIntent::Open))   // click-anywhere callback
    .on_dismiss(|cause, ctx| log_dismiss(cause))        // fires once
    .show_close_button(false)                           // default: true
    .closable_on_escape(false)                          // default: true
    .announcement(tr!(custom_at_text()))                // override AT name
    .archive(false)                                     // opt out of archive mirror
    .style(MyToastStyle)                                // per-call style override
}

ToastAction

#![allow(unused)]
fn main() {
ToastAction::new(label, on_invoke)              // Link (default)
ToastAction::primary(label, on_invoke)          // Filled button
ToastAction::destructive(label, on_invoke)      // Destructive button
ToastAction::new(label, on_invoke)
    .style(ToastActionStyle::Button { variant: ButtonVariant::Plain })
    .closes_toast(false)                        // default: true (IntelliJ "expiring")
    .shortcut_id("app.save")                    // for archive replay
    .tooltip(tr!(save_explainer()))
}

Link actions render inline in the body row; Button actions go in a footer row below the body. Mixed within the same toast is allowed.

Update-in-place

Toast::id("…") is the dedup key. When a second toast carrying a matching id is presented:

  • Live side: the existing entry's fields mutate in place (title, body, severity, actions, …) and the auto-dismiss timer resets. The original entry_id is preserved — the first call's ToastHandle keeps working.
  • Archive side: the existing archived entry merges (an UpdateRecord appended to its updates Vec); no new row appears in NotificationLog.

Preserved across updates that don't re-specify them:

FieldPreserved when update omits it
on_dismissYes (the original callback survives)
leadingYes (a Toast::loading spinner survives Toast::info updates)
Other fieldsNo (always overwritten by the update)

When the update DOES specify on_dismiss / leading, the replacement installs and the previous values drop silently.

Contract: on_dismiss fires exactly once per entry, with the most-recently-supplied callback.

Setting archive(false) on an update

The update's archive flag is honored. If the original was archived but a subsequent update sets archive(false), the existing archive record stays in place but no further updates are mirrored. Apps that want continuous archive capture should leave archive at default true across updates.

ToastHandle

Returned by ctx.show_toast(...) and toast.present(ctx). Cheap to clone (Rc<Inner>).

#![allow(unused)]
fn main() {
let h = ctx.show_toast(Toast::loading(lit!("Working…")));

// Some time later, in another handler:
if h.is_alive() {
    h.dismiss(ctx);            // ToastDismissCause::Programmatic
}
}

Dropping the handle does NOT dismiss the toast — toasts have their own lifecycle (timer + manual paths). The handle is OPTIONAL "I want to control this later" wiring.

Severity × priority → AT role × live region

The AT role / live region is computed from severity + priority:

SeverityPriorityRolearia-live
Info, SuccessanyStatusPolite
WarningNormalStatusPolite
WarningHigh / UrgentAlertAssertive
ErroranyAlertAssertive
anyUrgent(forces)Assertive

All toasts call set_live_atomic() so the entire title+body announces as one unit. The announcement(...) builder overrides the spoken text without changing the visible title (useful when the visible title is iconic but the spoken text needs context).

ToastDismissCause

CauseWhen
Timeoutauto_dismiss_after reached zero (timer expired naturally)
ActionInvokedA ToastAction with closes_toast(true) (the default) fired
CloseClickedUser clicked the X
EscapePressedUser pressed Escape while focused into the toast
ProgrammaticToastHandle::dismiss() or ctx.dismiss_toast(handle)
HostShutdownWindow is being torn down
SlotPoolFullPool full + Normal-priority drop, OR evicted by a higher-priority arrival

ToastRegistry

Cheap to clone (Rc<RefCell<…>>). Holds the queue + per-entry state, registered in app-state by install_toast. Apps don't usually construct one directly — use the install hook.

#![allow(unused)]
fn main() {
pub struct ToastRegistry { … }

impl ToastRegistry {
    pub fn new(opts: ToastInstallOptions) -> Self                      // no archive
    pub fn with_archive(opts, archive: Rc<NotificationArchiveModel>) -> Self
    pub fn archive(&self) -> Option<Rc<NotificationArchiveModel>>
    pub fn version_signal(&self) -> &Signal<u64>                       // bumps on every change
    pub fn hover_count_signal(&self) -> Signal<usize>                  // shared pause refcount
    pub fn live_count(&self) -> usize                                  // test helper
}
}

The version_signal is what ToastHost binds to at BindingLevel::Rebuild; bumps on enqueue / in-place merge / timer expiry / programmatic dismiss / close-click / action-invoked.

hover_count is shared between every ToastSurface: each surface's outer wrapper has an on_hover handler that increments / decrements this refcount. The host's frame-tick effect reads count > 0 as "pause all timers" (when pause_on_hover_group: true).


ToastHost

Invisible sibling widget owning the queue + per-frame timer. The install hook wraps every window with ZStack { user_root, ToastHost::new(registry, options) }. Direct construction is needed only when apps want manual mounting control:

#![allow(unused)]
fn main() {
pub fn new(registry: ToastRegistry, options: ToastInstallOptions) -> Self
}

Renders zero of its own chrome. Each live entry from the registry becomes a ToastSurface child placed at the configured corner with stacked offset. Newer entries are placed closer to the corner anchor (matches IntelliJ + Windows convention).

Per-frame-tick effect (subscribed via ctx.subscribe_frame_tick()) decrements every entry's time_left by the wall-clock dt — unless hover-pause is active. Expired entries are dismissed deferred (the user callback fires on the next pointer event).


NotificationArchiveModel

The persistent layer behind the live toast queue. Two backends:

BackendUse case
InMemory { limit }Session-only. No disk I/O. Cleared on exit.
Persistent { file_name, limit }<config>/<file>.toml via PersistedListModel. Survives restarts.

Default limit: DEFAULT_ARCHIVE_LIMIT = 200. IntelliJ keeps hundreds; Teksilo picks a pragmatic cap so persistent files don't grow unbounded.

NotificationEntry

The persistent shape — closures dropped, intent_name retained for archive replay:

#![allow(unused)]
fn main() {
pub struct NotificationEntry {
    pub id: u64,                         // per-archive stable id, monotonic
    pub severity: BannerSeverity,
    pub priority: ToastPriority,
    pub title: String,
    pub body: Option<String>,
    pub actions: Vec<ArchivedAction>,
    pub timestamp: jiff::Timestamp,
    pub group: Option<String>,           // optional visual grouping key
    pub source: Option<String>,          // optional originating-feature tag
    pub read: bool,                      // flipped by mark_all_read
    pub dedup_id: Option<String>,        // sourced from Toast::id
    pub updates: Vec<NotificationUpdate>,// appended by in-place merges
}

pub struct ArchivedAction {
    pub label: String,
    pub intent_name: Option<String>,     // sourced from ToastAction::shortcut_id
    pub style: ArchivedActionStyle,      // Link | PrimaryButton | SecondaryButton | Destructive
    pub closes_on_invoke: bool,
}

pub struct NotificationUpdate {
    pub timestamp: jiff::Timestamp,
    pub title: Option<String>,           // None if unchanged
    pub body: Option<String>,
    pub progress: Option<f32>,
}
}

Methods

#![allow(unused)]
fn main() {
pub fn open(archive, paths, debounce) -> Result<Self, NotificationArchiveError>
pub fn in_memory() -> Self                                  // convenience
pub fn entries(&self) -> &ListModel<NotificationEntry>      // observable
pub fn unread_count(&self) -> &Signal<usize>                // drives the badge
pub fn version_signal(&self) -> &Signal<u64>                // drives the log rebuild
pub fn push(&self, entry: NotificationEntry)                // bounded; deduplicates by dedup_id
pub fn mark_all_read(&self)                                 // resets unread to 0
pub fn clear(&self)
pub fn remove(&self, index: usize)
pub fn flush_now(&self) -> Result<(), SettingsFileError>    // persist immediately (test helper)
}

push semantics:

  • New entry → inserts at index 0 (newest first), evicts oldest past limit, bumps unread_count + version.
  • Existing dedup_id → updates the matching entry's title/body in place, appends a NotificationUpdate to its updates, flips it back to unread, bumps unread_count + version.

version_signal bumps on every actual mutation; no-ops (mark_all_read on a fully-read archive, clear on empty) don't bump.


NotificationLog

Widget rendering the archive as a filterable list. Composition:

VStack {
    Toolbar { Spacer, "Mark all read" button, "Clear all" button },
    ScrollArea {
        VStack {
            "Today" header,
            row, row, …
            "Yesterday" header,
            row, row, …
            "This week" header,
            …
            "Earlier" header,
            …
        }
    },
    // OR empty-state hint when the archive is empty
}

Each row is a StandardListItem: severity glyph leading, title in BodyBold (unread) / Body (read), body as subtitle, action buttons trailing. Outer node carries Role::List + set_name("Notifications").

Title and body are elided, not wrapped, and the full text is carried on the row's rich tooltip. Notification text is arbitrary app-supplied prose and the log does not control its own width, so a wrapping row (StandardListItem's default) would report its full one-line intrinsic width, over-constrain the row, and push the trailing action buttons outside it — clipped, with no horizontal scrollbar to reach them.

Sizing

The log grows into a host that bounds its height (a dialog, a side panel) and compresses inside one shorter than its natural height, down to a one-row floor. Only a host that hugs its content — which is how the overlay layer measures the NotificationCenterButton popover — falls back to the preferred size, since rows cannot report a meaningful intrinsic width of their own:

#![allow(unused)]
fn main() {
NotificationLog::new(archive)
    .preferred_width(380.0)   // default — width when the host proposes none
    .preferred_height(320.0)  // default — list height when the host proposes none
}

Builder

#![allow(unused)]
fn main() {
NotificationLog::new(archive: Rc<NotificationArchiveModel>)
    .show_toolbar(true)                                       // default true
    .empty_state(|| Box::new(my_empty_widget()))              // factory: re-run per rebuild
    .on_entry_invoked(|entry, ctx| open_details(entry, ctx))  // click-row callback
    .on_action_invoked(|entry, action, ctx| {                 // archive-replay callback
        match action.intent_name.as_deref() {
            Some("app.build.retry") => ctx.send_intent(AppIntent::BuildRetry),
            Some(name) => log::warn!("unknown archived intent: {name}"),
            None => {}
        }
    })
}

Archive-action replay model

ArchivedAction::intent_name is captured from ToastAction::shortcut_id. The log uses it to decide whether an action is clickable:

intent_nameon_action_invoked setRenders as
PresentYesClickable Link / Button → fires the callback
PresentNoInert text tag
NoneanyInert text tag with "(no longer available)" suffix

The "no longer available" tag matches IntelliJ's event-log semantics: actions that depended on live closures can't be replayed once the closure has torn down. Actions that go through the Intent system survive archival — apps map the archived intent_name to their typed AppIntent variants in the callback.

Day-bucket section headers

Entries are grouped into four buckets, computed against the user's local timezone via jiff::Zoned:

BucketRange
TodaySame local-calendar date
Yesterdaytoday - 1 day
This week2..=6 days ago
Earlier7+ days ago

Future timestamps (clock skew, peer sync) bucket as Today so they don't silently slip into Earlier.

Buckets recompute on every archive mutation (the log binds to archive.version_signal() at Rebuild). A log left open across midnight keeps stale labels until the next mutation OR until the user reopens — acceptable for the popover-shaped UX.


NotificationCenterButton

Bell-icon trigger + live unread badge + popover containing a NotificationLog. The de-facto status-bar widget.

#![allow(unused)]
fn main() {
NotificationCenterButton::new(archive: Rc<NotificationArchiveModel>)
    .size(IconButtonSize::Toolbar)                  // default Toolbar (30dp)
    .show_badge_when_zero(false)                    // default: hide badge at 0
    .max_badge_count(99)                            // default 99; counts above show "99+"
    .placement(OverlayPlacement::BelowPreferred)    // popover anchor
    .on_action_invoked(|entry, action, ctx| { … }) // forwarded to the embedded log
}

The badge label binds reactively to archive.unread_count(). On popover open, the archive's mark_all_read runs (the user is presumed to have seen the toasts now) — the badge resets, the badge widget disappears.

┌─────┐
│ 🔔3 │  ← bell icon + badge with unread count
└─────┘
   ↓ click
┌──────────────────────────────────────┐
│                  Mark all read │ Clear │
│ Today                                  │
│ • [⚠] Build #42 failed   [Retry][Log] │
│ • [✓] File saved                       │
│ Yesterday                              │
│ • […] Upload complete                  │
│ Earlier                                │
│ …                                       │
└──────────────────────────────────────┘

A custom BellButton example wrapper that pulls the archive from app-state lives in the demo: examples/toast_demo/src/main.rs.


NotificationLogDialog

One-liner modal preset wrapping NotificationLog in a ModalContainer:

#![allow(unused)]
fn main() {
NotificationLogDialog::show(archive, ctx);
NotificationLogDialog::show_with(archive, ctx, |log| {
    log.on_action_invoked(|entry, action, ctx| { … })
});
}

Default presentation: ModalPresentation::Auto, 720×520 size, title "Notifications", dismissed via Escape or click-outside.

Apps wire this to a menu item ("Window → Notification Log…") or a shortcut.


Accessibility checklist

ConcernHow it's handled
Role mappingStatus / Alert per the severity × priority table above.
Live regionLive::Polite / Live::Assertive per the same table; set_live_atomic() so title+body is one announcement.
Custom announcement textToast::announcement(text) overrides the AT name without changing the visible title.
Body descriptionToast::body(text)set_description(body) on the surface node.
Severity glyphset_hidden() — presentational, not in the AT tree (the surface itself carries the role + name).
Close buttonIconButton with its own Role::Button + tooltip / a11y label. Reachable by Tab once focus is inside the toast.
Action buttonsStandard Button / Link a11y.
EscapeDismisses with cause EscapePressed while focus is inside the toast.
Bell buttonIconButton with the localized "Notifications" tooltip. Badge widget contributes its own Role::Label with the count.
LogRole::List outer + Role::ListItem rows + section headers as plain TextWidget with Secondary color.

Urgent priority forces Live::Assertive regardless of severity — the escape hatch for "this Info-level toast is actually time-critical" cases.

Per the WAI-ARIA spec gotcha: the live-region node must exist in the AT tree before its content is populated, otherwise ATs miss the announcement. ToastSurface::build handles this — the surface's AT node is constructed empty, then bound to the title signal one frame later (via ctx.subscribe_frame_tick() one-shot).


Reduced motion

prefers-reduced-motion is consulted at the host's enqueue path. When set: no fade-in / fade-out on the overlay, no slide-in. The surface appears in place, instantly. The auto-dismiss timer still runs normally — reduced motion affects only the visual transition.


i18n keys

Built-in keys (en-US source + fr-FR translation shipped):

KeyDefault text
a11y-builtin-bell"Notifications"
notifications-title"Notifications"
notifications-empty"No notifications"
notifications-mark-all-read"Mark all read"
notifications-clear"Clear all"
notifications-filter-placeholder"Search notifications" (reserved)
notifications-bucket-today"Today"
notifications-bucket-yesterday"Yesterday"
notifications-bucket-this-week"This week"
notifications-bucket-earlier"Earlier"
notifications-archive-replay-disabled"(no longer available)"

Apps that need more locales register them through the framework's standard I18nConfig::framework_locales(teksilo_widgets::framework_locales()).


Where the code lives

ConcernFile
Toast request + ToastAction + ToastDismissCause + ToastHandlecrates/teksilo-widgets/src/toast.rs
ToastRegistry (queue + archive bridge + in-place merge)crates/teksilo-widgets/src/toast/registry.rs
EventContextToastExt (ctx.show_toast / ctx.dismiss_toast)crates/teksilo-widgets/src/toast/ext.rs
ToastSurface (chrome + a11y + custom actions)crates/teksilo-widgets/src/toast/surface.rs
ToastHost (queue display + timer + hover-pause) + ToastInstallOptionscrates/teksilo-widgets/src/toast/host.rs
RecipeToastStyle (default chrome)crates/teksilo-widgets/src/styles/recipe_toast_style.rs
ToastStyle trait + ToastPriority + ToastStyleConfigcrates/teksilo-core/src/styles/toast_style.rs
NotificationEntry + ArchivedAction + NotificationUpdatecrates/teksilo-widgets/src/notification.rs
NotificationArchiveModel (InMemory / Persistent)crates/teksilo-widgets/src/notification/archive.rs
NotificationLog (toolbar + buckets + rows)crates/teksilo-widgets/src/notification/log.rs
NotificationCenterButton (bell + badge + popover)crates/teksilo-widgets/src/notification/center_button.rs
NotificationLogDialog (modal preset)crates/teksilo-widgets/src/notification/log_dialog.rs
install_toast extension trait (TeksiloAppBuilderToastExt)crates/teksilo/src/toast_install.rs
Runnable demoexamples/toast_demo/src/main.rs

The toast system landed alongside small teksilo-core extensions reusable by other overlay-shaped widgets:

  • teksilo_tokens::Corner (TopLeading / TopTrailing / BottomLeading / BottomTrailing) with RTL-aware resolve(content, viewport, margin, rtl).
  • OverlayPlacement::ViewportCorner { corner, margin } — anchor-independent corner-snapped overlay placement.
  • OverlayManager::pause_auto_dismiss(id) / resume_auto_dismiss(id) — stash auto_dismiss_after - elapsed and restore with a fresh shown_at_*. Available to any overlay caller; ToastHost owns its own timer so doesn't use these, but Snackbar refinements or future hover-pause-able overlays can.
  • EventContext::pause_overlay_auto_dismiss(id) / resume_overlay_auto_dismiss(id) — handler-side queue that forwards to OverlayManager after the dispatcher returns.
  • TeksiloAppBuilder::configured_app_paths() — read-side companion to the existing app_paths(paths) setter. Builder-extension traits use it to open persistent files at install time before run.
  • teksilo_core::styles::ToastStyle slot + ComponentStyleSlots::toast.

Limitations & explicit non-goals

  • Multi-window: a single shared ToastRegistry per app. Toasts show up in whichever window's host last bound — apps with multiple windows share one queue. Per-window routing is a planned refinement.
  • OS-level notifications (libnotify / NSUserNotification / Windows toast): out of scope. Toast is an in-app widget. Apps wanting OS notifications wire that through a separate crate.
  • Inline reply / combo / picker inside a toast (Windows toast text-box / combo): non-portable, out of pattern for Teksilo.
  • Cross-window deduplication: Toast::id is per-app (the registry is app-singleton), but the NotificationLog rebuild signal is per-archive — multi-window apps see the SAME log in every bell button.
  • Sticky promotion via long hover (the tooltip dwell pattern): not needed — a Toast is either sticky from the start (persistent()) or timed; there's no progressive promotion.
  • Live update-in-place visual transition: the surface mutates in place (no fade-out + fade-in), which matches the IntelliJ pattern. Apps that want a visual handoff can dismiss + show separately instead of using Toast::id.
  • SearchField filter in NotificationLog: documented as a future refinement. Apps can compose external filtering with their own toolbar above the log.
  • Severity-chip filter in NotificationLog: same — composable with SegmentedControl.