diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-16 20:33:59 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-16 20:33:59 +0200 |
| commit | aa190a4775d99f205ef29a6c99ec7ab283c76ee8 (patch) | |
| tree | d28cb2cb1a01623dfb24854313cb6c7c3a224768 /docs/superpowers/plans | |
| parent | 2537315f53cb6d0dbc5238245451e85b388ae5c5 (diff) | |
| download | quickshell-aa190a4775d99f205ef29a6c99ec7ab283c76ee8.tar.gz quickshell-aa190a4775d99f205ef29a6c99ec7ab283c76ee8.zip | |
docs: notes drawer module spec and plan
A drawer tile and page for bash-notes: browse, write, edit and delete
notes in the page, sharing notes.sh's store so both tools work on the
same notes.
Git follows notes.sh's shape, pull before write, commit and push after,
with two departures the page needs: an unconfirmed Save leaves db.json
uncommitted (write and commit are two clicks here, one invocation there),
so the pulls carry --autostash, and lastpull is stamped after the pull
rather than before it, which notes.sh does and then ignores the failed
pull that follows.
Diffstat (limited to 'docs/superpowers/plans')
| -rw-r--r-- | docs/superpowers/plans/2026-10-16-notes.md | 415 |
1 files changed, 415 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. |
