# Post-0.1.0 usability backlog Status: **open, expandable by design.** This document is not a fixed release plan. It collects items found by actually using qtmaildir after 0.1.0, and it grows as more turn up. Nothing here is scheduled; picking what ships in a given release is a separate decision. Source: usage notes taken while running the app, 2026-08-03. **Numbers here are this document's own.** The user's own notes were numbered independently and the two sequences drifted apart once items were split: what those notes called 12 is item 13 here, and item 14 here (the tag column) was never in them at all. Items 15 to 17 come from a later pass over the same notes. Cite these numbers, not the notes', and do not renumber to reconcile. **The notes are the upstream source and they keep growing.** The user adds to them while using the app, so this document goes stale on its own. Items 28 to 35 came from one such pass on 2026-08-04 and included two defects that had gone unrecorded here for a while. Items 39 to 45 came from the 2026-08-05 pass, which found one more defect (41, a message body silently dropped by the MIME walk) and one item that cannot be planned at all until the user says where the thing it manages lives (44). Compare the two at the start of a session; the procedure is in `CLAUDE.md`. Numbering is stable. New items append with the next free number and never renumber, so a note referring to "item 7" keeps meaning the same thing. An item that is dropped stays in the table marked `dropped` with a one-line reason. ## Theme 0.1.0 was built to a spec written by someone who lives in neomutt. The result is a keyboard-driven reader with almost no visible affordances. The notes below are, with few exceptions, one complaint restated in several forms: **the app does not tell the user what it can do, and it does not remember what the user told it.** Two clusters follow from that: - **Persistence.** Splitter position, font size, window geometry, and the account selection all reset on restart. Each is small on its own and aggravating every single launch. - **Discoverability.** Shortcuts are the only route to most actions, and there is no menu bar, no toolbar, and no way to see the key bindings from inside the app. Both clusters are cheap to fix. Neither was an oversight in design so much as a consequence of specifying the app as "a GUI counterpart to neomutt" and then taking that too literally. ## Status table | # | Item | Cluster | Size | Status | |---|------|---------|------|--------| | 1 | Splitter/column widths do not survive restart | persistence | S | **done** | | 2 | No way to see full message details (From/To/Cc/Subject) | information | M | **done** | | 3 | Too few clickable affordances, shortcuts are the only route | discoverability | M | **done** | | 4 | Message-pane font size does not survive restart | persistence | S | **done** | | 5 | Thread list is cramped, poor readability | presentation | S | **done** | | 6 | Opened message stays unread | behavior | S | **done** | | 7 | HTML view should be default for HTML messages | behavior | XS | **done** (already worked) | | 8 | No buttons or menu entries for archive, undo, etc | discoverability | M | **done** | | 9 | No in-app view of configured shortcuts | discoverability | S | **done** | | 10 | Reaching an account's inbox takes two steps | workflow | S | **postponed** (partly done) | | 11 | Icon, `.desktop` file, SlackBuild | packaging | M | **done** | | 12 | Message pane is light-theme only | presentation | S | **done** | | 13 | No visual feedback that an action stuck | feedback | S | **done** | | 14 | Tag column unreadable, tags need another home | presentation | M | **done** | | 15 | Attachments are parsed but unreachable from the UI | information | M | **done** | | 16 | Delete on an already-deleted thread should undelete | behavior | S | **done** | | 17 | No completion for tags in the query bar | workflow | M | **done** | | 18 | No visual cue that there are unsynced edits | feedback | S | **done** | | 19 | No prompt to sync on exit when edits are pending | behavior | S | **done** | | 20 | Thread view does not match the user's mental model | presentation | L | **built 2026-08-08**, branch `item-20-message-rows`; see 53 | | 21 | Default shortcuts are not sensible enough | discoverability | S | open | | 22 | Translatability audit and i18n wiring | correctness | M | open | | 23 | No way to save a search query from the UI | workflow | M | open | | 24 | No right-click actions on the thread list | discoverability | S | **done** | | 25 | No select-all, and bulk actions are undiscoverable | workflow | S | **done** | | 26 | No way to add or remove an arbitrary tag from the UI | workflow | S | **done** | | 27 | The UI cannot see a sync it did not start | feedback | S | **done** | | 28 | Re-adding `unread` counts 2 unsynced changes, not 0 | correctness | S | **done** | | 29 | Sync button stays enabled during a background sync | feedback | XS | **done** | | 30 | The blank right pane is wasted space | presentation | M | **done** | | 31 | The quit prompt has no highlighted default button | discoverability | XS | **done** | | 32 | Esc does not blank the right pane | workflow | XS | **done** | | 33 | Status bar messages never expire | feedback | S | **done** | | 34 | No overview of the Maildir itself | information | M | **done** | | 35 | No refresh of the thread list after a sync | workflow | M | open | | 36 | `test_mainwindow` cannot reach the worker | testing | S | open, on demand | | 37 | The worker stalls on a tag edit made during a background sync | correctness | S | **done** | | 38 | `test_mainwindow` fails when a real sync holds the lock | testing | XS | **done** | | 39 | Thread list cannot be sorted by clicking a column header | workflow | S | open | | 40 | No live filter over the current view | workflow | M | open | | 41 | A message whose HTML body carries a `Content-Id` renders blank | correctness | S | **done** | | 42 | "Syncing..." says nothing about what is being synced | feedback | S | **done** | | 43 | No "Mark all read" for the current view | workflow | S | **done** | | 44 | No way to manage the filters applied at sync time | workflow | ? | open, unspecified | | 45 | Two Sync buttons, and only one of them works properly | correctness | S | **done** | | 46 | `uiStateSurvivesARestart` fails under the offscreen platform | testing | XS | **done** | | 47 | The query bar looks unfinished, and cannot be cleared by mouse | presentation | XS | **done** | | 48 | Removing a tag suggests every tag, not the thread's own | workflow | XS | **done** | | 49 | Sync runs every account regardless of what changed | workflow | M | **done** | | 50 | Esc blanks the pane but leaves the row selected | workflow | XS | **done** | | 51 | Clicking a subject scrolls the list sideways | presentation | XS | open | | 52 | `test_querycompleter` fails under Wayland, passes offscreen | testing | XS | **done** | | 53 | Message rows still read as a table, not as a conversation | presentation | ? | open, unspecified | Sizes are rough: XS under an hour, S a sitting, M a session. --- ## 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 `