aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-19 09:13:46 +0200
committerDanilo M. <danix@danix.xyz>2026-10-19 09:13:46 +0200
commit61ac7f2e62375c0644ffea30c8d93b81c2f29b96 (patch)
tree95a9640f10734e32c7b0ff49f8a48d7693fb5c50
parenta8d3fe8752d6b889eb053b30ff6e986fbb2d8951 (diff)
downloadquickshell-61ac7f2e62375c0644ffea30c8d93b81c2f29b96.tar.gz
quickshell-61ac7f2e62375c0644ffea30c8d93b81c2f29b96.zip
docs: plan the notification settings page
Ten tasks across notifyd (config loader, timeouts, caps, reload) and this repo (shared resolver with a node check, balloon placement and mute, the settings module). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/superpowers/plans/2026-10-19-notification-settings.md1153
1 files changed, 1153 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-19-notification-settings.md b/docs/superpowers/plans/2026-10-19-notification-settings.md
new file mode 100644
index 0000000..ece806e
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-19-notification-settings.md
@@ -0,0 +1,1153 @@
+# Notification Settings Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A settings window page that configures notifyd's timeouts and caps and the balloon shell's placement, DND rule and muted apps, through one file.
+
+**Architecture:** `~/.config/notifyd/config.json` is read by notifyd (timeouts, caps; re-read on `notifyctl reload`) and by both quickshell processes through `shared/Notify.qml` (watched `FileView`). A pure `NotifyConfig.js` library resolves defaults and decides suppression, so both shells and the node test agree. The page edits a draft held by its `Module` and saves with `cat >` then `notifyctl reload`.
+
+**Tech Stack:** Go 1.27 stdlib + godbus (notifyd repo, `~/Programming/GIT/notifyd`), Quickshell 0.3.1 QML, node for the JS check.
+
+Spec: `docs/superpowers/specs/2026-10-19-notification-settings-design.md`.
+
+Two repos. Paths under `notifyd:` are relative to `~/Programming/GIT/notifyd`, the rest to this repo. Every commit is GPG-signed (default config, never disable) and ends with the `Co-Authored-By` trailer the session gives. No home paths in committed files: write `~`.
+
+---
+
+## File map
+
+notifyd:
+- Create `internal/notify/config.go`: `Config`, `Timeouts`, `DefaultConfig`, `ConfigPath`, `LoadConfig`.
+- Create `internal/notify/config_test.go`.
+- Modify `internal/notify/policy.go`: timeouts come from a `Timeouts` argument.
+- Modify `internal/notify/policy_test.go`: new signature.
+- Modify `internal/notify/store.go`: caps become fields with `SetCaps`.
+- Modify `internal/notify/store_test.go`: a `SetCaps` case.
+- Modify `internal/notify/service.go`: holds a `Config`, loads at `Start`, `reload()`.
+- Modify `internal/notify/control.go`: `Reload` method.
+- Modify `cmd/notifyctl/main.go`: `reload` verb.
+- Modify `README.md`.
+
+quickshell:
+- Create `shared/NotifyConfig.js` + links `desktop/NotifyConfig.js`, `notifications/NotifyConfig.js`.
+- Create `shared/test_notify_config.js`.
+- Modify `shared/Notify.qml`: `config`, `rawConfig`, `configPath`.
+- Modify `notifications/Balloons.qml`: screen and corner from config.
+- Modify `notifications/NotificationBalloon.qml`: suppression through `NotifyConfig.withheld`.
+- Create `desktop/modules/notifications/NotificationsModule.qml`, `NotificationsPage.qml`, `README.md`.
+- Modify `desktop/shell.qml`: import + module entry.
+- Modify `notifications/README.md`, `AGENTS.md`.
+
+---
+
+### Task 1: notifyd config loader
+
+**Files:**
+- Create: `notifyd:internal/notify/config.go`
+- Test: `notifyd:internal/notify/config_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+`notifyd:internal/notify/config_test.go` (license header as in every file in the repo, copy from `store.go` lines 1-10):
+
+```go
+package notify
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func writeConfig(t *testing.T, body string) string {
+ t.Helper()
+ p := filepath.Join(t.TempDir(), "config.json")
+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+func TestLoadConfigMissingFileIsDefaults(t *testing.T) {
+ got := LoadConfig(filepath.Join(t.TempDir(), "absent.json"))
+ if got != DefaultConfig() {
+ t.Errorf("got %+v, want defaults", got)
+ }
+}
+
+func TestLoadConfigMalformedIsDefaults(t *testing.T) {
+ got := LoadConfig(writeConfig(t, `{"liveCap": 5,`))
+ if got != DefaultConfig() {
+ t.Errorf("got %+v, want defaults", got)
+ }
+}
+
+func TestLoadConfigPartialMergesOverDefaults(t *testing.T) {
+ got := LoadConfig(writeConfig(t, `{"timeouts": {"low": 3}, "historyCap": 50, "muted": ["x"]}`))
+ want := DefaultConfig()
+ want.Timeouts.Low = 3
+ want.HistoryCap = 50
+ if got != want {
+ t.Errorf("got %+v, want %+v", got, want)
+ }
+}
+
+func TestLoadConfigWrongTypeKeepsThatDefault(t *testing.T) {
+ got := LoadConfig(writeConfig(t, `{"liveCap": "many", "historyCap": 7}`))
+ if got.LiveCap != 20 || got.HistoryCap != 7 {
+ t.Errorf("got live %d history %d, want 20 and 7", got.LiveCap, got.HistoryCap)
+ }
+}
+
+func TestLoadConfigClamps(t *testing.T) {
+ got := LoadConfig(writeConfig(t, `{"liveCap": 0, "historyCap": 1000, "timeouts": {"normal": -4}}`))
+ if got.LiveCap != 1 || got.HistoryCap != 100 || got.Timeouts.Normal != 10 {
+ t.Errorf("got %+v", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Run: `cd ~/Programming/GIT/notifyd && go test ./internal/notify/ -run LoadConfig`
+Expected: build failure, `undefined: LoadConfig`.
+
+- [ ] **Step 3: Implement**
+
+`notifyd:internal/notify/config.go` (license header first):
+
+```go
+package notify
+
+import (
+ "encoding/json"
+ "errors"
+ "log"
+ "os"
+ "path/filepath"
+)
+
+// Timeouts are the balloon lifetimes in seconds per urgency, used only when a
+// client leaves the choice to the server. 0 means never expires.
+type Timeouts struct {
+ Low int `json:"low"`
+ Normal int `json:"normal"`
+ Critical int `json:"critical"`
+}
+
+// Config is the daemon's share of ~/.config/notifyd/config.json. The file
+// also carries the balloon shell's keys (placement, DND, mute); they are not
+// declared here and the decoder ignores them.
+type Config struct {
+ Timeouts Timeouts `json:"timeouts"`
+ LiveCap int `json:"liveCap"`
+ HistoryCap int `json:"historyCap"`
+}
+
+// DefaultConfig is the behaviour before the file existed.
+func DefaultConfig() Config {
+ return Config{Timeouts: Timeouts{Low: 10, Normal: 10, Critical: 0}, LiveCap: 20, HistoryCap: 20}
+}
+
+// ConfigPath is $XDG_CONFIG_HOME/notifyd/config.json, ~/.config when unset.
+func ConfigPath() string {
+ dir, err := os.UserConfigDir()
+ if err != nil {
+ dir = filepath.Join(homeDir(), ".config")
+ }
+ return filepath.Join(dir, "notifyd", "config.json")
+}
+
+// LoadConfig never fails: a hand typo must not stop the daemon. A missing
+// file or broken JSON is the defaults; a key of the wrong type keeps its
+// default while the rest of the file applies, which is what encoding/json
+// does on an UnmarshalTypeError. Out-of-range values clamp.
+func LoadConfig(path string) Config {
+ def := DefaultConfig()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ log.Printf("notifyd: config: %v", err)
+ }
+ return def
+ }
+ c := def
+ if err := json.Unmarshal(data, &c); err != nil {
+ var typeErr *json.UnmarshalTypeError
+ if !errors.As(err, &typeErr) {
+ log.Printf("notifyd: config %s: %v, using defaults", path, err)
+ return def
+ }
+ log.Printf("notifyd: config %s: %v", path, err)
+ }
+ c.LiveCap = clamp(c.LiveCap, 1, 100)
+ c.HistoryCap = clamp(c.HistoryCap, 1, 100)
+ if c.Timeouts.Low < 0 {
+ c.Timeouts.Low = def.Timeouts.Low
+ }
+ if c.Timeouts.Normal < 0 {
+ c.Timeouts.Normal = def.Timeouts.Normal
+ }
+ if c.Timeouts.Critical < 0 {
+ c.Timeouts.Critical = def.Timeouts.Critical
+ }
+ return c
+}
+
+func clamp(v, lo, hi int) int {
+ return min(max(v, lo), hi)
+}
+```
+
+`homeDir()` is in `icons.go:216`; reuse it.
+
+- [ ] **Step 4: Run, expect pass**
+
+Run: `go test ./internal/notify/ -run LoadConfig -v`
+Expected: 5 PASS. If `TestLoadConfigWrongTypeKeepsThatDefault` fails because `historyCap` was not applied, the decoder stopped at the type error: decode into `map[string]json.RawMessage` and unmarshal each known key separately instead.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/notify/config.go internal/notify/config_test.go
+git commit -m "feat(notify): read timeouts and caps from config.json
+
+A missing or broken file is the defaults, so a hand typo never stops the
+daemon; a key of the wrong type keeps its default while the rest applies."
+```
+
+---
+
+### Task 2: timeouts from config
+
+**Files:**
+- Modify: `notifyd:internal/notify/policy.go:25-50`
+- Modify: `notifyd:internal/notify/policy_test.go:42-63`
+
+- [ ] **Step 1: Update the test to the new signature**
+
+Replace `TestEffectiveTimeoutMS` in `policy_test.go`:
+
+```go
+func TestEffectiveTimeoutMS(t *testing.T) {
+ def := DefaultConfig().Timeouts
+ custom := Timeouts{Low: 3, Normal: 7, Critical: 60}
+ cases := []struct {
+ name string
+ expire int32
+ u Urgency
+ t Timeouts
+ want int64
+ }{
+ {"minus one uses low default", -1, Low, def, 10_000},
+ {"minus one uses normal default", -1, Normal, def, 10_000},
+ {"minus one critical never", -1, Critical, def, 0},
+ {"minus one uses configured low", -1, Low, custom, 3_000},
+ {"minus one uses configured critical", -1, Critical, custom, 60_000},
+ {"zero never", 0, Normal, custom, 0},
+ {"explicit wins", 3_000, Normal, custom, 3_000},
+ {"sub second exact", 1, Normal, def, 1},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := EffectiveTimeoutMS(c.expire, c.u, c.t); got != c.want {
+ t.Errorf("EffectiveTimeoutMS(%d, %q) = %d, want %d", c.expire, c.u, got, c.want)
+ }
+ })
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Run: `go test ./internal/notify/ -run EffectiveTimeout`
+Expected: `too many arguments in call to EffectiveTimeoutMS`.
+
+- [ ] **Step 3: Implement**
+
+In `policy.go`, replace `DefaultTimeoutMS` and `EffectiveTimeoutMS`:
+
+```go
+// DefaultTimeoutMS is the balloon lifetime for an urgency when the client
+// leaves the choice to the server (expire_timeout -1), from the config's
+// seconds. 0 is never.
+func DefaultTimeoutMS(u Urgency, t Timeouts) int64 {
+ switch u {
+ case Low:
+ return int64(t.Low) * 1000
+ case Critical:
+ return int64(t.Critical) * 1000
+ default:
+ return int64(t.Normal) * 1000
+ }
+}
+
+// EffectiveTimeoutMS resolves the client's expire_timeout per the freedesktop
+// spec: -1 means the server decides (the configured default), 0 means never,
+// and anything positive is milliseconds and wins. The spec is precise about
+// the direction and libnotify's default is -1, so getting it backwards would
+// make every plain notify-send immortal.
+func EffectiveTimeoutMS(expire int32, u Urgency, t Timeouts) int64 {
+ switch {
+ case expire == -1:
+ return DefaultTimeoutMS(u, t)
+ case expire == 0:
+ return 0
+ default:
+ return int64(expire)
+ }
+}
+```
+
+Note the `default` branch: an unknown urgency is normal, matching `UrgencyFromHints`.
+
+In `service.go`, add a config to `Service` (guarded by the existing `mu`):
+
+```go
+type Service struct {
+ conn *dbus.Conn
+ store *Store
+ dir string
+
+ mu sync.Mutex
+ timers map[uint32]*time.Timer
+ cfg Config
+}
+```
+
+In `NewService`, set `cfg: DefaultConfig()` in the literal. In `Notify`, replace `ms := EffectiveTimeoutMS(expireTimeout, u)` with:
+
+```go
+ s.mu.Lock()
+ timeouts := s.cfg.Timeouts
+ s.mu.Unlock()
+ ms := EffectiveTimeoutMS(expireTimeout, u, timeouts)
+```
+
+- [ ] **Step 4: Run all tests**
+
+Run: `go test ./...`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/notify/policy.go internal/notify/policy_test.go internal/notify/service.go
+git commit -m "feat(notify): take the per-urgency default timeout from config"
+```
+
+---
+
+### Task 3: caps as store fields
+
+**Files:**
+- Modify: `notifyd:internal/notify/store.go:39-56, 226-235`
+- Test: `notifyd:internal/notify/store_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `store_test.go`:
+
+```go
+func TestSetCapsAppliesAtNextAdd(t *testing.T) {
+ s, _ := newTestStore()
+ for i := 0; i < 5; i++ {
+ id, _ := s.Add(popup("a"), "", 0)
+ s.Dismiss(id, 2)
+ }
+ for i := 0; i < 4; i++ {
+ s.Add(popup("a"), "", 0)
+ }
+ s.SetCaps(2, 3)
+ // Lowering a cap drops nothing by itself.
+ if got := len(s.historySnapshot()); got != 5 {
+ t.Fatalf("history after SetCaps = %d, want 5", got)
+ }
+ s.Add(popup("a"), "", 0)
+ if got := len(s.order); got != 2 {
+ t.Errorf("live = %d, want 2", got)
+ }
+ if got := len(s.historySnapshot()); got != 3 {
+ t.Errorf("history = %d, want 3", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Run: `go test ./internal/notify/ -run SetCaps`
+Expected: `s.SetCaps undefined`.
+
+- [ ] **Step 3: Implement**
+
+In `store.go`, delete the `liveCap`/`historyCap` const block. Add fields to `Store` after `history`:
+
+```go
+ liveCap int
+ historyCap int
+```
+
+In `NewStore`, add to the literal:
+
+```go
+ liveCap: DefaultConfig().LiveCap,
+ historyCap: DefaultConfig().HistoryCap,
+```
+
+Add after `ClearHistory`:
+
+```go
+// SetCaps changes the queue and history bounds. Nothing is trimmed here: a
+// reload must not silently drop live notifications, so a lowered cap applies
+// at the next add.
+func (s *Store) SetCaps(live, history int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.liveCap = live
+ s.historyCap = history
+}
+```
+
+In `fileLocked` replace both `historyCap` with `s.historyCap`; in `evictLocked` replace `liveCap` with `s.liveCap`.
+
+- [ ] **Step 4: Run all tests**
+
+Run: `go test ./...`
+Expected: all PASS, including the existing `TestQueueCapEvictsToHistory` and `TestHistoryRingCapsAtTwenty`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/notify/store.go internal/notify/store_test.go
+git commit -m "feat(notify): make the queue and history caps settable
+
+A lowered cap applies at the next add rather than at once, so reloading
+the config never drops a notification the user has not seen."
+```
+
+---
+
+### Task 4: load at start, `notifyctl reload`
+
+**Files:**
+- Modify: `notifyd:internal/notify/service.go` (`Start`, new `reload`)
+- Modify: `notifyd:internal/notify/control.go`
+- Modify: `notifyd:cmd/notifyctl/main.go`
+
+- [ ] **Step 1: Implement**
+
+In `service.go`, add after `Start`:
+
+```go
+// reload re-reads the config file and applies it. Timers already armed keep
+// their old lifetime; the next notification gets the new one.
+func (s *Service) reload() {
+ c := LoadConfig(ConfigPath())
+ s.mu.Lock()
+ s.cfg = c
+ s.mu.Unlock()
+ s.store.SetCaps(c.LiveCap, c.HistoryCap)
+}
+```
+
+In `Start`, call `s.reload()` immediately before `s.store.Reset()`.
+
+In `control.go`, append:
+
+```go
+// Reload re-reads ~/.config/notifyd/config.json. The settings page calls it
+// after a save; a bad file is logged and read as defaults, never an error.
+func (c *control) Reload() *dbus.Error {
+ c.svc.reload()
+ return nil
+}
+```
+
+In `cmd/notifyctl/main.go`, add to the usage text after `clear-history`:
+
+```
+ reload re-read ~/.config/notifyd/config.json
+```
+
+and to the switch before `default:`:
+
+```go
+ case "reload":
+ callControl("Reload")
+```
+
+- [ ] **Step 2: Build and test**
+
+Run: `go vet ./... && go test ./...`
+Expected: no vet output, all PASS.
+
+- [ ] **Step 3: Check against a throwaway daemon is not possible here**
+
+The live daemon owns the bus name, so a second one cannot start. The live check is Task 9, step 4. Confirm only that a missing daemon is reported: `go run ./cmd/notifyctl reload; echo $?` against the *installed* (old) daemon prints `notifyctl: ... Unknown method` style error and `1`. That is the "daemon not reachable" path the page reports.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/notify/service.go internal/notify/control.go cmd/notifyctl/main.go
+git commit -m "feat(notifyctl): add reload, re-reading the config file
+
+The daemon also reads the file at start. Armed timers keep their lifetime;
+the next notification gets the new default."
+```
+
+---
+
+### Task 5: notifyd README
+
+**Files:**
+- Modify: `notifyd:README.md` (new section before `## License`)
+
+- [ ] **Step 1: Add the section**
+
+```markdown
+## Configuration
+
+`~/.config/notifyd/config.json` (`$XDG_CONFIG_HOME` is honoured). Every key
+is optional and the defaults are the behaviour without the file:
+
+ {
+ "timeouts": { "low": 10, "normal": 10, "critical": 0 },
+ "liveCap": 20,
+ "historyCap": 20
+ }
+
+Timeouts are seconds, 0 is never, and apply only when a client sends
+`expire_timeout -1`. Caps clamp to 1..100; a lowered cap applies at the next
+notification. A broken file is logged and read as the defaults.
+
+The daemon reads it at start and on `notifyctl reload`. The quickshell
+settings page writes it; the same file carries the balloon shell's keys
+(`balloons`, `dndPassCritical`, `muted`), which the daemon ignores.
+```
+
+- [ ] **Step 2: Commit and push**
+
+```bash
+git add README.md
+git commit -m "docs: document config.json and notifyctl reload"
+git push
+```
+
+- [ ] **Step 3: Hand the install to the user**
+
+Do not replace the running daemon from a tool call. Tell the user to run, from their terminal:
+
+ cd ~/Programming/GIT/notifyd
+ go build -o ~/bin/notifyd ./cmd/notifyd && go build -o ~/bin/notifyctl ./cmd/notifyctl
+ pkill -x notifyd; setsid -f notifyd
+
+and confirm with `notifyctl reload; echo $?` → `0`.
+
+---
+
+### Task 6: `NotifyConfig.js`, shared resolver
+
+**Files:**
+- Create: `shared/NotifyConfig.js`
+- Create links: `desktop/NotifyConfig.js`, `notifications/NotifyConfig.js` → `../shared/NotifyConfig.js`
+- Test: `shared/test_notify_config.js`
+
+- [ ] **Step 1: Write the failing test**
+
+`shared/test_notify_config.js` (license header as `desktop/modules/git/test_git.js`):
+
+```js
+// Run: node shared/test_notify_config.js
+// NotifyConfig.js is a QML library, so its .pragma line is stripped before
+// node evaluates it.
+
+const fs = require("fs");
+const assert = require("assert");
+
+const src = fs.readFileSync(__dirname + "/NotifyConfig.js", "utf8")
+ .replace(/^\.pragma library$/m, "");
+const C = new Function(src + "\nreturn { defaults, resolve, withheld };")();
+
+// Nothing, or garbage, is the defaults.
+assert.deepStrictEqual(C.resolve(undefined), C.defaults());
+assert.deepStrictEqual(C.resolve("x"), C.defaults());
+
+// Partial merges; wrong types and bad values keep their defaults.
+const r = C.resolve({ timeouts: { low: 3, normal: "x" }, liveCap: 500, historyCap: 0,
+ balloons: { corner: "middle", screen: "HDMI-A-1" },
+ dndPassCritical: false, muted: ["a", 3, "b"] });
+assert.deepStrictEqual(r.timeouts, { low: 3, normal: 10, critical: 0 });
+assert.strictEqual(r.liveCap, 100);
+assert.strictEqual(r.historyCap, 1);
+assert.deepStrictEqual(r.balloons, { screen: "HDMI-A-1", corner: "bottom-right" });
+assert.strictEqual(r.dndPassCritical, false);
+assert.deepStrictEqual(r.muted, ["a", "b"]);
+
+// Suppression: muted always, DND per dndPassCritical.
+const n = (app, urgency) => ({ app, urgency });
+const cfg = C.resolve({ muted: ["spam"] });
+assert.strictEqual(C.withheld(cfg, n("spam", "critical"), false), true);
+assert.strictEqual(C.withheld(cfg, n("ok", "normal"), false), false);
+assert.strictEqual(C.withheld(cfg, n("ok", "normal"), true), true);
+assert.strictEqual(C.withheld(cfg, n("ok", "critical"), true), false);
+assert.strictEqual(C.withheld(C.resolve({ dndPassCritical: false }), n("ok", "critical"), true), true);
+
+console.log("ok");
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Run: `node shared/test_notify_config.js`
+Expected: `ENOENT ... NotifyConfig.js`.
+
+- [ ] **Step 3: Implement**
+
+`shared/NotifyConfig.js` (license header as `desktop/modules/git/Git.js`):
+
+```js
+.pragma library
+
+// ~/.config/notifyd/config.json, resolved over its defaults. notifyd reads
+// the same file for timeouts and caps (its config.go clamps the same way);
+// the balloon keys are this side's alone. Tested by test_notify_config.js.
+
+const corners = ["top-left", "top-right", "bottom-left", "bottom-right"];
+
+function defaults() {
+ return {
+ timeouts: { low: 10, normal: 10, critical: 0 },
+ liveCap: 20,
+ historyCap: 20,
+ balloons: { screen: "DP-1", corner: "bottom-right" },
+ dndPassCritical: true,
+ muted: [],
+ };
+}
+
+function seconds(v, d) { return Number.isInteger(v) && v >= 0 ? v : d; }
+function cap(v, d) { return Number.isInteger(v) ? Math.min(Math.max(v, 1), 100) : d; }
+
+// Never throws: anything that is not the right shape reads as its default.
+function resolve(raw) {
+ const d = defaults();
+ if (!raw || typeof raw !== "object") return d;
+ const t = raw.timeouts || {};
+ const b = raw.balloons || {};
+ return {
+ timeouts: {
+ low: seconds(t.low, d.timeouts.low),
+ normal: seconds(t.normal, d.timeouts.normal),
+ critical: seconds(t.critical, d.timeouts.critical),
+ },
+ liveCap: cap(raw.liveCap, d.liveCap),
+ historyCap: cap(raw.historyCap, d.historyCap),
+ balloons: {
+ screen: typeof b.screen === "string" && b.screen !== "" ? b.screen : d.balloons.screen,
+ corner: corners.indexOf(b.corner) >= 0 ? b.corner : d.balloons.corner,
+ },
+ dndPassCritical: typeof raw.dndPassCritical === "boolean" ? raw.dndPassCritical : d.dndPassCritical,
+ muted: Array.isArray(raw.muted) ? raw.muted.filter(a => typeof a === "string") : d.muted,
+ };
+}
+
+// Whether a balloon is held back by mute or DND. Drawer and snooze are the
+// balloon's own checks; this is only what the config decides.
+function withheld(cfg, n, dnd) {
+ if (cfg.muted.indexOf(n.app) >= 0) return true;
+ return dnd && !(n.urgency === "critical" && cfg.dndPassCritical);
+}
+```
+
+Then the links:
+
+```bash
+ln -s ../shared/NotifyConfig.js desktop/NotifyConfig.js
+ln -s ../shared/NotifyConfig.js notifications/NotifyConfig.js
+```
+
+- [ ] **Step 4: Run, expect pass**
+
+Run: `node shared/test_notify_config.js`
+Expected: `ok`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add shared/NotifyConfig.js shared/test_notify_config.js desktop/NotifyConfig.js notifications/NotifyConfig.js
+git commit -m "notifications: resolve notifyd's config.json in a shared library
+
+One resolver for both shells and the settings page, with the defaults that
+reproduce today's behaviour, and the mute/DND decision beside it so the
+node check covers it."
+```
+
+---
+
+### Task 7: `Notify.qml` reads the config
+
+**Files:**
+- Modify: `shared/Notify.qml`
+
+- [ ] **Step 1: Implement**
+
+Add after `import QtQuick`:
+
+```qml
+import "NotifyConfig.js" as NC
+```
+
+`Notify.qml` is reached through the links in `desktop/` and `notifications/`, and a relative import resolves beside the link, which is why Task 6 created a link in each. If the shell logs `NotifyConfig.js: No such file`, the import resolved against `shared/` instead; then the links are unnecessary and harmless.
+
+Add properties after `property bool dnd: false`:
+
+```qml
+ // ~/.config/notifyd/config.json, shared with the daemon (timeouts, caps)
+ // and written by the settings page. rawConfig is the parsed file as is,
+ // so a save keeps keys this version does not know; config is it resolved
+ // over the defaults, which is what everything reads.
+ readonly property string configPath:
+ (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/notifyd/config.json"
+ property var rawConfig: ({})
+ readonly property var config: NC.resolve(root.rawConfig)
+```
+
+Add a `FileView` after `snoozeFile`:
+
+```qml
+ FileView {
+ id: configFile
+ path: root.configPath
+ watchChanges: true
+ printErrors: false
+ onFileChanged: reload()
+ onLoaded: {
+ try { root.rawConfig = JSON.parse(configFile.text() || "{}"); }
+ catch (e) { root.rawConfig = {}; }
+ }
+ onLoadFailed: root.rawConfig = {}
+ }
+```
+
+Hot reload does not follow the `shared/` symlink (AGENTS.md, Reloading), so the running shells pick this up only through the edits in Task 8 (notifications) and Task 9 (desktop). Nothing to check yet.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add shared/Notify.qml
+git commit -m "notifications: read notifyd's config.json in the Notify singleton"
+```
+
+---
+
+### Task 8: balloons use placement, mute and DND
+
+**Files:**
+- Modify: `notifications/Balloons.qml:17, 71-80`
+- Modify: `notifications/NotificationBalloon.qml:28-40`
+
+- [ ] **Step 1: Placement**
+
+`Balloons.qml` reads `Notify.config` and needs no new import. Replace the header comment `// The balloon stack, bottom-right of DP-1, over conky.` with:
+
+```qml
+// The balloon stack, in the corner and on the screen notifyd's config.json
+// names (bottom-right of DP-1 by default), over conky.
+```
+
+Inside `PanelWindow { id: win`, replace the `screen:`, `anchors`, `margins` lines with:
+
+```qml
+ readonly property var place: Notify.config.balloons
+ readonly property bool atTop: place.corner.startsWith("top")
+ readonly property bool atLeft: place.corner.endsWith("left")
+ screen: Quickshell.screens.find(s => s.name === place.screen) ?? Quickshell.screens[0]
+ anchors { top: atTop; bottom: !atTop; left: atLeft; right: !atLeft }
+ margins { top: 12; bottom: 12; left: 12; right: 12 }
+```
+
+In the `Column`, replace `anchors { bottom: parent.bottom; right: parent.right }` with nothing: the window is sized to the column, so the column's anchors do nothing. Delete that line.
+
+- [ ] **Step 2: Suppression**
+
+In `NotificationBalloon.qml`, add `import "NotifyConfig.js" as NC` after the existing imports. Replace the comment and `visible` block (lines 28-40) with:
+
+```qml
+ // Driven by the shell's tick. The balloon drops itself once its expiry
+ // passes, or while a suppression says so: the drawer and snooze withhold
+ // everything; a muted app and DND are config.json's call (DND passes
+ // critical unless dndPassCritical is off). The drawer still lists what
+ // was withheld, because a list the user opened is not an interruption.
+ visible: {
+ if (Notify.drawerOpen) return false;
+ if (Notify.snoozeUntil > b.now) return false;
+ if (NC.withheld(Notify.config, b.notification, Notify.dnd)) return false;
+ if (b.notification.expires !== 0 && b.now >= b.notification.expires) return false;
+ return true;
+ }
+```
+
+- [ ] **Step 3: Verify defaults unchanged**
+
+Check reload: `qs log -p notifications --log-times | tail -5` shows `Reloading configuration` after the edit and no `ReferenceError`/`TypeError`. If none followed, ask the user to run `./reload.sh notifications`.
+
+Run: `notify-send -u low test "default placement"`. Ask the user: balloon bottom-right of DP-1, gone after 10s?
+
+- [ ] **Step 4: Verify config drives it**
+
+```bash
+mkdir -p ~/.config/notifyd
+printf '{"balloons":{"corner":"top-left"},"muted":["mutetest"]}\n' > ~/.config/notifyd/config.json
+notify-send -a mutetest muted "should not pop"
+notify-send -a other placed "top-left"
+```
+
+Ask the user: no balloon for `mutetest`, `other` at top-left of DP-1, and `mutetest` listed in the drawer. If the balloon stays bottom-right, a live layer surface did not re-anchor (cf. AGENTS.md, a hot reload does not resize a layer surface): check whether it moves once the stack empties and a new notification maps it; if not, bind `visible` to also go false when `place` changes and note that in a comment. Then `rm ~/.config/notifyd/config.json`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add notifications/Balloons.qml notifications/NotificationBalloon.qml
+git commit -m "notifications: place, mute and DND balloons from config.json
+
+Screen and corner come from the shared config. Mute and the DND rule go
+through NotifyConfig.withheld, so the settings page's node check covers the
+same decision the balloon makes."
+```
+
+---
+
+### Task 9: settings page
+
+**Files:**
+- Create: `desktop/modules/notifications/NotificationsModule.qml`
+- Create: `desktop/modules/notifications/NotificationsPage.qml`
+- Modify: `desktop/shell.qml` (import list, `modules:` list after `IdleModule {},`)
+
+- [ ] **Step 1: The module, holding the draft**
+
+`desktop/modules/notifications/NotificationsModule.qml` (QML license header as `desktop/modules/git/GitModule.qml`):
+
+```qml
+import Quickshell.Io
+import QtQuick
+import "../.."
+
+// notifyd's config.json: the daemon's timeouts and caps, and the balloon
+// shell's placement, DND rule and muted apps. The draft lives here, not in
+// the page, so the sidebar's unsaved mark survives the page unloading.
+Module {
+ id: nm
+ name: "notifications"
+ label: "Notifications"
+ // nf-fa-bell, present in Inconsolata Nerd Font.
+ icon: ""
+ settings: true
+ group: "Desktop"
+ keywords: ["notifyd", "timeout", "history", "queue", "mute", "dnd", "corner", "monitor", "balloon"]
+
+ property var draft: Notify.config
+ property string notice: ""
+ dirty: JSON.stringify(nm.draft) !== JSON.stringify(Notify.config)
+ readonly property bool valid: [nm.draft.liveCap, nm.draft.historyCap].every(v => v >= 1 && v <= 100)
+
+ // Not over unsaved edits.
+ function refresh() { if (!nm.dirty) nm.draft = Notify.config; }
+ function discard() { nm.draft = Notify.config; nm.notice = ""; }
+ function edit(patch) { nm.draft = Object.assign({}, nm.draft, patch); }
+
+ // Unknown keys in the file survive. The text goes on stdin to cat (the
+ // notes pattern: the exit code is the outcome); 1 is the write, 2 the
+ // reload, so a stopped daemon reads as saved-but-not-applied.
+ // Written in onStarted, then stdinEnabled cleared to give cat its EOF:
+ // the sequence notes' bodyWriter uses (NotesModule.qml).
+ function save() {
+ nm.notice = "";
+ nm.saver.data = JSON.stringify(Object.assign({}, Notify.rawConfig, nm.draft), null, 2) + "\n";
+ nm.saver.stdinEnabled = true;
+ nm.saver.running = false;
+ nm.saver.running = true;
+ }
+ property Process saver: Process {
+ property string data: ""
+ command: ["sh", "-c", 'mkdir -p "$(dirname "$1")" && cat > "$1" || exit 1; notifyctl reload || exit 2',
+ "sh", Notify.configPath]
+ onStarted: {
+ nm.saver.write(nm.saver.data);
+ nm.saver.stdinEnabled = false;
+ }
+ onExited: code => nm.notice = code === 0 ? "saved"
+ : code === 2 ? "saved; notifyd not reachable, it reads the file at its next start"
+ : "could not write " + Notify.configPath
+ }
+
+ page: Component {
+ Page {
+ title: "Notifications"
+ actions: Component {
+ SaveBar { dirty: nm.dirty; valid: nm.valid; note: "caps are 1 to 100"
+ onSave: nm.save(); onDiscard: nm.discard() }
+ }
+ NotificationsPage { width: parent.width; mod: nm }
+ }
+ }
+}
+```
+
+The save does not `refresh()` the draft: the `FileView` reloads, `Notify.config` changes, and `dirty` goes false on its own when they match.
+
+- [ ] **Step 2: The page**
+
+`desktop/modules/notifications/NotificationsPage.qml` (license header):
+
+```qml
+import Quickshell
+import QtQuick
+import "../.."
+import "../appearance"
+import "../qtmaildir"
+
+// The rows of the notifications settings page. Everything edits mod.draft.
+Column {
+ id: pg
+ // Not "nm": a property named like the injecting id binds to itself.
+ required property QtObject mod
+ readonly property var d: mod.draft
+ spacing: 8
+
+ function setTimeout(key, v) {
+ pg.mod.edit({ timeouts: Object.assign({}, pg.d.timeouts, { [key]: v }) });
+ }
+
+ Heading { text: "Timeouts" }
+ Text {
+ width: pg.width; wrapMode: Text.Wrap
+ text: "Seconds a balloon stays when the app leaves it to the daemon. An app's own timeout still wins."
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Repeater {
+ model: [["low", "Low"], ["normal", "Normal"], ["critical", "Critical"]]
+ Row {
+ required property var modelData
+ spacing: 10
+ Text {
+ width: 90; anchors.verticalCenter: parent.verticalCenter
+ text: modelData[1]; color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Field {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: pg.d.timeouts[modelData[0]] !== 0
+ value: String(pg.d.timeouts[modelData[0]])
+ placeholder: "seconds"
+ onEdited: { const n = parseInt(value, 10); if (n > 0) pg.setTimeout(modelData[0], n); }
+ }
+ Toggle {
+ anchors.verticalCenter: parent.verticalCenter
+ label: "never"
+ checked: pg.d.timeouts[modelData[0]] === 0
+ onToggled: pg.setTimeout(modelData[0], checked ? 0 : 10)
+ }
+ }
+ }
+
+ Heading { text: "Limits" }
+ Repeater {
+ model: [["liveCap", "On screen"], ["historyCap", "History"]]
+ Row {
+ required property var modelData
+ spacing: 10
+ Text {
+ width: 90; anchors.verticalCenter: parent.verticalCenter
+ text: modelData[1]; color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Field {
+ anchors.verticalCenter: parent.verticalCenter
+ value: String(pg.d[modelData[0]])
+ border.color: pg.d[modelData[0]] >= 1 && pg.d[modelData[0]] <= 100 ? Qt.alpha(Theme.text, 0.12) : Theme.red
+ onEdited: pg.mod.edit({ [modelData[0]]: parseInt(value, 10) || 0 })
+ }
+ }
+ }
+
+ Heading { text: "Placement" }
+ Flow {
+ width: pg.width; spacing: 6
+ Repeater {
+ model: Quickshell.screens.map(s => s.name)
+ Button {
+ required property string modelData
+ text: modelData
+ border.color: pg.d.balloons.screen === modelData ? Theme.accent : Qt.alpha(Theme.text, 0.2)
+ onClicked: pg.mod.edit({ balloons: Object.assign({}, pg.d.balloons, { screen: modelData }) })
+ }
+ }
+ }
+ // The four corners as a miniature screen: click the one balloons go to.
+ Grid {
+ columns: 2; spacing: 4
+ Repeater {
+ model: ["top-left", "top-right", "bottom-left", "bottom-right"]
+ Rectangle {
+ required property string modelData
+ width: 60; height: 34; radius: 5
+ color: pg.d.balloons.corner === modelData ? Qt.alpha(Theme.accent, 0.6) : Qt.alpha(Theme.surface, 0.6)
+ border.width: 1; border.color: Qt.alpha(Theme.text, 0.15)
+ MouseArea {
+ anchors.fill: parent; cursorShape: Qt.PointingHandCursor
+ onClicked: pg.mod.edit({ balloons: Object.assign({}, pg.d.balloons, { corner: modelData }) })
+ }
+ }
+ }
+ }
+
+ Heading { text: "Suppression" }
+ Toggle {
+ label: "Critical notifications still pop up under Do Not Disturb"
+ checked: pg.d.dndPassCritical
+ onToggled: pg.mod.edit({ dndPassCritical: checked })
+ }
+ Text {
+ width: pg.width; wrapMode: Text.Wrap
+ text: "Muted apps get no balloon. They still appear in the drawer and in history."
+ color: Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Flow {
+ width: pg.width; spacing: 6
+ Repeater {
+ model: pg.d.muted
+ Button {
+ required property string modelData
+ text: modelData; icon: ""
+ onClicked: pg.mod.edit({ muted: pg.d.muted.filter(a => a !== modelData) })
+ }
+ }
+ }
+ // Apps seen in the queue and history, not yet muted, one click to add.
+ Flow {
+ width: pg.width; spacing: 6
+ Repeater {
+ model: [...new Set(Notify.queue.concat(Notify.history).map(n => n.app))]
+ .filter(a => a && pg.d.muted.indexOf(a) < 0).sort()
+ Button {
+ required property string modelData
+ text: modelData; icon: ""
+ onClicked: pg.mod.edit({ muted: pg.d.muted.concat([modelData]) })
+ }
+ }
+ }
+ Field {
+ width: 200
+ placeholder: "mute another app by name"
+ onEdited: {
+ const a = value.trim();
+ if (a !== "" && pg.d.muted.indexOf(a) < 0) pg.mod.edit({ muted: pg.d.muted.concat([a]) });
+ value = "";
+ }
+ }
+
+ Text {
+ width: pg.width; wrapMode: Text.Wrap
+ visible: pg.mod.notice !== ""
+ text: pg.mod.notice
+ color: pg.mod.notice === "saved" ? Theme.green : Theme.yellow
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+}
+```
+
+Glyphs: `` (times), `` (plus), `` (bell) are Font Awesome codepoints in Inconsolata Nerd Font; confirm with `fc-query` on the installed font, e.g. `python3 -c` with fontTools if present, or `fc-list ':charset=f0f3' family | grep -i inconsolata`. A missing glyph means picking another, not keeping it.
+
+`Field` here is `modules/appearance/Field.qml` and `Toggle` `modules/appearance/Toggle.qml`; `Heading` is `modules/qtmaildir/Heading.qml`. No file in this directory imports `QtQuick.Controls`, so `Button` is the drawer's `Button.qml`.
+
+- [ ] **Step 3: Register it**
+
+In `desktop/shell.qml`, add `import "modules/notifications"` in alphabetical place (after `import "modules/notes"`), and `NotificationsModule {},` after `IdleModule {},`. This edit is also what makes the running shell re-index the new directory and pick up the `shared/Notify.qml` change (AGENTS.md, Reloading).
+
+- [ ] **Step 4: Check it loads**
+
+Run: `qs log -p desktop --log-times | tail -20`
+Expected: `Reloading configuration` after the edit time, no `TypeError`, `ReferenceError` or `is not a type`. If no reload line followed, ask the user to run `./reload.sh desktop`.
+
+Ask the user to open settings, Desktop → Notifications, and check: the four sections render with today's defaults, an edit marks the page unsaved, Discard reverts, Save writes `~/.config/notifyd/config.json` and shows "saved". Then: set Low to 3s, save, `notify-send -u low t "3s"`, balloon gone after 3s (needs the new daemon from Task 5).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/notifications desktop/shell.qml
+git commit -m "desktop: notifications settings page
+
+Edits notifyd's config.json as a draft behind the SaveBar and runs
+notifyctl reload after the write; a stopped daemon is reported as saved
+but not applied, since it reads the file at its next start. Muting offers
+the apps already seen in the queue and history."
+```
+
+---
+
+### Task 10: docs
+
+**Files:**
+- Create: `desktop/modules/notifications/README.md`
+- Modify: `notifications/README.md`
+- Modify: `AGENTS.md` ("What this is" block)
+
+- [ ] **Step 1: Module README**
+
+`desktop/modules/notifications/README.md`:
+
+```markdown
+# notifications (settings page)
+
+Edits `~/.config/notifyd/config.json`, the one file behind both the daemon
+and the balloon shell:
+
+| key | read by |
+| --- | --- |
+| `timeouts`, `liveCap`, `historyCap` | notifyd, at start and on `notifyctl reload` |
+| `balloons`, `dndPassCritical`, `muted` | both shells, through `Notify.config` |
+
+`shared/NotifyConfig.js` resolves the file over its defaults and holds the
+mute/DND decision. Check: `node shared/test_notify_config.js`.
+
+Save writes the file with `cat` (unknown keys kept) then runs `notifyctl
+reload`. If notifyd is not running the file is still written and the page
+says so; the daemon reads it when it starts.
+
+Snooze is not here: the status tile has it.
+```
+
+- [ ] **Step 2: Balloon README**
+
+In `notifications/README.md`, replace the "Suppression lives here" section body with:
+
+```markdown
+The daemon does not know about DND, snooze or mute. This component withholds
+balloons: `status.dnd` suppresses low and normal (and critical too when
+`dndPassCritical` is off), a muted app is always withheld, and
+`notifyd/snooze` suppresses everything. The drawer's reserved space lists
+every live notification anyway.
+
+Placement, mute and the DND rule come from `~/.config/notifyd/config.json`,
+edited from the settings window (Desktop → Notifications).
+```
+
+And in "The files are the interface" add the line:
+
+```
+ ~/.config/notifyd/config.json settings, shared with the daemon
+```
+
+- [ ] **Step 3: AGENTS.md**
+
+In the "What this is" block, the `desktop/` description's settings list, change `sunset, idle (the former appearance shell), autostart,` to `sunset, idle (the former appearance shell), notifications, autostart,`.
+
+- [ ] **Step 4: Commit and push**
+
+```bash
+git add desktop/modules/notifications/README.md notifications/README.md AGENTS.md
+git commit -m "docs: the notification settings page and config.json"
+git push
+```