aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs')
-rw-r--r--docs/superpowers/specs/2026-09-15-notification-daemon-design.md312
-rw-r--r--docs/superpowers/specs/2026-09-15-notification-images-design.md201
-rw-r--r--docs/superpowers/specs/2026-09-15-status-registry-design.md231
3 files changed, 744 insertions, 0 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
new file mode 100644
index 0000000..4e2806a
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-15-notification-daemon-design.md
@@ -0,0 +1,312 @@
+# Notification Daemon
+
+Replaces dunst. A Go daemon owns `org.freedesktop.Notifications`, a quickshell
+shell draws the popups, and the desktop drawer gains a notification centre. The
+`dnd` mode that gates it belongs to the status registry, not here.
+
+This is the second of the two specs the status registry named. It consumes the
+registry's `dnd` mode rather than owning it.
+
+## Shape
+
+Three pieces, two repos:
+
+ notifyd/ separate repo: the daemon and notifyctl, Go
+ notifications/ this repo: the balloon shell
+ desktop/ this repo: the reserved space and history page
+ shared/Notify.qml this repo: the singleton the renderers share
+
+The daemon owns the D-Bus name and holds the state. The renderers are quickshell
+and read the state from files. `notifyctl` is the one control surface back to
+the daemon, over D-Bus.
+
+## What it is not
+
+It is not the status registry. `dnd` and `presentation` belong to `statusctl`,
+and this never writes them; the balloon shell only reads `status.dnd`.
+
+It is not a dunst wrapper. dunst stays installed until this proves itself, and
+then is removed. Nothing here calls into dunst.
+
+## The daemon
+
+`notifyd` is a Go program using `godbus/v5`. It registers
+`org.freedesktop.Notifications` on the session bus and serves the freedesktop
+notification spec:
+
+ Notify(app_name, replaces_id, app_icon, summary, body,
+ actions, hints, expire_timeout) -> id
+ CloseNotification(id)
+ GetCapabilities() -> capabilities
+ GetServerInformation() -> (name, vendor, version, spec_version)
+
+ NotificationClosed(id, reason) signal
+ ActionInvoked(id, action_key) signal
+
+`GetServerInformation` reports the identity `danix` for name and vendor. The
+spec version is `1.2`.
+
+`GetCapabilities` reports `actions`, `body-markup`, `icon-static` and
+`persistence`. The first two are load-bearing: `mail-notify.sh` sends actions
+and escapes its body because the running dunst advertises `body-markup`.
+`icon-static` means a client may pass an absolute icon path, which every live
+consumer does.
+
+Close reasons are the spec's: `1` expired, `2` dismissed by the user, `3`
+closed by a `CloseNotification` call.
+
+## Policy
+
+All of this is in the daemon and is independent of any renderer.
+
+**Timeout, and the two lifetimes.** `expire_timeout` from `Notify` is honoured
+exactly as the notification's balloon lifetime, in the spec's direction: `-1`
+means the server decides, so it takes the urgency default; `0` means never; any
+positive value is milliseconds and wins. The urgency defaults are `10s` for
+low, `10s` for normal and never for critical, matching the running dunst.
+libnotify sends `-1` by default, so a plain `notify-send` gets the urgency
+default, and `dunst` agrees. `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
+`mail-notify.sh` sends today) and `x-danix-stack-tag` (the new spelling) are
+honoured. A replace reuses the replaced notification's id and emits no
+`NotificationClosed` for it, which is what a client blocking on that id
+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. 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 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
+DND chose not to pop. The registry spec assumed the daemon would read `dnd`;
+that assumption is superseded.
+
+## The files
+
+The daemon publishes its state under `$XDG_RUNTIME_DIR/notifyd/`, the same
+runtime directory the registry uses, so a reboot clears it and there is no
+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 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
+
+A live notification is one object:
+
+ {
+ "id": 12,
+ "app": "New Mail",
+ "summary": "danixland (2)",
+ "body": "Ada Lovelace\nRe: ...\n\n+1 more",
+ "urgency": "normal",
+ "icon": "/home/you/.local/share/icons/.../mail-unread-multiple.svg",
+ "actions": [["default", "open"]],
+ "created": 1758000000000,
+ "expires": 1758000010000
+ }
+
+`app`, `summary` and `body` are markup. `icon` is an absolute path or empty.
+`actions` is the spec's key and label pairs. `created` and `expires` are epoch
+milliseconds, so a sub-second timeout (`ronema` sends `-t 1`) is exact rather
+than rounding to zero, which would read as never. `expires` is `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
+history.
+
+The renderers read these files and never write them, except `drawer`, which is
+the drawer's own state and the one file the daemon does not own.
+
+## notifyctl
+
+A second command in the same Go module, installed to `~/bin`. It is the only
+thing that talks to the daemon, and it is what the renderers and rofi drive:
+
+ notifyctl list the live queue, JSON
+ notifyctl history [n] the history ring, JSON, default 20
+ notifyctl close <id> close one, reason 2
+ notifyctl close-all close every live notification, reason 2
+ notifyctl action <id> <key> invoke an action on a live notification
+ notifyctl clear-history empty the history ring
+
+`rofipass` calls `dunstctl close-all` today; it switches to
+`notifyctl close-all`. That is the only change any existing consumer needs.
+
+## The balloon shell
+
+`notifications/` is a quickshell component in the same shape as the others: it
+holds itself open with a 1x1 transparent `PanelWindow` (see AGENTS.md), has its
+own namespace and its own `hl.layer_rule` for blur, and runs for the session.
+
+It watches `queue.json` through `Notify.qml` and draws one balloon per live
+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
+out of scope.
+
+**Suppression is here.** The shell reads `status.dnd` and `snooze`:
+
+- `dnd` on suppresses low and normal balloons; critical still pops.
+- `snooze` active suppresses every balloon, critical included.
+- A suppressed notification is still in the live queue and still expires on
+ schedule. Only its balloon is withheld.
+
+## The drawer
+
+The reserved `Item` in `Drawer.qml`, already present and documented as
+"Reserved for the notification engine", is filled with the live notifications,
+above the grid. There is no tile and no module: the space is part of the grid
+view.
+
+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
+notification appears as a balloon or in the reserved space, never both.
+
+Unlike the balloons, the reserved space lists every live notification,
+including the ones DND or snooze suppressed. A list the user deliberately
+opened is not an interruption, and hiding items from it would make DND
+indistinguishable from a lost notification.
+
+## Interactions
+
+Identical in both forms.
+
+- The **X** on a balloon or row closes that notification (`notifyctl close`).
+- **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
+the history ring, which is the point of the history page.
+
+Clicking an action on a `dunstify -b` notification (mail) is what makes the
+blocked `dunstify` process print its action key and launch `qtmaildir`. That
+round trip goes through `ActionInvoked`, exactly as it does with dunst today.
+
+## Snooze
+
+Snoozing suppresses every balloon, critical included, for a fixed time. It does
+not touch `dnd`.
+
+`notify-snooze.sh <minutes>` writes `$XDG_RUNTIME_DIR/notifyd/snooze` as an
+epoch second and `notify-snooze.sh off` removes it. The script is the entry
+point, so a rofi line or a keybind can snooze without opening the drawer.
+
+The Status page in the drawer gains a third row, **Snooze**, a switch and a
+free text minutes field. Flipping the switch on snoozes for the minutes in the
+field; flipping it off clears the file. The last used value is kept in
+`~/.local/state/notify-snooze.minutes` so it survives a reboot, which the
+runtime file does not.
+
+A snooze outliving a shell restart is desired and automatic: the file is in the
+runtime directory, so the shell reads it back. A snooze outliving a reboot is
+not, and does not happen.
+
+## Handover
+
+dunst is not removed until this is in place. The switch is: stop dunst, start
+`notifyd` (from `autostart.lua`, beside the quickshell lines) and let the
+`notifications/` shell start with the others. `notifyctl` and `notify-snooze.sh`
+install to `~/bin`. `rofipass` changes its one `dunstctl` line. Only then is
+dunst dropped.
+
+## Failure
+
+The daemon cannot lose mail, and it cannot lose a mode: a notification is
+transient by nature. What it can do is misreport.
+
+`notifyd` requests `org.freedesktop.Notifications` at startup. If the name is
+already taken, which is what happens while dunst is still running, it says so and
+exits non-zero rather than starting deaf.
+
+If `notifyd` dies, clients that send a notification fail to connect and say so,
+which is the honest outcome. On restart it writes an empty queue and starts a
+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 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.
+
+## Verification
+
+Pure daemon logic gets Go unit tests: the timeout rule, the replace by id and by
+stack tag, the history ring and its cap, and the DND and snooze policies as the
+renderer computes them. These are table tests over the policy functions, no bus
+and no clock.
+
+`notifyctl` gets one runnable check, `test-notifyctl.sh`, in the same shape as
+`test-statusctl.sh`: it points `XDG_RUNTIME_DIR` at a temporary directory, drives
+the file contract and the CLI, and asserts the parse, the replace, the close and
+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; 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
+
+Each of these is a real dunst feature and none has a live consumer:
+
+- Pausing a balloon's timeout while the pointer hovers it.
+- The progress bar (`value` and `progress` hints).
+- Resolving an icon *name* through a theme; absolute paths only for now.
+- Body images and hyperlink handling beyond markup.
+- A context menu on left click; rofi replaced it.
diff --git a/docs/superpowers/specs/2026-09-15-notification-images-design.md b/docs/superpowers/specs/2026-09-15-notification-images-design.md
new file mode 100644
index 0000000..fd7eabf
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-15-notification-images-design.md
@@ -0,0 +1,201 @@
+# Notification Images
+
+The daemon and the renderers gain content images: a screenshot or an
+application image attached to a notification, shown large in the balloon, plus
+inline images inside the body markup, plus resolution of app icons given as
+theme names.
+
+This extends the shipped daemon design
+(`2026-09-15-notification-daemon-design.md`) and the renderers built from
+`../plans/2026-09-15-notification-renderers.md`.
+
+## Why
+
+The daemon currently reads only the `app_icon` parameter. It ignores every
+image hint, so content images never arrive. Two live consumers show the gap:
+
+ opencode its notifier passes its logo with notify-send --icon
+ grimblast it passes the screenshot with notify-send -i
+
+libnotify 0.8.8 splits two flags that were once one: `-i/--icon` is the content
+image (it lands in the `image-path` hint) and `-n/--app-icon` is the
+`app_icon` parameter. Both consumers use the content image. The daemon drops
+that hint, so opencode shows no logo and a screenshot shows nothing.
+
+Separately, `app_icon` is only usable when it is an absolute path. Apps that
+send a theme name (the spec's other allowed form) render nothing, because the
+renderer builds `file://<name>`.
+
+## What it is not
+
+It is not a change to the daemon's or renderer's suppression, timeout, replace
+or history behaviour. It is not animated images, multiple images, progress
+bars, or body hyperlink handling. It is not a resolver for `desktop-entry`.
+
+## The freedesktop contract
+
+The specification defines one image per notification. An implementation that
+can display both the app icon and the image shows `app_icon` as the icon and
+picks the image in this order:
+
+ 1. image-data (raw pixels, a (iiibiiay) struct)
+ 2. image-path (a URI or a theme icon name)
+ 3. icon_data (deprecated, the same struct as image-data)
+
+An implementation that can show only one image picks from image-data,
+image-path, app_icon, then icon_data. The daemon here shows both, so it uses
+the first order and keeps `app_icon` as the icon.
+
+`image-data` and `icon_data` are a D-Bus structure `(iiibiiay)`:
+
+ width (i) width in pixels
+ height (i) height in pixels
+ rowstride (i) bytes between row starts
+ has_alpha (b) whether there is an alpha channel
+ bits_per_sample (i) always 8
+ channels (i) 4 with alpha, 3 without
+ data (ay) pixels, RGB byte order
+
+`image-path`, and `app_icon`, are each either a `file://` URI or a name in a
+freedesktop icon theme. A name must be resolved against a theme.
+
+## The daemon
+
+### Hints
+
+`Notify` gains hint parsing beside the existing urgency and stack-tag reads:
+
+- `image-data` and the deprecated `icon_data` (struct): decode to an image.
+- `image-path` (string): a `file://` URI, an absolute path, or a theme name.
+
+`image-data` wins when both a data and a path hint are present, per the
+priority above. The underscore spelling `image_data`, which older libnotify
+sent, is accepted as an alias.
+
+### Materialisation and lifecycle
+
+A raw `image-data` is decoded in Go and written as a PNG to
+`$XDG_RUNTIME_DIR/notifyd/img/<id>.png`. A replaced notification reuses its id
+and overwrites the same file. The file is removed when the notification leaves
+the live queue: on dismiss, on eviction, and on expiry. Only the balloon shows
+an image, and the drawer row does not, so nothing needs it once the balloon is
+gone. This bounds the directory to the live balloons.
+
+An `image-path` is the client's file and is published as-is; the daemon never
+deletes it. A theme name is resolved to a file first. A `file://` URI is
+converted to a path.
+
+### Capabilities
+
+`GetCapabilities` adds `body-images`, which is the spec's token for inline
+image support. It keeps `actions`, `body-markup`, `icon-static` and
+`persistence`.
+
+### Icon and image theme-name resolution
+
+`app_icon` and `image-path` values without a `/` are theme names and are
+resolved to a file. A value that is already a path or a `file://` URI is used
+as-is.
+
+The theme is read from qt6ct, `~/.config/qt6ct/qt6ct.conf`, key `icon_theme`.
+On this machine that is `Material-Black-Plum-Suru`, and it is authoritative;
+GTK3, gsettings and qt6ct agree, and the one dissent (GTK4's `breeze-dark`)
+has been corrected to match. If qt6ct has no value, fall back to the GTK3
+setting `gtk-icon-theme-name` (or `gsettings`), then `hicolor`.
+
+Lookup, once a theme name is known:
+
+1. Search `$XDG_DATA_HOME/icons/<theme>`, then each `$XDG_DATA_DIRS/icons/<theme>`
+ in order.
+2. Within the theme, prefer `apps/scalable`, then the largest available size
+ under `apps/`.
+3. Follow the theme's `Inherits` chain from its `index.theme`.
+4. Fall back to `hicolor`.
+5. If nothing matches, leave the value empty, which renders no image. This is
+ today's behaviour and stays the honest outcome.
+
+## The published contract
+
+`Popup` gains one field:
+
+ {
+ "id": 57,
+ "app": "grimblast",
+ "summary": "Screenshot of Area",
+ "body": "...",
+ "urgency": "normal",
+ "icon": "", // app identity, a resolved path
+ "image": "/run/user/1000/notifyd/img/57.png", // content image, empty if none
+ "actions": [],
+ "created": 1758000000000,
+ "expires": 1758000010000
+ }
+
+`icon` keeps its meaning and its consumers. `image` is new and empty when the
+notification has none. History objects carry the same field; history rows do
+not render it, so a stale path there is inert.
+
+## The renderers
+
+### Balloon
+
+When `image` is non-empty, the balloon shows it below the text block,
+`Image.PreserveAspectFit`, the balloon's width, capped at 240px tall. The
+32px `icon` slot is unchanged, and both can appear together. The balloon grows
+to fit, exactly as it does for a long body.
+
+### Inline images in the body
+
+The body is already rendered as `Text.RichText`, and Qt's rich text engine
+loads `<img>` from a local path or a `file://` URI (verified: a 200px
+`<img>` raised the text's `contentHeight` from 52 to 307). No new rendering
+code is needed for the common case.
+
+Policy: only local sources render. Before display, the renderer strips any
+`<img>` whose `src` begins with `http:` or `https:`, so a notification from an
+untrusted sender cannot make the shell fetch a URL. Remote inline images are
+not shown. Sources that are absolute paths or `file://` URIs are left alone.
+
+The existing body line caps still apply: three lines in the balloon, two in a
+drawer row.
+
+### Drawer rows
+
+No image. The reserved space and the history page stay text only, as they are
+today.
+
+## Privacy
+
+A notification is untrusted input. The only new outward action the design can
+cause is a network fetch, and it is closed by the inline policy above: remote
+image sources are stripped, never fetched. The daemon writes a decoded image
+only under its own runtime directory.
+
+## Verification
+
+Daemon (Go, table tests, no bus or clock):
+
+- image hint parsing: `image-data` beats `image-path`; `icon_data` and
+ `image_data` aliases are read; a `file://` URI becomes a path.
+- `image-data` decode to PNG for 3- and 4-channel data with a rowstride.
+- theme-name resolution against a fake icon directory: a plain name, a name
+ found only through `Inherits`, a missing name, and the largest-size
+ preference.
+
+`test-notifyctl.sh` is extended if the contract change reaches it.
+
+End to end, by hand:
+
+- a grimblast screenshot and an opencode event each draw a large preview.
+- `notify-send -n firefox` draws the themed Firefox icon.
+- a `gdbus` call sending `image-data` draws the image.
+- a body containing `<img src="https://example.org/x.png">` draws no image and
+ makes no request.
+- the renderer smoke checks stay clean.
+
+## Out of scope
+
+- Icon caches and theme re-scan; a name is resolved per notification.
+- `desktop-entry` resolution.
+- Animated images, more than one image, an image in a drawer row.
+- Progress bars and body hyperlink handling, unchanged deferrals.
diff --git a/docs/superpowers/specs/2026-09-15-status-registry-design.md b/docs/superpowers/specs/2026-09-15-status-registry-design.md
new file mode 100644
index 0000000..db1e79c
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-15-status-registry-design.md
@@ -0,0 +1,231 @@
+# Status Registry
+
+Desktop modes as state: do not disturb, presentation mode, and whatever
+follows. One place that owns them, one way to read them, one way to set them.
+
+This is the first of two specs. The notification daemon that replaces dunst is
+the second, and it consumes the registry's `dnd` mode rather than owning it.
+The registry comes first because DND belongs to the desktop, not to the
+notification daemon, and because the notification daemon is the larger and
+riskier component.
+
+## What a mode is
+
+A named boolean plus a set of effects asserted while it is true.
+
+Two modes in this project:
+
+**`dnd`** has no effects of its own. It is state that the notification daemon
+reads. On its own it is a flag; the behaviour lives in the consumer.
+
+**`presentation`** has three effects:
+
+| effect | mechanism |
+|---|---|
+| do not disturb | sets the `dnd` mode |
+| idle inhibited | `IdleInhibitor` on the drawer's keepalive window |
+| breaktimer paused | `breaktimer.sh pause`, restored with `resume` |
+
+Not in this project: any further mode. Gaming, focus, and the rest are one file
+and one row each when they arrive, which is the point of building the registry
+rather than two toggles.
+
+## Why a registry rather than two toggles
+
+Because the second mode is where the coupling appears. Presentation mode sets
+DND, so DND has two writers: the user, and presentation mode. Turning
+presentation mode off must restore DND to what the user had, not unconditionally
+clear it, or an afternoon of hand-set DND vanishes when a talk ends. That rule
+has to live somewhere, and a registry is that somewhere.
+
+## State
+
+One file per mode in `$XDG_RUNTIME_DIR`, containing `0` or `1`:
+
+ status.dnd
+ status.presentation
+
+Flat, one value per file, matching the convention already on disk:
+`breaktimer.pid`, `breaktimer.state`, `breaktimer.phase`, `breaktimer.remain`
+are written exactly this way by the breaktimer daemon.
+
+A missing file means the mode is off. This is not a fallback, it is the
+mechanism: `$XDG_RUNTIME_DIR` is `/run/user/1000`, a tmpfs, so a reboot clears
+every mode with no cleanup code and no persistence logic. A mode surviving a
+reboot would need code; a mode resetting is the absence of it.
+
+Logout without reboot is less certain and the design does not rely on it.
+`elogind` runs here and `pam_elogind.so` is in the PAM stack, and its manual
+says the runtime directory and its contents are removed when a user's last
+concurrent session ends. The same manual says the module does nothing if the
+system was not booted with elogind as its init, which on Slackware it is not.
+Session tracking demonstrably works, so the removal probably happens, but it is
+not guaranteed by the documentation for this configuration. If it does not, a
+mode survives a logout, which is the same behaviour as surviving a shell
+restart, described next.
+
+A shell restart is a different event. The files live in the runtime directory,
+not in the shell, so a `qs` restart, a hot reload failure or a crash leaves
+modes intact and the registry reads them back at startup. A presentation that
+outlives a shell crash is the desired behaviour; a presentation that outlives a
+reboot is not.
+
+### Reading and writing
+
+`FileView` covers both halves with no shell-out:
+
+- `atomicWrites: true` (the default) writes a temporary file and renames it over
+ the target, so no reader ever sees a half-written value.
+- `watchChanges: true` with `onFileChanged: reload()` means an external writer
+ changes the file and the drawer repaints. No polling, in either direction.
+
+Because atomic writes arrive as a rename rather than a write, any external
+watcher must watch for `close_write,moved_to`, not `close_write` alone. This is
+the same trap the mail watcher hit with Xapian, recorded in AGENTS.md: a watch
+that sees only writes never fires on a file that is replaced.
+
+### Ownership
+
+The registry writes `status.*` and nothing else. `breaktimer.state` belongs to
+the breaktimer daemon and is driven only through `breaktimer.sh pause|resume`.
+Two writers on one file is a race, and breaktimer's own daemon loop rewrites
+that file on every phase change.
+
+## Components
+
+ shared/Status.qml the singleton: modes, effects, files
+ desktop/Status.qml symlink to the above
+ desktop/modules/status/
+ Module.qml registration
+ Tile.qml active mode count, click opens the page
+ Page.qml one row per mode, a switch each
+ ~/bin/statusctl the CLI
+
+`Status.qml` is a `pragma Singleton` rather than a module service because modes
+outlive any page, and because the notification daemon needs to read `dnd` at
+startup, before any page is instantiated. A directory import resolves it with no
+`qmldir`, the same way `Theme.qml` resolves.
+
+The symlink rather than a shared import path follows the existing convention: a
+singleton outside the config directory needs a `qmldir`, which is the friction
+that keeps `Theme.qml` symlinked into each component.
+
+## The CLI
+
+ statusctl <mode> get prints 0 or 1
+ statusctl <mode> set 0|1
+ statusctl <mode> toggle
+ statusctl <mode> watch JSON on change, for waybar
+
+`get`, `set` and `toggle` read and write the file directly. They do not go
+through the shell, so they work when the shell is down, and they cost no
+process spawn beyond the script itself.
+
+`watch` runs `inotifywait -m -e close_write,moved_to` on the file's directory,
+debounced, and prints a waybar JSON line on each change. One long-lived process,
+no polling. When the file is absent it prints `class: "down"` rather than
+`deactivated`, so a dead registry is visibly different from a mode that is off.
+
+Setting a mode by file rather than through the shell means the effects do not
+fire. `statusctl presentation set 1` writes the file; the shell sees the change
+through its watch and asserts the inhibitor and pauses breaktimer. If the shell
+is down, the file changes and nothing else happens, which is the correct failure:
+the state is recorded and reasserted when the shell returns.
+
+## Effects
+
+Effects are asserted by the singleton when a mode turns on and released when it
+turns off.
+
+**Idle inhibit** is `IdleInhibitor` bound to the drawer's keepalive
+`PanelWindow`, which is the one window guaranteed to exist for the shell's
+lifetime. The property needs a non-null `window` to do anything.
+
+The compositor advertises `zwp_idle_inhibit_manager_v1` (version 1), confirmed
+by `wayland-info`, and waybar's built-in `idle_inhibitor` module already drives
+it on this machine. So hypridle honours the Wayland protocol here, which is why
+the inhibitor is asserted that way rather than over D-Bus. `elogind` does run on
+this system, so a D-Bus inhibit path exists, but the Wayland one is confirmed
+working and needs no extra service.
+
+**Breaktimer** is paused with `breaktimer.sh pause` and restored with `resume`.
+The verbs exist, are backed by the daemon's own state file, and survive the
+daemon restarting. The exit status is readable, so a failure to pause is
+detectable, unlike the inhibitor.
+
+**DND from presentation mode** records the user's DND value when presentation
+mode turns on, sets DND on, and restores the recorded value when presentation
+mode turns off. The recorded value is held in the singleton, not in a file: it
+is meaningful only while presentation mode is on, and presentation mode does not
+survive a reboot.
+
+## Waybar
+
+The built-in `idle_inhibitor` module is replaced by a `custom/presentation`
+module reading the registry.
+
+This is a replacement rather than a demotion because the built-in module has no
+input mode: it owns an inhibitor object, its `activated` state is that object's
+state, and it cannot display state owned by anything else. Left in place
+alongside the registry it would assert a second, independent inhibitor, and idle
+would resume only when both were released. "I turned presentation mode off and
+the screen still will not lock" is the failure that produces.
+
+ "custom/presentation": {
+ "exec": "~/bin/statusctl presentation watch",
+ "return-type": "json",
+ "on-click": "~/bin/statusctl presentation toggle",
+ "format": "{icon}",
+ "format-icons": { "activated": "󰅶 ", "deactivated": "󰾪 " }
+ }
+
+The glyphs, the slot and the CSS ids carry over from the built-in module, whose
+own tooltip already reads "Presentation Mode". The name is settled by precedent.
+
+A `custom/dnd` module is available the same way but is not part of this project:
+the notification daemon's own waybar presence is the second spec's business.
+
+## Drawer
+
+A Status tile in the grid, showing the number of active modes, and a page with
+one row per mode and a switch each. Adding a mode is one file and one row.
+
+## Failure
+
+The registry cannot lose data: every mode is a boolean that resets at reboot by
+design. What it can do is misreport.
+
+If the shell dies, the `status.*` files remain and waybar keeps showing the last
+known state while nothing enforces it. The inhibitor is released, because it is
+a Wayland object owned by the dead process, and breaktimer stays paused, because
+nothing told it otherwise. So a crash during presentation mode leaves the screen
+able to lock and breaktimer still quiet, which is the safer half of each pair.
+
+`statusctl watch` prints `class: "down"` when a file is missing, which
+distinguishes a dead registry from a mode that is off, but only for the missing
+case. A crash leaves the file present and stale. That is the honest limit of a
+file as a liveness signal, and the alternative, a heartbeat, is more machinery
+than two booleans justify.
+
+## Verification
+
+One runnable check, `test-statusctl.sh`, exercising the file contract without
+the shell:
+
+- `statusctl dnd set 1`, assert the file contains `1`, assert `get` prints `1`
+- `statusctl dnd toggle`, assert `0`
+- `statusctl presentation watch` in the background, change the file, assert a
+ JSON line appears, assert it carries `activated`
+- remove the file, assert the next line carries `down`
+
+That covers the parse, the write, the atomic rename and the watch together, and
+it fails if any of them break.
+
+The effects need the shell and are confirmed by hand, once, because each is
+observable: a throwaway `hypridle -c` instance with a short timeout writes a
+marker on `on-timeout`, and the marker must not appear while presentation is on
+and must appear within a few seconds of it being turned off; `breaktimer.sh
+status` reports `paused`; and the notification daemon's own behaviour under DND
+is the second spec's verification.
+
+Per AGENTS.md, anything visual is confirmed by the user rather than screenshotted.