1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
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.
|