From 2e62a0cf56a3424f6dfa2bd60bf7f9d5825a34b5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 13 Aug 2026 18:13:55 +0200 Subject: docs: split the closed backlog items out to a companion file Item 73. The backlog kept every item's full Observed/Cause/Approach section forever, including the sixty-eight that are closed, and had reached 5056 lines: past the point where it could be read in one pass, and past the point where a tool could open it at all. The closed sections move to 2026-08-03-post-0.1.0-usability-closed.md, taking the backlog to 570 lines. The status table stays where it was and remains the index of all 80 items, so a closed item keeps its row, its date and its outcome beside the open ones; only its evidence moved. Nothing was renumbered and nothing was deleted, which the item required: the numbering is cited from commit messages, from CLAUDE.md and from the specs, and both files share one sequence, so item 42 is `## 42.` in whichever file holds it. The split was done by script and verified by set difference rather than by reading: every non-blank line of the original appears in one of the two files, zero missing, and the only lines not in the original are the new file's header. All 80 numbers resolve, every open item has its section in the backlog, every closed one in the archive, with no duplicates and no orphans. Two things the item's own approach did not anticipate. Three cross-references said "see below" and their targets had just moved, so rows 60 and 75 and the header's note on item 20's parked branch now say where the entry went. And the cause was never the fifty done sections, it was that nothing moved a section on the day its item closed; doing this once buys a few months and then item 73 returns. The rule in "Adding to this document" now requires the move on the closing commit, and CLAUDE.md tells a future session that grepping the backlog for a closed item's evidence will find the table row and nothing else. Co-Authored-By: Claude Opus 5 --- .../2026-08-03-post-0.1.0-usability-closed.md | 4534 ++++++++++++++++++ .../plans/2026-08-03-post-0.1.0-usability.md | 4844 +------------------- .../plans/2026-08-08-item-20-message-rows.md | 4 +- 3 files changed, 4716 insertions(+), 4666 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md (limited to 'docs') diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md new file mode 100644 index 0000000..4ea175a --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md @@ -0,0 +1,4534 @@ +# Post-0.1.0 usability backlog: closed items + +Companion to `2026-08-03-post-0.1.0-usability.md`, which stays the backlog. +This file is the history: the full Observed / Cause / Approach section of every +item that is done, dropped or postponed, moved here by item 73 so the backlog +reads as what is still open. + +**Nothing here was deleted and nothing was renumbered.** The status table in the +backlog is unchanged and remains the index: it carries every item's number, size +and outcome, including the ones whose sections live here. Section numbering is +this document's own in the same sequence, so item 42 is `## 42.` in whichever +file holds it. + +Sections appear in their original order. A trap recorded here that is still true +of the code belongs in `CLAUDE.md`, where it will actually be read; several +already are, and this file is not a substitute for that. + +## 1. Splitter and column widths do not survive restart + +**Observed:** resizing the thread list pane, or a column inside it, is undone by +the next launch. + +**Cause:** `MainWindow::buildUi()` (`src/mainwindow.cpp:195`) builds the +`QSplitter` fresh every launch, sets a stretch factor, and never saves state. +`resize(1200, 800)` at `src/mainwindow.cpp:206` hardcodes window size too. There +is no `QSettings` window-state read or write anywhere in the class. + +**Approach:** one `saveState`/`restoreState` pair in +`MainWindow`, driven from a `QSettings` object separate from the hand-written +config file. + +- Save on `closeEvent`, restore at the end of `buildUi()`. +- Persist: `QMainWindow::saveGeometry()`, `QMainWindow::saveState()`, + `QSplitter::saveState()`, `QHeaderView::saveState()` for the thread list. +- Restore must be a no-op when the stored blob is absent or rejected, falling + back to the current hardcoded defaults. `restoreGeometry()` returns `false` + in that case; do not assume it succeeded. + +**Where the state file goes.** The hand-edited config lives at +`~/.config/qtmaildir/qtmaildir.conf` and is the user's to own. Machine-written +window blobs must not land in it: a base64 `QByteArray` appearing in a file the +user edits by hand is hostile, and rewriting that file on exit risks clobbering +comments and formatting QSettings does not preserve. Use a **separate** +`QSettings` instance for UI state, at +`~/.local/state/qtmaildir/uistate.conf` or the `QStandardPaths` equivalent, and +keep `Config` untouched. + +**Confirmed by the user, 2026-08-03.** This decision covers items 1, 4, and 10 +as well. Establish it once, in whichever lands first. + +**Verification:** manual. Resize both the window and the splitter, restart, +confirm both held. Then delete the state file and confirm the app still starts +with the 1200x800 default rather than a zero-size window. + +### Outcome (done) + +Built as described. `MainWindow::uiStatePath()` establishes the state file the +plan calls for, so items 4 and 10 inherit it. Two things worth recording: + +- **`QStandardPaths::StateLocation` is the wrong enum here.** It appends both + the organization and the application name, and this app sets both to + `qtmaildir`, so it yields `~/.local/state/qtmaildir/qtmaildir/`. The path is + built from `GenericStateLocation` plus an explicit `/qtmaildir`, the same + shape as `Config::defaultPath()`. A test pins the component count. +- **`restoreUiState()` runs after `buildMenus()`, not at the end of + `buildUi()`** as the plan proposed. `QMainWindow::restoreState()` matches + toolbars by object name, so a toolbar that does not exist yet has its + position silently dropped. + +Every restore is guarded on a non-empty blob, so absent state leaves the +`buildUi()` defaults rather than producing a zero-size window. + +## 2. No way to see full message details + +**Observed:** From, To, Cc, Subject and the rest are not visible for the +selected message. + +**Cause:** partially true rather than wholly. `MessageView::updateHeader()` +(`src/messageview.cpp:219`) shows only the thread subject and a message count. +Per-message From and Date *are* rendered inside the HTML body as `.msg-header` +(`src/htmlbuilder.cpp:241`), styled at 9pt grey, which is easy to miss and does +not include To or Cc at all. + +**Check before building:** does `MimeParser` already extract To and Cc into the +message struct, or does `src/types.h` / `MimeParser`'s output need extending +first? If the fields are not parsed, that is the real first task and it is +larger than the UI work. + +**Answered, 2026-08-04: they are already parsed.** `MimeParser::parse()` fills +both (`src/mimeparser.cpp:344-345`, into `ParsedMessage::to` and `::cc`, +declared at `src/mimeparser.h:105`). The larger task the check warned about +does not exist. + +They are parsed and then **dropped at the renderer**: `HtmlBuilder` interpolates +only `from`, `subject` and `date` (`src/htmlbuilder.cpp:216`, `:244-245`), and +`to`/`cc` appear nowhere in it, in `messageview.cpp`, or in `mainwindow.cpp`. +So this is UI work only, as the item's own two-part approach assumes. + +**Approach, two parts:** + +- Widen the persistent header at the top of the message pane to show the + selected message's From, To, Cc, Date and Subject. This is the note's stated + preference ("should appear on top in right pane"). +- Add a shortcut and menu entry for a full raw-header dialog, for the cases the + summary omits (Message-Id, List-Id, Received chain). Read-only, selectable + text, no rendering. + +Both, not one: the header widget answers "who is this from" at a glance, the +dialog answers "what actually happened to this message". They are different +questions. + +### Decided (user, 2026-08-04): the header adapts to the item count + +The pane shows a thread, not a message, so From/To/Cc are per-message while the +header is one strip. Rather than pick a message arbitrarily, the header shows +only what it can say honestly: + +- **One message in the thread:** From, To, Cc, Subject. The natural spot, and + every field is unambiguous. +- **N messages:** Subject and the thread count. Nothing more. +- **Everything else** lives in the popup, reached by a **button on the right of + the header** plus a keyboard shortcut. + +**No recipient line on a thread (user, 2026-08-04).** An earlier draft of this +decision put To on the thread header too, which forced a choice between the +union of recipients and their intersection: once the user has replied, message +1 is To: them and message 2 is To: the other party, so the intersection is +frequently empty and the union is really a participants list wearing the wrong +label. The user's call was that this is overcomplicating, and it is: the +per-message detail is what the popup is for. + +The thread header therefore keeps showing exactly what it shows today, subject +and count (`MessageView::updateHeader()`, `src/messageview.cpp:259`), and only +the single-message case gains fields. + +**Consequence: no address parsing is needed.** `ParsedMessage::to` and `::cc` +are raw header strings (`src/mimeparser.h:104-105`), and with no union or dedup +to compute they can be displayed as they stand. Splitting them into address +lists, which would have needed GMime's `internet_address_list_parse` to survive +a display name containing a comma, is not part of this item. + +**Deferred, not rejected (user, 2026-08-04): a participants line for threads.** +The union-of-recipients idea is worth revisiting as its own pass, where it can +be designed as a participants list rather than smuggled in under a "To:" label +that misdescribes it. It needs the address parsing above, so it is a genuine +piece of work rather than a display tweak. Build this item as specced first. + +**Noted for later, not now:** the user's mental model of the thread view +differs from what was built. That is a separate refactor and should not be +folded into this item. + +**Constraint:** header values are untrusted input. The existing header label is +`Qt::RichText` (`src/messageview.cpp:104`), so every value must be +`toHtmlEscaped()` before interpolation, exactly as `updateHeader()` already +does. A `From` display name containing markup must never be able to inject into +the label. The raw-header dialog should use `Qt::PlainText` and sidestep the +question entirely. + +### Outcome (done) + +Built as decided. `MessageView::updateHeader()` branches on the item count: one +message shows From, To and Cc under the subject, several show the subject and +the count exactly as before. `showDetailsDialog()` lists every message's +Subject, From, To, Cc, Date and Message-Id, numbered when there is more than +one, in a read-only `QPlainTextEdit`. A `Details...` button sits to the right of +the header, and `message_details` binds it to `Ctrl+Shift+D` (shifted because +`Ctrl+D` is delete, and the destructive binding keeps the key it had). + +- **Rendered and inspected**, not only asserted: both header shapes were grabbed + to PNG and looked at. The single-message case shows three rows under the + subject, the thread case shows the count and no recipients. +- **An empty Cc omits its row** rather than printing a label with nothing after + it, which reads as a rendering fault. +- **A test caught a latent flaw in an older test.** `attachmentButtonLabels()` + identified attachment buttons by excluding the one other button's label, so + the new details button was counted as an attachment the moment it existed. + It now finds the bar by object name and looks only at its children, which is + what it should have done: an exclusion list silently adopts every button + added later. + +**No address parsing was needed**, as the decision above anticipated. The header +prints `ParsedMessage::to` and `::cc` as they stand. + +## 3, 8, 9. Discoverability: menu bar, toolbar, shortcut reference + +Grouped because they are one piece of work. Item 3 is the complaint, items 8 +and 9 are two of its symptoms. + +**Observed:** for a GUI app there is almost nothing to click; every action needs +a memorized key. Archive and undo have no buttons. There is no way to see the +configured bindings without opening a terminal. + +**Cause:** `MainWindow` has no `menuBar()` and no `QToolBar`. Actions are not +`QAction`s at all: `registerActions()` (`src/mainwindow.cpp:209`) fills a +`QHash>` consumed by an `eventFilter`. Nothing in +that structure can appear in a menu, because a menu needs `QAction` objects. + +**Approach: convert the action registry to `QAction`s.** This is the core of the +work and everything else follows from it. + +- Each entry becomes a `QAction` with text, an object name matching the current + action key, and a shortcut set from `KeyMap`. +- `MainWindow::registeredActionNames()` and the `KeyMap::knownActions()` drift + test (already noted in `mainwindow.h` as hand-maintained) must keep working. + If the conversion lets both lists derive from one source, that test becomes + unnecessary, which is a real win. Check whether it can. +- The `eventFilter` route may become redundant once shortcuts live on the + `QAction`s. Removing it is the goal, but verify: the filter may be handling + focus cases (keys while the query line edit has focus) that `QAction` + shortcuts resolve differently. Do not delete it on the assumption that + `QAction` covers everything. +- Menu bar: File (Sync, Quit), Edit (Undo, Redo), Message (Archive, Delete, + Spam, Toggle unread, Toggle HTML, Load remote content), View (font size, see + item 4), Help (Shortcuts, About). +- Toolbar: the frequent subset only. Sync, Archive, Delete, Undo. A toolbar + holding every action is as unreadable as no toolbar. +- Shortcut reference (item 9): a dialog listing action, description, and current + binding, generated from the same `QAction` list. Generated, never hand-written + in parallel, or it drifts the way the two action lists already do. + +**Constraint:** undo must stay unconfirmed. `CLAUDE.md` is explicit that tag +mutations get undo instead of confirmation dialogs. Adding menu entries must not +smuggle in a "Are you sure?" for Delete. + +**Verification:** the existing keymap test must still pass unchanged, proving +user bindings survive the conversion. That is the load-bearing check here. + +### Outcome (done) + +Built as described: menu bar, toolbar, and a generated shortcut reference. +Four things the plan did not anticipate, all verified by probe rather than +assumed: + +- **The event filter was removable, but not for the stated reason.** The plan + worried that `QAction` shortcuts might lose to `QAbstractItemView`'s + type-to-search. They do not: shortcut dispatch runs before the focused + widget sees the key. The filter is gone, and the thread view no longer + needs its own. +- **Qt already solves the query-bar case.** A plain-letter shortcut is + suppressed while an editable widget has focus, so the `hasFocus()` guard + was unnecessary. Removing it also fixed `Ctrl+Q`, which the old filter + swallowed while typing a query. +- **Three default bindings had never worked.** `N`, `F` and `G` stored the + unshifted key, which no keystroke emits, so `toggle_unread`, `flag` and + `sync` were dead in 0.1.0. Fixed in `KeyMap::normalizeSequence()` and + committed separately from the menu work. +- **The drift test did become unnecessary**, as the plan hoped. + `registeredActionNames()` is now derived from the `QAction`s, and + `defaultBindings()` is the single source for the defaults. The two tests + that pinned the hand-maintained lists together were replaced by ones that + check a configured binding actually reaches its action. + +Defaults moved to modifier shortcuts, since a single letter cannot be a menu +accelerator without claiming that letter window-wide. Existing `[keys]` +entries are unaffected. + +## 4. Message-pane font size does not survive restart + +**Observed:** described as "very annoying", more so than item 1. + +**Confirmed by the user:** Ctrl+`+` / Ctrl+`-` do change the message pane font +size. It is only the persistence that is missing. + +**Cause:** qtmaildir does not implement that zoom. There is no `setZoomFactor`, +no zoom action, and no `Ctrl+=`/`Ctrl+-` binding anywhere in `src/`; grep finds +nothing. The behavior comes from `QWebEngineView`, which handles zoom keys +natively in Chromium. `htmlbuilder.cpp:26` separately hardcodes +`font-size: 10pt` as the document's base size, which the browser zoom then +scales. + +This changes the shape of the work. There is no application-side value to save, +because the application never learns the zoom changed: Chromium handles the key +and adjusts the factor without telling anyone. Persistence therefore requires +taking ownership of zoom first, rather than hooking a save onto something that +already exists. + +**Approach:** + +- Add explicit zoom in, zoom out and reset actions calling + `QWebEngineView::setZoomFactor()`, tracking the current factor in + `MessageView`. Use `setZoomFactor` rather than rewriting the CSS: it scales + the rendered document uniformly, needs no re-render, and does not disturb + `HtmlBuilder`'s output or its tests. +- Bind them to the same Ctrl+`+` / Ctrl+`-` the user already has in their + fingers, so the change is invisible except that it now sticks. Verify the + application binding actually takes precedence over Chromium's built-in + handling; if the web view swallows the key first, the action never fires and + the factor silently diverges from what is on screen. This is the one real + risk in the item and is worth checking before building the rest. +- Persist the factor to the UI state file from item 1, and reapply it on every + `setDocument()`. Do not assume zoom survives a load; `QWebEngineView` may + reset it on navigation. Verify empirically. +- Config entry as the user suggested: a `[general]` key for the starting zoom, + with the state file remembering runtime changes on top of it. The config value + is the default for a fresh profile, the state file is what the user last had. +- Clamp to a sane range. An accidental 0.1 or 25.0 leaves the pane unusable and + the user with no visible way back. Chromium's own limits are roughly 0.25 to + 5.0; match or tighten, never widen. +- Route the actions through item 3's `QAction` conversion so they appear in the + View menu, which also makes the reset discoverable. + +### Outcome (done) + +Built as described, and both of the plan's stated risks turned out not to +exist. Probed rather than assumed: + +- **The application `QAction` wins over Chromium's native zoom key.** The plan + called this "the one real risk in the item". It is not one: the action fires + and the web view's own handling never runs, so the tracked factor cannot + diverge from what is on screen. +- **Zoom survives `setHtml()`.** The plan expected the view might reset it on + navigation and asked for a reapply per render. Not needed; the web view keeps + the factor, so it is the single source of truth and there is no second copy. +- **Do not test key reachability with synthetic input.** A probe using + `QTest::keyClick()` reported `Ctrl++` as a dead binding, and a test was + written asserting it. Both were wrong: `Ctrl++` is exactly what the `+` key + emits on an Italian layout, confirmed against the real keyboard, and it is + the shipped default. Whether a symbol needs Shift is a property of the + layout, not of Qt, and `keyClick()` reproduces neither. The test now only + checks that every default parses. +- `Ctrl+=` is a second binding for reset, skipped when `[keys]` gives `Ctrl+=` + to something else. Ctrl+wheel zooms and Ctrl+middle-click resets, both + filtered by ancestry from an application-level filter: the events land on an + internal `QQuickWidget` the web view creates lazily, so a filter installed on + the view itself never sees them. + +**A pre-existing bug surfaced while adding the config key.** `[general]` +entries were read as `general/`, which matches nothing: QSettings' INI +backend treats a section literally named `[general]` as its own fallback +section and strips the prefix. `notmuch_config` had therefore never worked. +Both keys are now read without the prefix; the file format the user writes is +unchanged. Regression test in `test_config`. + +## 5. Thread list is cramped + +**Observed:** rows are tightly packed, everything is uniform, the UI reads as +"stuffed". + +**Approach:** presentation only, no model changes. + +- Row height: give the `QTableView` vertical breathing room. +- Alternating row colors, or a subtle separator. +- Make unread threads visually distinct (bold), which is the one distinction + that carries real information and currently does not exist. +- Consider dropping a column. Look at what `ThreadListModel` exposes and ask + whether every column earns its width. + +**Caution:** do not hardcode colors. The app should follow the desktop palette; +a hand-picked grey that looks right on a light theme is unreadable on a dark +one. Use `QPalette` roles. This applied to `HtmlBuilder`'s CSS too, which was +split out as item 12 and **done on 2026-08-07**: its colours now derive from +the palette, with the secondary ones blended rather than fixed. The same rule +governs whatever this item adds to the thread list, and item 12's test, which +asserts that no colour appears that the palette did not supply, is the pattern +to copy. + +### Refined by the user, 2026-08-04 + +Two concrete sub-items, from using the list rather than looking at it: + +- **"All items look unread (bold), maybe use regular for read items?"** + **Resolved 2026-08-07. The cause was a misconfigured desktop font, not code.** + + The user's Qt font was set to **Bold in qt6ct**, so every row rendered bold + and nothing could stand out. Bold in the model was working correctly the + whole time. Correcting the qt6ct setting fixed the original complaint on its + own. + + **Read this before trusting any measurement in this file.** Three wrong + conclusions were reached before that came out, and the reasoning behind each + is worth keeping, because the same mistakes are easy to repeat. + + 1. Dismissed from thread counts (99 unread against 4220 read), which + explained why two screenshots looked alike but said nothing about whether + bold rendered. + 2. Dismissed again by a probe counting lit pixels. Antialiasing makes a bold + and a regular glyph light a similar number, so the metric read "identical" + regardless of the truth. **Text width is the honest measure**: with the + font misconfigured both weights measured 277px, and once corrected they + measured 277px against 306px. + 3. Concluded that Qt or fontconfig was broken, from a bare `QTableView` with + a plain `QStandardItemModel` painting two rows identically. That test was + correct and its conclusion was wrong: the baseline font was already bold, + so `setBold(true)` genuinely changed nothing. + + **The dimming was kept anyway**, and stands on its own merits rather than on + that mistaken diagnosis. `ThreadListModel::readColour()` dims READ rows + toward the background while unread keeps the palette's text colour. With 99 + unread among 4220 read, dimming the bulk carries the list better than + emphasising the few, and it is a second cue that survives a font setting like + the one that caused this. Bold still applies on top. + + **A caution for anyone adding another `ForegroundRole` cue.** Qt resolves + that role into the palette and then prefers it over `HighlightedText`, so a + model-supplied colour wins on a SELECTED row too. The dim is blended against + the unselected background, so it landed as grey on the selection highlight, + near unreadable. `SubjectDelegate::initStyleOption` reverses that, and the + delegate is installed view-wide rather than on the subject column alone so + every column gets the same handling. + + **One test had to be rewritten when the font was corrected.** + `aSelectedReadThreadIsNotDimmedIntoTheHighlight` originally compared a + selected read row against a selected unread one and required them to paint + identically. That only held because every row was bold; with bold working, + the unread row differs legitimately. It now asserts the resolved palette + rather than pixels, which is the property the fix actually changes. + +- **A star column for flagged threads**, mirroring the paperclip column that + already exists for attachments. `ThreadSummary` carries the tags and + `flagged` is an ordinary notmuch tag, so this needs no new worker query, the + same way item 15's paperclip did not. Keep it narrow: an icon column, no + text. **Done 2026-08-07**, as `FlagColumn` beside `AttachmentColumn`, using + the same glyph-with-ASCII-fallback pattern (`flagGlyph()`). + +### Built 2026-08-07, and what the layout cost + +The row is roughly doubled in height, with the tags shown as chips beneath the +subject, alternating row colours, and the star column above. + +**The tag strip is painted by the VIEW, not by a delegate**, which is why +`ThreadListView` exists at all. A delegate is handed one cell's rectangle and +cannot paint outside its column, so a strip drawn from the subject column's +delegate stops at that column's edge, losing the last tags of a well-tagged +thread, and starts at that column's left edge, which puts it under the subject +rather than under the row. The user asked for it under the whole row: + +``` +[ date ][ from ][ subject ...................... ] + [ pill ][ pill ][ pill ] +``` + +**Which tags appear.** Everything except `inbox`, `unread`, `flagged`, +`attachment` and the account tag, since the row already shows those as +structure, dimming, the star, the paperclip and the chip. Sorted, because +notmuch's order is not guaranteed stable and a row whose chips reordered +between repaints would flicker. + +**Six defects were introduced and fixed while building this**, every one of +them a consequence of the same thing: a `QTableView` paints PER CELL, and a +row-wide strip is not a cell. Worth listing, because each is easy to +reintroduce. + +1. `SubjectDelegate` was installed view-wide to spread the selection fix + across every column. It reads `AccountLabelRole`, which belongs to the row, + so every column drew the account chip. Split into `RowStyleDelegate` (the + selection fix, every column) and `SubjectDelegate` (chip, subject column + only), with a `Q_ASSERT` guarding the latter. +2. Row height was returned from `sizeHint`, which does nothing: a table takes + ONE height per row, so a hint from a single column applies only if the view + happens to ask that column. Set on the vertical header instead. +3. The strip used `viewportMargins().left()`, which is 0, so it painted from + the viewport edge across the marker columns. It compiled because the method + is protected and the call was inside the subclass. +4. The text band and the strip were measured with one font, so the pills rode + up over the date and sender. +5. Alternating colours and the selection are painted per cell, so the strip's + band showed the bare viewport background as a stripe across every other + row. The view now fills that band itself, and must honour three cases: the + model's own `BackgroundRole` first (a deleted thread's fill would otherwise + be cut in half), then the selection, then the alternating colour. +6. That fill spanned the full width, and the marker glyphs are centred in the + full row height, so its top edge cut the paperclip and star at their + midpoint. The band starts at the date column now. + +**A note on verifying any of this.** Several rendering probes written during +this work returned results that were confidently wrong: counting "lit" pixels +cannot tell bold from regular, since antialiasing lights a similar number +either way, and `viewport()->render()` returned a blank image more than once. +Text width distinguishes weights; a strict pixel diff distinguishes renders; +an ink count distinguishes nothing. Two versions of the strip's own test passed +under mutation before one was written that matched the exact chip colours the +model supplies. + +## 6. Opened message stays unread + +**Observed:** opening a message leaves it tagged unread; the user expects it to +become read. + +**Decision (user, 2026-08-03): mark read after a 2 second delay, configurable.** + +**Approach:** + +- A `QTimer` started in `onThreadSelected()` (`src/mainwindow.cpp:373`), fired + once, removing the `unread` tag from the displayed thread. +- The timer **must** be restarted, not stacked, when the selection changes. + Arrowing quickly down a list must not mark ten threads read; only the one + still selected when the timer fires. +- **Decided (user, 2026-08-03): the automatic mark-read does NOT go on the undo + stack.** The tag change routes through `sendThreadTagChange()` directly, + bypassing the `ThreadTagCommand` push, exactly as an explicit non-undoable + mutation would. Rationale: a manual toggle-unread action already exists + (`toggle_unread`, `src/mainwindow.cpp:242`), so a user who wants the message + back as unread has a direct route and does not need undo for it. Leaving a + message read after undoing an unrelated archive is acceptable; hijacking + Ctrl+Z to undo an action the user never took is not. +- Consequence to keep in mind: `applyTags` is the funnel for all mutations per + `CLAUDE.md`, and that stays true. What changes is only whether the inverse is + pushed onto the `QUndoStack`, which is a `MainWindow` decision made above the + worker. Do not add a second write path to the worker for this. +- Config key in `[general]`, e.g. `mark_read_delay_ms`, default `2000`, with `0` + meaning "immediately" and a negative value meaning "never". Document all + three in the README. +- Interaction with item 3's toggle-unread action: if the user explicitly marks a + message unread, the timer must not immediately re-mark it read. Cancel the + pending timer on any manual unread toggle. + +**Verification:** unit-testable against the throwaway notmuch database the +`NotmuchWorker` tests already build, but the timer logic itself is UI-side and +easier to check by hand. At minimum, verify the rapid-arrow case manually. + +### Outcome (done) + +Built as specced, including every decision recorded above: a 2000 ms default, +`mark_read_delay_ms` in `[general]`, the automatic change kept off the undo +stack via `sendThreadTagChange()`, and an explicit `toggle_unread` cancelling +any pending timer. + +**The rapid-arrow case is unit-tested, not left to hand-checking.** The plan +expected it to need a database and a person; it needs neither. `ThreadListModel` +takes threads directly through `appendBatch()`, so a test builds three unread +rows, arrows through them, and asserts one timer stays armed. The timer carries +an object name so the test observes it through `findChild` rather than the +window exposing it. + +**The three tests were verified by breaking the code**, since a passing test +proves nothing until it has been seen to fail: + +- Removing the already-read check arms a timer for a read thread, caught. +- Creating a fresh timer per selection instead of restarting one, which is + precisely the stacking the plan warns about, fails two of the three. + +**Two guards the plan did not call for**, both from asking what happens when +the timer outlives its thread. `scheduleMarkRead()` refuses to arm for a thread +that is not unread, so a read thread never schedules a write that would change +nothing. `markCurrentThreadRead()` re-checks that the thread it was armed for +is still selected AND still unread before writing, so a timer that survives a +selection change or a manual toggle does nothing rather than tagging the wrong +thread. + +## 7. HTML view should be default for HTML messages + +**Verify before doing anything.** `MessageView::m_preferHtml` is already +initialized to `true` (`src/messageview.h`), and `clear()` resets it to `true` +(`src/messageview.cpp:164`). HTML should already be preferred where a message +offers it. + +Possible explanations for the observation: + +- The messages in question are `multipart/alternative` and `HtmlBuilder`'s + `PreferHtml` mode is not selecting the HTML part correctly. +- The HTML renders, but with remote content blocked it looks like plain text. +- `m_preferHtml` is being reset between messages by a `clear()` the user did not + intend to trigger. + +Reproduce first with a specific message, then decide. If it turns out to work +correctly, the item becomes a documentation gap rather than a bug, and the +user-preference key mentioned in the note (`prefer_html`, `[general]`) is still +worth adding for people who want plain text by default. + +**Do not**, in the course of this, relax anything in the web view security +section of `CLAUDE.md`. Preferring HTML is orthogonal to remote content, which +stays blocked and per-render. + +### Outcome (done, 2026-08-04): nothing was broken + +**Verified by the user against real mail: HTML messages do open as HTML.** The +item was raised on an observation that could not be reproduced afterwards, and +the code was already correct: `m_preferHtml` initialises to `true` and `clear()` +resets it to `true`, so every thread starts in `PreferHtml`. + +No code changed. Recorded as done rather than dropped, since the behaviour the +item asked for is the behaviour that ships. + +The `prefer_html` config key the item floated for people who want plain text by +default was **not** added: nobody has asked for it, and `toggle_html` +(`Ctrl+H`) already switches a thread by hand. Add it if someone wants the +default flipped, not before. + +## 10. Reaching an account's inbox takes two steps + +**Observed:** select account from the dropdown, then click inbox or unread. + +**Approach:** cheapest useful fix first. + +- Persist the selected account across restarts (uses item 1's state file). If + the user reads one account 90% of the time, this alone removes most of the + friction. +- Then: per-account entries in a menu, or saved queries that carry their own + account scope, so one action gets there. `Account::scopedQuery()` already + exists in `Config`, so the composition is available; it is a UI question, not + a query question. + +Do not build a full account sidebar for this. Persisting the selection may +resolve the complaint entirely, and it is a fraction of the work. Reassess after. + +### Partly done + +**The startup query is now chosen by name**, not by sort order. `[queries]` is +read through `childKeys()`, which sorts alphabetically, so the old +`savedQueries().first()` opened whichever entry happened to sort first, which +is why the app came up on Inbox. `[general] startup_query` names the entry, +defaults to `Unread`, and falls back to the first saved query when the name +matches nothing. Only a name the user wrote is worth a warning: the built-in +default naming a query they never created is not something they got wrong. + +Neither half of item 10 proper is done: the account selection still resets on +restart, and reaching an account's inbox is still two steps. + +### Postponed (user, 2026-08-04) + +**The user does not intend to go this route as of now.** Postponed rather than +dropped: the complaint was real, and the cheap fix the item proposes (persist +the account selection across restarts) is still the right first move if it is +picked up again. Nothing here is invalidated, it is simply not wanted yet. + +Only the startup-query half shipped, in 0.3.0. Do not propose the remaining +work unprompted. + +## 11. Icon, `.desktop` file, SlackBuild + +Packaging, independent of everything above, and can proceed in parallel. + +- **Icon:** an SVG plus rendered PNGs at the standard hicolor sizes. Needs a + design decision, not just code. +- **`.desktop` file:** `Categories=Network;Email;`, `Terminal=false`, + `MimeType=x-scheme-handler/mailto;` only if a `mailto:` handler is actually + implemented, which it is not in 0.1.0. Do not claim the MIME type until it + works; a desktop entry that registers as the mail handler and then does + nothing is worse than not registering. +- **CMake install rules:** icon into `share/icons/hicolor//apps/`, desktop + file into `share/applications/`. Neither exists yet. +- **SlackBuild:** per the global workflow, sources are authored here and the + user builds. Deliverables are `qtmaildir.SlackBuild`, `.info`, `README`, + `slack-desc`, plus an nvchecker stanza. Depends on a tagged release existing + to point `DOWNLOAD` at, so it follows a tag rather than leading it. + +--- + +## 12. Message pane is light-theme only + +**Split from item 5**, which recorded the rule against hardcoded colours. +Listed in the deferred table until it was picked up on 2026-08-07. + +**Observed:** the user runs a dark desktop (`color-scheme: prefer-dark`), and +plain-text mail rendered as black on white inside a dark window. + +**Cause (verified in code):** `kStyle` in `src/htmlbuilder.cpp` hardcoded +`#bbb`, `#555`, `#000`, `#666`, `#ddd` and `#4a6f8a`, and set **no background +at all**, so the web view's own default showed through whatever the desktop +was. + +**Approach as built.** A `HtmlBuilder::Palette` struct passed into +`build`/`buildThread`, derived from a `QPalette` by `paletteFrom()`. Passed in +rather than read from `qApp` inside the builder, so the stylesheet can be +tested against a known palette with no running application. + +- **`Base` and `Text`, not `Window` and `WindowText`.** The pane is a content + surface like a text edit, and on many themes `Base` differs from `Window`. +- **The derived colours are blends, not fixed greys.** This is the part that + makes it work both ways round: a `#555` chosen to read as "subtle" on white + is nearly invisible on `#2b2b2b`. `dim` and `border` are mixes of text and + background, so they land at the right contrast whichever way the theme goes. +- The quote colour keeps its hue, since "this is quoted" is carried by being a + different colour rather than a dimmer one, but it is pulled toward the + background so it stays readable rather than glowing on dark. + +Measured on the user's actual theme: background `#2b2b2b`, text `#dedede`, dim +`#969696`, border `#585858`, quote `#6490b0`. + +**Scope, and it is asserted in a test so it cannot drift.** A message that +brings its own HTML brings its own colours, and those are left alone. +Rewriting a sender's styling would break layouts that depend on it, and a +newsletter that sets a white background is entitled to stay white. This item +themes the plain-text render and the chrome around messages, nothing else. So +HTML-heavy mail will still look light, correctly. + +**`MessageView` passes its own widget palette**, not the application's: a style +sheet or a themed parent can give the pane different colours from `qApp`. It +also re-renders on `QEvent::PaletteChange`, because the document's colours are +baked into its stylesheet at build time and it does not restyle itself the way +a widget does; without that, switching the desktop theme would leave the open +thread on the old palette until the next selection. + +**Verification.** The load-bearing test asserts the **negative**: no hex colour +appears in the `