aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/specs/2026-09-15-notification-daemon-design.md81
1 files changed, 57 insertions, 24 deletions
diff --git a/docs/superpowers/specs/2026-09-15-notification-daemon-design.md b/docs/superpowers/specs/2026-09-15-notification-daemon-design.md
index e0aba25..fa287e1 100644
--- a/docs/superpowers/specs/2026-09-15-notification-daemon-design.md
+++ b/docs/superpowers/specs/2026-09-15-notification-daemon-design.md
@@ -59,11 +59,22 @@ closed by a `CloseNotification` call.
All of this is in the daemon and is independent of any renderer.
-**Timeout.** `expire_timeout` from `Notify` is honoured exactly: `0` means use
-the urgency default, `-1` means never expire, any positive value is
-milliseconds. The urgency defaults are `10s` for low, `10s` for normal and
-never for critical, matching the running dunst. `ronema` relies on `-t 0`
-meaning never and `-t 1` meaning effectively immediate; both work unchanged.
+**Timeout, and the two lifetimes.** `expire_timeout` from `Notify` is honoured
+exactly as the notification's balloon lifetime: `0` means use the urgency
+default, `-1` means never expire, any positive value is milliseconds. The
+urgency defaults are `10s` for low, `10s` for normal and never for critical,
+matching the running dunst. `ronema` relies on `-t 0` meaning never and `-t 1`
+meaning effectively immediate; both work unchanged.
+
+At expiry the daemon emits `NotificationClosed(id, 1)` and the balloon goes,
+which is exactly what `notify-send --wait` and `dunstify -b` clients are
+blocked on. The notification is **not** removed from the live queue. It stays
+in the drawer as an inert entry until it is dismissed or evicted. The two
+lifetimes are deliberately separate: the D-Bus lifetime is the balloon's, so a
+waiting client is freed on time, while the drawer's entry outlives it, so a
+notification missed while the drawer was shut is still there when it opens.
+An inert entry has no client left: its actions are gone, clicking it does
+nothing, and only the X removes it, to history.
**Replace.** A new notification replaces an existing one when its `replaces_id`
matches, or when its stack tag matches. Both `x-dunst-stack-tag` (what
@@ -73,11 +84,16 @@ honoured. A replace reuses the replaced notification's id and emits no
expects. A `replaces_id` that matches nothing is a new notification with a new
id, and `0` always means new. Replacing resets the timeout and moves the
notification to the top of the stack, which is what "one notification per
-account" has to mean when mail keeps arriving.
+account" has to mean when mail keeps arriving. A replace that targets an
+inert (already expired) entry re-arms its balloon and gives it a fresh expiry;
+the id is reused, so the new client's actions work and no `NotificationClosed`
+is emitted for the id it inherited.
-**History.** A ring of 20, sticky: a notification enters it when it closes or
-expires and is not removed by later arrivals beyond the cap. This is the
-`history_length` and `sticky_history` of the running dunst.
+**History.** A ring of 20, sticky. A notification enters it when it is
+dismissed, in either form, or evicted from the live queue by the cap. Expiry
+alone does not put it in history, because the drawer still lists it. This is
+the `history_length` and `sticky_history` of the running dunst, with the popup
+and the drawer separated.
DND is deliberately absent here. Suppression is a display decision, so it lives
in the balloon shell (see below), which lets the drawer list a notification that
@@ -92,7 +108,7 @@ cleanup code. Every write is atomic (temporary file, then rename), so a reader
never sees a half-written value.
queue.json the live notifications, in stack order, newest first
- history.json the last 20 closed notifications, newest first
+ history.json the last 20 dismissed or evicted notifications, newest first
drawer "1" while the drawer is open, written by the drawer
snooze an epoch second while snoozing; absent means off
@@ -112,7 +128,8 @@ A live notification is one object:
`app`, `summary` and `body` are markup. `icon` is an absolute path or empty.
`actions` is the spec's key and label pairs. `expires` is an epoch second, or
-`0` for a notification that never expires. The history objects are the same
+`0` for a notification that never expires; a value in the past means the
+balloon has gone and the entry is inert. The history objects are the same
shape.
`queue.json` is capped at 20; an arrival beyond the cap pushes the oldest into
@@ -147,6 +164,11 @@ notification, bottom-right on `DP-1`, over conky. `exclusionMode` is
`ExclusionMode.Ignore`: balloons are an overlay, not a reserved zone, and
covering conky is intended.
+A balloon is drawn only while the notification is inside its `expires`, so the
+shell runs a timer per balloon and drops it at expiry. An entry whose time has
+passed is not the shell's to draw; it is left to the drawer, whose copy of the
+same queue outlives the balloon.
+
A balloon is the app name in bold, the summary, and the body, with the icon at
the left when one is present, following the running dunst's `format`
(`<b>%a</b>` then `%s` then `%b`). Progress bars, hovering, and body images are
@@ -166,10 +188,17 @@ The reserved `Item` in `Drawer.qml`, already present and documented as
above the grid. There is no tile and no module: the space is part of the grid
view.
-The reserved space has a header holding a **History** button, and one row per
-live notification, sharing the balloon's content. The History button opens a
-QML history page: the ring of 20, newest first, each row closable, with a clear
-all.
+The reserved space is a **scrollable** list, not a fixed strip. The grid below
+it is fixed and never scrolled, so the reserved space absorbs the overflow: it
+is bounded by the grid's top and scrolls when the live queue is long. That
+matters now that an entry outlives its balloon, because the queue can hold up
+to 20 at once.
+
+It has a header holding a **History** button, and one row per live notification,
+sharing the balloon's content. A row whose balloon has expired is inert: it
+still lists and still closes, but it carries no action, because its client is
+gone. The History button opens a QML history page: the ring of 20, newest
+first, each row closable, with a clear all.
The drawer writes `notifyd/drawer` `1` on open and `0` on close. That is how
the balloon shell knows to withhold its balloons while the drawer is open, so a
@@ -185,9 +214,9 @@ indistinguishable from a lost notification.
Identical in both forms.
- The **X** on a balloon or row closes that notification (`notifyctl close`).
-- **Clicking** a notification with actions opens a rofi menu of its labels;
- choosing one invokes it (`notifyctl action`). A notification with no actions
- closes on click.
+- **Clicking** a notification with a live client and actions opens a rofi menu
+ of its labels; choosing one invokes it (`notifyctl action`). A notification
+ with no actions, or an inert one whose balloon has expired, closes on click.
- **Right click** closes all (`notifyctl close-all`).
Closing in either form removes it from the live queue, and it survives only in
@@ -239,8 +268,10 @@ fresh history; it does not resurrect the previous run's notifications, because
they are transient and their timeouts have passed.
If a renderer dies, the daemon keeps its queue and the other renderer keeps
-working. On restart the renderer reads the current queue. A notification that
-expired while no renderer was up is simply gone, which is correct.
+working. On restart the renderer reads the current queue. A notification whose
+balloon expired while no renderer was up is still in the queue: the drawer
+lists it as an inert entry, the balloon shell draws nothing for it, and
+nothing is re-shown.
`queue.json` is capped, so a renderer that is down cannot make the daemon grow
without bound.
@@ -260,10 +291,12 @@ the history ring. It fails if any of them break.
The bus and the pixels need a person. The end to end check is: send a
`notify-send`, confirm a balloon; open the drawer, confirm the same notification
is in the reserved space and not also a balloon; close it in the drawer, confirm
-the balloon goes too and the item is in the history page; toggle DND and confirm
-low and normal balloons stop while critical still pops and the drawer still
-lists; snooze and confirm nothing pops at all; click a mail notification's
-action and confirm `qtmaildir` opens.
+the balloon goes too and the item is in the history page; send one and let it
+time out, confirm the balloon goes but the entry is still in the drawer and is
+inert; send enough to overflow the reserved space and confirm it scrolls rather
+than pushing the grid; toggle DND and confirm low and normal balloons stop while
+critical still pops and the drawer still lists; snooze and confirm nothing pops
+at all; click a mail notification's action and confirm `qtmaildir` opens.
## Deferred