# 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.

**The status table below is the index of every item; the sections are only the
open ones.** Item 73 moved the done, dropped and postponed sections out to
`2026-08-03-post-0.1.0-usability-closed.md`, which took this file from just over
five thousand lines to under six hundred. Nothing was deleted and nothing was
renumbered: a closed item keeps its row here, with its date and outcome, and its
full Observed/Cause/Approach section is in that file under the same number. Look
there when a row cites evidence you need.

**Items 20 and 53 are both on master since 2026-08-10**, as the card list. Item
20's original presentation, the one the user rejected on sight, is preserved on
the branch `item-20-message-rows` at 029a50e and was never merged; the branch
`card-list` carries the work that was. Any file or line reference in item 20's
entry, now in the closed-items file, points at that PARKED branch, not at
master, where the same lines are unrelated. Item 53 records why the first
attempt was rejected and is worth reading before changing the thread pane again.

## 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 | **done** 2026-08-10, as the card list; see 53 |
| 21 | Default shortcuts are not sensible enough | discoverability | S | open |
| 22 | Translatability audit and i18n wiring | correctness | M | **done** 2026-08-15, unreleased; see `specs/2026-08-15-i18n-design.md`. Found eight rule-builder labels that could never be translated in any language, and twenty untranslatable warnings. Ships an Italian translation of all 355 strings |
| 23 | No way to save a search query from the UI | workflow | M | **done** 2026-08-13, shipped in 0.18.0; see `specs/2026-08-13-saved-queries-design.md` |
| 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 | **done** 2026-08-10; the list now follows a sync on its own |
| 36 | `test_mainwindow` cannot reach the worker | testing | S-M | **done** 2026-08-14, unreleased; see `specs/2026-08-14-mainwindow-worker-fixture-design.md`. `WorkerBackedWindow`, opt-in per test, no production change. Its first use ruled out the simple case of item 66 |
| 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 | **dropped** 2026-08-10; the card list has no column headers to click, and 0.13.0 shipped a sort dropdown instead |
| 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 | M | **done** 2026-08-13; see `specs/2026-08-12-tagging-rules-design.md`. Spans this repo and `mailctl` |
| 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 | **done** 2026-08-10; a card is viewport width, so there is nowhere to scroll |
| 52 | `test_querycompleter` fails under Wayland, passes offscreen | testing | XS | **done** |
| 53 | Message rows still read as a table, not as a conversation | presentation | M | **done** 2026-08-10, merged to master as the card list |
| 54 | A cron sync carries the edits but the count still says pending | correctness | S | **done** |
| 55 | In a narrow window the message pane is invisible | presentation | XS | **done** |
| 56 | No action carries an icon, so the toolbar reserves space for nothing | presentation | S | **done** |
| 57 | "Flag" would read better as "Important" or "Starred" | presentation | XS | **done** |
| 58 | `message_zoom` documents a 0.5 to 3.0 range and enforces none of it | correctness | XS | **done** |
| 59 | Archive and Mark all read shipped with the same icon | presentation | XS | **done** |
| 60 | Next thread dead-ends on the last reply of an expanded thread | defect | XS | **done**; already fixed by 5487d58, see the closed-items file |
| 61 | `test_mainwindow` fails intermittently, about 1 run in 20 | testing | S | **done** 2026-08-13; an `init()` fixture points every test at its own lock table |
| 62 | No config option for the date format on a card | presentation | XS | **done** 2026-08-11 |
| 63 | No way to see sent mail, and no filter for it | workflow | M | **done** 2026-08-11; see `specs/2026-08-11-sent-mail-design.md` |
| 64 | The Sync button carries a mailbox icon, not a refresh one | presentation | XS | **done** 2026-08-11 |
| 65 | No full code review and optimization pass | correctness | ? | open, unspecified |
| 66 | Selecting a thread root leaves the message pane blank until a reply has been selected | defect | S | **done** 2026-08-14, unreleased. Not the blank pane it was filed as: the root rendered the CONVERSATION until the thread had been expanded once, then one message. Now always one message, and the conversation view is removed at the user's request. **One case unverified by hand:** the notes also report a single-message `id:` query whose card would not open, which is the same empty-`MessageIdRole` failure and should be gone; confirmed 2026-08-15 as a SEPARATE defect with a different cause, see item 96 |
| 87 | Auto mark-read marks a whole thread, including replies never displayed | defect | S | open; measured 2026-08-14. Reachable only after 66 removed the conversation view. Blocked on 88 |
| 88 | `threadAt(current.row())` answers about the wrong thread for a reply row | defect | S | open; found 2026-08-14 by shipping a fix that marked an unrelated message read. Row numbers are per parent in a tree |
| 67 | The placeholder pane counts unread, flagged and inbox, but not sent or drafts | information | XS | **done** 2026-08-11, shipped in 0.15.0 |
| 68 | A forwarded subject gets no `passed` tag | workflow | S | open; no subject rule exists, measured 2026-08-11. Decision needed: display mark (XS) or write the flag (S, syncs out) |
| 69 | `passed` and `replied` read as words where every other state is a glyph | presentation | S | **done** 2026-08-11, inside item 70 |
| 70 | Pane icons are a private set where the main window uses the system theme | presentation | M | **done** 2026-08-11; six shipped SVGs |
| 71 | A toolbar action does not sync, so the edit sits until the next cron run | workflow | S | **done** 2026-08-11; 2s default, `auto_sync_delay_ms` |
| 72 | No khard/khal integration | workflow | ? | open, unspecified; the user places it after send, so v2 at the earliest |
| 73 | This backlog is past four thousand lines | maintenance | S | **done** 2026-08-13; 5056 lines to 578, closed sections moved to `2026-08-03-post-0.1.0-usability-closed.md` |
| 74 | "Searching..." keeps claiming a query is running while rows are already arriving | feedback | XS | **done** 2026-08-15, unreleased. The status-bar half only: the bar now counts threads per batch. The cold-cache delay itself was measured in 2026-08-11 and is not fixable here |
| 75 | The tagging rules window forgets its size and its column widths | persistence | S | **done** 2026-08-13, shipped in 0.17.0. The window-kind question is left open, see the closed-items file |
| 76 | Every field in the rules dialog is free text, so a rule is easy to get wrong | workflow | M | **done** 2026-08-13, shipped in 0.17.0. See `specs/2026-08-13-rule-builder-design.md` |
| 77 | No way to see what a rule would collect, in the thread list | workflow | S | **done** 2026-08-13, shipped in 0.17.0 |
| 78 | No way to build a rule from something visible in a message | workflow | S | open, narrowed 2026-08-14; the search half shipped as item 85, which is the road a rule is made from. Now a shortcut across that road: the menus and the seeded-dialog path both exist. Use 85 first and see which values are worth promoting |
| 80 | A rule with many conditions squeezes the rule list to one visible row | defect | XS | **done** 2026-08-13, shipped in 0.17.0. Follows item 76 |
| 79 | Opening the rules dialog and saving destroys the first rule | defect | XS | **fixed on `rule-builder`** 2026-08-13, unreleased. Shipped in 0.16.0; damaged one real rule, repaired by hand |
| 81 | No way to turn a saved query into a tagging rule | workflow | S | **done** 2026-08-14, unreleased; see `specs/2026-08-14-query-to-rule-design.md` |
| 82 | A saved query cannot be edited, unpinned or deleted from the UI | defect | S | **done** 2026-08-13, shipped in 0.18.0. Right-click offers Edit, Pin/Unpin and Delete |
| 83 | A rule named with spaces is written to the file and dropped by every reader | defect | S | **done** 2026-08-14, unreleased. The name is sanitised into an id, save validates, a bad id loads for repair |
| 84 | A config problem blocks `test_mainwindow` on a modal nobody can dismiss | testing | S | **done** 2026-08-14, unreleased. `showWarnings()` split: the status label stays in the constructor, `main.cpp` raises the modal after `show()` |
| 85 | Nothing on screen can be searched for by right-clicking it | workflow | M | **done** 2026-08-14, unreleased; see `specs/2026-08-14-search-from-message-design.md`. Split from 78; rebuilt the details dialog as rows |
| 86 | A right-click search can replace or narrow, but never exclude | workflow | S | **done** 2026-08-14, unreleased; see `specs/2026-08-14-exclude-from-search-design.md`. Follows 85. The `extend` bool became a `SearchMode` enum across four signatures |
| 89 | A sync moves the list under the user's hands, and the auto-sync skips rather than retries | workflow | XS | **done** 2026-08-15, unreleased. The timer half only: a skipped auto-sync re-arms instead of giving up. The list-churn half is **dropped**, not built: the user resolved it as a mental-model question, an Unread view is SUPPOSED to be volatile |
| 90 | A saved-query button clears the account selection | workflow | S | **folded into 93** 2026-08-15. Not fixed in place: the button that misbehaves stops being a saved query at all. See `specs/2026-08-15-builtin-filters-design.md` |
| 91 | Double-clicking a thread should open it on its own | workflow | S | open, fully specified 2026-08-15 and ready to build. A drill-down to that one thread, the same result an `id:` query gives. Nothing is built for getting back: the filter buttons already are that |
| 92 | Nothing distinguishes a tag written by a rule from one the user applied | information | M | **postponed** 2026-08-15 at the user's request: "I don't see the utility, so I don't really know how to answer." Needs per-MESSAGE provenance nothing records, a two-repo format change blank on all existing mail. Reopen only if the need appears in use |
| 93 | The query buttons are whatever the user pinned, not a designed set of filters | workflow | M | **done** 2026-08-15, unreleased; see `specs/2026-08-15-builtin-filters-design.md`. Absorbs item 90. Four built-in filters composing with the account dropdown; the user's own queries unpinned, never deleted |
| 95 | A query in the overflow menu cannot be run | defect | XS | **done** 2026-08-15, unreleased. Pre-existing and not caused by 93: the entry's action owned a submenu, and Qt emits no `triggered` for those, so the connection had never fired. Surfaced because 93 moved every query into the menu |
| 94 | `pinned` has nothing left to decide once the buttons are built-in | maintenance | S | open; **blocked on 93**, and deliberately not part of it. A user-visible removal: the row becomes built-ins only and every saved query lives in the menu |
| 96 | A query returning the thread already on display opens onto the placeholder | defect | S | **done** 2026-08-15, unreleased. Split from 66's unverified half, which had a different cause. Reproduced from two screenshots after four measured eliminations |
| 97 | An edit made during a sync is reverted in the list when the sync ends | defect | S | **done** 2026-08-15, unreleased. Found by hand-testing item 89's fix. The sync-end refresh ran BEFORE the held-edit flush, so it read a database that still carried the old tag |

Sizes are rough: XS under an hour, S a sitting, M a session.

---

## 21. Default shortcuts are not sensible enough

**Observed (user, 2026-08-04):** "improve the default shortcuts to some sensed
defaults."

**Unspecified in detail**, so ask which bindings feel wrong before proposing a
table. What is worth recording is the history, because the defaults have
already moved once and the reasons still constrain any second pass.

**Where the current defaults came from.** 0.1.0 used bare letters. They were
replaced in the 0.2.0 menu work for two reasons that have not gone away: a
single letter cannot be a menu accelerator without claiming that letter
window-wide, and a bare capital such as `N` parses to an unshifted `Key_N`,
which no keystroke emits, so `toggle_unread`, `flag` and `sync` were dead keys
that appeared to be bound. See `KeyMap::defaultBindings()` and
`normalizeSequence()`.

**Constraints on any new default.**

- **Do not test reachability with synthetic input.** `QTest::keyClick()` does
  not reproduce a keyboard layout: it reported `Ctrl++` as dead when it is
  exactly what the `+` key emits on the user's Italian layout. Verify against
  the real keyboard, as `CLAUDE.md` records.
- Every binding is overridable in `[keys]`, so this is about what a fresh
  install feels like, not about what is possible.
- `Return` is a special case already resolved: it belongs to `open_thread` but
  the query bar claims it back while focused, so a proposal that moves it must
  not resurrect that bug.

## 40. No live filter over the current view

**Observed (user, 2026-08-05):** "search in current view", spelled out as two
things: "a light filter applied live on the current view", and "a search bar
appearing as soon as we type while no entry box is focused".

**Cause (verified in code):** the only search is the query bar, which runs a
notmuch query and replaces the result set. There is no client-side filtering
of an existing result: no `QSortFilterProxyModel` anywhere in `src/`, and
`ThreadListModel` has no filter of its own. Narrowing the current view therefore
means writing a new notmuch query and losing the view.

**Approach.** Distinct from the query bar, and the distinction is the point: this
filters rows already fetched, without touching notmuch.

- A filter over the model's loaded rows, matching subject and from, case
  insensitively. No worker round trip.
- A filter strip that appears on the first keystroke while no entry box has
  focus, and disappears on Escape, restoring the full result set.

**Constraints.**

- **Type-to-filter competes with the plain-letter shortcuts.** Item 3's outcome
  records that a plain-letter `QAction` shortcut is suppressed only while an
  editable widget has focus, which is exactly the state this feature does not
  start in. Any binding that is a bare letter would be swallowed by the filter
  strip or would swallow it. Check the current defaults before choosing the
  trigger, and prefer appearing only for characters no action claims.
- Escape already blanks the message pane (item 32). If Escape also closes the
  filter, decide the precedence explicitly rather than letting whichever handler
  runs first win.
- The filter is presentation only: it must not clear the selection, the undo
  stack, or the query, and the pending-edit count must not move.
- Interaction with item 39: a filter and a sort over the same rows want the same
  proxy. Whichever is built first should leave room for the other.

## 65. No full code review and optimization pass

**Observed (user, from the notes):** "full code review and optimization."

**Cause:** not a defect. The codebase has grown from the 0.1.0 spec through
sixty-odd backlog items, and nothing has gone back over it as a whole.

**Why this cannot be planned from the backlog.** "Review and optimize" names no
symptom, no measurement and no target. There is no reported slowness to chase,
and the one performance property the design does commit to (threads emitted in
batches of 200 so a 10k-thread query paints immediately) already holds. An
optimization pass with no measurement behind it is the kind of work that
produces a large diff and no change a user can notice.

**What it needs before it can be sized.** The user saying which of these they
meant: a correctness/security review of a named area, a specific operation that
feels slow with the query that makes it slow, a dead-code and duplication sweep,
or the translatability audit that is already item 22. The first three are
different pieces of work with different sizes, and the fourth is already
recorded.

**Size: `?`, unspecified.** Do not propose a design for this; ask.

## 87. Auto mark-read marks a whole thread, including replies never displayed

**Observed (user, 2026-08-14):** "with the first message in a thread selected
(not expanded), the 2s delay that marks it read applies to the whole thread, so
all answers are marked read as well."

**Cause (verified in the code).** `markCurrentThreadRead`
(`src/mainwindow.cpp`) sends `sendThreadTagChange` with the thread id, so
`unread` is removed from every message in the thread.

**This was coherent until item 66 and is not any more.** While a thread root
rendered the whole conversation, everything marked read HAD been displayed.
Item 66 made a root render one message; the thread-wide write stayed.

**Not cosmetic.** `maildir.synchronize_flags` is on, so removing `unread`
rewrites Maildir filenames and the next sync carries it to the server. Mail the
user never opened stops being unread everywhere, and nothing here can put it
back except reading the messages again by hand.

**Approach: blocked on item 88, and that ordering is the lesson.** The obvious
fix is to send `sendMessageTagChange` for the message on screen. That was
written, tested, mutation-checked, shipped and reverted the same evening,
because it exposed item 88: the guards above the write read
`threadAt(current.row())`, which answers about an unrelated thread when a reply
row is current, and a fix that narrows the write turns that mismatch into "a
random message is now read". Fix 88 first.

**Constraints for the second attempt.**

- **Test selecting a REPLY, not only a root.** The reverted work asserted on a
  root selection, which is the one case where `current.row()` is correct, so a
  mutation-checked test passed against a bug that corrupted mail.
- The card must repaint. `sendMessageTagChange` deliberately makes no
  optimistic model update, since `applyTagChange` is thread-keyed; without a
  message-scoped equivalent the write lands, the unsynced count rises, and the
  card stays bold, which the user reported as a separate fault.
- A thread reads as unread while ANY message does, so the card can only stop
  looking unread when nothing else in it carries the tag. For an unexpanded
  thread the per-message tags are not loaded, so the honest answer needs
  per-message counts in `ThreadSummary` or nothing at all.

**Size: S**, after 88.

## 88. `threadAt(current.row())` answers about the wrong thread for a reply row

**Observed (user, 2026-08-14):** "I clicked on a message and another random
message was marked read."

**Cause (verified).** `markCurrentThreadRead` calls
`m_model->threadAt(current.row())`. A `QTreeView` numbers rows PER PARENT, so a
reply's `row()` indexes its siblings: `threadAt(0)` on the first reply of any
thread returns the FIRST THREAD IN THE LIST. The guards then compare correct ids
against the wrong thread and let a write through.

**`CLAUDE.md` already records this trap**, from item 20: "what did NOT survive
that port is anything keyed on a row NUMBER". This is a surviving instance, not
a new discovery, which is the uncomfortable part.

**Mostly masked before item 87's attempted fix.** With a thread-wide write the
mismatch usually resolved to the same thread or to a no-op; scoping the write to
one message made it visible immediately, as unrelated mail being marked read.

**Approach.** `threadAt(int)` takes a row and cannot be safe here. Every caller
that has a `QModelIndex` should reach the thread through the index: for a
message row, `ThreadListModel::threadIdForMessage()` or the parent index; for a
thread row, `index.row()` is correct. Audit every `threadAt(` and every
`.row()` in `mainwindow.cpp` rather than fixing this one site, since the same
shape almost certainly appears elsewhere.

**Consider deleting the row-taking overload** once the callers are converted, so
the trap cannot be re-entered. That is the change that makes this permanent
rather than fixed once.

**Size: S** for the one site, **M** if the audit finds several.

## 68. A forwarded subject gets no `passed` tag

**Observed (user, from the notes):** "passed tag should appear when subject is
`Fwd:` and `Fw:`." Refined in session on 2026-08-11: the user had noticed
`passed` appearing on messages whose subject carried `Fwd:` and not on `Fw:`,
and asked to expand the rule to both.

**Cause:** there is no rule to expand. `passed` is the Maildir `P` flag in the
message filename, translated into a tag by notmuch because
`maildir.synchronize_flags=true`. The flag is written by whichever client
forwarded the message, or by the server over IMAP; nothing reads a subject line
anywhere in the chain. qtmaildir only ever colours the tag
(`src/tagcolors.cpp:36-37`) and the database's `post-new` hook does not mention
it either.

**Measured against the real database (2026-08-11):**

| Query | Count |
|---|---|
| `tag:passed` | 6 |
| `tag:passed and subject:"Fwd:"` | 1 |
| `tag:passed and subject:"Fw:"` | 0 |
| `subject:"Fwd:" and not tag:passed` | 194 |
| `subject:"Fw:" and not tag:passed` | 28 |

Six tagged messages in the whole database, and every one of them carries `P` in
its filename flags. The single overlap with `Fwd:` is a message that was
forwarded and whose subject was already a forward, not evidence of a rule: 194
`Fwd:` subjects carry no tag at all. The correlation the observation rests on
does not exist.

**Approach and the decision it needs first.** Two different features, and the
measurements above decide how far apart they are.

*Display only.* The card shows a forwarded mark when the subject matches. Touches
no mail, changes no flag, reversible by deleting the rule. XS.

*Write the tag.* qtmaildir sets `P` from a subject heuristic. With
`maildir.synchronize_flags=true` that flag is a filename change that mbsync
carries out to the server, on 222 existing messages, on a guess about a string.
Not cleanly undoable, and it asserts a meaning for a flag this application did
not define. Recommended against; recorded so the choice is deliberate rather than
forgotten.

**Constraints:** localised clients use their own prefixes, and `Fwd:` can appear
inside a subject rather than at its head, so whatever matches must be anchored.
If the tag is ever written, it must not be re-applied on every sync in a way that
produces pending edits the user never made, item 28 is the record of a count
going wrong. The display-only route avoids that entirely, since it derives the
mark at paint time and stores nothing.

**Size: S** as written, XS if it is display only. Most of it is the decision, not
the code.

**Status:** left open deliberately on 2026-08-11. The cause is settled and the
options are costed; the user has not chosen, and no code was written.

## 72. No khard/khal integration

**Observed (user, from the notes):** "investigate khard/khal integration (light
PIM, probably worthy after we add send capabilities)."

**Cause:** not a defect. v1 is read-and-organize; there is no address book and no
calendar anywhere in the codebase.

**Why this cannot be planned.** The user's own note places it after send, and
send is v2. What "integration" means is undecided: completing recipients from
khard when composing, showing a sender's card, or acting on an invitation.
Those are three different features.

**Size: `?`, unspecified**, and out of scope until v2 exists. Ask before designing
anything.

## 78. No way to build a rule from something visible in a message

**Observed.** The user would like to select an address or another piece of a
message in the main window, right-click, and be offered a rule built from it.

**Cause.** Not a defect, unbuilt.

**Narrowed 2026-08-14, and most of the work is already done.** The search half
shipped as item 85, which is the road a rule is made from: search for a value,
save the query, create a rule from the saved query. What remains here is a
SHORTCUT across that road, and both of its halves now exist.

- The menus are built and every surface already extracts its value as a
  finished query (`SearchOffer`, `src/searchterm.h`). A rule entry is another
  action beside the two search ones, not new plumbing.
- The seeded-dialog path exists from item 81:
  `MainWindow::showTagRulesDialog(const TagRule &seed)`. A rule from a message
  becomes a second caller of it, with a different seed, which is what item 81's
  spec anticipated when it made the seed a whole `TagRule` rather than a query
  string.

**Approach.** Decide it after using item 85 for a while. Which values are worth
promoting straight to a rule is a usage question, and the earlier answer to it
was wrong (see below), so it is worth having the evidence first.

**Constraints.** The original approach here said to start from the thread
list's context menu "where the sender is already a value the model holds".
**That is false and item 85 verified it.** `ThreadSummary::authors` comes from
`notmuch_thread_get_authors` and is a DISPLAY SUMMARY, reading `Alice, Bob` or
`Alice| Bob`, so a `from:` built from it matches nothing. A real address comes
from `MessageNode::from` or `ParsedMessage::from`, neither of which the thread
list carries. Any thread-list entry needs an address resolved from a message
first.

JavaScript is disabled in the profile and must stay disabled. Item 85 reads a
body selection with `QWebEnginePage::selectedText()`, which injects no script;
reuse that rather than adding anything.

The rules file is shared with mailctl, so a rule created here must go through
`TagRules` and preserve unknown fields; see "Changing the shared rule format"
in CLAUDE.md.

**Size: S**, down from M now that item 85 has built the menus and item 81 the
seeded dialog.


## 94. `pinned` has nothing left to decide once the buttons are built-in

**Observed (user, 2026-08-15),** thinking past item 93 rather than from the
notes:

> after we've migrated [...] we can drop my 4 redundant (by then) saved queries,
> and there won't be a need for pinning anymore. The buttons will be driven by
> the hardcoded queries, the menu will be the home for saved queries.

**The end state this describes:** the query row is built-in filters ONLY, and
every saved query lives in the menu. No mixing, so nothing has to decide which
saved queries get button real estate, and `SavedQuery::pinned` is dead weight.

**This also disposes of a problem item 93 would otherwise have to solve.** With
both tiers sharing one row, something must order the four filters against the
user's pinned queries. Under this end state the question does not arise.

**Blocked on 93, and deliberately not part of it.** The user needs to live with
the four buttons first and confirm they cover what they actually use. If one is
wrong, pinning is the escape hatch, and it has to still be there to be used.
Closing 93 and this together would remove the fallback before it was needed.

**This is a user-visible removal, not a cleanup.** `pinned` shipped in 0.18.0:
`SaveQueryDialog` offers "Show as a button" (`src/savequerydialog.cpp:104`) and
the right-click menu offers "Move to menu" / "Show as a button"
(`src/mainwindow.cpp:1797`). Anyone who put a saved query on the row loses that
permanently. Semver on the user-visible surface makes it a minor bump with an
`### Upgrading` note.

**The stored field is a separate decision from the UI.** `pinned` is written to
queries.json (`src/config.cpp:603`) and read back (`:542`). Two options, and the
cheaper one is also the reversible one:

- **Stop reading it, leave it in the file.** Harmless: an ignored key, preserved
  by the unknown-field handling, and a build that reintroduces pinning would
  find every user's setting intact.
- **Strip it on the next save.** Cleaner file, and irreversible for anyone who
  had it set.

Prefer leaving it unless the user asks otherwise. No `kQueriesFormatVersion`
bump either way: an ignored optional field is not a breaking change.

**Size: S.** Removing a field, two UI affordances and their tests.

## 91. Double-clicking a thread should open it on its own

**Observed (user, notes):** "double clicking a message/thread should open it in
its own view (?) UX not sure."

**Clarified by the user, 2026-08-15**, and it is NOT a second window: "I didn't
mean open in a new window, I meant that double clicking on a message/thread in
the left pane should open it by itself, same as searching by id and displaying
only that message." So this is a drill-down into the existing view, the same
result an `id:` query produces, reached with a gesture instead of by typing.

That removes the constraint this entry was mostly made of. No second
`QWebEngineView`, no second render process, nothing reparented: the pane and
the list are the ones already on screen. Size drops from `?` to S.

**Cause (verified in code):** nothing is wired to `doubleClicked` on
`ThreadListView`, so a double-click today is two single-clicks and selects the
row twice. The machinery a drill-down needs already exists and is exercised by
the query bar: `SearchTerm` builds the `id:` query and `runQuery()` runs it.

**Approach.** On `doubleClicked`, run the query naming that row: `id:` for a
message row, `thread:` for a thread row, built through `SearchTerm` so the
quoting rules stay in one place.

**How the user gets back: nothing is built for it.** Answered by the user,
2026-08-15: "I didn't think of a back action, usually I'd go back to a known
list like unread or inbox at that point." The filter buttons are already that,
and they are one click away in every view. A Back action, a history stack and a
restore-the-previous-query scheme were all drafted and are all unnecessary.

That collapses the only open question in this item. A drill-down is an ordinary
query, entered by gesture instead of by typing, and it is left the way every
other query is left.

**The undo-stack note below therefore stops being a concern.** `runQuery()`
clears the undo stack because its rows are about to be discarded, and a
drill-down discards them exactly as a typed query does. The user already
expects that from the query bar, so the gesture inherits a behaviour that is
consistent rather than surprising. No filter-over-the-model alternative is
needed, and the item stays S rather than growing into item 40's machinery.


## Deferred, unsized, or split out

Items noted while triaging but not part of the original list. Same numbering
sequence, appended as they arise.

| # | Item | Why here |
|---|------|----------|
| 12 | `HtmlBuilder` CSS is light-theme only | **Done 2026-08-07**, and moved to the main status table. Kept listed here so the split from item 5 stays traceable. |

## Adding to this document

Append a row to the status table with the next free number, then a section using
the same shape: **Observed** (what the user saw), **Cause** (the code, with file
and line, verified not assumed), **Approach**, **Constraints**, and
**Verification** where it is not obvious. Do not renumber. Do not delete: mark
`dropped` with a reason.

**When an item closes, move its section to
`2026-08-03-post-0.1.0-usability-closed.md`** and leave the status table row
here with its date and outcome. This is what keeps the file readable, and it is
the step that was missing for seventy items: doing it only once, as item 73 did,
buys a few months and then the problem returns. Move the section on the commit
that closes the item, not in a later cleanup pass. Where the closed section
records a trap that is still true of the code, that trap belongs in `CLAUDE.md`,
which is where it will actually be read.

**A fully specified item goes in its own file under `docs/superpowers/specs/`,
not inline here.** This document is a backlog: its job is to say what is open,
how big it is, and what decides whether it can be picked up. A design that runs
to a hundred lines buries that under itself.

The split is by depth, not by size on the day. An entry stays here while it
records an observation, a cause and an approach. It moves out once it carries
decisions the user made, measured evidence, and constraints that have to be read
before writing code. Items 53 and 63 are the pattern: the entry keeps the
finding and the size, and points at the spec with one line saying to read that
instead. Carry the two or three constraints a reader needs in order to decide
whether to open the spec at all, and leave the rest there.

Name the spec `<date>-<name>-design.md`, and state in its header which backlog
items it resolves, so the numbering stays traceable in both directions.
