summaryrefslogtreecommitdiffstats
path: root/CLAUDE.md
diff options
context:
space:
mode:
Diffstat (limited to 'CLAUDE.md')
-rw-r--r--CLAUDE.md1194
1 files changed, 5 insertions, 1189 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index d698295..7a26e74 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,1192 +1,8 @@
# CLAUDE.md
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+This file is intentionally thin. AGENTS.md is the single source of truth for
+how to work on this repository, shared across every agent tool (Claude Code,
+opencode, etc). Do not re-populate this file with duplicated content, edit
+AGENTS.md instead.
-## Build and test
-
-```bash
-cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
-cmake --build build
-ctest --test-dir build --output-on-failure
-```
-
-Run a single test binary directly for a tighter loop, **always with the
-offscreen platform**:
-
-```bash
-QT_QPA_PLATFORM=offscreen ./build/tests/test_keymap
-```
-
-Or by ctest name (the name is the suffix, not the binary): `ctest --test-dir build -R keymap`.
-
-**Never run a test binary without `QT_QPA_PLATFORM=offscreen`, and never launch
-`./build/src/qtmaildir` unasked.** `tests/CMakeLists.txt` sets that variable for
-ctest only, so a binary invoked directly inherits the desktop's own setting
-(`wayland;xcb` here) and throws real windows onto the user's screen. Each test
-function builds its own `MainWindow`, so one direct run of `test_mainwindow`
-flashes over a hundred windows across the desktop. This is not cosmetic: the
-user has asked for it to stop, having been given a headache by it.
-
-The same applies to the application. Running it is a hand test and belongs to
-the user; ask rather than launching it, and when a change genuinely needs
-looking at, say what to look for and let them run it.
-
-Adding a test: create `tests/test_<name>.cpp` and add `add_qtmaildir_test(<name>)` to
-`tests/CMakeLists.txt`. That function links `qtmaildir_lib` and `Qt6::Test` and registers
-the test. Fixture-driven tests get `FIXTURE_DIR` via `target_compile_definitions` (see
-`test_mimeparser`).
-
-Adding a source file: add the `.cpp` to the `qtmaildir_lib` list in `src/CMakeLists.txt`.
-All logic lives in that static library; the `qtmaildir` executable is only `main.cpp`, so
-tests can link everything without duplicating source lists.
-
-## Environment constraints (verified 2026-08-02, Slackware)
-
-- **notmuch installs no `notmuch.pc`.** CMake locates it with `find_path`/`find_library`.
- Never convert it to `pkg_check_modules`. GMime does ship `gmime-3.0.pc` and uses pkg-config.
-- Qt 6.11.1 including WebEngine ships in Slackware's monolithic `qt6` package; there is no
- separate `qt6-webengine`.
-- CMake 4.3.4 rejects `cmake_minimum_required(VERSION <3.5)`. Keep 3.21.
-- **gmime headers must be included before any Qt header in the same translation unit.**
- glib declares a struct field named `signals`, which Qt defines as a macro.
-
-## Architecture
-
-One process, two threads. A GUI counterpart to neomutt over a local notmuch-indexed
-Maildir. **No network protocol work at all** — fetching and sending are external scripts.
-
-```
-UI thread Worker thread
-MainWindow NotmuchWorker
- ├ query row: QComboBox, QLineEdit, └ owns the only notmuch_database_t*
- │ saved-query QPushButtons
- ├ ThreadListView (QTreeView) ── ThreadListModel (QAbstractItemModel)
- │ ONE column of cards; CardDelegate paints each whole, from CardLayout
- └ MessageView (a QStackedWidget over two pages)
- ├ the message page (header QLabel, QWebEngineView, attachment bar, TagStrip)
- └ ThreadDashboard (a conversation, from a ThreadDigest; its own TagStrip)
-
-ComposeWindow (its own top-level window, one per message being written)
- ├ MarkdownFormat (namespace: what the formatting buttons do to a selection)
- ├ MarkdownRenderer (namespace, cmark-gfm) MessageBuilder (namespace, GMime)
- ├ MessageSender (QProcess, the per-account send_command on stdin)
- └ SendDialog (the undo countdown) DraftStore (autosave to the drafts folder)
-
-ComposeContext (a struct: what a Reply or Forward inherits)
-ComposeContextBuilder (namespace: fills one, and picks the account)
-CardLayout (pure geometry, no painting)
-SearchTerm (pure query strings, no widget)
-Config (INI) KeyMap MailSync (QProcess) MimeParser (GMime)
-SyncMonitor (/proc/locks) TagColors QueryCompleter ThreadCidMap
-MaildirName (fresh Maildir filenames)
-```
-
-The query row and the message-pane header are **built inline in `MainWindow` and
-`MessageView`**, not as named widget classes. Earlier revisions of this diagram
-listed `QueryBar`, `SavedQueryBar`, `HeaderWidget` and `AttachmentBar`; none of
-those types have ever existed, and looking for them wastes a search. The widget
-classes that do exist are `MessageView`, `ThreadListView`, `TagStrip`,
-`TagDialog`, `MessageDetailsDialog`, `PendingChangesDialog`,
-`RowStyleDelegate`, `CardDelegate`, `ComposeWindow`, `SendDialog`,
-`ThreadDashboard` and `BusyIndicator`; `TagChip` is a namespace of
-painting helpers, not a widget, `SearchTerm` is a namespace of query builders,
-and `ThreadCidMap`, `CardLayout`, `SearchOffer`, `HeaderRow`, `ThreadDigest`
-and `PendingChangeRow` are structs.
-`SubjectDelegate` existed until item 53 and is gone.
-
-**The compose units are mostly NAMESPACES, and the same warning applies to
-them.** `MarkdownRenderer`, `MarkdownFormat`, `MessageBuilder`,
-`ComposeContextBuilder`, `DraftStore` and `MaildirName` are namespaces of free
-functions over values, deliberately, so the markdown, the MIME assembly and
-the account-picking are all testable without a widget. `MessageSender` IS a
-QObject, because it owns a `QProcess`. There is no `FormatToolbar` class: the
-composer's formatting row is built inline in `ComposeWindow` and asks
-`MarkdownFormat` what each button does to the selection.
-
-**`MessageDetailsDialog` was a `QPlainTextEdit` inside `MessageView` until item
-85.** It is rows now so each value can carry its own context menu, and its
-plain-textness was a SECURITY property rather than a style: header values come
-from strangers and plain text cannot interpret markup. Every value label states
-`Qt::PlainText` explicitly, because a `QLabel` guesses under `Qt::AutoText`.
-Escaping into a rich-text label is the same protection one mistake away from
-failing, so do not "simplify" it back.
-
-**`ThreadListView` survives only for the expander hit-test.** `CardDelegate`
-draws the reply count, and a delegate gets no click of its own without an
-editor, so the view owns the click and asks the delegate for the rect rather
-than recomputing it.
-
-Until item 53 it also painted a row-wide strip of tag chips after the cells,
-because a delegate cannot paint outside its column and the strip spanned all
-five. That is why the class exists at all, and the history is worth keeping:
-the arithmetic it needed produced a deleted row cut in half and every other row
-showing a bare stripe, both because the view had to re-honour alternating
-colours, the selection and `BackgroundRole` across cells it did not own. With
-one column there is nothing to span, so the `paintEvent` and its band
-arithmetic are deleted and none of that applies any more.
-
-**The panes' marks are shipped SVGs, not font glyphs and not a `.qrc`.** `Marks`
-(`src/marks.h`) carries six payloads as compiled-in string literals, generated
-from `assets/icons/marks/*.svg`, which stay the editable originals. Not a
-resource, because `src/CMakeLists.txt` already records that a qrc in the static
-library registers itself from a global initialiser the linker drops, and the
-tests link the library rather than the executable. Every payload paints with
-`fill="currentColor"`, which `QSvgRenderer` renders BLACK rather than resolving;
-`Marks::pixmap` composites the real colour with `CompositionMode_SourceIn`, which
-is what lets one asset serve a light and a dark palette. The toolbar and menus
-still use `QIcon::fromTheme` and must keep doing so: the split between "panes are
-ours, chrome is the system's" is item 70's whole point. A tag drawn as a mark
-must not also appear as a chip, which `isDrawnAsAMark()` in `threadlistmodel.cpp`
-enforces for both roles at once; the duplicate survived every geometry test and
-was found only by rendering a card and looking at it.
-
-**A card layout must be testable without a painter.** `CardLayout` computes
-every rect on a card and touches no `QPainter` and no widget, so the geometry
-has tests that a blank render cannot defeat. When changing what a card shows,
-change `CardLayout` and assert there; a test that renders the delegate and
-counts pixels proves nothing, for the reasons under "Rendering probes lie".
-Two traps it already handles: `QRect::right()` is inclusive, so the right edge
-is carried as an exclusive one, and `QFont::pointSizeF()` returns -1 for a font
-set in pixels, which qt6ct does.
-
-**`QTreeView`'s Up/Down already walk into an expanded thread's replies**, and
-that is where message-to-message navigation comes from. Do not bind arrow keys
-as `QAction` shortcuts to get it: a shortcut is dispatched before the focused
-widget sees the key and Qt withholds only plain LETTERS from editable widgets,
-so a bare `Up` would break the query bar, the tag dialog and the web view at
-once. `Alt+Up`/`Alt+Down` are chords and therefore safe; `Shift+Up`/`Down` is
-the built-in extend-selection and must be left alone. Binding two sequences to
-one action needs `setShortcuts`, not `setShortcut`, which keeps only the last.
-
-**`Q_ENUM` is not enough to send an enum across a queued connection.** It gives
-the type a meta-object entry, not a metatype registered under the name
-`invokeMethod` resolves, so a `Q_ARG` carrying it is dropped at runtime with a
-warning and the slot runs with a default. `NotmuchWorker::SortOrder` is
-registered beside the type for this reason, not in `MainWindow`, so a caller
-that never constructs one still gets it.
-
-**It is a `QTreeView` over a `QAbstractItemModel` since item 20**, because a
-thread's replies are child rows and a table can neither indent nor expand. What
-did NOT survive that port is anything keyed on a row NUMBER: a tree numbers rows
-per parent, so `row 0` exists once per expanded thread and `current.row() + 1`
-names a sibling rather than the next thread. Navigation walks with
-`indexBelow`/`indexAbove`; `QTableView::isRowSelected(int)` has no equivalent —
-use `selectionModel()->isSelected(index)`. Row height comes from
-`setUniformRowHeights` plus `CardDelegate::sizeHint`, since a tree has no
-vertical header to carry a default section size. Indentation is
-`setIndentation(0)`: `CardLayout` draws the indent inside the card's own rect,
-so `visualRect` reports the SAME left edge for a thread and its reply and a
-geometry probe sees no nesting in a correctly nested list.
-
-**Three traps in the expander, all of which shipped a plausible-looking broken
-build before being caught.** `QTreeView::drawBranches` is the documented hook and
-does not work when the expander sits on a content column: it runs BEFORE the
-row's cells, so the delegate's background paints over it (a 60-pixel triangle
-survived as 8). `CardDelegate` draws it instead, as the reply count on the
-card's second line. `setRootIsDecorated(false)`, needed to stop the style
-drawing its own indicator underneath, also removes the style's HIT AREA, so the
-glyph renders perfectly and is inert; `ThreadListView::mousePressEvent` handles
-the click, asking `CardDelegate::expanderRectFor` for the target so the drawn
-and clickable rects cannot drift. A fourth trap died with the grid: `isExpanded`
-is keyed on column 0, which used to disagree with the subject-column index.
-
-**Visible, clickable and toggling are three separate properties.** A test for
-one passes against the other two being broken, which happened twice in one
-session: a pixel test proved the triangle was drawn while nothing could click
-it, and a click test proved it opened while it could never close.
-
-**Assert a reply's indent on where the TEXT lands, never on `visualRect`.** The
-reason has inverted twice and the rule has not. Under item 20 the geometry was
-indented while the text was not, because the delegate laid text out from its own
-left edge; now `setIndentation(0)` means `visualRect` reports no indent at all
-while the text is indented, because `CardLayout` draws it inside the card's rect.
-A probe on `visualRect` therefore endorsed a broken layout then and would fail a
-correct one now. Assert on `CardLayout::contentLeft`.
-
-**`paintEvent` ran AFTER the cells**, which is why anything the view filled
-across a row covered the text the delegate had just drawn: the reply tint filled
-the full row height in one version and erased every sender and subject, measured
-at zero surviving text pixels. Recorded because it is the class of bug a view
-that paints invites. `ThreadListView` no longer paints at all.
-
-**No `notmuch_*` pointer ever crosses the thread boundary.** Data crosses as the plain
-value structs in `src/types.h` (`ThreadSummary`, `MessageRef`, `MessageNode`,
-`ActionScope`, `TagChange`), over queued
-signals in both directions. `notmuchworker.cpp` is the only file that includes `notmuch.h`
-outside `src/nmraii.h`; C handles are owned by the `NmQuery`/`NmMessages`/`NmThread`/…
-RAII aliases there so they cannot leak.
-
-**The one exception, and it is a double-free if undone.** Messages reached
-through `notmuch_thread_get_toplevel_messages` / `notmuch_message_get_replies`
-are owned by the THREAD and freed with it (`notmuch.h:1637`), so `walkReplies`
-in `notmuchworker.cpp` holds them as raw `notmuch_message_t*`: an `NmMessage`
-wrapper would call `notmuch_message_destroy` on memory the thread frees again.
-The whole walk must finish while the `NmThread` is alive. Related: replies are
-unreachable from a query walk at all — `notmuch_message_get_replies` returns
-NULL for a message from `notmuch_query_search_messages` (`notmuch.h:1617-1628`),
-which is why `loadThreadTree` exists beside `loadThread` rather than replacing
-it.
-
-**Generation counters, not cancellation.** Each query bumps a `quint64` generation passed
-through to the worker and back on every result signal. The UI discards results whose
-generation is stale. The worker never needs to know a query was superseded. Threads are
-emitted in batches of `kBatchSize` (200) so a 10k-thread query paints immediately.
-
-**Read-only by default, read-write in bursts.** notmuch's write lock is exclusive
-process-wide, so holding it open would block the user's cron `notmuch new`. `applyTags`
-closes the read-only handle, opens read-write, applies, closes. notmuch permits only one
-open handle per process, so that close-first ordering is required, not stylistic.
-
-**No dry-run, no destructive-action confirmation.** Those gates belonged to the retired
-`mailctl` CLI, where they restrained an agent; a human at a GUI gets **undo** instead — every
-mutation pushes its inverse (`TagChange::inverted()`) onto a `QUndoStack`. Do not add
-confirmation dialogs for tag mutations.
-
-**An undo covers what the write CHANGED, never what it asked for, and getting
-this wrong rewrote real mail.** Item 176. `ThreadTagCommand::undo()` inverted
-the tags and kept the THREAD as its scope, so undoing "mark thread read" on a
-conversation of 44 messages that held 2 unread added `unread` to all 44 and left
-43 unread. `maildir.synchronize_flags` is on, so the Maildir filenames were
-rewritten and the next sync would have carried it to the mail server; it was
-repaired by hand. The undo stack is this application's substitute for a
-confirmation dialog, so an undo that damages state is worse than the dialog it
-replaces.
-
-`NotmuchWorker::applyTags()` is the only place that can know: it reads each
-message's tags before writing and reports only the ids whose tags actually
-moved. A `TagCommand` base carries that effective set for both
-`ThreadTagCommand` and `MessageTagCommand`, which had the same shape and the
-same defect on a multi-row selection. Two consequences that look like details
-and are not:
-
-- **`tagsApplied` does NOT fire when the effective list is empty.** Emitting an
- empty change would push an undo entry whose inverse adds a tag no message ever
- carried, which is the same bug one step later.
-- **`sendThreadTagChange` takes an `onlyMessageIds` parameter** so it keeps its
- thread-scoped REPAINT while restricting the WRITE. Those two scopes differ on
- purpose: the conversation card is what changed on screen, and a subset of its
- messages is what changed on disk. Do not collapse them.
-
-A test for this needs a thread whose messages DISAGREE about the tag, for the
-same reason item 87 records: two messages in the same state answer identically
-whichever way the code resolves them.
-
-**There is exactly ONE exception, and its shape is the rule's own logic rather
-than a hole in it.** `empty_trash` (item 118) destroys files and index entries,
-so it has no inverse to push, and the protection the rule actually provides —
-that a user never loses work to a keystroke — has to come from somewhere else.
-It therefore asks, naming the count and the account, defaulting to Cancel, and
-it carries **no default shortcut** for the same reason. `NotmuchWorker::purgeMessages()`
-is a separate entry point from `moveMessages()` deliberately: the two look
-alike and only one of them can be undone. A second confirmation anywhere is a
-defect unless the action is likewise irreversible. All actions funnel through one `applyTags` path;
-multi-row selections go through `applyTagsToThreads`, which resolves every thread in ONE
-combined `thread:a or thread:b` query rather than one query per thread.
-
-**`notmuch_database_get_path()` is not the mail root, and assuming it is
-moves mail somewhere mbsync cannot see.** notmuch can split the index from the
-mail with `mail_root` and `path` as separate keys, which is how the Xapian
-index goes on faster storage while the Maildir stays put. Under that layout
-`get_path()` returns the INDEX directory. `mailRootOf()` in `notmuchworker.cpp`
-wraps `notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT)`, which is correct under
-BOTH layouts, so there is no conditional and no reason to reach for the old
-accessor again. Item 124, and the developer's own index has run split since
-2026-08-20, so this is live rather than hypothetical.
-
-The consequences were asymmetric, which is why it is worth remembering: a wrong
-root made message paths resolve to `../..` escapes that match no account, a
-display defect, but `moveMessages` composes its destination from the same root,
-so Delete would have written into the Xapian tree. Two related traps sit outside
-the code. `database.hook_dir` defaults to `<database.path>/.notmuch/hooks`, so a
-split config silently stops running `post-new` while `notmuch new` still reports
-success; it must be set explicitly. And a test cannot see any of this in the
-ordinary fixture layout, where the index lives inside the mail root and both
-accessors return the same string: `NotmuchFixture::splitIndex()` exists for
-that, and a test without it passes whichever accessor the code uses.
-
-**The Maildir path is deliberately not configurable.** notmuch stores it as
-`database.path` and libnotmuch reads it; duplicating it would create two sources of truth.
-The only escape hatch is `general/notmuch_config`, pointing at an alternate notmuch config.
-Per-account subdirectories *are* configured, since notmuch does not model accounts at all.
-
-**Delete MOVES the file, and a wrong folder name reaches the mail server.**
-Item 103. Every account carries a mandatory `trash` key and an optional
-`inbox` one, both naming a folder relative to `maildir`. Naming a folder that
-does not exist does not fail: the move CREATES it, mbsync adopts it and writes
-state files for it, and under `Create Both` it then propagates to the server,
-where every other client sees it. This is not theoretical. A folder name
-containing a space was truncated by the origin tag, a bogus folder was created
-beside the real one, and four messages of a thread were stranded in it on the
-user's real mail. Treat any code that composes a folder name as reaching the
-server, because it does.
-
-**A message records where it came from in a tag, because nothing else can.**
-`deleted-from:<folder>` is written when Delete moves the file, and read back by
-Restore. The file has moved, so neither the path nor anything in notmuch still
-knows the original folder. A notmuch tag MAY contain a space, so tags crossing
-the thread boundary are joined by a TAB rather than a space; joining on a space
-truncated every folder name containing one. A message trashed by another client
-carries no such tag at all, which is why the trash view is path-based and why
-Restore falls back to the account's inbox rather than refusing.
-
-**Restore reads the DATABASE, never the model.** The model's tags come from the
-query, so a row whose delete has not been re-queried still carries its pre-delete
-tags: measured `[inbox,unread]` on a message already in the trash, one run in
-three. The origin tag is then not found, the message falls into the no-origin
-branch, and it goes to the inbox instead of where it came from, silently and
-irreversibly. A restore must be right about its destination or it is worse than
-doing nothing.
-
-**The sync script lives here, in `assets/mailsync.sh`.** It moved from the
-retired `mailctl` project, which never called it: the script is `mbsync` plus
-`notmuch new` with a lock, and qtmaildir is the only thing that runs it
-programmatically. Two properties exist for this application's sake and
-must survive any edit. It **prints to stdout as well as its log file**, because
-`MailSync` shows what the command prints and a self-redirecting script leaves
-the pane empty; and it **exits with the real status**, because a `0` from a
-failed sync makes qtmaildir report success, clear the unsynced-changes count,
-and quit on a sync that never happened.
-
-**Every user-facing string is translatable.** Wrap UI text in `tr()`, including strings
-that are only ever shown in passing: status bar messages, tooltips, dialog prose,
-completion descriptions. Query syntax itself is not user-facing text — notmuch keywords
-like `tag:` and `date:` are wire format and must never be translated, only the prose
-describing them. The tree was audited against this rule by item 22 on
-2026-08-15, and an Italian translation ships, so a new string that misses
-`tr()` is now a regression rather than pre-existing debt.
-
-**`tr()` alone does not make a string translatable, and the source cannot tell
-you which.** A literal in an ARRAY or any other place with no enclosing class
-needs the context named on the literal itself:
-`QT_TRANSLATE_NOOP("TheClass", "Text")`. `QT_TR_NOOP` there compiles, reads
-correctly, and extracts NOTHING — `lupdate` prints "tr() cannot be called
-without context" and skips it, while the use site's `tr()` looks it up at
-runtime under a context no `.ts` file contains. That shipped for the eight
-rule-builder field labels in `tagrulesdialog.cpp` and made every one of them
-permanently untranslatable in any language.
-
-`Q_DECLARE_TR_FUNCTIONS` is NOT the fix for that case, though it is the fix for
-a free FUNCTION calling `tr()` (which is what `querycompleter.cpp` uses it
-for). Measured: a class carrying the macro beside the array still extracts 0.
-The context must be on the literal.
-
-**`lupdate` output is the evidence, never reading**, and `ctest -R
-translations` encodes it: it fails on a string with no translation and on one
-`lupdate` cannot see. Refresh with `lupdate-qt6 src/ -ts
-translations/qtmaildir_it_IT.ts -no-obsolete -locations none` after changing
-any user-facing string; a clean run reports zero context warnings, and
-`lrelease` must report 0 unfinished, since it silently DROPS an unfinished
-string and ships it as English inside an otherwise Italian UI.
-
-A `QTranslator` must live on `main`'s stack: one scoped to a helper function
-unloads on return and every string reverts to English with nothing to see.
-
-**Translating a string that something MATCHES on breaks config in a language
-the author never runs.** The built-in filters' names are labels and are
-translated; `startup_query` resolved by comparing the config's text against
-those names, so `startup_query = Inbox` matched nothing under `LANG=it_IT`
-where the filter is called "In arrivo". The application opened the wrong view
-AND warned that the user's own working config was invalid. It resolves on the
-GENERATOR as well now, which is stored in queries.json and identical in every
-locale. Before wrapping a string in `tr()`, ask whether anything compares
-against it; if so, match on the wire-format identifier and treat the
-translated name as an additional convenience, never as the identity. The
-regression test installs a real `QTranslator` rather than a stub, because the
-bug lives in the gap between the stored string and the displayed one and only
-a real translation opens it.
-
-A second trap sits under that test and cost a wrong green: the warning it
-asserts on is guarded by `!m_savedQueries.isEmpty()`, so a test with no
-`queries.json` never reaches the branch and passes against a broken check. It
-writes one, and asserts the file loaded before asserting on what it produced.
-
-**One config file has two readers, and both are now in this repo.**
-`~/.config/mailrules/rules.json` is read and written by `src/tagrules.cpp` and
-by `assets/hooks/mailrules.py`, which share no code and agree by test.
-**Before changing anything about that file's format, read "Changing the rule
-format" at the bottom of this document.** It used to be a cross-repo coupling
-with the `mailctl` CLI; that project is retired and the hooks moved here on
-2026-08-23, so a format change is now one repo and two suites.
-
-**The auto-tagging rules live in a config file, not in the source, and notmuch's
-parser rejects almost nothing.** Rules are in `~/.config/mailrules/rules.json`,
-applied by the notmuch `post-new` hook in `assets/hooks/`, which the live
-`database.hook_dir` symlinks to; `TagRules` here reads and writes the same file
-and `TagRulesDialog` edits it.
-Two things bite. A stored query carries NO scope: the hook supplies `tag:new`
-and wraps the query in parentheses, because `tag:new and a or b` binds as
-`(tag:new and a) or b` and a rule that is a disjunction of senders would escape
-its scope and match everything. And **a malformed query is not an error to
-notmuch**: `from:((((` parses cleanly and matches nothing, so a test asserting
-a failure or a `-1` count fails against correct code. This was recorded in
-`test_notmuchworker.cpp` for thread counts and then learned again, twice, while
-building the rules. Assert on the positional contract, never on a provoked
-failure.
-
-**Qt emits no `triggered` for a `QAction` that owns a menu.** Setting a submenu
-on an action makes clicking it open that submenu and nothing else, so any
-`connect(action, &QAction::triggered, ...)` on the same action is dead code that
-compiles, links and never runs. The saved-query overflow menu shipped this way:
-every entry carried both a run connection and a submenu of edit actions, and no
-entry in that menu had ever been runnable. It went unnoticed because the menu was
-the rarely-used half while the user's queries were pinned buttons, and surfaced
-only when item 93 moved every query into it. An action that must both run
-something and offer actions needs the run as an item INSIDE its submenu.
-
-**A generator must be asked for one account's query, never handed its
-all-accounts query to wrap.** `Config::resolvedQuery(query, accountKey)` exists
-for this. Wrapping produces `path:"a/**" and (path:"a/Sent/**" or
-path:"b/Sent/**")`, which returns exactly the right rows, because `path:` is
-hierarchical and the other account's half cannot match inside `a`. That is why
-it is dangerous: a row-count assertion passes against it, so the tests assert on
-the generated STRING. The mutation putting the wrap back fails two of them.
-Related: an EMPTY query means "match everything" to notmuch, so a generator with
-nothing to match returns `Config::matchNothingQuery()` rather than an empty
-string. An account that configures no sent folder would otherwise give a button
-labelled Sent that shows the whole Maildir.
-
-**Every query this application builds goes through `SearchTerm`
-(`src/searchterm.h`), and that is what stops five surfaces growing five quoting
-rules.** It holds no widget, so the grammar is tested without a painter or a web
-engine. Two of its rules are load-bearing rather than cosmetic. `quote()`
-escapes backslashes BEFORE quotes, since the other order escapes the
-backslashes it just added; it truncates before escaping, so a cut cannot land
-mid-escape. And `extend()` parenthesises BOTH sides, because the query bar can
-hold a hand-written disjunction and `a or b AND c` binds as `a or (b AND c)`,
-which widens a search the user asked to narrow, reporting nothing. This is the
-same trap the `post-new` hook handles when it scopes a rule with `tag:new`.
-
-**A writer that does not validate what its reader requires loses data
-silently.** `TagRules::save()` wrote any id and `load()` required
-`^[a-z0-9][a-z0-9-]*$`, so a rule named `justeat orders` in a field labelled
-**Name** was written correctly, dropped on every read, invisible in the dialog,
-still occupying the file, and never applied by the hook. The next save from the
-dialog would have deleted it outright. `TagRules::validate()` is now the single
-predicate both sides use; a bad id loads REPAIRED rather than dropped, so the
-rule can be seen and fixed. Two lessons beyond the fix. The load warning already
-existed and was correct and useless, because the rule it named could not be
-reached, and a warning the user cannot act on teaches them to ignore warnings.
-And the repair belongs in the editor, not in `mailrules.py`: the hook tags real
-mail unattended, where a silent rename is worse than a drop.
-
-**Rule counts must count MESSAGES.** `requestCounts` counts threads, which is
-right for the placeholder pane because a click there produces thread rows. A
-rule tags messages, so a thread count understates every rule matching part of a
-large thread; `requestMessageCounts` exists beside it for that reason. The two
-are separate signals with separate generation counters, and a count request
-must never bump `m_generation`: that is the *query* generation, and bumping it
-discards any thread load in flight, blanking the message pane because the user
-asked for counts.
-
-**Config format gotcha:** QSettings treats `/` in a section name as a group separator, so
-account sections are `[account.work]`, not `[account/work]`. `childKeys` returns keys
-sorted alphabetically, never in file order. **`[general]` keys are read WITHOUT the
-`general/` prefix** — QSettings' INI backend treats a section literally named `[general]`
-as its own fallback section and strips it, so a `general/<key>` lookup silently matches
-nothing (this is how `notmuch_config` went unnoticed as broken). Config lives at
-`~/.config/qtmaildir/qtmaildir.conf`.
-
-Machine-written UI state is a **separate** file, `~/.local/state/qtmaildir/uistate.conf`
-via `MainWindow::uiStatePath()`. Never write window blobs into the hand-edited config.
-Build the path from `QStandardPaths::GenericStateLocation`, not `StateLocation`: the
-latter appends both the organization and the application name, and both are `qtmaildir`.
-
-**`QLineEdit::setCompleter` is wrong for any field holding more than one
-value.** It hands completion to the line edit, which then overwrites the
-completer's `completionPrefix` with the widget's **entire text** on every
-keystroke. In a field holding a list, the first value completes and nothing
-after it ever does, because "unread, fl" is matched whole against the
-candidates. Setting the prefix from a `textEdited` handler does not help: the
-line edit sets it again afterwards. Use `setCompleter` only for a field whose
-whole contents are the thing being completed; otherwise attach with
-`QCompleter::setWidget` and drive `setCompletionPrefix` and `complete()`
-yourself, and replace the token under the cursor on `activated` rather than
-letting QCompleter overwrite the field. This has been hit twice, in
-`QueryCompleter` (01ba356) and in `TagDialog`; the trap belongs to Qt, not to
-either class. A test that uses `setText()` passes against the bug, since
-`setText` does not drive a completer at all: the keys must be typed.
-
-**No test may read the real `/proc/locks`, and restoring it after a test is a
-BUG, not cleanup.** `TestMainWindow::init()` points every test at an empty lock
-table in its own `QTemporaryDir`. Without that the suite observes the machine's
-real sync state, so a `mailsync.sh` run makes `SyncMonitor` report a sync in
-progress and tests that never mention syncing fail: measured 0 failures in 30
-runs with no lock held, 30 in 30 with one held, and it caused three separate
-misdiagnoses (item 61). Reproduce with `flock /tmp/mbsync.lock -c 'sleep 60'` in
-one shell and the suite in another. The three tests that observe a sync write
-their own table content; none of them restores `"/proc/locks"` at the end any
-more, because doing so handed the real table to the next test and re-exposed the
-whole suite. `noTestCanSeeTheRealLockTable` fails if that protection is ever
-lost.
-
-**`QItemSelectionModel::currentRowChanged` is emitted BEFORE the selection model is
-updated.** A handler on it reading `selectedRows()` sees the *previous* selection, not the
-one the user just made. Verified against Qt 6.11. This produced two separate faults in one
-change (987a9e7): a Ctrl+click taking a selection from one row to two arrived reporting
-one, and a click collapsing three rows to one arrived reporting three. Any decision that
-depends on how many rows are selected belongs in a `selectionChanged` handler, which does
-see the true count; `currentRowChanged` is only safe for "which row is current".
-
-The related trap: **`selectAll()` emits no `currentRowChanged` at all** and leaves the
-current index invalid when nothing was current. A test that calls `selectAll()` on a fresh
-view therefore passes against a missing selection guard, because no signal ever fires. Test
-multi-select from a row that is already current, which is also how a user reaches it.
-
-**A `QDialog`'s buttons do not send a `QCloseEvent`.** `accept()` and `reject()`
-go through `done(int)`, which hides the dialog without ever closing a window, so
-a `closeEvent` override runs only for the window manager's X button. Anything a
-dialog must persist on the way out belongs in a `done(int)` override, which both
-buttons and `close()` reach. This shipped wrong in the rules dialog and the test
-covering it passed, because the test used `close()` and the user used Cancel:
-one route out of three. Assert every route. Underneath sits a second trap:
-`close()` on a widget that was never shown returns early WITHOUT reaching
-`done()`, so a test for the closed path has to `show()` the dialog first or it
-asserts nothing at all.
-
-**A modal dialog must close BEFORE the action it asked for runs, not after.**
-A signal from a dialog to its parent is a DIRECT connection, so the emit runs
-the handler synchronously while `exec()` is still on the stack: the details
-dialog's search ran the query, cleared the model and blanked the message pane
-while the dialog was still up, holding the `m_items` it was built from. Call
-`accept()` first, then emit. The mutation check for this HANGS rather than
-failing, since without the `accept()` nothing ever leaves `exec()`, and a hung
-test binary is item 84's second trap waiting to mislead the next run.
-
-**`Qt::RFC2822Date` validates the weekday against the date.** `Thu, 14 Aug
-2026` parses as INVALID because that day is a Friday, and an invalid parse here
-is indistinguishable from the trailing-comment trap `MimeParser::parseDate`
-exists to handle. Two fixtures carried a wrong weekday, one of them
-pre-existing and unnoticed until something finally parsed it. Write a date
-fixture with `date -d <yyyy-mm-dd> +%A`, never from memory.
-
-**Under a tiling compositor a window's size is not the application's to
-restore, and the user's desktop is Hyprland.** `saveGeometry` stores
-`frameGeometry` and `normalGeometry`; `restoreGeometry` restores the NORMAL
-one. When the compositor tiles the window to fill its slot, the size the user
-drags is the tile's, and `normalGeometry` keeps whatever the code last passed to
-`resize()`. Measured against the real state file after a hand test: frame
-2248x806, normal 760x664, so the dialog correctly restored 760 and correctly
-looked broken. A whole session went into "the geometry restore is broken" before
-the blob was decoded. Decode the stored geometry before theorising, and expect
-`maximized` to read as a value no bool should hold, which is the tiled state Qt
-records and does not round-trip.
-
-The corollary for tests: **the offscreen platform cannot test window sizing at
-all.** It prints "This plugin does not support propagateSizeHints()" and returns
-an identical frame for a correct restore and a broken one, verified in a
-standalone program containing none of this project's code. A size assertion
-there passes against both, and a mutation putting the bug back leaves the suite
-green. Assert on the stored value, and leave the frame to a hand test.
-
-**`ThreadListModel::threadAt(int)` takes a ROW and is wrong for any index that
-might be a reply.** A tree numbers rows per parent, so a reply's `row()` indexes
-its siblings and `threadAt(current.row())` on the first reply of any thread
-returns the FIRST THREAD IN THE LIST. This shipped in `markCurrentThreadRead`,
-was mostly masked while the write it guarded was thread-wide, and became "a
-random message was marked read" the moment a fix scoped that write to one
-message (items 87 and 88).
-
-**Use `threadFor(const QModelIndex &)`**, which resolves a message row through
-its parent and a thread row through itself. Item 88 added it on 2026-08-16 and
-converted every caller; `threadAt(int)` survives only for loops over
-`rowCount()`, which genuinely hold a top-level number. A new caller that has an
-index and reaches for `threadAt(index.row())` is reintroducing the bug.
-
-**The audit found four live sites, not the one that was reported**, which is the
-part worth remembering: `delete` and `toggle_unread` each chose their DIRECTION
-from the wrong thread, and the tag dialog counted the wrong thread's tags. All
-three were reachable by clicking a reply, none had a test, and the reported
-symptom named only `markCurrentThreadRead` (which was in fact protected by an
-unrelated guard and could not fire). One bad accessor produced four defects with
-one symptom between them.
-
-**A thread's first message is NOT among its children, and two lookups forgot
-it.** `setThreadMessages` drops depth 0 because the root row stands for that
-message, so `children` never holds it. `applyMessageTagChange` and
-`messageById` both search the root first now (item 109); before that, a
-message-scoped write to a root card repainted nothing, and the strip refresh set
-the pane's chips to the empty node the lookup returned, destroying a strip that
-had been correct. Item 108 made that the ordinary gesture rather than an edge
-case: the two changes were each correct and broken together.
-
-**A defensive counter for an unreachable case is worse than nothing, because
-it blocks the feature that needs the data.** `pendingEditCount()` summed a
-fourth term, a bare int for confirmed changes carrying no message ids, added
-on the sound reasoning that an edit which cannot be netted must not be lost.
-It made the count impossible to LIST: three groups could name what they held
-and the remainder could not, so item 119 sat open for a week carrying "a list
-cannot be complete without changing how the count is kept".
-
-The remainder was empty. `NotmuchWorker::applyTags()` is the only emitter of
-`tagsApplied()` and returns early on an empty id list, which is the exact
-condition the counter required. Reading the code said it was reachable, and
-that reading was wrong twice before it was measured: a `qFatal` in the branch
-fired in 4 of 70 `test_mainwindow` cases, all four building a `TagChange` by
-hand and invoking the slot directly, and a `Q_ASSERT` before the worker's own
-emit never fired across the whole suite. **Instrument the branch and run the
-suite; do not conclude reachability by reading.** The tests that exercised it
-were the evidence it was live, and they were driving it from outside the
-production path.
-
-**`PendingChangesDialog` groups by a RUN, not by a map**, and the snapshot it
-consumes is sorted with `std::stable_sort` for that reason: the actions under
-one message have to keep the order they were made in, and `QHash` has no order
-of its own, so a map would reshuffle the list between openings.
-`PendingChangeRow::startsMessage` is carried rather than inferred from a
-non-empty subject, because an id the index no longer holds has an EMPTY
-subject and must still open a run; inferring it folds a stale row's actions
-under the message above it, which is a worse lie than "subject unknown".
-
-**A queued call carrying a container needs the same suspicion as a `Q_ENUM`.**
-An unregistered metatype is dropped at runtime with a warning and the slot
-runs with a default. Measured on Qt 6.11: `QList<bool>` and `QList<int>` both
-cross `resolvePendingSubjects()` intact with no registration, but a standalone
-probe showed `QMetaType::fromName("QList<int>")` invalid while
-`QList<bool>` resolved, so the property is not obvious from the type. The test
-that drives it across a real thread is what says it works, and what fails if
-it stops.
-
-**`ThreadSummary::tags` is notmuch's UNION over the thread, and a conversation
-row draws exactly that.** This REVERSES items 110 and 111, deliberately, under
-item 177 on 2026-08-28, and the reversal is the point rather than a regression:
-both of those solved the problem of a card standing for one message while
-carrying its thread's tags, and item 177 removed the premise instead. A row
-with replies IS the conversation, so the union is the honest answer and needs
-no correction. A row with `totalCount == 1` has a union that IS its message, so
-the union is honest there too. There is no case left where a card claims a tag
-its subject does not have.
-
-What went with them: `setRootMessageTags()`, `ThreadSummary::firstMessageTags`,
-the two-tier `PillTagsRole` / `PillOwnCountRole` split, `CardLayout::siblingFont()`
-and `CardDelegate::mutedChipColour()`. The chips are one tier, in one font, in
-one colour treatment. **The user was asked and confirmed they are happy to lose
-the second tier**, so do not restore it on the reasoning that item 111 recorded:
-that reasoning was sound for a card that stood for one message, and no card does
-any more. Restoring it would reintroduce a per-message identity on a row that
-has none, which is exactly the ambiguity item 177 exists to remove.
-
-The one thing to keep from that history: `applyMessageTagChange` keeps the
-summary in step only when `totalCount <= 1`, where the union IS the message.
-On a longer thread it deliberately leaves the summary alone, which is why the
-membership rule below has a documented lag.
-
-**`TagStrip::visibleTags()` measures the LAYOUT, not the data.** It is one row
-that collapses the overflow into a trailing "+N" chip, and an unshown window
-under the offscreen platform has no width, so nearly everything lands in
-`hiddenTags()`. A test asserting on `visibleTags()` alone passes or fails on how
-many tags happened to fit; two shipped that way before it was noticed. Assert on
-`visibleTags() + hiddenTags()`.
-
-**The message pane holds TWO `TagStrip`s since item 177**, one in the message
-view and one in the dashboard, so an unqualified `findChild<TagStrip *>()`
-returns whichever the object tree happens to yield first and a test written that
-way asserts about the wrong widget. Both carry object names:
-`messageTagStrip` and `dashboardTagStrip`. Find by name.
-
-**A message-scoped write repaints the MESSAGE's row, never the thread's.**
-`ThreadListModel::applyMessageTagChange()` is the counterpart to
-`applyTagChange()` and exists because there was no optimistic update at all for
-a one-message edit: the correct observation that repainting a thread card for a
-one-message change is a lie was turned into the wrong conclusion that nothing
-should repaint, so Delete and Toggle unread on a reply moved the pending count
-and changed nothing on screen (item 105). The thread card deliberately stays
-put; one deleted reply does not doom the conversation.
-
-**A row is either a CONVERSATION or a MESSAGE, and one question decides which.**
-Item 177, 2026-08-28. This REVERSES item 108, which had made a thread row mean
-the one message its card displayed. That was a coherent answer to a real
-ambiguity and it did not hold: a row that means a message needs a second set of
-actions to reach its thread, and the two sets then disagree about what the
-gesture the user just made was for. Item 177 answers it at the row instead.
-
-`ThreadListModel::isConversationRow()` is the single predicate. A summary with
-replies is the conversation; a summary with `totalCount == 1` is its message and
-behaves exactly as it always has, opening on one click; a reply row inside an
-expanded thread is its own message. `scopeForSelection()` is the ONE resolver,
-replacing the `scopeFor()` / `messageScopeFor()` pair, because a pair made the
-CALLER choose and that choice was the ambiguity. It resolves per row, so a mixed
-selection carries both kinds.
-
-**Do not restore `messageScopeFor()`, the five `*_thread` action names or the
-"Whole thread" submenu.** They are removed, with an `### Upgrading` note in the
-changelog. A second set of actions is a second answer to a settled question, and
-`ThreadSummary::firstMessageId` no longer decides a thread row's scope at all
-(it survives for other callers). A new action asks `isConversationRow()`; it
-does not add a `_thread` twin.
-
-**Delete and Archive are ABSENT on a reply row, not disabled**, at the user's
-own decision: removing one reply from a conversation is not offered. A thread of
-one keeps them, since there the two acts are the same, so the test in
-`refreshActionLabels()` is "is this a reply", which only a message row can be.
-Forward and Save disappear on a conversation row, and Reply is one entry,
-"Reply to this thread", which is reply-all and quotes nothing.
-
-**The unread toggle is a CATCH-ALL, and that reverses item 112's hiding rule.**
-Item 112 hid the toggle whenever the selection disagreed with itself, on the
-correct reasoning that a union is not a state. That was affordable only because
-the `Whole thread` submenu carried absolute entries beside it; with the submenu
-gone, hiding leaves no way to act at all. So: ANY unread message, a mixed
-conversation included, reads "Mark thread as read" and marks every message read;
-only a fully read selection reads "Mark thread as unread". Two presses reach
-either state from anywhere. **The write direction must move with the label**, or
-a mixed conversation gets marked unread under a label promising read.
-
-The automatic mark-read still follows item 87's rule for a message row
-(`m_markReadMessageId`, scoped to the displayed message). It is NOT armed for a
-conversation row, because a conversation puts no single message on display and
-there is nothing to mark.
-
-**Membership is the union, with no exceptions.** A thread belongs to a view
-while ANY of its messages match it, so reading one message of five does not take
-the conversation out of Unread. `MainWindow::syncViewMembership()` is the guard
-and is called from all three funnels (message, thread, move). Three properties
-of it are decisions rather than implementation:
-
-- A row is **never evicted while it is current.** The automatic mark-read fires
- two seconds after selection, so evicting would take the row out from under the
- user mid-read.
-- A write the user **asked for** evicts at once; an **automatic** one defers
- until the selection moves (`m_deferredEvictions`). The deferral keys on the
- write being automatic, so a test that calls the send path directly exercises
- the opposite branch: drive it through the mark-read TIMER.
-- The inverse case **refreshes rather than inserting.** The model holds no
- summary for a thread the query never returned, so a row that starts matching
- cannot be inserted optimistically. Without this an undone mark-read stayed
- invisible in the view it was undone in.
-
-**One lag is deliberate and is not a wrong answer.** Reading the LAST unread
-message of a long conversation does not evict it immediately, because
-`applyMessageTagChange` leaves a long thread's summary alone (see the union rule
-above), so the union the model holds is stale and judging on it would be wrong
-in BOTH directions. `threadCountFor()` is the question a message-scoped write
-asks before judging: a thread of one has a union that moved, a longer one does
-not. The row leaves at the next query or sync.
-
-**Adding an action is FIVE places, and three of them are enforced by tests that
-fail in confusing ways.** `KeyMap::knownActions()` (a `Q_ASSERT` in the
-constructor fires otherwise, and it surfaces in whichever suite happens to build
-a `MainWindow` first — `test_tagrules` did), `defaultBindings()` (OPTIONAL
-since item 132: a shortcut is a chosen subset, not a requirement, so an action
-nobody would press a chord for simply gets no entry and the shortcut reference
-prints it as `(unbound)`), the icon table (every action must carry one), and
-a MENU. The no-duplicate-icons rule is narrowed to actions that can reach the
-toolbar, by a named exception list, and the test asserts none of the exempt
-actions is on the toolbar so the exemption cannot be abused. The list held six
-whole-thread actions until item 177 deleted them; it is down to
-`reply_no_quote`, which is a menu entry that always carries its text. Note it is
-named for the PROPERTY that earns the exemption, not for the tier that first
-needed one, which is why it survived that tier's deletion unchanged.
-
-**The menu was the fifth place, and this document said four until item 103.**
-Nothing enforced it, so `restore` shipped on the trash branch reachable by
-`Ctrl+R` and by nothing a user could see or discover. The three existing
-coverage tests each assert a different property and all three pass against an
-action that appears nowhere in the interface.
-`everyActionIsReachableFromAMenu()` closes it, walking every menu and submenu
-from the menu bar; it found three more of the same the moment it was written
-(`open_thread`, `clear_pane`, `clear_selection`). The toolbar is deliberately
-NOT the test's instrument: it is a small chosen subset and always will be. An
-action owning a submenu is not itself counted as reachable, since Qt emits no
-`triggered` for it.
-
-**A toggle must read the state of what the row STANDS FOR, not of its thread.**
-`MainWindow::everySelectedRowHasTag()` is the one question `delete` and
-`toggle_unread` both ask; a reply row answers from its message, a thread row
-from its thread. Reading the thread makes a toggle ONE-WAY on a reply, and the
-failure is silent in a specific way worth knowing: the write is message-scoped,
-so it never changes the thread's tags, so the answer never moves however many
-times the key is pressed. The second press re-sends a tag the message already
-has, which is a no-op, and a no-op repaints nothing. The user reports this as
-"the key does nothing", not as "the key did the wrong thing" (item 105).
-
-This is the SECOND fix to the same three lines. Item 88 corrected which thread
-they resolved; that was necessary and not sufficient, because a reply needs a
-message read rather than a better thread. "Resolved through the index" and
-"resolved to the right object" are separate properties, and a test for the first
-passes against the second being wrong.
-
-**Any state a thread row draws, a reply row has to draw too, and this was
-missed once already.** The message-row branch of `data()` is a separate switch
-from the thread branch, so a cue added to one is simply absent from the other
-with nothing to flag it. The doomed fill and the strike-out were thread-only
-from item 13 until item 105, which is why updating the node was not enough on
-its own to make Delete visible. When adding a visual state, check both branches.
-One asymmetry is deliberate and must survive: a reply carries no tag strip. A
-reply is now BOLD when unread, at the user's request on 2026-08-16, combining
-with the dimming for the same two-cue reason a thread row has both; the smaller
-reply font is what keeps it subordinate. A `deleted` reply deliberately shows
-the chip as well as the fill and strike-out, matching the thread row, confirmed
-with the user rather than treated as redundancy to remove.
-
-**And the reverse: a thread-scoped write must reach the thread's LOADED
-replies.** `applyTagChange` updated the summary only, so marking an expanded
-thread read left every reply bold and undimmed until the next query (item 107).
-The symptom reads as a missed repaint and is not: the rows were redrawn from
-data that had not changed. When a model update looks like it did not paint,
-check whether the data behind those rows actually moved.
-
-**Every path a thread-scoped write travels, a message-scoped one travels too,
-and each one was missed separately.** Three of them, found one hand-test round
-apart: the optimistic model update (item 105), the message pane's tag strip
-(also 105, keyed on `m_currentMessageId` and read by id through
-`ThreadListModel::messageById()`, never from `currentIndex()`), and
-`flushHeldEdits()`, which re-sent only thread edits and therefore DROPPED any
-message edit made during a sync after showing it and counting it as pending
-(item 106, data loss, never reported). When adding anything to
-`sendThreadTagChange`, check whether `sendMessageTagChange` needs it. Escalating
-a message edit to its thread is never the fix: it deletes a whole conversation
-when the user deleted one reply.
-
-**Anything applied optimistically must also be reverted.**
-`revertPendingTagChange()` keyed on `m_pendingThreadIds` alone, so adding the
-message-scoped optimistic update would have left a FAILED message write showing
-its optimistic state for good. Both scopes revert now. Undo needs nothing extra:
-`MessageTagCommand` routes back through `sendMessageTagChange`.
-
-**Testing this needs two things that are easy to miss.** Put the reply under the
-SECOND thread, so the wrong answer is plausible rather than accidentally right,
-and give the two threads OPPOSITE states, since two threads in the same state
-answer identically whichever way the code resolves them. That second point is
-why the reverted item 87 fix was mutation-checked and green while corrupting
-real mail. For a toggle, assert on `undoText()`: both directions push one
-command over the same rows, so depth and ids cannot tell them apart.
-
-**A test for a mutation on a data-writing path must exercise the REPLY case,
-not only the root.** The reverted fix above was mutation-checked and green: it
-asserted on a root selection, which is the one case where `current.row()` is
-correct. A green mutation check proves the test can fail, not that it covers the
-case that matters.
-
-**`test_mainwindow` can now drive a real worker, and three things about it will
-waste a session each.** `WorkerBackedWindow` builds a throwaway notmuch
-database and writes a `qtmaildir.conf` pointing at it; `wireWorker()` reads
-`notmuch_config` like any other key, so no production hook exists or is needed.
-It is opt-in per test because the fifty-odd bare-window cases must not pay for a
-`notmuch new`. The three traps, all found by a probe that reported success while
-measuring nothing:
-
-- **The worker is unreachable by `findChild`.** It is created parentless and
- moved to its own thread, so it is not in the window's hierarchy. Wait on
- observable state with `QTRY_VERIFY_WITH_TIMEOUT`, never on worker signals and
- never on a fixed `qWait(n)`, which passes when the result never arrives.
-- **`rowCount()` on a thread row is 0 until the thread is expanded**, since
- children are populated by the expansion. `hasChildren()` is the pre-expansion
- question and falls back to `summary.totalCount > 1`. An assertion on
- `rowCount` fails against correct code.
-- **A `ThreadSummary` fixture needs `totalCount` set to what it MEANS**, since
- item 177 reads exactly that to decide whether a row is a conversation or a
- message. A summary left at the default is a message row, so a test meaning to
- exercise a conversation quietly exercises the other branch and passes for the
- wrong reason. `makeThread()` sets it; a hand-built summary must too.
- `firstMessageId` still matters for a `totalCount == 1` row, which resolves to
- its message: without one it names nothing and every action on it silently does
- nothing. Ten tests failed that way at once under item 108, all reporting "the
- action did not happen", which reads as a defect in the action rather than a
- gap in the fixture.
-- **`currentThreadId()` reports INTENT, not content.** It is assigned
- synchronously in the selection handler before any worker round-trip, so a test
- asserting on it passes with `onThreadLoaded()` disabled entirely, measured.
- `MessageView::showingPlaceholder()` is what the user sees; assert the pane is
- blank BEFORE the gesture so the check after it means something.
-- **A `QStackedWidget` takes the LARGEST minimum width of all its pages**, so a
- page that is not showing can inflate the pane's minimum and squeeze the widget
- beside it. The dashboard raised the message pane's minimum to 395px over
- `MainWindow`'s own 300px floor, and the thread list lost the difference. Fixed
- with `setMinimumWidth(0)` and `QSizePolicy::Ignored` on the stack. An existing
- resize test caught it, which is the argument for keeping resize tests that
- look like they assert nothing interesting.
-
-**Five tests changed under item 177 and must not be restored as they were.**
-Two are RETIRED because the behaviour they asserted is the behaviour that was
-reversed: `selectingARootCardKeepsItsThreadForMarkRead` (replaced by
-`selectingAConversationArmsNoMarkRead`, since a conversation arms no mark-read
-at all) and `anUnexpandedRootRendersOneMessageNotTheConversation` (replaced by
-`anUnexpandedRootShowsTheDashboardLikeAnExpandedOne`, which keeps item 66's real
-value, consistency across the expansion boundary, and inverts only which way
-that consistency runs). Three are RETARGETED to `totalCount == 1` rows, where
-their assertions still hold and still matter:
-`autoMarkReadTouchesOnlyTheMessageOnDisplay` (item 87's data-safety property,
-still asserted), `theStaleNoticeKeepsTheMessageOfAThreadRootToo` and
-`aLoadedMessageCorrectsTheStripFromTheThreadsUnion`.
-
-**A queued load can outlive the state that started it.** `loadThread` crosses to the worker
-on a queued connection, so its reply lands after whatever the UI did in the meantime. The
-generation counter covers a superseded *query*, not a superseded *selection*: blanking the
-pane and then receiving an in-flight thread repaints it. `onThreadLoaded` therefore drops a
-reply that arrives while more than one row is selected. This class of bug USED to be
-unreproducible in `test_mainwindow`, which had no worker and never fired `threadLoaded`.
-Item 36 changed that: `WorkerBackedWindow` (above) gives a test a real worker, and the
-`onThreadLoaded` guard is covered by one.
-
-**Do not conclude a key binding is dead from `QTest::keyClick()`.** Whether a symbol needs
-Shift is a layout property, not a Qt one. `Ctrl++` is the shipped `zoom_in` default and is
-exactly what the `+` key emits on an Italian layout, while synthetic input never delivers
-it. Verify against a real keyboard before changing a default on reachability grounds. The
-separate, real trap `normalizeSequence()` handles is a **bare capital** (`N` parses to
-unshifted Key_N, which no keystroke emits).
-
-**Rendering probes lie in specific, repeatable ways.** A whole session was spent chasing
-a defect that did not exist because of these; each was believed until it was contradicted.
-
-- **Counting lit pixels cannot tell bold from regular.** Antialiasing lights a similar
- number either way, so an "ink count" reads identical whichever is true, in both
- directions. It measures nothing. **Text width** distinguishes weights (277px against
- 306px for one string at 12pt), and a **strict pixel diff** distinguishes renders.
-- **`viewport()->render()` returns a blank image** in several ordinary situations: before
- the widget is exposed, when the content sits outside a viewport narrower than the
- columns, and sometimes with no discernible cause. A probe that reports "no ink anywhere"
- is far more likely broken than the code it is testing. Check that it finds the thing it
- expects to find *before* trusting it to report the thing it expects to miss.
-- **A row scrolled out of the viewport reports a `visualRect` with a real height**, so a
- guard asserting `rect.height() > 0` passes while the pixel loop below it walks zero
- rows and reports "0 pixels, the row was painted over". Item 93 hit this by adding four
- buttons to the query row: the thread list shrank, and a test sizing its window to 300px
- started failing with a message naming a defect that did not exist. Assert the rect is
- INSIDE the viewport, not merely non-empty.
-- **A probe can be correct and still measure nothing, by being pointed at the
- wrong object.** A test for the sibling chip's padding called
- `TagChip::sizeFor()` directly: that proves what the function does and nothing
- about whether the delegate asks it for a scaled padding, so a mutation
- dropping the scale at the call site stayed green. Assert through the function
- the production path actually calls (`CardDelegate::chipSize()`), not through
- the one it calls INTO.
-- **A "saturated pixel" threshold catches antialiased edges of the selection highlight**,
- hundreds of distinct near-background colours, and will pass whatever the code does. Match
- the exact colours the model supplies instead. Two versions of one test passed under
- mutation before this was noticed.
-- Every rendering test needs a **mutation check** and a guard proving it *can* fail: assert
- the geometry it depends on (a column is on screen, a row has non-zero height) rather than
- assuming it.
-
-The bug that started all this was not in the code at all: the desktop's Qt font was
-configured **Bold** in qt6ct, so every row rendered bold and `setBold(true)` changed
-nothing. Before concluding a Qt facility is broken, check the desktop's own font and theme
-configuration.
-
-**`QString::arg()` does not collapse `%%` into `%`.** `printf` does, and the habit
-transfers silently. In generated CSS this is quietly destructive: every percentage written
-`%%` to escape it reaches the browser malformed, and a browser does not report a bad
-declaration, it **drops that one rule and renders the rest**. The 0.11.0 placeholder lost
-its mask, its glow and both radial gradients this way while still painting a plausible
-pane, so nothing looked broken. Write `%` directly; `arg()` only ever consumes `%1`..`%99`.
-
-The reason it survived review is worth more than the rule: **a geometry probe endorsed the
-layout**, because it measured only properties that carried no percentage. A probe that
-cannot see the thing that breaks will report success forever. When asserting on generated
-CSS, assert on the **generated string** as well as on the rendered result, and make sure
-the assertion covers the declarations that actually went missing.
-
-## Web view security
-
-The most security-sensitive area: a browser engine pointed at input from strangers. Do not
-loosen any of these without an explicit decision.
-
-- Off-the-record `QWebEngineProfile`, JavaScript disabled, `LocalContentCanAccessRemoteUrls`
- and `LocalContentCanAccessFileUrls` both false.
-- The interceptor **blocks every request by default** and **fails closed**: with no document
- URL set, every `qtmaildir:` URL is denied. The document-load exemption matches the
- **exact** base URL passed to `setHtml()`, never the `qtmaildir:` scheme as a whole — a
- scheme-wide allow would let a hostile body reference `qtmaildir://anything` and be trusted.
- Consequence: `MessageView` **must** call `setDocumentUrl()` with the same URL it gives
- `setHtml()`, or nothing renders.
-- The pane renders a LIST of messages into one HTML document in one web view (a
- `QWebEngineView` per message would spawn a Chromium render process each). That makes `cid:`
- ids collide across messages, so every reference is rewritten to `cid:<prefix>!<id>`.
- **A `cidPrefix` must never contain `!`** — it is the namespace separator.
- Since item 66 removed the conversation view every caller passes exactly ONE message, so the
- collision cannot currently arise; the prefixing stays because the list-rendering path does,
- and a security property must not rest on every caller happening to pass one item.
-- Remote content grants are per-render and never sticky.
-- **Attachment filenames are untrusted input.** Reduce to basename, strip separators, resolve
- against the chosen directory, and refuse anything escaping it. Compare resolved paths as
- paths, not with `startsWith` — `/tmp/safe-evil` passes a `startsWith("/tmp/safe")` check.
-
-## At the start of a session: reconcile the backlog with the user's notes
-
-The backlog at `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md` is
-**downstream** of the user's own notes at
-`~/Documents/Obsidian/note/notes on qtmaildir.md`. The user writes to those notes
-whenever they use the application and hit something, so the backlog goes stale on
-its own between sessions.
-
-**The backlog holds the status table and the open sections only.** A closed
-item's section lives in `2026-08-03-post-0.1.0-usability-closed.md` beside it,
-moved there by item 73 on 2026-08-13, so grepping the backlog for a done item's
-evidence finds the table row and nothing else. Both files use one numbering
-sequence: item 42 is `## 42.` in whichever file holds it. When an item closes,
-move its section across on the same commit rather than leaving it for a later
-cleanup, which is exactly how the file reached five thousand lines the first
-time.
-
-**Read both and diff them before picking up work.** Anything in the notes with no
-item in the backlog gets appended with the next free number, in the backlog's
-own format (Observed / Cause / Approach / Constraints), with the cause **verified
-in the code, not copied from the note**. The two documents are numbered
-independently and drifted long ago; never renumber to reconcile them.
-
-This is not busywork. The 2026-08-04 pass found nine unrecorded entries, two of
-them defects rather than enhancements, and one of those was a constraint this
-backlog had already specified and that shipped unbuilt (item 29). A note saying
-"X does not work" is a bug report, and it will sit in a personal notes file
-indefinitely unless someone goes looking.
-
-**The backlog covers the mail system, not only this binary.** An item can land
-in `assets/hooks/` rather than in `src/`, and item 166 is one: the tagging hook
-is part of the mail system the user sees, so a defect there gets an item here
-like any other. Item 44 predates that and shipped as commits in this repo and in
-the retired `mailctl`, which is why older entries mention a sibling repo; there
-is no longer one to split work across.
-
-**Then print the open items as a table, and stop.** The user picks what to work
-on; do not start on one, and do not recommend a single item as though the choice
-were made. Read the status table for anything not marked `done`, `dropped` or
-`postponed`, and render:
-
-| # | Item | Size | Note |
-|---|------|------|------|
-
-- **Item** is a short description, not the table's own terse title. Say what the
- user would notice, not the internal name.
-- **Size** is the backlog's own: XS under an hour, S a sitting, M a session,
- `?` for an item whose shape is not known yet.
-- **Note** is the one thing that decides whether it can be picked up now: a
- defect rather than an enhancement, a decision needed from the user first, a
- dependency on another item, or a constraint that makes it bigger than it
- looks. Leave it empty when there is nothing of the sort.
-
-Flag defects separately from enhancements. They read alike in a numbered list
-and do not deserve equal billing: item 28 sat as "a counter is wrong" while the
-indicator was quietly lying about whether the user's work was safe to quit on.
-
-Items marked `open, unspecified` (20, 21) cannot be planned from the backlog
-alone; they need the user to describe what they pictured. Say so in the Note
-rather than proposing a design.
-
-Two gotchas when reading the status table. Item 12 lives in the **"Deferred,
-unsized, or split out"** table further down, which has different columns and
-carries no size, so a grep across `^| <n> |` picks it up with its description
-where the size should be. And an item's status cell is prose, not a keyword:
-`open, on demand` (36) and `open, unspecified` (20) are both open. Read the
-cell, do not match on `open` alone.
-
-## Working on this repo
-
-Implementation follows `docs/superpowers/plans/2026-08-02-qtmaildir-v1.md` (14 tasks)
-against `docs/superpowers/specs/2026-08-02-qtmaildir-design.md`.
-
-**Treat every code block in the plan document as a draft, not as correct.** Nine defects
-have already been found in code that was written confidently into it, two of them
-security-relevant. Verify Qt API assumptions empirically rather than from memory — several
-of those defects were wrong assumptions (e.g. `QKeySequence::fromString` never returns an
-empty sequence for garbage input).
-
-TDD, per the user's global preference. `NotmuchWorker` is unit-tested against a throwaway
-notmuch database built in a temporary directory (generated Maildir + `notmuch new` +
-`NOTMUCH_CONFIG` scoped to the test process), superseding the spec's original "no unit
-test" position — it is the only code that writes to a notmuch index.
-
-Work goes directly on `master`, no PR flow. Commits must be GPG-signed (`git commit -S`).
-`HANDOFF.md` is local-only and gitignored; never stage or commit it.
-
-**There is no "v1" and no "v2".** The project follows semver on its
-user-visible surface, and those labels described a scope split that stopped
-being true when compose and send shipped. Reading, organizing and sending are
-all part of the application now. The phrase survives in the older spec and
-plan documents, which are historical records and are not being rewritten; read
-it there as "before compose" and "after compose".
-
-## Cutting a release
-
-Five steps, and **the GitHub Release is part of cutting a release, not a
-follow-up.** The repo had fourteen tags and zero releases until 2026-08-09,
-because the first four steps were treated as the whole job.
-
-1. Move the `[Unreleased]` entries under a new `## [X.Y.Z] - YYYY-MM-DD`
- heading, leaving `[Unreleased]` in place and empty above it. Add a one or
- two sentence summary under the heading, and an `### Upgrading` section when
- a user's own config or habits need to change.
-2. Bump `project(qtmaildir VERSION ...)` in `CMakeLists.txt`, the only place
- the version lives. Reconfigure, build, and check `./build/src/qtmaildir
- --version`. An ordinary dev build answers `X.Y.Z build N`, because
- `QTMAILDIR_BUILD_NUMBER` counts rebuilds so one binary of an unreleased
- version can be told from another (item 167). That is the DISPLAY version;
- what a release ships is the clean one, from
- `-DQTMAILDIR_BUILD_NUMBER=OFF`, which is what the SlackBuild configures and
- what a tarball with no build directory produces anyway. Check the clean
- form before tagging.
-3. Commit as `release: X.Y.Z`, then `git tag -s vX.Y.Z -m "qtmaildir X.Y.Z"`.
- Tags are annotated and GPG-signed, matching every existing one.
-4. `git push && git push --tags`. `origin` carries two push URLs, the personal
- server and GitHub, so one push reaches both.
-5. Create the GitHub Release from the tag, with the body taken from that
- version's changelog section rather than written fresh:
-
- ```bash
- gh release create vX.Y.Z --repo danixland/qtmaildir \
- --title "qtmaildir X.Y.Z" --notes-file <section>.md --verify-tag
- ```
-
- Normal release, never `--prerelease`: GitHub's "latest" badge ignores
- prereleases, and every 0.x here is a real release the user runs daily. The
- pre-1.0 stability caveat is already stated at the top of the changelog.
-
-**Version choice is semver on the user-visible surface**, pre-1.0 included. A
-changed label, a changed default, or anything in an `### Upgrading` section is a
-minor bump, not a patch: 0.12.0 renamed an action and changed the toolbar's
-button style, and both are things a user notices without reading the changelog.
-
-**The SlackBuild is no longer in this repository.** It moved on 2026-08-15 to
-the `my-slackbuilds` repo, under `qtmaildir/`, SBo compliant and building from
-the GitHub release tarball. It is **not** part of this procedure and never
-was: bumping it is a task in that repo, which has its own workflow in its
-`AGENTS.md` (edit the version in the `.SlackBuild` and the `.info`, two-pass
-`sbodl` for the checksum, `sbolint`, then wait to be told to commit). It also
-tracks upstream through an nvchecker stanza there, so a release here is picked
-up by that repo's own sweep.
-
-## Changing the rule format
-
-`~/.config/mailrules/rules.json` has **two independent implementations**, both
-in this repo, and they agree by test rather than by sharing code:
-
-| | reads/writes | applies rules |
-|---|---|---|
-| `src/tagrules.cpp` | yes | no |
-| `assets/hooks/mailrules.py` | yes | via the `post-new` hook |
-
-They are two languages either side of one file, so nothing but the format
-couples them. Both readers preserve fields they do not understand
-(`TagRule::unknown`, `Rule.unknown`), which is what lets one save a file the
-other wrote without stripping it. This was a cross-repo coupling with the
-`mailctl` CLI until that project was retired and the hooks moved here on
-2026-08-23; the discipline below survives the move because the two readers do.
-
-**The live hook runs every ten minutes on real mail.** Before touching the
-schema:
-
-1. Change both readers, not one. A field added on one side and not the other is
- silently dropped on the next save from the other, which looks like data loss
- with no error anywhere.
-2. Bump `kFormatVersion` / `FORMAT_VERSION` together only for a BREAKING change.
- Both readers refuse a file whose version they do not know, which is the
- correct behaviour and also means a half-applied bump stops the hook from
- tagging. Adding an optional field needs no bump.
-3. Run both suites: `ctest --test-dir build -R tagrules`, and
- `./test_post_new.py && ./test_mailrules.py` from `assets/hooks/`.
-4. Verify the round trip by hand, since no automated test spans the C++ and the
- Python: save from the dialog, then run the hook over a throwaway index, and
- confirm the rule count and a note survive.
-
-**Two hook properties are safety-critical, and being ours now is not a reason to
-weaken them.**
-The hook refuses to remove `unread` or `inbox` (`maildir.synchronize_flags` is
-true, so removing `unread` rewrites Maildir filenames and reaches the server),
-and it does not consume the `tag:new` marker when the rules fail to load
-(clearing it while rules did not run orphans that mail permanently and
-invisibly). A dialog here that offers to write such a rule would produce one the
-hook then refuses; that is the correct direction, but say so in the UI rather
-than letting it fail silently.
-
-Backfill, applying a rule to existing mail, is deliberately unbuilt. See
-`docs/superpowers/specs/2026-08-12-tagging-rules-design.md` for what it needs
-first, including the revision it forces to the no-confirmation rule above.
+@AGENTS.md