aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-15-status-registry-design.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs/2026-09-15-status-registry-design.md')
-rw-r--r--docs/superpowers/specs/2026-09-15-status-registry-design.md231
1 files changed, 231 insertions, 0 deletions
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.