summaryrefslogtreecommitdiffstats
path: root/CLAUDE.md
AgeCommit message (Collapse)AuthorFilesLines
11 daysdocs: close item 36, narrow item 66 with a negative resultDanilo M.1-3/+26
Item 36's section moves to the closed file and its three traps go into CLAUDE.md, which is where they will be read: the worker is unreachable by findChild, rowCount on an unexpanded thread row is 0 by design, and currentThreadId reports intent rather than content. The claim that this class of bug cannot be reproduced in test_mainwindow is now false, so it is corrected rather than left standing beside its replacement. One in-test reference to item 36 as a permanent limitation is reworded: bare-window cases still have no worker, but that is now a choice per case rather than a property of the binary. Item 66 stays open with the simple case ruled out. The negative result sharpens this entry's own candidate rather than contradicting it: the test drives setCurrentIndex, which updates the selection model synchronously, while the suspect guard turns on a real click not having done so yet. Two cheaper conditions are named as still unexcluded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 85, searching from the message paneDanilo M.1-3/+41
Five surfaces in the message pane offer a search built from what they show, replacing the query or narrowing it. The details dialog became rows along the way, which the user wanted independently of this feature. Item 78 is narrowed to the rule shortcut alone and drops to S: item 85 built the menus and item 81 the seeded dialog, so both halves already exist. Its approach text is corrected too, since it claimed the thread list holds a usable sender and notmuch_thread_get_authors returns a display summary, not an address. Three traps recorded in CLAUDE.md: a modal dialog must close before the action it asked for runs, Qt::RFC2822Date validates the weekday against the date, and every query goes through SearchTerm so five surfaces cannot grow five quoting rules.
11 daysfix(rules): stop a rule with a spaced name from vanishing on saveDanilo M.1-0/+13
A rule named "justeat orders" in the field labelled Name was written to rules.json correctly and then dropped by every reader, because load() required ^[a-z0-9][a-z0-9-]*$ and the save path validated nothing. The rule stayed in the file, invisible in the dialog, never applied by the post-new hook, and the next save from the dialog would have deleted it outright. The asymmetry was the defect, not the pattern. TagRules::validate() is now the single predicate: the dialog refuses to save against it, and load() uses it to repair rather than drop, so a rule that fails is visible and fixable instead of silently discarded. - The typed name is sanitised into an id when the field is committed, so the field shows what will reach the file. uniqueId() suffixes a collision, since sanitising is many-to-one and can manufacture the duplicate that load() then drops. - An already-legal id is never rewritten, including one like "a---b" that sanitising would otherwise collapse. Rewriting valid ids would churn a file mailctl also reads. - A bad id loads repaired, with the warning kept: what is on disk is not what the hook runs until the file is saved back. Deliberately not mirrored into mailrules.py. The hook tags real mail unattended every ten minutes, where silently renaming an id is worse than dropping the rule; the file converges as soon as the dialog saves. No format change, so no version bump and no two-repo commitment. The load warning was not missing: it had been showing "1 rule could not be read and was skipped" on every open, in the same font and colour as the intro prose two lines above it, and read as more explanation. It is now a red banner beside Save, with an icon and a dismiss button, and it says the rules need attention rather than that they were skipped, which is no longer true. Dismissal is per-appearance only; a persistent one would re-hide the problem that went unnoticed for a session. Both new dialog tests were confirmed to fail with the sanitiser reverted, and the banner's styling, position and dismissal each fail under mutation. 20 of 20 suites green, 34 tests in test_tagrules. Closes item 83. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: split the closed backlog items out to a companion fileDanilo M.1-0/+9
Item 73. The backlog kept every item's full Observed/Cause/Approach section forever, including the sixty-eight that are closed, and had reached 5056 lines: past the point where it could be read in one pass, and past the point where a tool could open it at all. The closed sections move to 2026-08-03-post-0.1.0-usability-closed.md, taking the backlog to 570 lines. The status table stays where it was and remains the index of all 80 items, so a closed item keeps its row, its date and its outcome beside the open ones; only its evidence moved. Nothing was renumbered and nothing was deleted, which the item required: the numbering is cited from commit messages, from CLAUDE.md and from the specs, and both files share one sequence, so item 42 is `## 42.` in whichever file holds it. The split was done by script and verified by set difference rather than by reading: every non-blank line of the original appears in one of the two files, zero missing, and the only lines not in the original are the new file's header. All 80 numbers resolve, every open item has its section in the backlog, every closed one in the archive, with no duplicates and no orphans. Two things the item's own approach did not anticipate. Three cross-references said "see below" and their targets had just moved, so rows 60 and 75 and the header's note on item 20's parked branch now say where the entry went. And the cause was never the fifty done sections, it was that nothing moved a section on the day its item closed; doing this once buys a few months and then item 73 returns. The rule in "Adding to this document" now requires the move on the closing commit, and CLAUDE.md tells a future session that grepping the backlog for a closed item's evidence will find the table row and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: the window size cannot be restored under a tiling compositorDanilo M.1-0/+19
Item 75 shipped claiming the rules window remembers its size. It does not, and no code here can make it. Hyprland tiles the window to fill its slot, so the size dragged belongs to the tile. saveGeometry stores frameGeometry beside normalGeometry and restoreGeometry restores the normal one, which stays at whatever resize() last set it to. Decoded from the real state file after a hand test: frame 2248x806, normal 760x664. The dialog restores 760 correctly and still opens tiled. Three diagnoses were tried before this one and each was disproved by a probe rather than argued away: that restoreGeometry rejected the blob as off-screen, that the layout overrode a geometry applied before the first show, and that a test could tell the broken and fixed versions apart. The last one matters most: the offscreen platform returns an identical frame for both, so a size assertion passed against the bug and a mutation restoring it left the suite green. That assertion is not reinstated. The column widths, which are what actually works, keep their test. The changelog and the backlog entry are corrected to say what ships, and CLAUDE.md gains both the tiling-compositor trap and the rule that the offscreen platform cannot test window sizing at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(rules): save the window size on Cancel and Save, not only on XDanilo M.1-0/+11
The geometry was saved from closeEvent, and neither dialog button sends one: Cancel calls reject(), Save calls accept(), and only the window manager's X button produces a QCloseEvent. So the size and the column widths were kept for the one route out of three that a user almost never takes, and a resize followed by Cancel came back forgotten. The save moves to a done(int) override, which both buttons funnel through and which QWidget::close() also reaches. The test that covered this passed against the bug because it asserted with close(). It now drives all three routes rather than trusting one to stand for the others, and shows the dialog before the close leg: close() on a widget that was never visible returns early without reaching done(), so that assertion would otherwise prove nothing. Both traps recorded in CLAUDE.md, since neither is specific to this dialog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysdocs: never run test binaries without the offscreen platformDanilo M.1-1/+19
tests/CMakeLists.txt sets QT_QPA_PLATFORM=offscreen for ctest only, so a binary invoked directly inherits the desktop's setting and throws real windows onto the user's screen. Each test function builds its own MainWindow, so one direct run of test_mainwindow flashes over a hundred windows. Launching the application unasked is the same problem: running it is a hand test and belongs to the user.
12 daystest(mainwindow): stop the suite reading the real kernel lock tableDanilo M.1-0/+13
Item 61. An init() fixture gives every test its own empty lock table in a QTemporaryDir, so no test observes the machine's real sync state. The failure was never intermittent in the usual sense: 0 failures in 30 runs with no lock held, 30 in 30 with one held. It presented as three tests failing that never mention syncing, and cost three misdiagnoses. The three tests that already used the seam each restored "/proc/locks" when finished, which was itself the defect: it handed the real table to whichever test ran next, so one test opting in re-exposed all the others. Those restores are gone and cleanup() leaves the temporary path in place. noTestCanSeeTheRealLockTable guards the fixture, since a silent revert would go back to failing for reasons no assertion mentions. Verified with the lock deliberately held: 3 failures before, 119/119 after, full suite 19/19. Mutation-checked by disabling the fixture, where the guard fails first and a real test fails behind it.
12 daysdocs: record the one coupling between qtmaildir and mailctlDanilo M.1-0/+60
The two tools are independent except for ~/.config/mailrules/rules.json, which has two independent implementations agreeing by test rather than by shared code. That is the only way work here can break mailctl, so it now has a named procedure: change both readers, bump the format version only for a breaking change, run both suites, and verify the round trip by hand since no automated test spans the repos. Also records that the backlog covers the mail system rather than this binary alone. Item 44 already shipped as commits in both repos and future format work will too.
12 daysdocs(backlog): record item 44 as doneDanilo M.1-0/+23
The tagging rules moved from the shell post-new hook to a shared JSON store both qtmaildir and mailctl read. Seventeen real rules were converted, each keeping its shell comment as a note, and the conversion was proved against the real index before anything was installed. Four findings are recorded in CLAUDE.md rather than only here, because they will outlive the item: a stored query carries no scope and the hook parenthesises it (a disjunction would otherwise escape tag:new and match everything); notmuch's parser rejects almost nothing, so a test asserting a provoked query failure fails against correct code; rule counts must count messages rather than threads; and a count request must not bump the query generation, which would blank the message pane.
14 daysfeat(panes): draw the pane marks from shipped SVGs, not font glyphsDanilo M.1-0/+15
Items 70 and 69, the second folded into the first as item 70's own size note predicted it should be. The panes drew their state marks as font glyphs: U+1F4CE for an attachment and U+2605 for a flagged thread, each with a fallback for a font that cannot render it. Both fell back to "*", so on such a font a flagged thread and one carrying an attachment were indistinguishable, which is a defect the fallback introduced rather than prevented. What a mark looks like was also the desktop's decision rather than this application's, and the panes are exactly where it should not be: the user asked for the toolbar and menus to keep following their icon theme while the panes stop. Six marks now ship in assets/icons/marks/: flagged, attachment, passed, replied and the two expander triangles. QIcon::fromTheme still resolves every toolbar and menu icon and was not touched. Licensing chose the shapes. The look came from a GPL3 icon theme, and this project is GPLv2-only, which are incompatible: GPLv2's "no further restrictions" clause bars shipping GPL3 assets in a v2-only work. The six were drawn fresh in the same idiom instead, with no path data copied. The idiom is generic: solid single-path silhouettes at 16x16 with no strokes. They are compiled in as string literals rather than loaded from a .qrc. src/CMakeLists.txt already records why resources belong to the executable: a qrc in the static library registers itself from a global initialiser the linker drops. The tests link the library, so a resource-based mark would be missing exactly where it needs asserting. assets/icons/marks/ stays the editable source. One asset serves both palettes. Every payload paints with fill="currentColor", which QSvgRenderer renders black rather than resolving, so Marks::pixmap composites the wanted colour with CompositionMode_SourceIn. A mark then takes the card's own pen colour and follows selection and the read/unread dimming without a second variant to keep in step. CardLayout reserves a rect per mark and CardDelegate paints into it. The marks were glyphs inside the subject STRING, so their width came free from the text metrics; as icons the geometry has to know they are there or the subject runs underneath them. The expander pill had the same trap, its triangle being a glyph in expanderLabel(), and now reserves that width explicitly. Item 69's part: passed and replied were words in the tag strip and are marks beside the subject now. The message pane's header carries the flagged and attachment marks next to the subject, per the user's decision that the right pane needs those two and only outside the message area. A duplicate that no test caught is worth recording. Every geometry assertion passed while a card showed passed as BOTH an arrow and a green tag chip: the chip filter had no reason to know a mark had appeared. It was found by rendering real cards to an image and looking at them. isDrawnAsAMark() is now one list consulted by both PillTagsRole and MessageOwnTagsRole, since two copies drifting apart is how a tag ends up drawn twice on one row and not at all on another. Fourteen tests: nine in test_marks, four in test_cardlayout, one in test_threadlistmodel. Mutation-checked at four points, each failing a test: the subject ignoring the marks, the flag not indenting the subject, the pill forgetting the triangle's width, and the recolour composite removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10docs: record the card list, and correct what CLAUDE.md claimsDanilo M.1-45/+75
The architecture section described ThreadListView as existing to paint a strip across columns. That was true until this change and is now the opposite of true: it survives only for the expander hit-test. Kept as one paragraph of history, since it explains the file's shape, but no longer stated as current behaviour. Two traps are recorded inverted rather than deleted, because the rule survived its own reason changing. The reply indent is still asserted on where the TEXT lands, but where visualRect lies has flipped: it used to report an indent the text did not have, and now reports none while the text is indented. And Q_ENUM is documented as insufficient for a queued Q_ARG, which cost a silently dropped sort argument. Item 60's recorded cause was wrong and is corrected in place. It was read off master, where the row arithmetic really is current.row() + 1; the branch had already fixed it a commit earlier with threadRowOf(). The entry stays, with the correction, because the reasoning was sound and the tests it demanded now exist. Items 20, 51 and 53 are marked built on the branch rather than done. Nothing is merged and the user has not seen it, which is the whole point of Task 10.
2026-08-10docs: record message rows, and the user's verdict on themDanilo M.1-9/+64
CLAUDE.md described a QTableView over a table model, which has not been true since the view port. Updated with the traps the port produced, each of which shipped a plausible-looking broken build before being caught: - A tree numbers rows per parent, so nothing may be keyed on a row NUMBER. - drawBranches runs before the row's cells, so an expander on a content column is painted over by the delegate's background. - setRootIsDecorated(false) removes the style's HIT AREA along with its indicator, leaving a glyph that renders and does nothing. - isExpanded and setExpanded are keyed on column 0. - A reply's indent must beat the account chip's width, and visualRect reports the indent correctly even when nothing is visibly indented. - paintEvent runs after the cells, so a full-row fill erases their text. Also the notmuch ownership rule, which is a double-free if undone: messages reached through a thread are freed with it, so walkReplies holds them raw against this file's own RAII convention. Item 20 is marked built, not done, and item 53 records why. The user's verdict on the finished result was that the table view does not fit the use, said with every cue in and working. That is a design finding rather than a defect: the item shipped exactly what its four decisions specified, and all four were the user's own choices. Recording it as a defect would misattribute the cause; recording nothing would leave the next session building on a rejected design. Item 53 carries the cause verified in code rather than guessed. A message row fills the same five columns as a thread row (threadlistmodel.cpp:275-283 mirroring :428-431), so replies land on the same rigid column boundaries as the threads around them, and the eye reads columns before indentation or tint. The reference the user gave has no column rules through its reply rows at all, and that absence is the one thing three added cues cannot supply.
2026-08-09docs: make the GitHub Release part of cutting a releaseDanilo M.1-0/+38
The repo reached v0.12.0 with fourteen tags and zero GitHub Releases, because every release had stopped after commit, tag and push. The user noticed and asked for the backfill, then asked that it not happen again. Records the whole procedure rather than the missing step alone, since nothing had written it down: changelog section, version bump, signed tag, push to both remotes, then `gh release create` with the body taken from that version's changelog section rather than composed fresh. Also records that the version choice follows semver on the user-visible surface pre-1.0, which is why 0.12.0 was minor rather than patch, and that the stale SlackBuild version is deliberately not part of this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07docs: record that QString::arg does not collapse %% into %Danilo M.1-0/+13
printf does, and the habit transfers silently. In generated CSS it is quietly destructive: a percentage written %% reaches the browser malformed, and a browser drops that one declaration and renders the rest, so the pane still paints and nothing looks broken. The 0.11.0 placeholder lost its mask, its glow and both radial gradients this way. Records the review lesson alongside it, which generalizes further than the rule: a geometry probe endorsed that layout because it measured only properties carrying no percentage. A probe that cannot see the thing that breaks reports success forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07docs: record how rendering probes mislead, and ThreadListViewDanilo M.1-4/+42
A session was spent chasing a defect that did not exist, because three separate rendering probes returned confidently wrong results and each was believed until contradicted. The specific failure modes are worth recording, since the next attempt will otherwise rediscover them: counting lit pixels cannot tell bold from regular in either direction, viewport()->render() returns blank images in several ordinary situations, and a "saturated pixel" threshold catches the antialiased edge of the selection highlight and passes whatever the code does. The bug that started it was not in the code at all: the desktop's Qt font was configured Bold in qt6ct, so every row rendered bold and setBold() changed nothing. Also records ThreadListView in the architecture diagram, with the three consequences of painting a row-wide strip in a per-cell view that are easiest to undo by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06fix(ui): one Sync control, and a query bar that looks finishedDanilo M.1-3/+13
Sync had two controls that behaved differently. The QPushButton beside the query bar cleared the log, opened the pane and disabled itself; the QAction behind the toolbar, menu and shortcut called start() and did nothing else, discarding its return value so a rejected start was silent. Worse, item 29's "disable Sync during a background sync" set the button only, so the toolbar entry stayed clickable through a cron sync and could only produce the script's EX_TEMPFAIL skip. startSync() is now the single handler behind every route in, and the enabled state lives on the QAction, which reaches the toolbar, the menu and the shortcut at once. It also reports when no sync command is configured rather than doing nothing. The QPushButton is gone. It read as a Search button given it sat beside a text field, which is the user's own observation and the reason the toolbar one survives instead. Its unavailable-command tooltip moved to the action, since that is the only thing that says why the control is dead. Removing it left the query field running flush to the window edge, so the saved-query buttons move from their own row onto the query row. The bar is now framed by the account dropdown on the left and the saved queries on the right, the empty row is gone, and the thread list gains the space. The field also gains setClearButtonEnabled, which is Qt's own themed clear icon rather than a hand-rolled button. A "Search" button was considered and rejected: Return already runs the query. No overflow handling for [queries], which is unbounded. Three entries fit; item 23 already specifies buttons-plus-menu and is where that belongs. CLAUDE.md's architecture diagram named four widget classes that have never existed, QueryBar, SavedQueryBar, HeaderWidget and AttachmentBar. The query row and the message header are built inline. Corrected, and the components that do exist but were missing from it added. Tests: the new action test was verified red first and load-bearing by mutation. The old button test is deleted rather than repointed, being an exact duplicate of it, and the unobservable-lock test now drives the action. The clear button and the row layout were confirmed by hand; no test clicks the icon, which is a mouse path. Backlog: 45 done and reclassified as a defect rather than a cosmetic redundancy, 47 added for the bar.
2026-08-04docs: print the open backlog as a table at session startDanilo M.1-0/+32
Extends the reconciliation step rather than adding a second one: after diffing the user's notes against the backlog, render what is open as a table with a short description, the size, and one note saying what decides whether the item can be picked up now. Then stop. The user picks. Recommending a single item reads as though the choice were already made. Defects are flagged apart from enhancements, because they read alike in a numbered list and do not deserve equal billing: item 28 sat as "a counter is wrong" while the indicator was quietly lying about whether the user's work was safe to quit on. Records two gotchas found by running the grep: item 12 lives in the deferred table, which has different columns and no size, and a status cell is prose rather than a keyword, so "open, on demand" and "open, unspecified" both mean open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04docs: record items 28-35 from the user's notes, and how to keep them in syncDanilo M.1-0/+20
The backlog is downstream of the user's own notes, which they add to while using the app. Comparing the two turned up nine entries with no item here, two of them defects rather than enhancements: - Re-adding `unread` after the automatic mark-read counts 2 unsynced changes when the mail store is back where it started. The counter counts writes and never decrements, so any add-then-remove of the same tag inflates it (item 28). - The Sync button stays enabled while a background sync holds the lock. That was a written constraint of item 27 and shipped unbuilt, which is the strongest argument for doing this comparison at all (item 29). The rest are enhancements: a useful blank right pane, for which the user has produced the two logo images added here; a default button on the quit prompt, recorded as needing a repro because the code does set one; Esc to blank the pane; expiring status messages; a Maildir overview; and an automatic refresh after a sync. Item 35 is the one 0.8.0 deliberately did not build. A background sync reports rather than refreshes, because runCurrentQuery() clears the undo stack, the selection and the message pane. A non-destructive refresh is real work, so it is an item rather than a flag. Item 27 gains its outcome, including that its own proposed approach was the wrong one: flock -n acquires in order to test, so polling with it would have caused the exact skips the sync script reports. CLAUDE.md now carries the reconciliation step, since a note saying "X is broken" is a bug report that will sit in a personal file indefinitely unless someone goes looking. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04docs: record the two selection-model traps found in 987a9e7Danilo M.1-0/+21
currentRowChanged fires before the selection model updates, and selectAll() emits it not at all. Both cost real debugging, and the first caused two distinct faults in one change, so they belong beside the setCompleter trap rather than only in commit history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04feat(sync): bring mailsync.sh here, print to stdout, exit truthfullyDanilo M.1-0/+10
The reference sync script lived in the companion mailctl project, which documents that it never calls it: sync and organization are separate there on purpose. qtmaildir is the only thing that runs it programmatically, and two of its behaviours were live bugs here, so it belongs in this repo where the constraints on it are. It printed nothing a caller could see. Everything was redirected to $LOGFILE, so MailSync captured an empty stream and the sync log pane stayed blank, a known limitation carried since 0.1.0. It now writes to both through tee: cron keeps its log, and the pane has something to show. It also ended in an unconditional exit 0, discarding statuses it had already computed. qtmaildir believes that exit code: a failed mbsync reported success, cleared the unsynced-changes count, and would have allowed a sync-on-exit to quit over a sync that never happened, which is exactly the case the exit prompt exists to prevent. It now exits with mbsync's status, or notmuch's when mbsync succeeded. The statuses move through files rather than shell variables because the run block is piped into tee, which puts it in a subshell where an assignment does not survive. Verified against stub mbsync and notmuch binaries in a fake HOME, so no mail or database was touched: output reaches stdout, the log still gets every line, and a failing mbsync or notmuch produces its own exit code rather than zero. Both properties are now recorded in CLAUDE.md, since they exist for this application's sake and an edit that looks like a cleanup would remove them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04fix(tags): complete every tag in the list, not only the firstDanilo M.1-0/+15
Reported by the user: in the Edit tags fields, the first tag suggested existing tags and the second did not. Typing a comma, a space and a letter offered nothing. QLineEdit::setCompleter hands completion to the line edit, which overwrites the completer's prefix with the widget's ENTIRE text on every keystroke. These fields hold a comma-separated list, so once one reads "unread, fl" that whole string is matched against the tag names, nothing matches, and completion silently stops after the first tag. Confirmed with a probe: the prefix really is "unread, fl" and the completion count really is zero. Attach with setWidget instead, which keeps the popup anchored without ceding control of the prefix, and drive it from the token under the cursor on every edit. Setting the prefix from a textEdited handler while leaving setCompleter in place does NOT work, which was the first attempt: the line edit sets it again afterwards. Accepting a candidate needed the same treatment, and is the other half of the fix. QCompleter's own insertion replaces the whole field, so taking "flagged" from the popup would have discarded every tag already typed. replaceCurrentToken() overwrites only the token under the cursor and keeps the separator's spacing, so the result is "unread, flagged" rather than "unread,flagged". This is the same defect QueryCompleter hit in c98b179. Having now cost two debugging rounds, it is written into CLAUDE.md as a Qt trap rather than a property of either class, together with the reason a test using setText() passes against it: setText does not drive a completer at all, so the keys have to be typed. Both new tests were confirmed to fail against setCompleter before the fix was kept. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04docs: spec query bar completion, require translatable stringsDanilo M.1-0/+6
Design for backlog item 17. Completion covers query prefixes, tag values, date values, path values and mimetype values, each carrying a description so the bar documents the query language while it is typed. A new QueryCompleter class owns it, with the cursor-context tokenizer as a pure function so the parsing rules are testable without a widget or a database. NotmuchWorker gains an all-tags call, which did not exist. Addresses for from:/to: are out of scope: libnotmuch exposes no all-addresses call. Both prefixes still appear so the vocabulary reads complete. Account maildirs belong to path:, not folder:. Account::scopedQuery builds path:"<maildir>/**", and folder: is a different matcher in notmuch, against the folder name rather than the directory path. Also records the translatable-strings rule in CLAUDE.md. Existing code is not yet audited against it.
2026-08-04feat: own the message-pane zoom and persist itDanilo M.1-1/+16
Zoom was Chromium's, not the application's: the web view handled the keys natively and never told anyone, so there was no value to save. qtmaildir now owns it. Zoom in, out and reset are real actions, in the View menu and rebindable through [keys], and the factor is persisted to the UI state file. Ctrl+wheel over the body zooms and Ctrl+middle-click resets, both filtered by ancestry from an application-level filter: the events are delivered to an internal QQuickWidget the web view creates lazily, so a filter on the view itself never sees them. The factor is clamped to 0.5 - 3.0, and NaN, infinity, zero and negative values fall back to 1.0, since a corrupt state file must not be able to leave the pane unreadable with no visible way back. Both risks the plan flagged turned out not to exist, verified by probe rather than assumed. The application QAction wins over the web view's native zoom key, so the tracked factor cannot diverge from what is on screen. And the factor survives setHtml(), so the web view is the single source of truth and needs no reapply per render. A third finding is worth recording because it produced a wrong fix first. A probe using QTest::keyClick() reported Ctrl++ as a dead binding, and a test was written asserting that. Both were wrong: Ctrl++ is exactly what the '+' key emits on an Italian layout, confirmed against the real keyboard, and it is the shipped default. Whether a symbol needs Shift is a property of the layout, not of Qt, and keyClick() reproduces neither. The test now only checks that every default parses, and the comment in defaultBindings() says not to re-derive this from synthetic input. Ctrl+= is a second binding for reset, skipped when [keys] gives it to something else. Also fixes a pre-existing bug the new config key exposed. [general] entries were read as "general/<key>", which matches nothing: QSettings' INI backend treats a section literally named [general] as its own fallback section and strips the prefix. notmuch_config had therefore never worked. Both keys are now read without it; the file format is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04docs: add CLAUDE.md with build commands and architectureDanilo M.1-0/+124
Records the non-obvious constraints that require reading several files to reconstruct: the two-thread split with no notmuch pointer crossing it, generation counters instead of cancellation, the close-before-reopen ordering the exclusive write lock forces, and the web view's fail-closed interceptor contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>