summaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md')
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md349
1 files changed, 349 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
new file mode 100644
index 0000000..8847b9b
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -0,0 +1,349 @@
+# 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.
+
+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 | open |
+| 2 | No way to see full message details (From/To/Cc/Subject) | information | M | open |
+| 3 | Too few clickable affordances, shortcuts are the only route | discoverability | M | open |
+| 4 | Message-pane font size does not survive restart | persistence | S | open |
+| 5 | Thread list is cramped, poor readability | presentation | S | open |
+| 6 | Opened message stays unread | behavior | S | open |
+| 7 | HTML view should be default for HTML messages | behavior | XS | **verify first, may already be done** |
+| 8 | No buttons or menu entries for archive, undo, etc | discoverability | M | open |
+| 9 | No in-app view of configured shortcuts | discoverability | S | open |
+| 10 | Reaching an account's inbox takes two steps | workflow | S | open |
+| 11 | Icon, `.desktop` file, SlackBuild | packaging | M | open |
+
+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.
+
+## 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.
+
+**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.
+
+**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.
+
+## 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<QString, std::function<void()>>` 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.
+
+## 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.
+
+## 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 applies to `HtmlBuilder`'s CSS too, which
+currently hardcodes `#bbb`, `#555`, `#000`, `#666`, `#ddd` and no background,
+and will look wrong under a dark theme. That is arguably its own item; see
+item 12 below if it gets split out.
+
+## 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.
+
+## 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.
+
+## 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.
+
+## 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/<size>/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.
+
+---
+
+## 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 | Split from item 5. Hardcoded greys and no background color; a dark desktop theme will render message bodies badly. Fix likely means passing palette-derived colors into the CSS, which affects `HtmlBuilder`'s tests. |
+
+## 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.