aboutsummaryrefslogtreecommitdiffstats
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.md161
1 files changed, 158 insertions, 3 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 1216542..eec544e 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
@@ -131,10 +131,12 @@ taking that too literally.
| 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 | open |
| 74 | "Searching..." keeps claiming a query is running while rows are already arriving | feedback | XS | open; cause measured 2026-08-11, the delay itself is the cold page cache and is not fixable here |
-| 75 | The tagging rules window forgets its size and its column widths | persistence | S | open; follows item 44 |
-| 76 | Every field in the rules dialog is free text, so a rule is easy to get wrong | workflow | M | open; design approved 2026-08-13, 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 | open; the dialog counts matches, it cannot show them |
+| 75 | The tagging rules window forgets its size and its column widths | persistence | S | **done** 2026-08-13 on `rule-builder`, unreleased. The window-kind question is left open, see below |
+| 76 | Every field in the rules dialog is free text, so a rule is easy to get wrong | workflow | M | **done** 2026-08-13 on `rule-builder`, unreleased. 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 on `rule-builder`, unreleased |
| 78 | No way to build a rule from something visible in a message | workflow | M | open; wants 76 first, so the created rule lands in a form that can hold it |
+| 80 | A rule with many conditions squeezes the rule list to one visible row | defect | XS | **done** 2026-08-13 on `rule-builder`, unreleased. 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 |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -4763,6 +4765,55 @@ the test asserts on the saved value rather than on the resulting frame.
**Size: S.**
+**Done 2026-08-13** on `rule-builder`, unreleased, for the COLUMN WIDTHS.
+`saveGeometry()` and the list header's `saveState()` go to `tagrules/geometry`
+and `tagrules/header` in `uistate.conf`, written on `done(int)` so they survive
+Cancel as well as Save; `resize(760, 520)` stays as the first-run fallback.
+
+**The window SIZE does not come back, and that half of the item cannot be
+fixed here.** The user's desktop is Hyprland, a tiling compositor. It tiles the
+window to fill its slot, so the size dragged is the tile's; `saveGeometry`
+records `frameGeometry` and `normalGeometry` and `restoreGeometry` restores the
+NORMAL one, which stays at whatever `resize()` last set. Decoded from the real
+state file after a hand test: frame 2248x806, normal 760x664. The code restores
+760 faithfully and the window still opens tiled.
+
+Three wrong diagnoses were tried and each was disproved by a probe rather than
+by argument: that `restoreGeometry` rejected the blob as off-screen (it returns
+true on the real display; the negative y is the DP-1 origin), that the layout
+overrode a geometry set before the first show (a `showEvent` restore produced
+the identical size), and that the offscreen test could tell the two apart (it
+returns the same frame for both, so the mutation survived).
+
+Nothing worth building remains unless the user wants the dialog to open at a
+remembered size when floated, which needs a Hyprland window rule rather than
+code here.
+
+**The approach above was wrong on one point, and a test caught it.** It said to
+drop the `resizeColumnToContents` calls "once a saved header state exists",
+which fixes the restore and leaves the original defect standing: with no saved
+state, a width the user had just dragged was still discarded by the next add or
+delete. The rule shipped instead is that each column is auto-sized ONCE, on its
+first fill, after which its width belongs to the user however it was set. Two
+flags, because the count column is filled later by a reply from the worker.
+
+**It then shipped broken once more, and the test that covered it passed.** The
+save was written in `closeEvent`, and the test asserted with `close()`. Neither
+button goes anywhere near either: Cancel calls `reject()`, Save calls
+`accept()`, and only the window manager's X button sends a `QCloseEvent`. So
+the size was kept for the one route out of three that the buttons never take,
+and the user found it in one try by resizing and pressing Cancel. The save now
+overrides `done(int)`, which both buttons funnel through and `close()` reaches,
+and the test asserts all three routes rather than trusting one to stand for the
+others. A second trap sits underneath: `close()` on a widget that was never
+shown returns early without reaching `done()`, so that leg of the test has to
+`show()` first or it proves nothing.
+
+The **popup or primary window** question was put to the user and deliberately
+not taken: it stays a `QDialog`. Reopening it needs the unsaved-edit story that
+being modal currently sidesteps, and that is its own decision rather than part
+of this item.
+
## 76. Every field in the rules dialog is free text, so a rule is easy to get wrong
**Observed.** A rule is written by typing into four line edits, and the user
@@ -4840,6 +4891,18 @@ means the preview discards whatever thread load was in flight.
**Size: S.**
+**Done 2026-08-13** on `rule-builder`, unreleased. A **Preview in list**
+button emits `previewRequested(query)`; `MainWindow::onRulePreviewRequested`
+clears the account selector, puts the query in the bar and runs it, then
+raises itself. The dialog stays open, which is the point.
+
+Both constraints above became assertions, and BOTH mutations were needed: a
+test that emitted the hook's `tag:new and (...)` wrapping fails, and one that
+skips the account reset fails. The second only bites once the test config
+actually has an account to select, since the default empty config leaves the
+selector on "All accounts" already and the assertion passed against the
+mutation until that was fixed.
+
## 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
@@ -4866,6 +4929,98 @@ in CLAUDE.md.
**Size: M.**
+## 79. Opening the rules dialog and saving destroys the first rule
+
+**Observed.** Open Tagging rules, press Save, change nothing. The first rule
+in the list loses its query and its tags. It then vanishes entirely the next
+time anything reads the file, because a rule with an empty query is dropped
+as malformed on load.
+
+Found while building item 76, by a test written to catch a different problem.
+Reproduced against the released tag rather than the branch, with a throwaway
+worktree at 9585674 and a two-rule fixture: after constructing the dialog and
+calling its save path, the store held one rule instead of two.
+
+**Cause.** `TagRulesDialog::onSelectionChanged` blocks signals for `m_note`
+only. Two lines later, `m_enabled->setChecked(rule.enabled)` emits `toggled`,
+which is connected to `applyEditsToCurrentRule()`. That handler writes every
+field of the current rule from the widgets, and it runs BEFORE
+`m_query->setText(rule.query)` has filled the query widget, so it writes the
+previous rule's text. On the first open there is no previous rule and the
+widgets are empty, so rule 0 gets an empty query and an empty tag list.
+`TagRules::load` then drops it (`src/tagrules.cpp:150`).
+
+The existing comment above the `QSignalBlocker` shows the hazard was known for
+`m_note` and simply not extended to `m_enabled`. A blocker per widget is the
+wrong shape: the whole load needs one guard.
+
+**Fix.** Raise `m_reloading` for the duration of `onSelectionChanged` and
+restore it afterwards, replacing the single-widget blocker. `m_reloading`
+already exists for exactly this class of problem and
+`applyEditsToCurrentRule()` already honours it. Also take the rule by value
+rather than by const reference: the reference points into `m_working`, which
+the handler mutates, so it could be read back half overwritten.
+
+Fixed on the `rule-builder` branch as part of item 76, with
+`switchingRulesDoesNotLeakRowsBetweenThem` in `test_tagrules` as the
+regression test. It fails against the unfixed code.
+
+**Damage in the field, and the repair.** The live rules file had exactly one
+casualty: the account rule sitting first in the list, with its `query`, its
+`add` and its `note` all empty while every sibling account rule was intact.
+
+**The note was missed on the first pass of the repair**, because the shell
+backup was read for the tagging command and the note comes from the comment
+block ABOVE it, which the migration had given to all five account rules
+alike. The user spotted the gap. `applyEditsToCurrentRule()` writes every
+field, so every field is equally exposed: repair work here must check the
+whole rule, not the fields that first drew attention. Restored from the four
+siblings, which carry byte-identical notes.
+Restored from `post-new.shell-backup`, which item 44's migration kept, and
+verified by loading the file through mailctl's own reader: 17 rules, correct
+scoping. The rule had stopped tagging, but only one message had arrived in the
+meantime (14968 of 14969 in that account still carried the tag); it was tagged
+by hand and the account is now complete.
+
+**Constraints.** The user chose to leave the fix on the branch rather than cut
+a patch release, so 0.16.0 in the field still has it. Do not open that dialog
+in a released build.
+
+**Size: XS** for the fix. The reproduction and the field repair were the work.
+
+## 80. A rule with many conditions squeezes the rule list to one visible row
+
+**Observed.** A rule with eight From conditions left the rule list showing
+about one and a half rows, with the second rule half cut off under the first.
+Reported with a screenshot; the builder filled the window and the list it sits
+under kept almost nothing.
+
+**Cause.** `m_list` was added to the dialog's `QVBoxLayout` with stretch 1
+(`src/tagrulesdialog.cpp:109`) and the form below it with none, which looks
+like the list wins. It does not: a stretch factor only distributes space ABOVE
+each widget's minimum, and the form's minimum grows with every condition row,
+so each row came directly out of the list. Measured on the builder's size hint:
+120px with one row, 414px with eight.
+
+**Approach.** Done 2026-08-13. A `QSplitter` divides the list from the editor,
+so the balance is the user's and is saved to `uistate.conf` beside the column
+widths, and the condition rows sit in a `QScrollArea` capped at 190px so the
+editor cannot grow without bound whatever the splitter is set to. The scroll
+area rather than the builder is what text mode hides, since hiding the inner
+widget would leave an empty frame.
+
+**Constraints.** Three measures were tried before one distinguished the bug
+from the fix, and two passed against broken code: the dialog's
+`minimumSizeHint` does not track form rows and read 580 either way, and a
+`qMin` against the scroll area's own size hint read small whether or not the
+cap was set, because an uncapped `maximumHeight` is `QWIDGETSIZE_MAX`. The
+assertions that survive mutation are the editor pane's minimum inside the
+splitter, and the cap read directly. A row's size hint is also invalid until
+the event loop has run, so the test needs `processEvents` after selecting a
+rule or it measures one row's height twice.
+
+**Size: XS.**
+
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering