1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
|
# 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). Items 121 to 123 came from the 2026-08-20 pass, which found
that item 74 had closed only half of what its note asked for, and that the
README had gone stale enough to document a mandatory config key by omitting it.
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 | **done** 2026-08-16, unreleased. Built on 108, which is why it stayed small: the timer tracks a MESSAGE id now, and arms for a reply too, which it never did before |
| 88 | `threadAt(current.row())` answers about the wrong thread for a reply row | defect | M | **done** 2026-08-16, unreleased. The audit found FOUR live sites, not one. `ThreadListModel::threadFor(index)` resolves a reply through its parent; every caller holding a selected index converted, and no `.row()` on a selected index remains in `mainwindow.cpp`. Unblocks 87 |
| 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 | **dropped** 2026-08-17 at the user's request. Never a defect: item 85 built the road (right-click any value, search it, save the query) and item 81 the last step (a rule from a saved query), so the whole journey is available. This was only a shortcut across it, and the entry had already said to use 85 for a while before deciding which values were worth promoting. Reopen if that use turns up a value worth a one-click rule |
| 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 | **done** 2026-08-15, unreleased. The view is always the whole thread, EXPANDED; the pane shows whichever row was double-clicked, so a reply drills to its thread and not to itself. Reuses `recoverStaleThread()` outright |
| 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 |
| 98 | "Important" adds the tag but cannot remove it, unlike every other toggle | defect | XS | **done** 2026-08-17, unreleased. Calls `everySelectedRowHasTag()`, as the entry required. Its reply test needed THREE different states (list-first thread, the reply's own thread, the reply) before it could tell the two wrong answers apart; with the reply defaulted to its thread's state the item 105 mutation stayed green, measured |
| 99 | The unread action is labelled "Toggle unread" whichever way it will go | presentation | S | open; depends on 98's toggle shape, and the label is harder than it looks |
| 100 | The message pane offers Back, Forward, Reload and Save page, none of which mean anything | defect | XS | **done** 2026-08-17, unreleased. `MessageView::removeBrowserActions()` filters the standard menu by `pageAction()` POINTER, never by text; `ViewSource` went with them, and stranded separators are swept |
| 101 | Sync is account-aware for edits but not for the account the user is looking at | workflow | S | open; item 49 built the edit half deliberately. Needs a decision, see the entry |
| 102 | The rules table shows no note, so the field explaining a rule is invisible until it is opened | workflow | XS | **done** 2026-08-17, unreleased. A Note column before `ColumnCount`, so the appended Matches column stays last. Found a second defect on the way: `restoreState` REFUSES a header state with a different column count, and the sized flags were being set regardless |
| 103 | What Delete does to mail on the server is undocumented and unverified | clarification | S+M | done; Delete moves to the account trash, with Restore and a stranded-mail cleanup. Section in the closed file |
| 104 | Mail visible in Thunderbird never reaches qtmaildir | defect | ? | open, reported 2026-08-16, cause NOT established. Most likely outside this repo; see the entry before writing code |
| 109 | A root card's own message is invisible to a message-scoped write | defect | S | **done** 2026-08-16, unreleased. Found by hand-testing 108. `applyMessageTagChange` and `messageById` searched only the loaded replies, and a root's message is never among them, so the ORDINARY gesture repainted nothing and wiped the pane's chip row |
| 110 | A card and the message pane show tags belonging to a message's siblings | defect | S | **done** 2026-08-16, unreleased. Found by hand-testing 109 against a real 4-message thread. `ThreadSummary::tags` is notmuch's UNION; a card standing for one message drew it. Also the reason a root card could not repaint at all |
| 111 | A card should show its siblings' tags smaller, not drop them | presentation | S | **done** 2026-08-16, unreleased. The user's own design, from looking at 110's result: own tags full size, the thread's others smaller and muted, so nothing appears to vanish on selection |
| 105 | Acting on a reply changes the counter and nothing on screen | defect | M | **done** 2026-08-16, unreleased. Found by hand-testing 88, and took three passes. FOUR causes: no optimistic update for a message-scoped write, no doomed cue on a reply row, both toggles reading the reply's THREAD state so they were one-way, and the message pane's strip not following a message edit. Also bolds an unread reply, at the user's request |
| 106 | A tag change made on one message during a sync is silently lost | defect | XS | **done** 2026-08-16, unreleased. Found by READING while fixing 105, never reported. `flushHeldEdits` re-sent only thread-scoped edits, so a message-scoped one was shown, counted as pending, and never written |
| 107 | A thread-scoped write leaves the loaded replies showing their old tags | defect | XS | **done** 2026-08-16, unreleased. `applyTagChange` updated the summary only, so marking a thread read left its expanded replies bold |
| 108 | Acting on a thread root means the whole thread, though it displays one message | workflow | M | **done** 2026-08-16, unreleased. `messageScopeFor()` beside `scopeFor()`; five `*_thread` actions in a "Whole thread" submenu on `Ctrl+Alt+<key>`. User-visible: minor bump, `### Upgrading` written |
| 112 | Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread | defect | S | open, found 2026-08-17. A toggle over a UNION has no direction on a mixed thread |
| 113 | No way to see a message's HTML source | information | S | open, 2026-08-17. Chromium's own View source cannot work here; needs our own plain-text dialog. Item 100 removed the dead entry, which was an overreach: the user had not asked for it |
| 114 | Save image is offered on every image and does nothing | defect | S | open, found 2026-08-17, re-confirmed by hand 2026-08-20. No `downloadRequested` handler exists, so the request is emitted and never answered. The handler is per-profile, so it must decide per request or it revives the Save link item 127 removed |
| 115 | A copy from the message pane gives no confirmation | presentation | XS | **done** 2026-08-19, unreleased. Four entries report, each naming what it copied; connected to the page's own QActions, so the entry is covered wherever it is triggered from |
| 116 | Copy image copies markup instead of the image | defect | XS | **dropped** 2026-08-17, same day. NOT A DEFECT: `wl-paste --list-types` run immediately after a copy reports `image/png`, `application/x-qt-image` and 30 more image flavours. The clipboard is correct and Chromium is behaving. The earlier "text only" reading was taken minutes late off a clipboard that had been overwritten, and a whole cause was theorised on it |
| 117 | The message pane offers no Select all | workflow | XS | **done** 2026-08-19, unreleased. `addPaneActions()` supplies it. The call site is NOT covered by a test and cannot be: the production menu needs a real context-menu event. Stated in the test rather than faked |
| 118 | No way to empty the trash from inside the app | workflow | S | open, 2026-08-17. **Blocked on 103**, which creates the trash in the first place. Deliberately left out of 103's spec at the user's request rather than squeezed in |
| 119 | The unsynced-changes count cannot be opened to see what it counts | information | S | open, 2026-08-19, from the notes. One of the four things it sums carries no message ids at all, so a list cannot be complete without a change to how the count is kept |
| 121 | The thread list shows nothing while a query is running | feedback | S | open, 2026-08-20, from the notes. Follows item 74, which fixed the status-bar half and left the list itself blank |
| 122 | The README documents a version of the app that no longer exists | documentation | M | open, 2026-08-20, from the notes. Delete-to-trash is entirely undocumented, including a config key a user must now set |
| 123 | Sending mail is not designed | v2 | L | **specified** 2026-08-20, on branch `compose-and-send`. Design in `docs/superpowers/specs/2026-08-20-compose-and-send-design.md`; read that, not this row. Send is a per-account `send_command` on stdin, so the no-network-protocol rule stands. Composer is a separate window, body is markdown via cmark-gfm, drafts autosave to the account's drafts folder. No code written |
| 124 | The worker reads the index directory as the mail root | defect | S | **done** 2026-08-20, unreleased. `mailRootOf()` over `NOTMUCH_CONFIG_MAIL_ROOT`, correct under both layouts. Verified by migrating the developer's own index to NVMe the same day: cold start 38.6 s to 0.67 s |
| 125 | A skipped sync leaves the spinner running for ever | defect | S | open, 2026-08-20, found by hand. `mailsync.sh` exits 75 (EX_TEMPFAIL) when another run holds the lock; the indicator never clears, and a held edit waits for a completion that never comes |
| 126 | A link with `target="_blank"` does nothing when clicked | defect | S | **done** 2026-08-20, unreleased. `createWindow()` returns a relay page that receives the navigation, hands the URL to the browser and refuses. The URL cannot be read in `createWindow()` itself, which is why a relay rather than a lookup |
| 127 | A link's context menu offers four browser actions that cannot work | defect | XS | **done** 2026-08-20, unreleased. Three Open-in actions removed, `CopyLinkToClipboard` kept. Item 126 made them more dangerous rather than less: with a real `createWindow()` they would have started working |
| 128 | No outbox: a send with no network fails instead of queueing | v2 | M | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** The seam is designed in (`MessageSender` is the one funnel), so this wraps it rather than reworking it. Needs its own indicator story first: items 18, 19, 28 and 54 are all an indicator lying, and 125 is one still open |
| 129 | No inline images in a composed message | v2 | M | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** Wanted by the user. `cid:` from the HTML part with `multipart/related` nested inside the alternative, the most nesting-heavy part of MIME assembly, and markdown offers no syntax for it |
| 130 | A message cannot be attached to another message directly | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** A `message/rfc822` part, which GMime builds natively. The manual route exists from 123's first commit: `save_message` writes the `.eml` and it is attached as a file |
| 131 | The markdown dialect and extensions are fixed | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** Configurable in the shape Hugo's config uses. Deliberately fixed initially: CommonMark plus autolink, strikethrough and tasklist |
| 132 | Every action must have a shortcut, and that no longer serves | policy | S | done, 2026-08-20. `everyActionHasAShortcut` is deleted and nothing replaces it: `everyActionIsReachableFromAMenu()` is the required rule and a shortcut is now a chosen subset. Nothing else needed changing, since `showShortcutReference()` already printed `(unbound)` for an empty sequence. Verified by unbinding `tag_rules` and running the suite green, which would have failed before |
| 133 | The composer shows no markdown syntax highlighting | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** A `QSyntaxHighlighter` over the composer's editor, so `**bold**` reads as bold while the buffer stays plain markdown. Standard Qt, no dependency. Deliberately after 123's formatting toolbar: agreeing with the grammar about nesting and about code spans suppressing what is inside them is the expensive part, and the toolbar is what makes the feature usable |
| 134 | The busy indicator is built inline and is about to be built twice | maintenance | S | done, 2026-08-20, af902e0. `BusyIndicator` (`src/busyindicator.h`) carries both modes: `MainWindow` uses the indeterminate one, and item 123's send popup takes the determinate half for its undo countdown, switching the same widget over when the command starts. Only the BAR was extracted, not the status label this row paired with it. `m_statusLabel` has 34 uses across `MainWindow` for transient messages, selection counts and sync phases, so it belongs to the window rather than to the indicator, and the send popup owns its own phase text |
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.
## 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.
## 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.
## 99. The unread action is labelled "Toggle unread" whichever way it will go
**Observed (user, from the notes):** "the label for 'toggle unread' should be
dynamic: on an 'unread' message it should be 'Mark as read', on a 'read'
message it should be 'Mark as unread'."
**Cause (verified in the code).** `src/mainwindow.cpp:867` registers one static
label, `tr("Toggle &unread")`, and the lambda decides the direction at
invocation time from the current row. The action carries that text in three
places at once: the Message menu (`:1060`), the thread context menu (`:1167`)
and the toolbar (`:1122`, with the `mail-mark-unread` icon). Nothing updates it
when the selection changes.
**Not as simple as reading the current row**, which is why this is S and not XS.
- The action applies to the WHOLE selection and picks one direction from the
current row, so with a mixed selection any label naming a single outcome is
either wrong for some rows or has to describe the rule ("Mark all as read").
- A menu action's text is read when the menu opens, but a TOOLBAR button's text
is on screen continuously, so it has to track `selectionChanged` rather than
being computed at popup time. `currentRowChanged` is the wrong signal for
anything selection-shaped, per `CLAUDE.md`.
- The accelerator is inside the word (`Toggle &unread`). Two different labels
need two accelerators chosen so neither collides in the Message menu, which
already holds "Mark &spam" and "&Important".
- The shortcut list (Help > Keyboard shortcuts) and the config's `[keys]`
section both name the action `toggle_unread`. The action NAME must not change
with the label, or every user's config breaks. Same rule as item 57, which
changed "Flag" to "Important" on screen and left the action and tag alone.
**Approach.** Compute the label from the same state the lambda already uses,
which since item 105 is `MainWindow::everySelectedRowHasTag("unread")`, update
it on `selectionChanged`, and keep a neutral fallback for an empty or mixed
selection. Decide with item 98, which raises the identical question for
"Important".
**Use that helper rather than re-deriving the state**, or the label and the
action can disagree. It already encodes the two things this gets wrong on its
own: a reply answers about its MESSAGE, not its thread, and the answer is over
the whole selection rather than the current row.
**Constraints.** Every label is user-facing and needs `tr()`. Since the strings
are chosen at runtime rather than written once, all of them must exist as
literals `lupdate` can see; a string built by concatenation is not translatable.
`ctest -R translations` is the check.
**Size: S.** Mostly the mixed-selection and toolbar decisions, not the code.
## 101. Sync is account-aware for edits but not for the account the user is looking at
**Observed (user, from the notes):** "sync button should be account-aware."
**Cause (verified in the code).** `MainWindow::pendingSyncChannels()`
(`src/mainwindow.cpp:3550`) resolves channels from `m_editedAccounts`, the set of
accounts the user has made EDITS in, and from nothing else. The account dropdown
is not consulted. With nothing pending it returns empty on purpose, and
`mailsync.sh` turns that into `mbsync -a`, every channel.
**Item 49 built exactly this and the reasoning still holds.** The comment states
it: with nothing pending the run is a FETCH, and narrowing a fetch to wherever
the last edit happened would "quietly stop collecting mail everywhere else".
Fetching is global by nature; carrying edits is not.
**So this needs a decision, not a fix.** The note does not say which of two
things the user means, and they are different features:
*Sync only the selected account, on demand.* A deliberate "sync this account"
that ignores the pending set, presumably beside the existing Sync rather than
replacing it. Useful when one account is slow and the user wants their mail from
another one now. The risk is the one item 49 named: a button that looks like
Sync and quietly does not collect the rest of the mail.
*Show which accounts a sync will cover.* No behaviour change at all, just making
the existing account-awareness visible, since today the user cannot tell whether
a run is narrowed or full. The status bar already names each channel as mbsync
reaches it (item 42), so most of this exists.
**Constraints.**
- The account dropdown is a VIEW filter. Making it also steer sync couples two
things the user may reasonably want apart: looking at one account while
fetching all of them is the normal case, not an edge case.
- Whatever narrows a run must still carry every pending edit, or an edit is
stranded with nothing on screen to say so. `pendingSyncChannels()` already
falls back to a full sync when it cannot resolve a channel for an edited
account, and that safety must survive.
- An account with no `[account.<key>]` section, or one whose section names no
channel, has no channel to sync. The fallback covers it today.
**Size: S** for the on-demand button, XS for the visibility half. Ask which.
## 104. Mail visible in Thunderbird never reaches qtmaildir
**Observed (user, from the notes):** "sync doesn't work compared to thunderbird.
New mail received on thunderbird did not appear in qtmaildir. Need to investigate
further."
**Cause: NOT established.** Recorded because it is a defect report about mail
going missing, which is the most serious kind this backlog carries, and it has
been sitting in the notes unrecorded. What follows is one measured mechanism that
would produce exactly this symptom, not a diagnosis.
**qtmaildir cannot show what mbsync did not fetch, and mbsync fetches folders by
pattern.** Three of the five channels in the user's `~/.mbsyncrc` name their
folders explicitly:
```
Patterns "INBOX" "[Gmail]/Posta inviata" "[Gmail]/Bozze" "[Gmail]/Speciali"
```
and one names only `"INBOX"`. The two non-Gmail channels use `Patterns *`.
Gmail applies labels, and a message whose label is not one of those four is in a
folder mbsync never asks for. Thunderbird speaks IMAP directly and sees every
folder, so the same message is visible there and absent locally. This is a
configuration property of the user's mbsyncrc, outside this repository entirely.
**One inconsistency worth reporting regardless**, found while checking the
above: one of the Gmail accounts is configured in `qtmaildir.conf` with
`sent = [Gmail]/Posta inviata` and `drafts = [Gmail]/Bozze`, while its mbsync
channel has `Patterns "INBOX"` and fetches neither. The Sent and Drafts filters
for that account can therefore only ever be empty. That is real, and it is
independent of whatever this item turns out to be.
**Approach.** Reproduce before anything else, and the reproduction has to
distinguish three layers, because the fix lives in a different place for each:
1. Is the message on disk? `find` in the Maildir, or `notmuch count` on a term
from it. If not, this is mbsync or `.mbsyncrc`, and there is nothing to
change here.
2. If it is on disk, is it indexed? `notmuch new` and count again. If not, this
is notmuch config, `new.ignore` or the hook.
3. Only if it is indexed and still not shown is this qtmaildir's defect, and
then the question is which query hid it: the account scope, the built-in
filter, or a rule that tagged it out of the inbox.
**Constraints.**
- Ask the user for one concrete example before investigating: which account,
roughly when, and what Thunderbird shows for it. A general "sync doesn't work"
cannot be reproduced, and the last four defects in this backlog were all found
from a specific message.
- The `post-new` hook from mailctl tags mail unattended. A rule that removes
`inbox` would make a correctly fetched, correctly indexed message vanish from
the default view, which looks identical to a sync failure from the outside.
`notmuch search` without a filter is what tells them apart.
- Do not change `.mbsyncrc` as part of this. It is the user's, it is outside the
repo, and a Patterns change refetches folders.
**Size: `?`** until reproduced. Most likely not a code change here at all.
## 112. Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread
**Observed (user, 2026-08-17):** clicking a thread root and asking to mark the
whole thread unread does not do it. On a seven-message thread with two unread
replies, the result is that every message is toggled unread **except those
two**, which are left as they were. The user asks for an explicit "mark whole
thread read/unread" rather than a toggle.
**Cause (verified in code):** the action exists, and its direction is the
defect. `toggle_unread_thread` (`src/mainwindow.cpp:931`, `Ctrl+Alt+U`) chooses
between adding and removing by asking
`everySelectedRowHasTag("unread", TagScope::Thread)`, which reads
`ThreadListModel::threadFor(index).tags`. That is notmuch's **union over the
thread** (`CLAUDE.md`, item 110), so a thread containing even one unread message
answers "unread" and the action picks *Mark thread read*. There is no input a
user can give that reaches *Mark thread unread* on a mixed thread: the only
threads that take that branch are the ones already entirely read, and the only
threads reporting "not unread" are the ones the user does not need the action
for.
The write itself is absolute and correct. `tagSelected` with `TagScope::Thread`
adds or removes `unread` across every message, so the two unread replies in the
report are not skipped by the write. They are the reason the write ran in the
opposite direction from the one the user wanted.
**A union is not a state, and a toggle needs a state.** This is the same class
as item 110 and the third time the union has produced a defect. Items 105 and 88
fixed *which object* a toggle resolved; this one is about a thread having no
single answer to give. `everySelectedRowHasTag` is a two-valued predicate over a
three-valued reality: all read, all unread, or mixed. The mixed case is the one
that has no correct toggle direction, and picking either one silently is what
ships as "the action does the wrong thing".
**Approach.** The user has already named it: stop toggling at thread scope.
- Split `toggle_unread_thread` into two explicit actions, **Mark thread read**
and **Mark thread unread**, each with a fixed direction. Both appear in the
"Whole thread" submenu, where an entry always carries text, so a fixed label
is honest in a way a toggle's cannot be.
- The message-scoped `toggle_unread` stays a toggle. One message has a real
two-valued state, so the trap does not exist there. Do not "unify" the two:
the asymmetry is the point.
**Constraints.**
- **Adding an action is four places**, all enforced by tests that fail
confusingly: `KeyMap::knownActions()`, `defaultBindings()`, the icon table,
and the no-duplicate-icons exception list. See `CLAUDE.md`. Splitting one
action into two means one new entry in each, and the pair shares the twin's
icon under the existing named exemption for thread actions.
- **`Ctrl+Alt+U` is taken by the action being split**, and the whole-thread
bindings are already one modifier out from their twins because `Ctrl+Shift+U`
was claimed. Two directions need two sequences; if a second chord cannot be
found that is not worse than the menu, bind one and leave the other to the
submenu rather than inventing a three-modifier chord nobody will press.
- **This interacts with items 98 and 99**, which is the reason to decide all
three together. 99 asks for a dynamic label on the message-scoped toggle,
which is the opposite move: keep the toggle, make the label tell the truth.
A thread cannot do that, because on a mixed thread there is no true label to
show. Deciding 99 first will produce the wrong answer here by analogy.
- The undo entry must name the direction that ran (`Mark thread unread`), not
the action. `tagSelected` already takes the text, so this comes free from
splitting.
- **The test needs a MIXED thread**, which is the whole defect: a thread whose
messages are all in one state answers identically whichever way the direction
is computed, so a fixture built from a uniformly-unread thread passes against
the bug. Same trap as item 88's opposite-states requirement, recorded in
`CLAUDE.md`.
**Size: S.** The write path is already correct and thread-scoped; the work is
the action split, the four registration sites, the binding decision, and a test
over a mixed thread.
## 113. No way to see a message's HTML source
**Observed (user, 2026-08-17):** reviewing item 100's removals, "view source
could be useful, we might have to implement it."
**This item exists because item 100 removed something it was not asked to.**
The user named Back, Forward, Reload and Save page. `ViewSource` was added to
that list by the agent, on the reasoning that it was "the same kind of thing",
and it is not: the other four have nothing to act on, while view-source has a
real document and a real use, checking what a message actually contains. The
removal is recorded here rather than quietly reverted, because the reasoning
that produced it is the part worth not repeating.
**Cause (verified in code):** restoring Chromium's entry would not work anyway,
which is why this is an implementation item rather than a one-line revert.
`QWebEnginePage::ViewSource` navigates to `view-source:<url>`. The pane's
document is a `data:` URL (`requestinterceptor.cpp:90-105` records that
`setHtml()` navigates to data: and applies the base URL afterwards), and
`MessagePage::acceptNavigationRequest` accepts only a typed main-frame
navigation, so the attempt is refused before the interceptor even sees it.
Restoring the entry would produce a live-looking menu item that does nothing,
which is the same defect item 100 was reported for.
**Approach.** Our own action, not Chromium's: a dialog showing the message's
HTML as plain text. The source is already in hand, since `HtmlBuilder` produced
it and `MimeParser` holds the original part; nothing needs fetching.
**Constraints.**
- **Plain text is a SECURITY property here, not a style.** `CLAUDE.md` states
it for `MessageDetailsDialog`, and it applies with more force to this: the
content is a stranger's markup, and the whole point of the dialog is to show
it uninterpreted. Set `Qt::PlainText` explicitly on whatever displays it; a
`QLabel` guesses under `Qt::AutoText`. A `QPlainTextEdit` cannot render
markup at all and is the obvious choice.
- Decide which source is shown: the message's ORIGINAL HTML part, or the
document `HtmlBuilder` generated around it. They are different, and the
useful one is almost certainly the original, since the wrapper is ours and
known. Say which in the dialog rather than leaving the user to guess.
- A message with no HTML part needs an answer that is not an empty window.
`messageview.cpp:933` already has the string for this case.
- Reachable from the body context menu, where the removed entry was, so the
gesture the user reached for keeps working.
**Size: S.**
## 114. Save image is offered on every image and does nothing
**Observed (user, 2026-08-17):** right-clicking an image in the message pane
offers "save image", among other entries item 100 never saw because the test
built a menu by hand and no real image was ever clicked.
**Cause (verified in code):** there is no download handling anywhere in the
tree. `grep -rn "downloadRequested\|DownloadRequest" src/` returns nothing, so
Chromium emits the request and no handler answers it. The entry is present,
looks live, and silently does nothing, which is the same class of defect as
item 100 itself.
**Approach, and the security question it raised was resolved by the user.**
The first proposal was to scope this to `cid:` parts and refuse remote images,
on the grounds that saving a remote image means a fetch triggered from a
message. **The user pointed out that this is wrong**, and it is: once remote
content has been granted and loaded, the bytes are already fetched and cached.
Saving them is a local copy, not a new request, and blocking it adds no
security while making the entry useless. The reasoning applied to *fetching*,
which has already happened by the time the entry is reachable.
The real constraint is the neighbouring one: **the save must not itself cause a
fetch.** An image that was never loaded, because it is remote and not granted,
has no bytes to save, and the entry should be unavailable rather than reaching
for the network to satisfy it.
- Connect `QWebEngineProfile::downloadRequested` and let Chromium write the
file, with the directory chosen through `QFileDialog` as the attachment save
already does.
- The rejected alternative: fetching the bytes ourselves to reuse
`Attachment::saveTo()`. That is a second network request originating from a
message, which is exactly what the interceptor exists to prevent, and it
would bypass the per-render remote grant. `cid:` images would map onto that
path cleanly and remote ones cannot, which is why the uniform route wins.
**Constraints.**
- **The path checks are not optional and are already written.** `CLAUDE.md`'s
web-view notes: reduce to basename, strip separators, resolve against the
chosen directory, refuse anything escaping it, and compare resolved paths as
paths rather than with `startsWith`. A filename suggested by a download
request is untrusted input in exactly the way an attachment filename is.
- Do not let this become a second general download route. It saves an image the
user right-clicked, nothing else; `SavePage` stays removed.
- No overwriting. `saveWithoutOverwriting` exists because a silent overwrite
lost six of sixteen files while reporting every one as saved.
- Report the outcome through `statusMessage`, as every other save does. A save
with no feedback is the failure item 13 was about.
**Size: S.**
**Re-confirmed by hand on 2026-08-20**, after items 126 and 127 shipped: the
user right-clicked an image whose remote content had already been loaded and
reported "Save Image but does nothing". Still this item, still unfixed, and the
circumstances sharpen two things.
`m_allowRemote` is a live flag on the shared interceptor
(`src/requestinterceptor.h:56`), granted by `loadRemoteContent()` for the
displayed message and cleared by the next `showThread()`. So a download handler
would be subject to whatever the flag says AT THE MOMENT OF THE CLICK, not at
render time. In the reported case the grant is still live, so a naive handler
would appear to work perfectly, which is exactly the trap: the same code fails
for a `cid:` image after the user moves on, and succeeds for a remote one only
while the grant happens to stand. **Test both against a message whose grant has
been cleared**, or the implementation is only tested in its easy state.
The second is a corollary of item 127's decision below. The handler is
per-PROFILE, so connecting `downloadRequested` lights up every download entry
Chromium offers at once, including the Save link this pane deliberately removed.
The entry being gone from the menu is not the same as the capability being
absent: a page can still originate a download by other means. Whatever answers
`downloadRequested` must decide per request, not merely exist.
**Save LINK is no longer part of this item** (2026-08-20, item 127). It was
deferred here on the grounds that both are inert for want of a
`downloadRequested` handler, which is true and beside the point: they are not
the same question.
Save image is content the message already carries, and making it work is what
this item is about. Save link fetches a REMOTE URL chosen by the sender,
through the pane's profile, which is the one profile in the application that
must never fetch remote content. It is removed from the menu rather than
implemented, and `theLinkMenuDropsTheOpenInWindowActions` asserts its absence.
**That assertion constrains this item.** A `downloadRequested` handler added to
make Save image work must not make Save link reachable again. The test fails if
it does, which is the point: the handler is per-profile, so the natural
implementation would light up both entries at once.
## 118. No way to empty the trash from inside the app
**Observed (user, 2026-08-17):** raised while reviewing item 103's spec, as
something that had been forgotten rather than newly noticed: "we could add
'Empty Trash' to the backlog as a future item. I forgot it existed, but I don't
want to squeeze it in this spec."
**Blocked on 103**, which creates the trash folder this would empty. Until that
ships there is nothing to empty: Delete writes a tag and moves no file, so no
account has a populated trash folder except through another client.
**Deliberately excluded from 103's spec**, at the user's request and recorded in
its "Out of scope" section. Worth keeping separate for a reason beyond scope
control: emptying the trash is the first action in this application that would
destroy mail with no undo. Every mutation so far is a tag or, after 103, a move,
and both are reversible. A purge is not.
**Approach, unspecified.** The shape depends on decisions not yet made, and the
spec for 103 answers none of them:
- **Local or remote.** Deleting the files locally and letting `Expunge Both`
carry it to the server is one thing; asking the provider to empty its own
trash is another, and mbsync offers no verb for the latter. The first is
probably what "Empty Trash" should mean here.
- **Whether the no-confirmation rule survives it.** It does not, on the face of
it. `CLAUDE.md` grants undo in place of confirmation dialogs, and this is the
action where undo cannot exist. That makes it the second item, after 103, that
re-examines the rule rather than assuming it, and unlike 103 it will probably
have to break it.
- **Per-account or all-accounts**, which should follow whatever the Trash filter
does once 103 ships rather than being decided independently.
**Size: S**, provisionally, and not worth sizing properly until 103 exists.
## 119. The unsynced-changes count cannot be opened to see what it counts
**Observed (user, from the notes):** "the bottom left statusbar message needs to
be clickable and show what 'N unsynced changes' are in a modal window".
**Cause (verified in the code).** `m_pendingLabel` is a plain `QLabel` added to
the status bar with `addPermanentWidget` (`src/mainwindow.cpp:502-505`). A
`QLabel` has no clicked signal and none is installed, so there is nothing to
click and no route to a list. It carries a tooltip and nothing else.
**The count is a SUM OVER FOUR SOURCES, and that is what makes this bigger than
it looks.** `pendingEditCount()` returns
`m_pendingTagEdits.size() + m_unnettablePendingEdits + held + heldMoves`.
Three of those can name what they hold: `m_pendingTagEdits` is a
`QHash<QString, bool>` keyed by message id, `m_heldEdits` and `m_heldMoves` are
queues of edits waiting for a sync to end. **`m_unnettablePendingEdits` is a
bare `int`** (`src/mainwindow.h:1248`), deliberately so: it counts confirmed
changes that carry no message ids and therefore cannot be netted against
anything.
So a dialog built from what is currently kept would list three of the four
groups and then have to account for a remainder it cannot describe. Showing "and
3 more" is worse than the tooltip, because the user opened the window
specifically to find out what those were.
**Approach.** Two halves, and the second is the real work.
- The clickable half is small: a label that emits on click (an event filter, or
a flat `QToolButton` styled as a label), plus a dialog listing what the three
describable groups hold. The message pane already resolves an id to a subject.
- The complete half needs `m_unnettablePendingEdits` to become something that
can name its entries. Its comment says why it is an int: understating the
indicator is the direction that costs the user work, so it counts what it
cannot identify rather than dropping it. Making it describable means finding
out what those changes actually are and whether they can carry an id.
**Constraints.**
- **The count is deliberately conservative and must stay so.** Item 28 and item
54 both landed on this indicator being wrong in the direction that made the
user think their work was safe. A dialog that lists fewer changes than the
count claims is the same failure in a new place: reconcile the two, or state
the remainder honestly rather than hiding it.
- **An external `notmuch` run can clear pending changes without this count
noticing**, which the tooltip already admits. A dialog makes that staleness
much more visible, since a listed change may no longer exist. Worth deciding
whether the dialog re-verifies against the database before showing.
- Read-only. This is an information window, not a place to retry or discard a
change; either would be a new mutation path with its own undo question.
**Size: S** for the clickable half over the three describable groups. **Unknown**
for the fourth, and the item is not complete without it.
## 121. The thread list shows nothing while a query is running
**Observed (user, from the notes):** "can we show a spinner in the left panel
while 'Searching' is going? Especially at first run, the loading wait is several
seconds, and the status bar starts updating 'Searching N threads' after the
first have already appeared. Before that the program seems broken."
**This is the half of item 74 that was never built**, and the note is precise
about which half. Item 74 closed on 2026-08-15 having fixed the status bar,
which used to set "Searching..." once and hold it for the whole walk. The count
the note describes is that fix working as designed: it is written from
`m_model->rowCount()` in `onThreadsReady`, so by construction it cannot report
anything before the first batch has landed.
**Cause (verified in the code).** `MainWindow::runQuery` clears the model and
sets the status text (`src/mainwindow.cpp:2414`), and nothing else in the view
changes. The thread list is then an empty `QTreeView` until `appendBatch` runs
on the first batch, so **a query in progress and a query that matched nothing
render identically**. There is no busy state on the view at all.
**The gap is measured, and item 74's numbers understate it badly.** Re-measured
on 2026-08-20 against the user's real inbox, seven minutes after boot, with the
index verifiably unread (0.0% of 1037 MB resident). Item 74's figures came from
`posix_fadvise(POSIX_FADV_DONTNEED)` eviction, which does not reproduce a real
cold boot on this hardware:
| phase | item 74, 2026-08-11 | measured cold, 2026-08-20 | warm |
|---|---|---|---|
| `search_threads` returns | 411 ms | **673 ms** | 2 ms |
| first batch of 200 rows | 642 ms | **2008 ms** | 12 ms |
| walk complete | 5714 ms | **38618 ms** | 154 ms |
| threads | 4444 | 4628 | 4628 |
So the list is blank for **two seconds**, and keeps growing for **thirty-eight**,
on 4% more mail. The user's note said "several seconds" and the note was right.
**The cause is the storage, not the code**, and that is the reason to BUILD
this rather than to skip it. `/data` was `/dev/sda1`, a 7200rpm platter
(`rotational: 1`); warm, the identical walk is 154 ms, a 250x difference.
**The developer's own index moved to NVMe on 2026-08-20** (item 124 was its
prerequisite), which took the cold figures to 12 ms / 50 ms / 668 ms and makes
this invisible *on that machine*. That is precisely why the item stays open. A
mechanical disk is not an exotic configuration, it is the cheap one, and a user
who keeps a large Maildir on spinning rust has nowhere to migrate to. The
measurements above are now the best evidence this project has for what such a
user sees on every cold start, and they were taken on real mail rather than
simulated:
| storage | first rows | complete walk |
|---|---|---|
| 7200rpm platter, cold | 2008 ms | 38618 ms |
| NVMe, cold | 50 ms | 668 ms |
Fixing one developer's hardware is not fixing the application. The indicator is
what makes a slow query legible on any disk, and the slower the disk the more it
matters.
**Approach.** A busy state on the left pane between `runQuery` and the first
`onThreadsReady`, cleared by whichever of the first batch or `queryFinished`
arrives first. The empty-result case must be distinguishable from it: when
`queryFinished` reports zero, the pane should say so rather than returning to a
blank list, which is the same ambiguity one step later.
The likely shape is an overlay or a placeholder row rather than a literal
spinner widget, but that is a design question for the user, not a decision to
take here. A spinner also has to be animated by the UI thread, which is free
here since the work is on the worker, but that is worth stating because it is
the usual reason a spinner does not spin.
**Constraints.**
- **A background refresh must stay silent.** `onThreadsReady` returns early on
the refresh branch and `onQueryFinished` does the same, deliberately, so a
sync-driven refresh does not flicker the status bar. A busy indicator that
ignored that guard would make every cron sync flash the list. That silence is
already a test, and it should cover this too.
- **Item 74's decision not to address the cold cost was taken on wrong
numbers**, and its conclusion still holds for a different reason. It judged a
5.7 s wait not worth prefaulting 1.1 GB; the real figure was 38.6 s. Do not
reopen prefaulting: it trades a large fixed cost at every startup against a
wait that only some users pay, and it is worse on exactly the low-memory
machines most likely to have a slow disk.
- **Do not treat "move the index to an SSD" as this item's fix.** It is the
right advice for a user who has an SSD, and it is documented, but it is
hardware guidance rather than a change to the application. This item must
stand on its own for a user with one mechanical disk and no migration
available.
- Nothing about the query timing may change.
**Size: S.**
## 122. The README documents a version of the app that no longer exists
**Observed (user, from the notes):** "documentation needs updating, EG the
README.md reports various things not up-to-date anymore."
**Cause (verified).** `README.md` was last touched on 2026-08-15 by b405e32,
which moved the SlackBuild out to the `my-slackbuilds` repo. Everything released
since then is absent from it. Releases 0.19.0 through 0.26.1 all landed after
that commit.
**Measured, by grepping both documents for the same terms:**
| term | README | CHANGELOG |
|---|---|---|
| `trash` | 0 | 14 |
| `restore` | 0 | 7 |
| `Select all` | 0 | 3 |
| `deleted-from` | 0 | 0 |
**One of these is worse than stale documentation.** Item 103 made a per-account
`trash` key MANDATORY: an account without one produces a config warning, and
Delete cannot work. The README is the only place a user reads about configuring
an account, and it does not mention the key at all. So the documented config
produces a warning against the current binary, and the feature that needs it is
undocumented. The `deleted-from:<folder>` tag is likewise invisible, and a user
who sees it on a message has nowhere to look it up.
**Approach.** An audit against the changelog rather than a rewrite: walk the
sections from 0.19.0 forward and check each user-visible change for a README
home. The config section and the keyboard-shortcut table are the two most
likely to have drifted, since both enumerate things that have been added to.
**Constraints.**
- **The changelog is the evidence, not memory.** Every entry since b405e32 is
written down; work from it.
- **`### Upgrading` sections are the priority.** They exist precisely because a
user's config or habits had to change, and those are the paragraphs whose
absence from the README costs the user a broken setup rather than a moment of
confusion.
- The "Development Approach" section at the bottom is required by the user's
global preference and must survive any edit.
- No personal details, per the same preference: account names in examples stay
generic.
**Size: M.** The audit is most of it; the writing is small once the list exists.
## 123. Sending mail is not designed
**Specified 2026-08-20.** Read
`docs/superpowers/specs/2026-08-20-compose-and-send-design.md` instead of this
section. Brainstormed with the user on branch `compose-and-send`; no code
written, which is what the note's `#plan-only` asked for.
**The three constraints a reader needs before opening the spec.**
- **There is no MTA on this machine**, measured 2026-08-20. `msmtp` and
`sendmail` are both absent, and neomutt sends over its own built-in SMTP. So
"an external script on the same model as `mailsync.sh`" had no model to copy.
The design keeps the no-network-protocol rule by making send a **per-account
`send_command`** taking the message on stdin, exactly as `[sync] command`
works. What the user installs behind it is their choice.
- **An account with no `send_command` is receive-only by construction**, which
is how one of the five accounts is meant to work. Reply, reply-all and forward
are disabled on its mail, with a ribbon in the message pane saying why.
- **The body is markdown**, parsed by cmark-gfm (autolink, strikethrough,
tasklist; tables off), sent as `multipart/alternative` or plain text per a
per-message toggle. A hand-written parser for a limited set was rejected
because it would be deleted wholesale the moment the set widened.
**What it blocks and what it opened.** Item 72 (khard/khal) is placed after send
by the user's own note. The brainstorm opened items 128 to 132: an outbox,
inline images, attaching a message to a message, a configurable markdown
dialect, and a review of the every-action-has-a-shortcut rule.
**Size: L.** Four new units, six new actions, a formatting toolbar over the
markdown source (whose shortcuts live in the composer's own scope and do not
touch `KeyMap`), and one new build dependency,
`cmark-gfm`. That dependency is cheap: it ships in stock Slackware
(`cmark-gfm-0.29.0.gfm.13-x86_64-3`, verified 2026-08-20), so it needs a
`pkg_check_modules` line here and **no** `REQUIRES` entry in the SlackBuild,
which lists only non-stock dependencies.
## 125. A skipped sync leaves the spinner running for ever
**Observed (user, 2026-08-20):** during the item 124 index migration, a Delete
appeared to do nothing: the view did not refresh, the message did not move, and
restarting the application showed it exactly where it had been. The user then
reported "I see a spinner in the bottom right, is going indefinitely. maybe
that's what stopped the move?" That observation is what identified the cause.
**Cause (verified from `/proc` and the sync log).** The migration procedure held
`/tmp/mbsync.lock` to keep cron out of the way. qtmaildir auto-syncs a couple of
seconds after a tag action (item 71), so Delete queued its edit and started a
sync; `mailsync.sh` found the lock held and exited **75**, by design:
```
2026-08-20T10:00:01+02:00 === SKIPPED: previous run still in progress ===
2026-08-20T10:10:01+02:00 === SKIPPED: previous run still in progress ===
```
75 is `EX_TEMPFAIL`, chosen deliberately so a click landing during a cron run is
not reported as a failure (`assets/mailsync.sh`, the comment at the `flock -n`
guard). The application appears to treat it as neither success nor failure: the
sync indicator never cleared, and because an edit made during a sync is HELD
until the sync ends, the delete sat in that queue waiting for a completion that
could never arrive. `notmuch count tag:deleted` on the account confirmed the
write had not reached the database.
**Nothing was lost**, and that is worth recording separately: held edits are
written to disk (item 106), so the delete survived and applied as soon as a real
sync ran. The defect is that the user cannot tell.
**Approach.** Handle exit 75 explicitly wherever `MailSync` reports a finish.
It is a third outcome, not a variant of the other two: the work did not happen,
nothing is wrong, and it should be retried rather than reported. Clearing the
indicator is the minimum; re-arming the auto-sync timer is probably right too,
and item 89 already made a skipped auto-sync re-arm rather than give up, so
there is a precedent to follow rather than a policy to invent.
**Constraints.**
- **Do not turn 75 into an error.** The exit code exists precisely so an
overlapping click is not reported as a failed sync, and item 89 settled that a
skip is routine. Showing the log pane here would be a regression.
- **The held-edit queue must still flush.** Whatever clears the indicator has to
leave the queue in a state where the next successful sync sends it, which is
what happened by luck here rather than by design.
- **A stuck indicator is worse than a wrong one**, because it also blocks the
quit prompt's "unsynced changes" story (items 18, 19, 28, 54). This is the
same class of indicator dishonesty those four items each fixed once.
**Reproducing it.** Hold the lock in one terminal and act in the application:
```bash
flock /tmp/mbsync.lock -c 'sleep 300'
```
Then Delete a message. Verified by hand on 2026-08-20; this is how it was found.
**Size: S.**
## 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. |
| 120 | No way to tell a tag applied by a rule from one applied by hand | **Postponed by the user**, and recorded here on 2026-08-19 from their notes ("should the UI allow to discriminate when a message has been tagged by a rule?"). Not plannable as it stands: nothing records the provenance. A rule has an `id` in `~/.config/mailrules/rules.json`, but `mailrules.py` writes only the tags the rule names and keeps no note of which rule wrote them, so the information does not exist to display. Answering it means the HOOK storing something per message, which is a shared-format change across both repos and needs the procedure in `CLAUDE.md`. Ask the user what they would do with the answer before designing that. |
## 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.
|