summaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md22
-rw-r--r--docs/superpowers/specs/2026-08-09-card-list-design.md229
2 files changed, 247 insertions, 4 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
index 159643f..c799e23 100644
--- 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
@@ -108,9 +108,9 @@ taking that too literally.
| 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 |
+| 51 | Clicking a subject scrolls the list sideways | presentation | XS | open; resolved as a side effect of 53, do not work separately |
| 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 |
+| 53 | Message rows still read as a table, not as a conversation | presentation | M | open, specified 2026-08-09; see the card-list spec |
| 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** |
@@ -3241,8 +3241,22 @@ exists, and its band arithmetic assumes a uniform row height and a known column
layout. Anything that varies row height or removes columns for one row kind has
to answer for the strip on thread rows, which must not change.
-**Do not start any of these from this description.** It says what is and why,
-not what was wanted.
+**Specified 2026-08-09.** The user's answer was that the grid is wrong for the
+WHOLE left pane, not only for reply rows, which is wider than any of the four
+directions above. Threads and replies both become cards in a single column, with
+no column grid at all. The design is at
+`docs/superpowers/specs/2026-08-09-card-list-design.md`; read that rather than
+this entry, which records only the finding.
+
+Two things settled here that the directions above got wrong. Direction 2's `Re:`
+prefix removal is folded in rather than tried first, since the grid goes anyway.
+And the constraint about the tag strip inverts: the strip is not something the
+design has to answer for, it is deleted, because `ThreadListView` exists ONLY to
+paint across columns that no longer exist. This item is a net removal of code.
+
+**Item 51 is resolved by this design as a side effect** and should not be worked
+separately. Cards are exactly viewport width, so the pane has no horizontal
+scroll range for a click to scroll into.
## 54. A cron sync carries the edits but the count still says pending
diff --git a/docs/superpowers/specs/2026-08-09-card-list-design.md b/docs/superpowers/specs/2026-08-09-card-list-design.md
new file mode 100644
index 0000000..3c717eb
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-09-card-list-design.md
@@ -0,0 +1,229 @@
+# Card list: the thread pane without a column grid
+
+**Status:** specified 2026-08-09, not implemented.
+**Supersedes:** the presentation half of item 20, built on `item-20-message-rows`
+and rejected. Resolves backlog item 53. Retires item 51 as a side effect.
+
+## Why
+
+Item 20 shipped message rows exactly as its four decisions specified, and the
+user's verdict on the finished result was that the table view does not fit the
+use. Item 53 recorded the cause verified in code: a message row fills the same
+five columns as a thread row, so replies land on the same rigid column
+boundaries as the threads around them, and the eye reads columns before it reads
+indentation or tint.
+
+The user's decision on 2026-08-09 is that the grid is wrong for the **whole**
+left pane, not only for reply rows. Threads and replies both become cards.
+
+This is a presentation change. The model's data, the reply tree from notmuch,
+the action scope, undo, and the worker are all kept.
+
+## The card
+
+One column. Every row is a card of exactly three lines, thread and reply alike.
+
+```
+ sender ............................................ date
+ ★ subject @ ▾ 3 replies
+ [unread] [work]
+```
+
+- **Line 1:** sender, and the date flush right.
+- **Line 2:** the flag mark, the subject, the attachment mark, and the reply
+ count. Flag and attachment become inline marks on this line rather than
+ columns of their own; `attachmentGlyph()` and `flagGlyph()` already answer
+ what character to draw and keep their font-fallback behaviour.
+- **Line 3:** the thread's pill tags, from the existing `PillTagsRole` and
+ `PillColoursRole`.
+
+A **reply card** is the same three lines, indented, dimmed with `readColour()`,
+tinted with `replyBackground()`, with the `Re: ` prefix stripped from line 2 and
+no reply count. Its line 3 is specified below.
+
+**Every card is the same height**, including replies and including cards whose
+line 3 is empty. This is the single cheapest property of the design:
+`setUniformRowHeights(true)` stays, the delegate's `sizeHint` is one constant
+computed from the font metrics, and no scrolling or hit-testing arithmetic has
+to account for rows of differing size. The cost is a blank band under untagged
+cards, which the user accepted explicitly.
+
+## Reply line 3: only what the thread does not already say
+
+A reply card's line 3 carries **the tags that message has and its thread does
+not**. A reply tagged `todo` inside an untagged thread shows `[todo]`. A reply
+carrying only the thread's own tags shows nothing.
+
+The rule exists so a tag applied to one message stays findable inside its
+thread, without the thread's tags repeating identically down the whole
+expansion. That repetition is the striping `ThreadListView`'s own header
+comments give as the reason the current strip is painted on thread rows only.
+
+**Measured against the user's database, 2026-08-09**, because the alternative
+(showing a reply's full tag set) was rejected on this evidence rather than on
+taste: of 48691 messages, 7 carry `unread` and 75 carry `flagged`. Those are the
+only two tags that vary within a thread in practice; the rest (`account-*`,
+`lists`, and so on) are applied to whole threads and are identical on every
+message in them. Both varying tags are already visible another way, `unread` as
+the sender's weight and `flagged` as the mark on line 2. So a full per-message
+tag set would render blank on essentially every reply and identical chips on the
+rest, which is cost without payoff. The set difference degrades to blank in the
+same places and lights up exactly where the user put a tag deliberately.
+
+Computed in the model from data it already holds: `MessageNode::tags` against
+the parent `ThreadSummary::tags`. **No worker change.**
+
+## The spine
+
+Replies are indented by depth with a **continuous vertical line per depth
+level**, drawn the full height of each reply card, in `threadLineColour()`.
+
+No elbows, no horizontal tick into the card, and no different glyph on the last
+child. The alternatives were shown and this one chosen: elbows would require the
+delegate to know whether a row is its parent's last child, and box-drawing
+characters (`├─`, `└─`) depend on the UI font carrying glyphs a proportional
+font often lacks or spaces badly, and do not scale with the row when the font
+size changes.
+
+**Indent caps at depth 4.** A reply at depth 5 or deeper renders at depth 4's
+indent, spines included, with **no marker** saying it was flattened. Item 20
+already accepted that deep chains must be capped in the view rather than
+flattened in the model, and the cap belongs to the delegate.
+
+**No horizontal scrolling.** Uncapped indent with a horizontally scrollable pane
+was considered and rejected: it reopens item 51 in a worse form. Today's
+sideways scroll on click happens because the Subject column is wider than the
+viewport, and `QAbstractItemView`'s auto-scroll brings the clicked index into
+view. With cards there are no columns and a card is exactly viewport width, so
+the horizontal scrollbar disappears and **item 51 is resolved for free**.
+Restoring an over-wide row would put it back, and this time clicking any deep
+reply would scroll the pane sideways. The card's right-aligned date is a second
+casualty: it either scrolls out of sight or stops being right-aligned.
+
+## Expanding
+
+**The reply count on line 2 is the expander.** Clicking it toggles the thread;
+clicking anywhere else on the card selects it and opens the message.
+
+No separate chevron in a left gutter. That would cost horizontal space on every
+card including the ones with no replies, and the branch already hit the trap
+that `setRootIsDecorated(false)`, needed to stop the style drawing its own
+indicator, also removes the style's **hit area**, leaving a glyph that renders
+and does nothing.
+
+Two consequences that must be honoured, both learned on the branch:
+
+- The delegate draws the expander, so the **view** must own the click, since a
+ delegate gets no click of its own without an editor. `ThreadListView` keeps
+ `mousePressEvent` for exactly this.
+- `isExpanded` and `setExpanded` are keyed on **column 0**, which is now the
+ only column.
+
+## Sorting
+
+A **sort dropdown** in the query row, two entries: newest first (the default)
+and oldest first. Passed to `notmuch_query_set_sort`, which today is hardcoded
+to `NOTMUCH_SORT_NEWEST_FIRST` at `notmuchworker.cpp:135`.
+
+**This adds a feature rather than replacing one.** The current column header is
+decorative: nothing in the codebase implements click-to-sort, so removing the
+header loses nothing.
+
+Two entries and not four. notmuch offers `NOTMUCH_SORT_MESSAGE_ID` and
+`NOTMUCH_SORT_UNSORTED` as well, and neither is a sort order a human wants.
+Sorting by sender or subject was declined: notmuch cannot do it, so the model
+would have to sort after results arrive, which fights the 200-at-a-time batching
+that makes a 10k-thread query paint immediately.
+
+The chosen order persists in `~/.local/state/qtmaildir/uistate.conf` via
+`MainWindow::uiStatePath()`, never in the hand-edited config.
+
+Changing the sort re-runs the current query, so it bumps the generation counter
+like any other query.
+
+## What is deleted
+
+A net removal of code:
+
+- `ThreadListView::paintEvent` and the tag strip's band arithmetic.
+- `SubjectDelegate` and `RowStyleDelegate`.
+- The five `Column` enumerators (`AttachmentColumn`, `FlagColumn`, `DateColumn`,
+ `AuthorsColumn`, `SubjectColumn`), collapsing `ColumnCount` to 1.
+- `headerData` and the view's header.
+- The `HasRepliesRole` reservation logic that told the subject cell to leave
+ room for a glyph, now the delegate's own layout.
+
+**`ThreadListView` survives, with a much smaller job.** It keeps
+`mousePressEvent` for the expander hit-test. It no longer paints anything.
+
+This retires two bug classes `CLAUDE.md` documents for the strip: a deleted row
+cut in half, and every other row showing a bare stripe, both caused by the view
+having to re-honour alternating colours, selection and `BackgroundRole` itself
+because the strip spanned cells it did not own. With one column and one delegate
+painting the whole card, neither is reachable.
+
+## What is kept
+
+- Every model role on the branch: `ThreadIdRole`, `AccountLabelRole`,
+ `AccountColourRole`, `TagsRole`, `PillTagsRole`, `PillColoursRole`,
+ `IsMessageRole`, `MessageIdRole`, `MessageDepthRole`, `HasRepliesRole`.
+- `QAbstractItemModel` with the reply tree, lazy child loading, and
+ `hasChildren` answered from `totalCount` rather than from loaded children.
+- Action scope by row kind, and the status-bar scope naming before and after an
+ action. No confirmation dialogs, per the standing rule.
+- Undo through `TagChange::inverted()`.
+- The account chip, the `deletedColour()` / `spamColour()` row fills, and the
+ unread/read weight and colour cues.
+- `setUniformRowHeights(true)`.
+
+## New
+
+- **`MessageOwnTagsRole`** and **`MessageOwnColoursRole`**, the set difference
+ described above and its chip colours in the same order. Model-side only, and
+ mirroring the existing `PillTagsRole` / `PillColoursRole` pair so the colours
+ keep coming from the model's `TagColors` rather than from a delegate reading
+ config as a second source of truth.
+- **`CardDelegate`**, replacing `SubjectDelegate` and `RowStyleDelegate`. It
+ paints the whole card and owns every measurement: the three line baselines,
+ the indent per depth with its cap, the spine rects, the expander's rect, and
+ the chip run on line 3.
+- The sort dropdown and its `uistate.conf` key.
+
+## Testing
+
+Per the rendering-probe warnings in `CLAUDE.md`, which were written after a
+whole session was lost to probes that lied:
+
+- **Assert on the delegate's computed geometry, not on pixels.** `CardDelegate`
+ exposes its layout (line rects, indent width, expander rect, spine rects) as
+ a testable function of a row and a width. Those are the assertions.
+ `sizeHint` is asserted to be constant across thread rows, reply rows, tagged
+ and untagged.
+- **Never count lit pixels.** It cannot tell bold from regular in either
+ direction. Where a rendered check is genuinely needed, use text width or a
+ strict pixel diff.
+- **Every rendering test carries a mutation check** and a guard proving it can
+ fail: assert the geometry it depends on rather than assuming it.
+- The set-difference rule gets a plain model test: a reply tagged with one tag
+ its thread lacks reports that tag alone; a reply carrying only thread tags
+ reports nothing.
+- The indent cap gets a test at depths 3, 4, 5 and 9, asserting depth 5 and 9
+ compute the same indent as 4.
+- The expander hit-test gets a test that a click on the reply count toggles and
+ a click elsewhere on the card does not, since being visible and being
+ clickable are separate properties here.
+- **Item 51 gets a regression test**: with cards, the view reports no horizontal
+ scroll range, and clicking a card does not change `horizontalScrollBar()`'s
+ value.
+
+Two constraints on writing these, from `CLAUDE.md`: nothing may be keyed on a
+row **number**, because a tree numbers rows per parent; and the offscreen
+platform chooses the window width itself and has been seen to choose
+differently between runs, so a test must not depend on a particular width.
+
+## Open, deliberately not decided here
+
+- **Moving between messages in a thread without returning to the list.** Named
+ by the user during item 20 and deferred there. Still deferred.
+- Whether the message pane's own presentation should change to match. Out of
+ scope: this spec is the left pane only.