aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-10-19-notification-sounds.md696
1 files changed, 696 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-19-notification-sounds.md b/docs/superpowers/plans/2026-10-19-notification-sounds.md
new file mode 100644
index 0000000..dd949ed
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-19-notification-sounds.md
@@ -0,0 +1,696 @@
+# Notification Sounds 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 sound per urgency for notification balloons, chosen on the notifications settings page, honouring apps' `sound-file`, `sound-name` and `suppress-sound` hints, with the sound theme mirrored to gsettings.
+
+**Architecture:** notifyd resolves the hints into two published fields (`sound`, `silent`), looking `sound-name` up in `config.json`'s `soundTheme`. The balloon shell plays with `pw-play` when a new notification would show a balloon, choosing via `NotifyConfig.soundFor`. The settings page edits `sounds` and `soundTheme`, writes the theme to gsettings on save, and reads gsettings back to flag outside changes.
+
+**Tech Stack:** Go stdlib + godbus (notifyd, `~/Programming/GIT/notifyd`), Quickshell 0.3.1 QML, `Qt.labs.platform` FileDialog, `pw-play`, `gsettings`, node for the JS check.
+
+Spec: `docs/superpowers/specs/2026-10-19-notification-sounds-design.md`. Builds on `docs/superpowers/specs/2026-10-19-notification-settings-design.md` (already shipped).
+
+Two repos. `notifyd:` paths are relative to `~/Programming/GIT/notifyd`, others to this repo. Commits GPG-signed (never disable), each ending with a blank line and `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. No em dashes, no `/home/` paths in committed files. Never `pkill -f`, never start/kill `qs` or `notifyd`, never run `./reload.sh`.
+
+---
+
+## File map
+
+notifyd:
+- Create `internal/notify/sounds.go`, `internal/notify/sounds_test.go`.
+- Modify `internal/notify/config.go` (+`SoundTheme`), `config_test.go`.
+- Modify `internal/notify/store.go` (`Popup` gains `Sound`, `Silent`).
+- Modify `internal/notify/service.go` (`Notify` fills them).
+- Modify `README.md`.
+
+quickshell:
+- Modify `shared/NotifyConfig.js` (+`sounds`, `soundTheme`, `soundFor`), `shared/test_notify_config.js`.
+- Modify `notifications/Balloons.qml` (player).
+- Modify `desktop/modules/notifications/NotificationsModule.qml` (themes list, gsettings read, save merges `sounds`, saver sets gsettings), `NotificationsPage.qml` (Sounds section).
+- Modify `desktop/modules/notifications/README.md`, `notifications/README.md`.
+
+---
+
+### Task 1: notifyd sound lookup
+
+**Files:** Create `notifyd:internal/notify/sounds.go`, `notifyd:internal/notify/sounds_test.go`.
+
+- [ ] **Step 1: Failing test**
+
+`sounds_test.go` (GPL header from `store.go` lines 1-10):
+
+```go
+package notify
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// soundTree makes $XDG_DATA_HOME/sounds/<theme>/stereo/<file> for each entry
+// and an index.theme per theme with the given Inherits, and points
+// XDG_DATA_HOME there with XDG_DATA_DIRS empty-but-set to another temp dir.
+func soundTree(t *testing.T, inherits map[string]string, files ...string) string {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("XDG_DATA_HOME", home)
+ t.Setenv("XDG_DATA_DIRS", t.TempDir())
+ for theme, inh := range inherits {
+ dir := filepath.Join(home, "sounds", theme)
+ if err := os.MkdirAll(filepath.Join(dir, "stereo"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ body := "[Sound Theme]\nName=" + theme + "\n"
+ if inh != "" {
+ body += "Inherits=" + inh + "\n"
+ }
+ if err := os.WriteFile(filepath.Join(dir, "index.theme"), []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, f := range files {
+ p := filepath.Join(home, "sounds", f)
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return filepath.Join(home, "sounds")
+}
+
+func TestLookupSound(t *testing.T) {
+ root := soundTree(t,
+ map[string]string{"mine": "parent", "parent": "", "freedesktop": ""},
+ "mine/stereo/bell.oga",
+ "mine/stereo/bell.wav",
+ "parent/stereo/message-new-instant.ogg",
+ "freedesktop/stereo/dialog-warning.oga",
+ "freedesktop/stereo/message.oga",
+ )
+ cases := []struct{ name, want string }{
+ {"bell", root + "/mine/stereo/bell.oga"}, // direct, .oga before .wav
+ {"message-new-instant", root + "/parent/stereo/message-new-instant.ogg"}, // inherited
+ {"dialog-warning", root + "/freedesktop/stereo/dialog-warning.oga"}, // freedesktop fallback
+ {"message-new-email", root + "/freedesktop/stereo/message.oga"}, // name fallback
+ {"nothing-here", ""},
+ }
+ for _, c := range cases {
+ if got := LookupSound(c.name, "mine"); got != c.want {
+ t.Errorf("LookupSound(%q) = %q, want %q", c.name, got, c.want)
+ }
+ }
+}
+
+func TestLookupSoundInheritsCycle(t *testing.T) {
+ soundTree(t, map[string]string{"a": "b", "b": "a"})
+ if got := LookupSound("bell", "a"); got != "" {
+ t.Errorf("got %q, want empty", got)
+ }
+}
+
+func TestSoundFromHints(t *testing.T) {
+ root := soundTree(t, map[string]string{"freedesktop": ""}, "freedesktop/stereo/bell.oga")
+ file := filepath.Join(t.TempDir(), "ding.wav")
+ if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ v := dbus.MakeVariant
+ cases := []struct {
+ name string
+ hints map[string]dbus.Variant
+ sound string
+ silent bool
+ }{
+ {"none", map[string]dbus.Variant{}, "", false},
+ {"name", map[string]dbus.Variant{"sound-name": v("bell")}, root + "/freedesktop/stereo/bell.oga", false},
+ {"file wins", map[string]dbus.Variant{"sound-name": v("bell"), "sound-file": v(file)}, file, false},
+ {"relative file rejected", map[string]dbus.Variant{"sound-file": v("ding.wav")}, "", false},
+ {"missing file rejected", map[string]dbus.Variant{"sound-file": v(file + ".gone")}, "", false},
+ {"bad file falls to name", map[string]dbus.Variant{"sound-file": v("x"), "sound-name": v("bell")}, root + "/freedesktop/stereo/bell.oga", false},
+ {"suppress", map[string]dbus.Variant{"suppress-sound": v(true), "sound-name": v("bell")}, root + "/freedesktop/stereo/bell.oga", true},
+ {"wrong types", map[string]dbus.Variant{"suppress-sound": v("yes"), "sound-name": v(3)}, "", false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ sound, silent := SoundFromHints(c.hints, "freedesktop")
+ if sound != c.sound || silent != c.silent {
+ t.Errorf("got (%q, %v), want (%q, %v)", sound, silent, c.sound, c.silent)
+ }
+ })
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+`go test ./internal/notify/ -run 'Sound'` → `undefined: LookupSound`.
+
+- [ ] **Step 3: Implement**
+
+`sounds.go` (GPL header):
+
+```go
+package notify
+
+import (
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// XDGSoundDirs is the sound theme search path: the user's dir then each data
+// dir, the same roots as XDGIconDirs.
+func XDGSoundDirs() []string {
+ dirs := XDGIconDirs()
+ for i, d := range dirs {
+ dirs[i] = filepath.Join(filepath.Dir(d), "sounds")
+ }
+ return dirs
+}
+
+var soundExts = []string{".oga", ".ogg", ".wav"}
+
+// LookupSound resolves a sound naming spec name: the theme, its Inherits
+// chain, then freedesktop, each searched across every root; within that, the
+// full name first and then with trailing -components dropped, as the spec's
+// fallback (message-new-email, message-new, message). "" when nothing matches.
+func LookupSound(name, theme string) string {
+ var chain []string
+ seen := map[string]bool{}
+ for t := theme; t != "" && !seen[t]; {
+ seen[t] = true
+ chain = append(chain, t)
+ next := ""
+ for _, root := range XDGSoundDirs() {
+ if v := inheritsOf(filepath.Join(root, t)); v != "" {
+ next = v
+ break
+ }
+ }
+ t = next
+ }
+ if !seen["freedesktop"] {
+ chain = append(chain, "freedesktop")
+ }
+ for n := name; n != ""; {
+ for _, t := range chain {
+ for _, root := range XDGSoundDirs() {
+ for _, ext := range soundExts {
+ p := filepath.Join(root, t, "stereo", n+ext)
+ if fileExists(p) {
+ return p
+ }
+ }
+ }
+ }
+ i := strings.LastIndexByte(n, '-')
+ if i < 0 {
+ break
+ }
+ n = n[:i]
+ }
+ return ""
+}
+
+// SoundFromHints reads the three freedesktop sound hints. sound-file wins over
+// sound-name, but only as an absolute path to an existing file: anything else
+// is logged and treated as absent, so the name still gets its turn. silent is
+// suppress-sound, published as is; the renderer decides what it means.
+func SoundFromHints(hints map[string]dbus.Variant, theme string) (sound string, silent bool) {
+ if v, ok := hints["suppress-sound"]; ok {
+ silent, _ = v.Value().(bool)
+ }
+ if v, ok := hints["sound-file"]; ok {
+ if p, _ := v.Value().(string); p != "" {
+ if filepath.IsAbs(p) && fileExists(p) {
+ return p, silent
+ }
+ log.Printf("notifyd: sound-file %q is not an absolute path to a file", p)
+ }
+ }
+ if v, ok := hints["sound-name"]; ok {
+ if n, _ := v.Value().(string); n != "" {
+ return LookupSound(n, theme), silent
+ }
+ }
+ return "", silent
+}
+```
+
+Note `XDGIconDirs` appends `/usr/local/share/icons` and `/usr/share/icons` only when `XDG_DATA_DIRS` is empty; the test sets it to a temp dir so the real system themes do not leak in. `inheritsOf` takes the first comma-separated value only, same as for icons.
+
+- [ ] **Step 4: Run, expect pass**
+
+`go test ./internal/notify/ -run 'Sound' -v`, then `go vet ./... && go test ./...`, `gofmt -l .` empty.
+
+- [ ] **Step 5: Commit**
+
+```
+feat(notify): resolve sound-file, sound-name and suppress-sound hints
+
+sound-name is looked up in a sound theme, its Inherits chain and
+freedesktop, with the naming spec's trailing-component fallback. A
+sound-file must be an absolute path to an existing file, else it is
+treated as absent so a sound-name in the same notification still applies.
+```
+
+---
+
+### Task 2: notifyd config and published fields
+
+**Files:** Modify `notifyd:internal/notify/config.go`, `config_test.go`, `store.go`, `service.go`, `README.md`.
+
+- [ ] **Step 1: Failing test**
+
+Append to `config_test.go`:
+
+```go
+func TestLoadConfigSoundTheme(t *testing.T) {
+ if got := LoadConfig(writeConfig(t, `{"soundTheme": "mine"}`)).SoundTheme; got != "mine" {
+ t.Errorf("soundTheme = %q, want mine", got)
+ }
+ if got := LoadConfig(writeConfig(t, `{"soundTheme": ""}`)).SoundTheme; got != "freedesktop" {
+ t.Errorf("empty soundTheme = %q, want freedesktop", got)
+ }
+ if got := LoadConfig(writeConfig(t, `{"soundTheme": 4, "liveCap": 3}`)); got.SoundTheme != "freedesktop" || got.LiveCap != 3 {
+ t.Errorf("got %+v", got)
+ }
+}
+```
+
+Run `go test ./internal/notify/ -run SoundTheme` → `got.SoundTheme undefined`.
+
+- [ ] **Step 2: Implement**
+
+`config.go`: add to `Config` after `HistoryCap`:
+
+```go
+ SoundTheme string `json:"soundTheme"`
+```
+
+and update its doc comment's list of what the daemon reads ("timeouts, caps and the sound theme for sound-name hints"). In `DefaultConfig()` add `SoundTheme: "freedesktop"`. In `LoadConfig`, after the timeout clamps:
+
+```go
+ if c.SoundTheme == "" {
+ c.SoundTheme = def.SoundTheme
+ }
+```
+
+`store.go`, `Popup`: add after `Image`:
+
+```go
+ Sound string `json:"sound"`
+ Silent bool `json:"silent"`
+```
+
+`service.go`, `Notify`: where it copies `timeouts` under `s.mu`, also copy the theme:
+
+```go
+ s.mu.Lock()
+ timeouts := s.cfg.Timeouts
+ theme := s.cfg.SoundTheme
+ s.mu.Unlock()
+```
+
+and in the `n := &Popup{...}` literal add nothing; after it:
+
+```go
+ n.Sound, n.Silent = SoundFromHints(hints, theme)
+```
+
+- [ ] **Step 3: Test**
+
+`go vet ./... && go test ./...`, `gofmt -l .` empty. `TestLoadConfigMissingFileIsDefaults` etc. compare whole structs and still pass because `DefaultConfig` carries the theme.
+
+- [ ] **Step 4: README**
+
+In `README.md`'s Configuration section, add `"soundTheme": "freedesktop"` to the JSON example and this paragraph after the caps one:
+
+```markdown
+`soundTheme` is the freedesktop sound theme an app's `sound-name` hint is
+looked up in (then its `Inherits`, then `freedesktop`). The daemon publishes
+each notification's resolved `sound` (a path, or `""`) and `silent` (the
+app's `suppress-sound`); a `sound-file` hint wins over `sound-name` when it
+is an absolute path to an existing file. Playing is the renderer's job.
+```
+
+- [ ] **Step 5: Commit, push**
+
+```
+feat(notify): publish each notification's sound and suppress-sound
+
+The sound-name lookup uses config.json's soundTheme, default freedesktop.
+The quickshell settings page mirrors that key to gsettings; the daemon
+only reads the file.
+```
+
+Then `git push`.
+
+- [ ] **Step 6: Build for the user**
+
+Build only, into the session scratchpad the controller gives you; never into `~/bin`:
+
+```bash
+go build -o <scratch>/notifyd ./cmd/notifyd && go build -o <scratch>/notifyctl ./cmd/notifyctl
+```
+
+---
+
+### Task 3: `NotifyConfig.js` sounds
+
+**Files:** Modify `shared/NotifyConfig.js`, `shared/test_notify_config.js`.
+
+- [ ] **Step 1: Failing test**
+
+In `test_notify_config.js`, export `soundFor` (`return { defaults, resolve, withheld, soundFor };`) and add before `console.log("ok")`:
+
+```js
+// Sounds: strings only, empty theme falls back.
+const s = C.resolve({ sounds: { low: "/a.oga", normal: 3 }, soundTheme: "" });
+assert.deepStrictEqual(s.sounds, { low: "/a.oga", normal: "", critical: "" });
+assert.strictEqual(s.soundTheme, "freedesktop");
+assert.strictEqual(C.resolve({ soundTheme: "mine" }).soundTheme, "mine");
+
+// soundFor: silent beats everything, the app's sound beats the urgency's.
+const sc = C.resolve({ sounds: { low: "", normal: "/n.oga", critical: "/c.oga" } });
+const sn = (urgency, sound, silent) => ({ app: "x", urgency, sound, silent });
+assert.strictEqual(C.soundFor(sc, sn("normal", "", false)), "/n.oga");
+assert.strictEqual(C.soundFor(sc, sn("low", "", false)), "");
+assert.strictEqual(C.soundFor(sc, sn("low", "/app.oga", false)), "/app.oga");
+assert.strictEqual(C.soundFor(sc, sn("critical", "/app.oga", true)), "");
+// An old daemon publishes neither field.
+assert.strictEqual(C.soundFor(sc, { app: "x", urgency: "critical" }), "/c.oga");
+```
+
+Run `node shared/test_notify_config.js` → assertion failure on `s.sounds`.
+
+- [ ] **Step 2: Implement**
+
+In `defaults()` add:
+
+```js
+ sounds: { low: "", normal: "", critical: "" },
+ soundTheme: "freedesktop",
+```
+
+In `resolve`, read `const s = raw.sounds || {};` beside `t` and `b`, and add to the returned object:
+
+```js
+ sounds: {
+ low: typeof s.low === "string" ? s.low : d.sounds.low,
+ normal: typeof s.normal === "string" ? s.normal : d.sounds.normal,
+ critical: typeof s.critical === "string" ? s.critical : d.sounds.critical,
+ },
+ soundTheme: typeof raw.soundTheme === "string" && raw.soundTheme !== "" ? raw.soundTheme : d.soundTheme,
+```
+
+Append:
+
+```js
+// The file a balloon plays, "" for none: the app's suppress-sound wins, then
+// the app's own sound (notifyd resolved its hints), then the urgency's.
+function soundFor(cfg, n) {
+ if (n.silent) return "";
+ if (n.sound) return n.sound;
+ return cfg.sounds[n.urgency] || "";
+}
+```
+
+Update the header comment's mention of what is "this side's alone" to include `sounds`.
+
+- [ ] **Step 3: Pass, commit**
+
+`node shared/test_notify_config.js` → `ok`.
+
+```
+notifications: resolve the sound keys and choose a balloon's sound
+```
+
+---
+
+### Task 4: balloon shell plays
+
+**Files:** Modify `notifications/Balloons.qml`.
+
+- [ ] **Step 1: Implement**
+
+Add `import "NotifyConfig.js" as NC` (the link `notifications/NotifyConfig.js` exists). Inside the root `Scope`, after `readonly property var live`:
+
+```qml
+ // A sound per new or replaced notification (replace keeps the id and
+ // changes created), played only when its balloon would show now: the
+ // same drawer, snooze, mute and DND checks as NotificationBalloon. The
+ // first read after a start only records what is there, so a restart is
+ // silent. seen is rebuilt from the queue each time, so it never grows.
+ property var seen: ({})
+ property bool primed: false
+ Connections {
+ target: Notify
+ function onQueueChanged() {
+ const next = {};
+ let file = "";
+ for (const n of Notify.queue || []) {
+ const key = n.id + ":" + n.created;
+ next[key] = true;
+ if (!root.primed || root.seen[key] || file !== "") continue;
+ if (Notify.drawerOpen || Notify.snoozeUntil > Date.now()) continue;
+ if (NC.withheld(Notify.config, n, Notify.dnd)) continue;
+ file = NC.soundFor(Notify.config, n);
+ }
+ root.seen = next;
+ root.primed = true;
+ if (file !== "") {
+ // ponytail: one player, a new sound cuts the last; a queue of
+ // sounds if bursts ever need every one heard.
+ player.command = ["pw-play", file];
+ player.running = false;
+ player.running = true;
+ }
+ }
+ }
+ Process { id: player }
+```
+
+The queue is newest first, so the first unseen, unwithheld entry with a sound is the newest. `Quickshell.Io` is already imported for `FileView`.
+
+- [ ] **Step 2: Check reload**
+
+`qs log -p notifications --log-times | tail -5`: a `Reloading configuration` after the edit, no errors. `shared/` is untouched in this task so the hot reload is enough for this file; `Notify.qml` is unchanged.
+
+- [ ] **Step 3: Commit**
+
+```
+notifications: play a sound for a new balloon
+
+pw-play, once per new or replaced notification, only when the balloon
+would show: the drawer, snooze, mute and DND that withhold the balloon
+silence it too. A restart records the queue without playing it.
+```
+
+---
+
+### Task 5: settings page
+
+**Files:** Modify `desktop/modules/notifications/NotificationsModule.qml`, `NotificationsPage.qml`.
+
+Read both files first; they changed after the first plan (draft binding via `follow()`, nested merge in `save()`, `busy` on the SaveBar, Field rebinding, red/dim chips).
+
+- [ ] **Step 1: Module: themes, gsettings, save**
+
+In `NotificationsModule.qml`:
+
+1. Properties:
+
+```qml
+ // The installed sound themes (directories with an index.theme), and the
+ // system theme as gsettings has it, "" when unreadable. Both reread on
+ // every refresh, so a change made outside the page shows.
+ property var themes: []
+ property string systemTheme: ""
+```
+
+2. `refresh()` becomes:
+
+```qml
+ function refresh() {
+ if (!nm.dirty) nm.follow();
+ nm.themeLister.running = false; nm.themeLister.running = true;
+ nm.gsGet.running = false; nm.gsGet.running = true;
+ }
+```
+
+3. Processes:
+
+```qml
+ property Process themeLister: Process {
+ command: ["sh", "-c", 'find "$HOME/.local/share/sounds" /usr/share/sounds -mindepth 2 -maxdepth 2 -name index.theme 2>/dev/null']
+ stdout: StdioCollector {
+ onStreamFinished: nm.themes = [...new Set(text.split("\n").filter(l => l)
+ .map(l => l.split("/").slice(-2)[0]))].sort()
+ }
+ }
+ // gsettings prints the string quoted: 'freedesktop'.
+ property Process gsGet: Process {
+ command: ["gsettings", "get", "org.gnome.desktop.sound", "theme-name"]
+ stdout: StdioCollector {
+ onStreamFinished: nm.systemTheme = text.trim().replace(/^'(.*)'$/, "$1")
+ }
+ onExited: code => { if (code !== 0) nm.systemTheme = ""; }
+ }
+```
+
+4. `save()`: add `sounds` to the one-level merge beside `timeouts` and `balloons`:
+
+```qml
+ sounds: Object.assign({}, obj(raw.sounds), nm.draft.sounds),
+```
+
+5. The saver: the theme goes to gsettings between the write and the reload. Replace its `command` and `onExited`:
+
+```qml
+ command: ["sh", "-c",
+ 'mkdir -p "$(dirname "$1")" && cat > "$1" || exit 1; '
+ + 'gsettings set org.gnome.desktop.sound theme-name "$2" || g=3; '
+ + 'notifyctl reload || exit 2; exit ${g:-0}',
+ "sh", Notify.configPath, nm.saver.theme]
+ property string theme: ""
+ onExited: code => {
+ nm.notice = code === 0 ? "saved"
+ : code === 2 ? "saved; notifyd not reachable, it reads the file at its next start"
+ : code === 3 ? "saved; the system sound theme could not be set"
+ : "could not write " + Notify.configPath;
+ nm.gsGet.running = false; nm.gsGet.running = true;
+ }
+```
+
+and in `save()` set `nm.saver.theme = nm.draft.soundTheme;` before starting it. Update the comment above `save()` to say 3 is the gsettings write.
+
+- [ ] **Step 2: Page: Sounds section**
+
+In `NotificationsPage.qml` add `import Qt.labs.platform as P` and `import Quickshell.Io`, and a Sounds section right after the Timeouts `Repeater`:
+
+```qml
+ Heading { text: "Sounds" }
+ Text {
+ width: pg.width; wrapMode: Text.Wrap
+ text: "Played when a balloon pops, never for one muted, held by Do Not Disturb or snoozed. An app's own sound, or its request for silence, 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
+ readonly property string file: pg.d.sounds[modelData[0]]
+ Text {
+ width: 90; anchors.verticalCenter: parent.verticalCenter
+ text: modelData[1]; color: Theme.text
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Text {
+ width: 180; anchors.verticalCenter: parent.verticalCenter
+ elide: Text.ElideMiddle
+ text: parent.file ? parent.file.split("/").pop() : "none"
+ color: parent.file ? Theme.text : Theme.overlay
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ }
+ Button {
+ text: "Choose…"
+ onClicked: { picker.key = modelData[0]; picker.open(); }
+ }
+ Button {
+ icon: "" // nf-fa-play
+ enabled: parent.file !== ""
+ onClicked: { preview.command = ["pw-play", parent.file]; preview.running = false; preview.running = true; }
+ }
+ Button {
+ icon: ""
+ enabled: parent.file !== ""
+ onClicked: pg.mod.edit({ sounds: Object.assign({}, pg.d.sounds, { [modelData[0]]: "" }) })
+ }
+ }
+ }
+ Text {
+ topPadding: 6
+ text: "Theme for sounds apps ask for by name, also set as the system sound theme"
+ color: Theme.subtext
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4; bold: true }
+ }
+ Flow {
+ width: pg.width; spacing: 6
+ Repeater {
+ model: pg.mod.themes
+ Button {
+ required property string modelData
+ text: modelData
+ border.color: pg.d.soundTheme === modelData ? Theme.accent : Qt.alpha(Theme.text, 0.2)
+ onClicked: pg.mod.edit({ soundTheme: modelData })
+ }
+ }
+ }
+ Row {
+ spacing: 10
+ visible: pg.mod.systemTheme !== "" && pg.mod.systemTheme !== pg.d.soundTheme
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ text: "System sound theme is " + pg.mod.systemTheme + ", changed outside this page"
+ color: Theme.yellow
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ }
+ Button {
+ text: "Use " + pg.mod.systemTheme
+ onClicked: pg.mod.edit({ soundTheme: pg.mod.systemTheme })
+ }
+ }
+
+ Process { id: preview }
+ // Qt.labs.platform, not QtQuick.Dialogs: the Quick dialog opens light on
+ // this desktop (AGENTS.md); this one takes the widget style.
+ P.FileDialog {
+ id: picker
+ property string key: ""
+ folder: "file://" + Quickshell.env("HOME") + "/.local/share/sounds"
+ nameFilters: ["Sounds (*.oga *.ogg *.wav)"]
+ onAccepted: pg.mod.edit({ sounds: Object.assign({}, pg.d.sounds,
+ { [picker.key]: decodeURIComponent(file.toString().replace(/^file:\/\//, "")) }) })
+ }
+```
+
+`Quickshell` is already imported by the page (check; add `import Quickshell` if not). Check the play glyph `` exists: `fc-list ':charset=f04b' family | grep -ci inconsolata` must be non-zero; if not, pick another play glyph that is.
+
+Note the yellow line hides when gsettings is unreadable (`systemTheme === ""`) and when it agrees with the draft, so "Use x" clears it immediately and Save then sets both.
+
+- [ ] **Step 3: Check it loads**
+
+Edit `desktop/shell.qml`? Not needed: no new file, both edited files reload on save. Check `qs log -p desktop --log-times | tail -20` for a reload after the edits and no `TypeError`, `ReferenceError`, `is not a type`, `Unable to assign`, `Cannot assign`. Open the page through IPC if a target exists (`qs -p desktop ipc show`) and recheck the log.
+
+- [ ] **Step 4: Commit, push**
+
+```
+desktop: notification sounds on the settings page
+
+A sound per urgency chosen with the widget file dialog, previewed with
+pw-play, and the sound theme for apps' sound-name hints. Save writes the
+theme to gsettings as well, and the page reads gsettings back on every
+open so a change made elsewhere is shown, with a button to adopt it,
+rather than overwritten.
+```
+
+---
+
+### Task 6: docs
+
+**Files:** `desktop/modules/notifications/README.md`, `notifications/README.md`.
+
+- [ ] **Step 1:** Module README: add `sounds` (balloon shell) and `soundTheme` (notifyd, mirrored to gsettings by the page) rows to the key table, and a paragraph: the page reads gsettings on every open and flags a mismatch; the save exit codes (1 write, 3 gsettings, 2 reload).
+- [ ] **Step 2:** Balloon README: a "Sounds" section: played with `pw-play` once per new or replaced notification, only when the balloon would show; precedence `silent` > the app's `sound` > `sounds[urgency]`; a restart is silent; one player, a new sound cuts the last.
+- [ ] **Step 3:** Commit `docs: notification sounds`, push.