diff options
Diffstat (limited to 'docs/superpowers/specs')
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. |
