aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-10-16-notes.md415
-rw-r--r--docs/superpowers/specs/2026-10-16-notes-design.md236
2 files changed, 651 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-16-notes.md b/docs/superpowers/plans/2026-10-16-notes.md
new file mode 100644
index 0000000..9c04d53
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-16-notes.md
@@ -0,0 +1,415 @@
+# Notes Module 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:** Add a `notes` drawer tile and page that browses, writes, edits and deletes `bash-notes` notes in the drawer, syncing to git on save.
+
+**Architecture:** One drawer module (`NotesModule.qml`) that resolves `bash-notes`' own config, owns `db.json` through a watched `FileView`, and exposes CRUD and git; one page (`NotesPage.qml`) with a list state and an editor state; pure helpers in `Notes.js`, tested under node.
+
+**Tech Stack:** Quickshell 0.3.1 QML (`Quickshell.Io.FileView` / `Process` / `StdioCollector`), QtQuick (`Text`, `TextEdit` with `TextEdit.MarkdownText`, `ListView`, `TextField`, QtQuick.Controls), node for the helper test.
+
+**Spec:** `docs/superpowers/specs/2026-10-16-notes-design.md`
+
+## Global Constraints
+
+- GPLv2 only. Every created `.qml` and `.js` file opens with the repo's header notice, copied verbatim from a sibling file (e.g. `desktop/modules/clipboard/Clip.js`).
+- No home paths in committed files (a gitleaks hook blocks them and has been right every time). Build every path from `Quickshell.env("HOME")`; a literal `~` is fine only in the README.
+- Colours and fonts come from `Theme` only; no local palette. Glyphs use `Theme.iconFamily`; the tile glyph is `\uf249` (`sticky-note`), verified present in Inconsolata Nerd Font.
+- `id`, `title` and `file` are stored in `db.json` as **strings**.
+- File writes go through `FileView` with `atomicWrites: true`.
+- Component notes go in the module `README.md`, not `AGENTS.md`.
+- Commits are GPG-signed by the global config; a plain `git commit` is enough.
+
+## Review Focus
+
+1. A `notes` entry with a missing or non-numeric `file` must not throw in sorting or render a bogus date; it still lists and opens.
+2. git off (`USEGIT` not `true`, or empty `GITREMOTE`): Save writes and returns in one click, Update list is absent, and no git `Process` runs.
+3. An unconfirmed Save (second click never made), then another create or Update list: the pull must not be blocked by the uncommitted `db.json`.
+4. Leaving the editor with unsaved text (Cancel, back arrow, Escape, click outside): the draft is dropped and no partial note is written.
+5. Opening a note whose body file is missing: an error line shows; nothing crashes and nothing is overwritten.
+
+---
+
+### Task 1: Pure `db.json` helpers
+
+**Files:**
+- Create: `desktop/modules/notes/Notes.js`
+- Test: `desktop/modules/notes/test_notes.js`
+
+**Interfaces:**
+- Produces, all pure, `.pragma library`:
+ `parse(text) -> object`, `stringify(db) -> string`, `sorted(notes) -> array`,
+ `formatDate(file) -> string`, `nextId(notes) -> string`, `filter(notes, query) -> array`,
+ `addNote(db, title, file) -> object`, `updateNote(db, id, title) -> object`,
+ `removeNote(db, id) -> object`, `setLastpull(db, now) -> object`.
+- `parse` returns the whole object, `{ notes: [] }` for invalid JSON, a non-object, or a missing `notes` array.
+- `sorted` copies (never reorders the input), sorts numeric on `file` descending, and puts unusable entries last in input order.
+- `addNote`, `updateNote`, `removeNote`, `setLastpull` return a new object via spread and never mutate the input; every key other than the one they change carries through.
+
+- [ ] **Step 1: Write the failing test**
+
+`test_notes.js`, in the style of `desktop/modules/clipboard/test_clip.js`: strip the pragma, evaluate with `new Function(src + "\nreturn { ... };")()`, `assert`, end with `console.log("test_notes: ok")`.
+
+```js
+const db = {
+ params: { version: "0.4git", dbversion: "0.4git_1" },
+ git: { lastpull: "1700000000" },
+ notes: [
+ { id: "1", title: "a", file: "999" },
+ { id: "2", title: "b", file: "1000" },
+ { id: "3", title: "c", file: "300" },
+ ],
+};
+
+// numeric, not lexicographic: 1000 > 999 > 300
+assert.deepStrictEqual(N.sorted(db.notes).map(n => n.id), ["2", "1", "3"]);
+assert.strictEqual(db.notes[0].id, "1"); // input not reordered
+
+// a missing or non-numeric file sorts last, input order, and formats as ""
+const bad = [{ id: "1", title: "x" }, { id: "2", title: "y", file: "abc" },
+ { id: "3", title: "z", file: "10" }];
+assert.deepStrictEqual(N.sorted(bad).map(n => n.id), ["3", "1", "2"]);
+assert.strictEqual(N.formatDate(undefined), "");
+assert.strictEqual(N.formatDate("abc"), "");
+assert.match(N.formatDate("1000"), /^\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}$/);
+
+assert.strictEqual(N.nextId(db.notes), "4");
+assert.strictEqual(N.nextId([]), "1");
+
+// the whole object round-trips; params and git are kept
+assert.deepStrictEqual(N.parse(N.stringify(db)), db);
+assert.deepStrictEqual(N.parse("not json"), { notes: [] });
+assert.deepStrictEqual(N.parse('{"a":1}'), { notes: [] });
+
+// pure transforms preserve everything else and do not mutate
+const added = N.addNote(db, "T", "1750");
+assert.strictEqual(added.notes.length, 4);
+assert.deepStrictEqual(added.notes[3], { id: "4", title: "T", file: "1750" });
+assert.strictEqual(db.notes.length, 3);
+assert.deepStrictEqual(added.params, db.params);
+assert.deepStrictEqual(added.git, db.git);
+assert.deepStrictEqual(N.removeNote(added, "4"), db);
+assert.strictEqual(N.updateNote(db, "2", "Renamed").notes[1].title, "Renamed");
+assert.deepStrictEqual(N.updateNote(db, "99", "x"), db);
+assert.strictEqual(N.setLastpull(db, 1234).git.lastpull, "1234");
+assert.strictEqual(db.git.lastpull, "1700000000");
+
+assert.deepStrictEqual(N.filter(db.notes, " A ").map(n => n.id), ["1"]);
+assert.strictEqual(N.filter(db.notes, "").length, 3);
+```
+
+- [ ] **Step 2: Run it, watch it fail**
+
+Run: `node desktop/modules/notes/test_notes.js`
+Expected: FAIL, `Cannot find module .../Notes.js` (or `parse is not a function`).
+
+- [ ] **Step 3: Implement `Notes.js`**
+
+Write the ten functions to the signatures above. `parse` guards with try/catch and checks the object shape; `sorted` uses `Number(n.file)` with `NaN` pushed behind finite values; `stringify` is `JSON.stringify(db, null, 2) + "\n"`; the transforms spread `db` and its `notes`/`git`.
+
+- [ ] **Step 4: Run it, watch it pass**
+
+Run: `node desktop/modules/notes/test_notes.js`
+Expected: `test_notes: ok`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/notes/Notes.js desktop/modules/notes/test_notes.js
+git commit -m "notes: pure db.json helpers, node-tested"
+```
+
+---
+
+### Task 2: Module and read-only list page
+
+**Files:**
+- Create: `desktop/modules/notes/NotesModule.qml`
+- Create: `desktop/modules/notes/NotesPage.qml` (list state only)
+- Modify: `desktop/shell.qml` (add `import "modules/notes"` with the other module imports, and `NotesModule {}` in the `modules` list)
+
+**Interfaces:**
+- Consumes: `Notes.js` (Task 1).
+- Produces, on the module (`id: mod`): `baseDir`, `dbPath`, `notesDir` (strings), `gitEnabled` (bool), `client` (string), `ready` (bool), `db` (object), `notes` (array, sorted), `count` (int), `lastError` (string), `reparse()`.
+- Produces `NotesPage`: a required `property QtObject owner`.
+
+- [ ] **Step 1: Write `NotesModule.qml`**
+
+`Module { id: mod; name: "notes"; label: "Notes"; alwaysActive: true }` in `desktop/modules/notes/`, `import "../.."` and `import "Notes.js" as Notes`.
+
+Resolve config with one Process, then let the `db.json` `FileView` load. The shell is built from **single-quoted** JS strings so `${...}` is not interpolated by the JS engine:
+
+```qml
+Process {
+ id: cfg
+ running: true
+ command: ["sh", "-c",
+ '[ -r "$HOME/.config/bash-notes.rc" ] && . "$HOME/.config/bash-notes.rc"\n' +
+ 'base="${BASEDIR:-$HOME/.local/share/bash-notes}"\n' +
+ 'git=0; [ "${USEGIT:-false}" = "true" ] && [ -n "${GITREMOTE:-}" ] && git=1\n' +
+ 'printf "%s\\n" "$base" "${DB:-$base/db.json}" "${NOTESDIR:-$base/notes}" "$git" "${GITCLIENT:-$(hostname)}"']
+ stdout: StdioCollector { onStreamFinished: mod.applyConfig(this.text) }
+}
+```
+
+`applyConfig(text)` splits on `\n` into `baseDir`, `dbPath`, `notesDir`, `gitEnabled` (`"1"`), `client`.
+
+```qml
+FileView {
+ id: dbView
+ path: mod.dbPath
+ atomicWrites: true
+ watchChanges: true
+ printErrors: false
+ onFileChanged: mod.reparse()
+ onLoaded: mod.reparse()
+ onLoadFailed: if (mod.dbPath !== "") mod.reparse() // missing store reads as empty
+}
+```
+
+`reparse()` sets `mod.db = Notes.parse(dbView.text())` and `mod.ready = true` (but does nothing while `dbPath === ""`, so `ready` only turns true after config resolves). `count` reads `Array.isArray(mod.db.notes) ? mod.db.notes.length : 0`; `notes` is `Notes.sorted(mod.db.notes ?? [])`. `lastError` starts `""`.
+
+Tile, mirroring `MailTile`'s shape inline (the `mod` id resolves inside a `Component` declared in this file, as `gm` does in `GitModule.qml`):
+
+```qml
+tileContent: Component {
+ Text {
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.subtext
+ text: !mod.ready ? "…"
+ : mod.count === 0 ? "empty"
+ : mod.count === 1 ? "1 note" : mod.count + " notes"
+ }
+}
+```
+
+Page: `page: Component { Page { title: "Notes"; NotesPage { width: parent.width; height: parent.height - 57; owner: mod } } }` — the `- 57` is the header plus its rule, the same arithmetic `ClipboardModule` uses.
+
+- [ ] **Step 2: Write `NotesPage.qml`, list state**
+
+`Item { id: page; required property QtObject owner }`, `import "../.."` and `import "Notes.js" as Notes`. Layout mirrors `ClipboardPage`:
+
+- A top `Row` with a `TextField` (`id: search`, placeholder `Search`, `Component.onCompleted: forceActiveFocus()`, the same styled `background` Rectangle as the clipboard search field).
+- Below it a `ListView` bound to `Notes.filter(page.owner.notes, search.text)`, `clip: true`, `spacing: 4`, `ScrollBar.vertical: ScrollBar {}`, an empty-state `Text` reading `page.owner.ready ? "No notes" : "Loading…"`.
+- Each row is a `Rectangle` (height 36, radius 8, the clipboard row's hover colour) holding the title (elided, `Theme.text`) and the date `Notes.formatDate(modelData.file)` (right-aligned, `Theme.overlay`), with a `MouseArea onClicked` left as a no-op for this task.
+- A `Text` under the list bound to `page.owner.lastError`, `visible: page.owner.lastError !== ""`, `color: Theme.red` (unused until Task 3, but the binding is set now).
+
+- [ ] **Step 3: Register**
+
+In `desktop/shell.qml`, add `import "modules/notes"` alongside the other module imports and `NotesModule {},` in the `modules:` list.
+
+- [ ] **Step 4: Verify**
+
+From your own terminal (not an agent tool call: a detached `qs` dies with the call, see `AGENTS.md`) run `./reload.sh desktop`. The drawer's grid gains a Notes tile reading `N notes` or `empty`, matching the count in `~/.local/share/bash-notes/db.json`. Opening it lists the notes newest first with their dates, and typing filters by title. A new module directory also needs the import change in `shell.qml` to be seen at all; if the tile does not appear, restart the shell.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/notes/NotesModule.qml desktop/modules/notes/NotesPage.qml desktop/shell.qml
+git commit -m "notes: drawer tile with count and the note list"
+```
+
+---
+
+### Task 3: Editor and the write/git path
+
+**Files:**
+- Modify: `desktop/modules/notes/NotesModule.qml`
+- Modify: `desktop/modules/notes/NotesPage.qml`
+
+**Interfaces:**
+- Module adds: `create(title, body, done)`, `update(id, title, body, done)`, `remove(id, done)`, `pull(done)`, `commitPush(kind, id, done)` (`kind` is `"create"`/`"edit"`/`"delete"`; the module builds the message from `kind` and `client`), `writeDb(db)`, `writeBody(path, text, done)`; `lastCreatedId` (string); `gitOutput` (string); `syncing` (bool) while a git command runs. Each `done(ok, message)` is optional.
+- Page adds: `view` (`"list"` | `"editor"`), `editing` (the note object or `null`), and the editor controls.
+
+- [ ] **Step 1: Add the write path to the module**
+
+`db.json` writes reuse the existing `dbView`: `mod.writeDb(db)` is `dbView.setText(Notes.stringify(db))`.
+
+Body writes use a second `FileView` with a pending-write, because `setText` on a `FileView` that has not completed a load of the target path writes empty (`appearance/Icons.qml` documents this):
+
+```qml
+FileView {
+ id: bodyWrite
+ printErrors: false
+ property var pending: null
+ function queue(path, text, done) {
+ bodyWrite.pending = { path: path, text: text, done: done };
+ bodyWrite.path = path; // retarget; load attempt follows
+ }
+ function commit() {
+ if (bodyWrite.pending === null) return;
+ const p = bodyWrite.pending;
+ bodyWrite.pending = null;
+ bodyWrite.setText(p.text);
+ if (p.done) p.done(true, "");
+ }
+ onLoaded: commit()
+ onLoadFailed: commit()
+}
+```
+
+After `setText` the file watch fires `onLoaded` again; `pending` is null, so `commit` does nothing (the self-write rule `Status.qml` keeps).
+
+- [ ] **Step 2: Add the git path to the module**
+
+Two `Process`es. A git command is retriggered with `running = false` then `running = true`, never a bare reassignment (`AGENTS.md`, `Status.qml`).
+
+```qml
+Process {
+ id: gitPull
+ property string after: "" // "create" | "stamp"
+ property string title: ""
+ property string body: ""
+ property var done: null
+ onExited: code => {
+ mod.lastError = code === 0 ? "" : mod.gitOutput;
+ const a = gitPull.after; gitPull.after = "";
+ if (a === "create") mod.finishCreate(code, gitPull.title, gitPull.body, gitPull.done);
+ else if (a === "stamp") mod.stampPull(gitPull.done);
+ }
+ stdout: StdioCollector { onStreamFinished: mod.gitOutput = this.text }
+}
+
+Process {
+ id: gitSync
+ property var done: null
+ onExited: code => {
+ mod.syncing = false;
+ mod.lastError = code === 0 ? "" : mod.gitOutput;
+ if (gitSync.done) { const d = gitSync.done; gitSync.done = null; d(code === 0); }
+ }
+ stdout: StdioCollector { onStreamFinished: mod.gitOutput = this.text }
+}
+```
+
+`mod.gitOutput` is shared by both, which is safe because the two never run at once (a second click while `syncing` is ignored).
+
+Chains, exactly as the spec's Git section:
+
+- Pull: `gitPull.command = ["sh", "-c", 'git -C "$1" pull --rebase --autostash 2>&1', "sh", mod.baseDir]` (the directory is `$1`, never in the command string), with `stdout: StdioCollector { onStreamFinished: mod.gitOutput = this.text }`.
+- Sync: `gitSync.command = ["sh", "-c", 'git -C "$1" add . && { git -C "$1" commit -m "$2" || true; } && git -C "$1" push origin HEAD 2>&1', "sh", mod.baseDir, message]` — the message is `$2`, never inside the command string.
+
+Both fold stderr into stdout with `2>&1` so one collector holds the reason. **`StdioCollector.onStreamFinished` can fire after `Process.onExited`**, so `onExited` must not assume the text is ready: the collector stores it in `mod.gitOutput`, and `onExited` reads that property (and the exit code) rather than the collector directly. Show `mod.gitOutput` in `lastError` on a non-zero code.
+
+Messages come from one `msg(kind, id)` in the module: create `"quickshell - adding note from " + mod.client`; edit `"quickshell - " + mod.client + " note edited."`; delete `"quickshell - " + mod.client + " removing note ID " + id + "."`
+
+`pull(done)` sets `gitPull.after = "stamp"` and starts; `stampPull()` reloads `dbView`, writes `Notes.setLastpull(Notes.parse(dbView.text()), Math.floor(Date.now()/1000))`, then calls `done`.
+
+`create(title, body, done)`: if `!mod.gitEnabled`, call `finishCreate(0, title, body, done)` directly. Otherwise stash `title`/`body`/`done` on `gitPull`, set `after = "create"`, start it. `finishCreate(code, title, body, done)` **re-reads** `dbView` after the pull:
+
+```js
+dbView.reload();
+const db = Notes.parse(dbView.text());
+const now = Math.floor(Date.now() / 1000);
+const file = String(now);
+const next = Notes.setLastpull(Notes.addNote(db, title, file), now);
+mod.lastCreatedId = next.notes[next.notes.length - 1].id;
+mod.writeDb(next);
+mod.writeBody(mod.notesDir + "/" + file, body, () => { if (done) done(true, ""); });
+```
+
+`update(id, title, body, done)`: no pull. `mod.writeDb(Notes.updateNote(Notes.parse(dbView.text()), id, title))`, then `mod.writeBody(mod.notesDir + "/" + fileFor(id), body, done)` (`fileFor` scans `mod.notes`).
+
+`remove(id, done)`: no pull. `mod.writeDb(Notes.removeNote(...))`, then `Quickshell.execDetached(["rm", "--", mod.notesDir + "/" + fileFor(id)])` (the spec's one filesystem call, `FileView` having no delete), then `done`.
+
+`commitPush(kind, id, done)`: ignore the click while `mod.syncing`; set `mod.syncing = true`; set `gitSync.done = done`; set `gitSync.command` with the `msg(kind, id)` message as `$2`; retrigger.
+
+`finishCreate` also records the new note's id in `mod.lastCreatedId` (the last entry `Notes.addNote` appended), so a second click on a brand-new note can name it.
+
+- [ ] **Step 3: Add the editor to the page**
+
+`view` starts `"list"`. A row click calls `open(note)`:
+
+```js
+function open(note) { // note === null for New note
+ page.editing = note;
+ page.titleText = note ? note.title : "";
+ page.body = "";
+ page.preview = note !== null;
+ page.view = "editor";
+}
+```
+
+Body read: one more `FileView` in the page, `path: page.editing ? page.owner.notesDir + "/" + page.editing.file : ""`, `onLoaded: page.body = text()`, `onLoadFailed: { page.body = ""; page.bodyError = page.editing ? "note file missing" : ""; }`.
+
+Controls, all reusing `desktop/Button.qml`:
+
+- List header `Row`: the search field, **New note** (`page.open(null)`), and, `visible: page.owner.gitEnabled`, **Update list** → `page.owner.pull()` (the button text becomes `Updated` for 2s on success, or `page.owner.lastError` shows under it).
+- Editor: a `Cancel` button returning to `"list"`; a title `TextField` bound to `page.titleText`; an **Edit / Preview** pair of buttons toggling `page.preview` (a two-button segmented look: the active one accents); the body as a `TextEdit` (`readOnly: page.preview`, `textFormat: page.preview ? TextEdit.MarkdownText : TextEdit.PlainText`, `wrapMode: TextEdit.Wrap`, `selectByMouse: true`, `onLinkActivated` restricted to `http`/`https`/`mailto` as `Segments.qml` does) with a `Text` in front of it when `page.bodyError !== ""`.
+- The body `TextEdit` height: the page's `height` minus the header controls, as `ClipboardPage` sizes itself, so the `Page`'s outer `ScrollView` never scrolls.
+
+Save is two clicks with a `Timer` for the armed state, the shape `ClipboardPage`'s "Clear all" uses. The first click sends only the local write; the second only runs git.
+
+```js
+function save() {
+ page.owner.lastError = "";
+ if (!confirm.running) {
+ const done = (ok, msg) => { if (!ok) page.owner.lastError = msg; };
+ if (page.editing) page.owner.update(page.editing.id, page.titleText, page.body, done);
+ else page.owner.create(page.titleText, page.body, done);
+ if (!page.owner.gitEnabled) { page.view = "list"; return; } // one click, git off
+ confirm.start();
+ return;
+ }
+ confirm.stop();
+ const kind = page.editing ? "edit" : "create";
+ const id = page.editing ? page.editing.id : page.owner.lastCreatedId;
+ page.owner.commitPush(kind, id, () => { page.view = "list"; });
+}
+```
+
+(`lastCreatedId` is set by `finishCreate`, so the second click on a brand-new note can name it.)
+
+Delete, existing notes only, is the same two clicks: the first calls `page.owner.remove(page.editing.id, done)`, the second `page.owner.commitPush("delete", page.editing.id, () => page.view = "list")`. With git off the first click removes and returns to the list.
+
+
+- [ ] **Step 4: Verify**
+
+Reload with `./reload.sh desktop` from your terminal, then check, in order:
+
+1. **New note**: Edit mode, typed title and body; first click reads `Commit & push?`, second commits — `git -C ~/.local/share/bash-notes log -1 --stat` shows the note file and `db.json` in one commit with the `quickshell - adding note from …` message.
+2. **Round trip with the CLI**: `notes.sh -l` lists the note you wrote; `notes.sh -p <id>` or `notes.sh <id>` shows its body.
+3. **A CLI note opens here**: create one with `notes.sh -a "cli note"`, then open it in the drawer, confirm Preview renders it and the row dates sort right.
+4. **git off**: set `USEGIT=false` in the rc (or empty `GITREMOTE`), reload, and confirm Save is one click and Update list is gone, then restore the rc.
+5. **Unconfirmed save then pull**: write a note, click Save once, do not confirm, then press Update list — it must complete, not report `cannot pull with rebase`.
+6. **Unsaved edits dropped**: type into the editor, click Cancel (and separately Escape), and confirm the list is unchanged and no new file appeared in `notes/`.
+7. **Missing body**: `rm` one note's body file behind the page's back, open its row, and confirm the error line shows rather than a crash.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add desktop/modules/notes/NotesModule.qml desktop/modules/notes/NotesPage.qml
+git commit -m "notes: in-drawer editor with commit-on-save and git pull"
+```
+
+---
+
+### Task 4: Component README
+
+**Files:**
+- Create: `desktop/modules/notes/README.md`
+
+**Interfaces:**
+- Consumes: everything above; nothing consumes it.
+
+- [ ] **Step 1: Write the README**
+
+Mirror `desktop/modules/clipboard/README.md`'s shape: what the module is, that the store is `notes.sh`'s own under `BASEDIR` and both tools share it, the two-click Save and the git order and why it differs from `notes.sh` (the page's write and commit are two clicks, hence `--autostash`), that `lastpull` is stamped after the pull, that unsaved text is discarded on leaving, and the node test line `node desktop/modules/notes/test_notes.js`. Note the endpoint of the spec's "Not in scope": no `$EDITOR`, no backup/restore, no export, no pull on open.
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add desktop/modules/notes/README.md
+git commit -m "notes: component README"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** store and string ids (Task 1, `parse`/`addNote`); config sourcing (Task 2); icon, tile count, module (Task 2); list, search, New, Update list (Tasks 2–3); editor, Preview/Edit, Markdown, Cancel (Task 3); writes and the create pull-then-write (Task 3); git order, messages, `lastpull` (Task 3); files and registration (Tasks 1–3); testing (Task 1); README (Task 4). Nothing in the spec is unowned.
+
+**Known looseness, deliberately left to the implementer:** the exact pixel layout of the editor and the segmented Edit/Preview toggle (follow `ClipboardPage` and `desktop/Switch.qml`).
+
+**Type consistency:** `Notes.*` names are used identically in every task; `owner` is the module's id `mod` throughout; `writeBody`/`writeDb` are the module's only write entry points.
diff --git a/docs/superpowers/specs/2026-10-16-notes-design.md b/docs/superpowers/specs/2026-10-16-notes-design.md
new file mode 100644
index 0000000..9b460a8
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-16-notes-design.md
@@ -0,0 +1,236 @@
+# Notes module
+
+A drawer tile and page for `bash-notes`, the shell note taker. Listing,
+writing, editing and deleting all happen in the page; the store is the same
+one `notes.sh` uses, so both tools work on the same notes. Git sync moves
+from "run `notes.sh` and it pushes for you" to a confirm on save.
+
+The predecessor is `rofi-notes.sh`, which lists notes in rofi and opens one
+in a terminal editor. It can list and choose, but writing and reading both
+leave the desktop, and nothing shows a note without a pager.
+
+## The store
+
+`notes.sh` keeps everything under `BASEDIR`, default
+`~/.local/share/bash-notes`:
+
+- **`db.json`** — the index. `{"params": {...}, "git": {"lastpull": ...},
+ "notes": [{"id": "1", "title": "...", "file": "1750000000"}]}`.
+- **`notes/`** — one file per note, named by the creation time in epoch
+ seconds. The file holds the body; the title is only in `db.json`.
+
+`id`, `title` and `file` are all **strings** in `db.json`. `notes.sh` writes
+them with `jq --arg` and looks a note up with `select(.id == $i)`, comparing
+string to string, so a number where a string belongs would make the CLI stop
+finding a note the page wrote. The page matches: `id` and `file` are written
+as strings.
+
+`notes.sh` lists by iterating `notes/*` and looking each filename up in
+`db.json` for its title. The page lists the other way round, from
+`db.json.notes`, which is where the titles are; a file with no entry would
+not show, and an entry with no file shows an error when its body is read.
+The two stay in step because every page write touches both.
+
+A write replaces only `.notes`; `params` and the rest of `git` round-trip
+unchanged, so a store the page writes before `notes.sh` has ever run still has
+the shape `firstrun` gives it. `git.lastpull` is the one exception, set
+deliberately on a pull (see Git); it is what stops `notes.sh` pulling on every
+run after the page has pulled.
+
+## Configuration
+
+Paths and git settings come from `notes.sh`'s own config, not from constants,
+so a changed `BASEDIR` moves the page with it. At load the module runs one
+shell, sourcing the rc and printing what it needs:
+
+ sh -c '[ -r "$HOME/.config/bash-notes.rc" ] && . "$HOME/.config/bash-notes.rc"
+ base="${BASEDIR:-$HOME/.local/share/bash-notes}"
+ printf ... "$base" "${DB:-$base/db.json}" "${NOTESDIR:-$base/notes}" ...'
+
+Sourcing is what makes `${BASEDIR}` in the rc expand the way `notes.sh`
+expands it, `~` included. Git is enabled when `USEGIT` is `true` and
+`GITREMOTE` is non-empty, the same test `notes.sh` makes. `GITREMOTE`'s
+value is never read by the page: the remote is already configured in the
+notes repository, so `git push` needs only the directory. `GITCLIENT` is
+read for the commit message.
+
+## Module
+
+`desktop/modules/notes/NotesModule.qml`, name `notes`, `alwaysActive: true`.
+
+- **Icon**: `\uf249`, `sticky-note`, present in Inconsolata Nerd Font.
+- **Tile line**: the note count, `12 notes`, from `db.json`. The module holds
+ a `FileView` on `db.json` with `watchChanges`, so the count is right the
+ moment the grid appears and moves when a note is added from either side.
+ That is a watch held while the drawer is closed, which the other pages
+ avoid; here it is the tile's whole content and it is one small file, the
+ same call mail makes for its unread count.
+- **Page**: a `Page` hosting `NotesPage`, `owner: mod`.
+
+## Page
+
+`desktop/modules/notes/NotesPage.qml`, UI only, in two states.
+
+### List
+
+- A search box, a **New note** button and, when git is on, an **Update list**
+ button.
+- Newest first, each row a title and its date (`dd/mm/yyyy hh:mm`, from the
+ `file` timestamp, local time). A click opens the note. Case-insensitive
+ substring search on the title, the same filter the clipboard page uses.
+- **Update list** runs the page's pull (`pull --rebase --autostash`, see Git)
+ and stamps `git.lastpull`, so `notes.sh -l` does not immediately pull
+ again. The file watch then re-reads `db.json`, and notes pulled from another
+ machine appear. A failed pull shows git's reason in a line under the button.
+
+### Editor
+
+Opened by a row (existing note) or **New note**.
+
+- A title field and a body, with an **Edit / Preview** toggle. Preview
+ renders the body as Markdown; Edit shows the raw Markdown in a `TextEdit`.
+ An existing note opens in Preview, a new one in Edit.
+- **Save** is two clicks, so the note is on disk before git is involved: the
+ first writes it and the button becomes **Commit & push?**; the second runs
+ the git half (see Git) and returns to the list. Leaving with the second click
+ unmade keeps the saved note and does not push it. With git off there is no
+ git half, so Save writes and returns to the list in one click.
+- **Cancel** returns to the list without saving; **Delete**, existing notes
+ only, is two clicks the same way and pushes the removal.
+- **Unsaved text is discarded.** Leaving the editor with Cancel, the page's own
+ back arrow, Escape or a click outside the drawer drops whatever was typed and
+ not saved. `Page` has no veto over a back, and a drawer that destroys its page
+ is the clipboard page's own ethos; the save is the explicit act.
+- Body `TextEdit`s take the visible height and scroll themselves, as
+ `ClipboardPage` does, so the `Page`'s outer `ScrollView` never scrolls.
+
+## Writes
+
+All index writes go through the module's `db.json` `FileView` with
+`atomicWrites: true`, so a write that dies mid-way cannot leave a half
+`db.json` for `notes.sh` to trip on. A body is written with a `FileView` on
+its own path, which creates the file.
+
+- **Create**: the first click pulls first (see Git), then re-reads `db.json`
+ so the append is based on what the pull brought in, not on what was read
+ before it; `notes.sh` reads the id after its own pull for the same reason.
+ `file` is the current time in epoch seconds, `id` is the highest existing id
+ plus one (as a string), and `lastpull` is set to now in the same write.
+ Write the body to `notes/<file>`, append `{id, title, file}` to
+ `db.json.notes`.
+- **Edit**: replace the title in the entry, rewrite `notes/<file>`.
+- **Delete**: drop the entry from `db.json.notes` and `rm -- notes/<file>`.
+ `FileView` has no delete, so this is the one `execDetached` filesystem
+ call.
+
+A save is committed only on the second click, so a note written and not
+confirmed stays uncommitted, and so does the `lastpull` stamp **Update list**
+writes. That is why the page's pulls carry `--autostash`.
+
+An id and a `file` colliding with an existing note is not guarded against:
+`notes.sh` takes `date +%s` once per run and a run is interactive, so two
+notes in one second are as unlikely here as there, and a collision would
+overwrite the earlier note's file. `shortcut: second-granularity filenames,
+upgrade to a fresh second or a suffix if a collision is ever seen.`
+
+## Git
+
+Every git action shells out and shows git's stderr when it fails; the page
+never parses git's output.
+
+The order is `notes.sh`'s: pull before the write, then add, commit, push. The
+pull happens on the first click, before anything is written, so it runs on a
+clean tree and never needs a rebase. The commit and the push happen on the
+second click.
+
+- **Create**: click 1 `pull --rebase --autostash`, then the write; click 2
+ `git add .`, `commit`, `git push origin HEAD`.
+- **Edit / Delete**: click 1 writes; click 2 `git add .`, `commit`,
+ `git push origin HEAD`. `notes.sh` pulls on add only, and the page matches.
+- **Update list**: `pull --rebase --autostash`, then stamp `git.lastpull`.
+
+`--autostash` is there because the page's write and commit are two clicks, so
+an unconfirmed save leaves `db.json` uncommitted, and this machine's
+`pull.rebase=true` refuses a pull over any unstaged change. With the second
+click made, the tree is clean and it does nothing. `notes.sh` has no such
+gap: its write and its commit are one invocation, so its tree is always clean
+when it pulls.
+
+The commands run as one `sh -c` chain with the base directory and the message
+passed as `$1` and `$2`, never built into the command string, the rule the
+clipboard page keeps for its own untrusted text. `git add .` runs from
+`BASEDIR`, as `notes.sh` runs it. A second click with nothing staged, an edit
+that changed nothing, is not a failure: git has nothing to record and the push
+still runs.
+
+`git.lastpull` is stamped after the pull, not before. `notes.sh` stamps first
+and then pulls, which leaves its own `db.json` dirty for the pull it is about
+to run, and it ignores the resulting failure; stamping on return keeps the
+field truthful without that. The field is what `notes.sh -l` reads to decide
+whether to pull again inside `GITSYNCDELAY`, so a page pull that did not stamp
+would make every CLI list pull again.
+
+The commit message follows `notes.sh`'s wording but names the page, so
+history says where a change came from: `quickshell - adding note from
+<client>`, `quickshell - <client> note edited.`, `quickshell - <client>
+removing note ID <id>.` (`<client>` is `GITCLIENT`, defaulting to the
+hostname, as in `notes.sh`.) Flip the prefix to `notes.sh - ` if identical
+messages matter more than the origin.
+
+Nothing pulls on open, so the list shows local state until **Update list** is
+pressed. `notes.sh`'s own `list` pulls on a timer; the drawer makes it an
+explicit click instead.
+
+## Markdown
+
+Preview is a read-only `TextEdit` with `textFormat: TextEdit.MarkdownText`,
+selectable as the assistant canvas is. Qt's Markdown is a subset: headings,
+emphasis, lists, links, inline code and quotes render; tables and fenced-code
+highlighting do not. Links open externally, restricted to `http`, `https`
+and `mailto` as `Segments.qml` does. The body is the user's own, so it is not
+passed through `Notify.sanitize`; a remote image in a note will be fetched
+when its preview renders.
+
+The Markdown body is shown verbatim when editing, so the page is not a WYSIWYG
+editor: it is a plain note with a rendered view of it.
+
+## Files
+
+ desktop/modules/notes/
+ NotesModule.qml module, tile count, db.json FileView, writes and git
+ NotesPage.qml list and editor
+ Notes.js pure helpers, node-tested
+ test_notes.js the test
+ README.md
+
+`Notes.js` (`.pragma library`) holds the pure parts: parsing the whole
+`db.json`, sorting newest first, formatting a timestamp, the next id, and the
+transforms that return a new `db.json` object with a note added or removed
+(every other key carried through untouched). The module calls them; the file
+operations and git stay in QML.
+
+Registering: `import "modules/notes"` and a `NotesModule {}` in `shell.qml`'s
+`modules` list.
+
+## Testing
+
+`node desktop/modules/notes/test_notes.js`:
+
+- Parsing a `db.json` written by `notes.sh`, `params` and `git` wrappers
+ included, keeps the whole object, not just `notes`.
+- Sorting is by `file` descending, numeric, not lexicographic.
+- The next id is the highest existing plus one, and `1` for an empty list.
+- Adding then removing a note returns a db equal to the original, with
+ `params` and `git.lastpull` intact, and adding does not mutate the input
+ object.
+- A malformed or truncated `db.json` yields an empty list rather than a
+ throw, so the page renders "no notes" instead of failing to load.
+
+The tests must be shown to fail on a broken input before they are trusted.
+
+## Not in scope
+
+Opening a note in `$EDITOR`. Restoring backups (`--restore`) or making one
+(`--backup`). Markdown export. `--showconf` / `--userconf`. Pulling on open.
+Watching `notes/` itself: a file deleted behind the page's back is noticed
+when its body is next read, not before.