Skip to content

Notes export, full-text extraction, and the reading window - #11

Merged
BrendonJL merged 66 commits into
mainfrom
develop
Sep 13, 2026
Merged

BrendonJL merged 66 commits into
mainfrom
develop

Conversation

@BrendonJL

Copy link
Copy Markdown
Owner
  • Design: expand stage 4b with the UI, settings and write ordering
  • Design: Phase 5 reader and annotation app
  • Summary cache for Phase 3b, plus its tests and a plans-index refresh
  • Record how DMS stores plugin settings, and correct the 4b design
  • Phase 4b: notes export wired into the widget
  • Fix multi-article export, and a path collision it made dangerous
  • Design: stage 4c local full-text extraction
  • Phase 4c-a: HtmlExtract.js, measured at 91% of Readability
  • Design: Phase 5 becomes a reading window, not an annotation app
  • Phase 4c-b: full-text extraction wired into export
  • Resolve relative links in extracted articles
  • Drop promo sections and stop repeating tags in the note body
  • Design: stage 4d, open in any editor
  • Phase 4d: open the note in any editor
  • Phase 5: a reading window
  • Open the editor detached, and return focus when the reader closes
  • Design: stage 5b reader typography, and record colour theming
  • Phase 5b: typeset the reader rather than rendering it
  • Reader: n/p to move between articles without closing
  • Docs: slim the README, move detail to wiki pages, changelog to its own file
  • CI: read the version heading from CHANGELOG.md
  • Stow the remaining work in a backlog
  • Reader: Shift+J / Shift+K instead of n / p
  • Add a wiki landing page

BrendonJL and others added 30 commits September 11, 2026 06:33
The 4b section covered the FileView mechanism and nothing else. Pins the
parts a thin spec would leave to guesswork: the action lives in the
selection bar plus 'e' on the keyboard following the same
selection-or-cursor rule as m and s, not a fourth per-row control;
notes settings are global rather than per-instance, since there is one
vault; and a bulk export must write SEQUENTIALLY, because FileView has
one path at a time and firing twelve at it races them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
The hard part is anchoring, not the UI: a highlight has to survive the
article being refetched with different whitespace or an inserted banner,
which character offsets do not. Quote plus surrounding context, relocated
by search, with a failed relocation degrading to a visibly orphaned note
rather than guessing -- a highlight silently landing on the wrong
sentence is worse than one that admits it is lost.

Staged so 5a (the pure anchoring module) comes first and alone, because
if fuzzy relocation cannot be made reliable the rest of the phase needs
rethinking, and that is far cheaper to learn before a window exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Adds the bounded AI summary cache to ReaderState.js: addSummary,
getSummary, hasSummary, pruneSummaries. Capped at 100 rather than the id
lists' 1000, because those store ids and this stores paragraphs, and the
whole state file is rewritten on every change. The map is rebuilt from
the bounded order rather than having keys deleted as they fall off --
same result, one fewer chance to leak.

 is a real cached value, so getSummary distinguishes it from absent by
returning null for unknown ids. A model can legitimately return nothing,
and treating that as a cache miss would re-run a 5s job every time.

A test agent found a bug in this code an hour after I wrote it and
documented it rather than working around it: the carry-forward scan
excluded only the id being inserted, so a duplicate already in the order list
survived. boundIdList self-heals exactly that for read and bookmark
history, and a cache that stayed corrupt where the other lists recover
would be a surprising asymmetry. Fixed, and its test now asserts the
healing instead of the bug.

Also adds a live test that summarises through real ollama and asserts
the shape of the result rather than its wording -- a model's prose is
not a test fixture.

Tests: 624 -> 640.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Verified in DesktopPluginWrapper.qml: loadPluginData reads the instance
config and falls back to the shared store, savePluginData writes the
instance config only. Global-as-default, instance-as-override -- so
every setting this plugin has is already per-instance for an instanced
widget, which nothing in this repo said anywhere.

That makes the 4b design's 'notes export is global' decision wrong on a
premise rather than on judgement: achieving it meant bypassing the
plugin API, which also cut the provider dropdown off from the shared
SelectionSetting component and made one section look unlike the rest of
the panel. Corrected on the doc rather than quietly edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Export lands in the selection bar and on 'e', following the same
selection-or-cursor rule as m and s. Settings gain a Notes Export
section: provider, folder, vault name for Obsidian, filename template
and tags. The action is hidden entirely until a folder is set, so a
widget with nothing configured stays silent.

Writes go through Quickshell.Io's FileView with atomicWrites, and they
are SEQUENTIAL. FileView has one path at a time, so firing twelve at it
races them and notes are lost or land in the wrong file -- the kind of
bug that works perfectly with one item selected. One toast reports the
batch; a failure names the first article that failed and how many
succeeded rather than abandoning the rest.

The queue was not extracted as a pure reducer. Every transition is
driven by a real FileView side effect, so there is no meaningful next
state function independent of the I/O, and a wrapper built only to
satisfy a test would be worse than an honest untested one. Flagged for
review instead.

Storage uses the standard plugin path rather than the SettingsData
bypass the first pass reached for -- see the correction appended to the
design doc and the new README section on how DMS tiers plugin settings.

Tests: 640, unchanged -- this stage adds QML, which has no automated
coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Multi-article export wrote one file. One FileView was driven through a
queue, but assigning path starts a background load (preload defaults
true) and setText fired before it settled, so later writes hit the old
path or vanished. My proposed fix -- blockLoading -- would not have
worked: per quickshell's fileview.hpp it only makes text()/data() block,
not the path assignment. Now one FileView per note, created with
preload: false and destroyed when it finishes, so there is no shared
mutable path left to interleave.

That makes writes parallel, which turned a latent path collision into
silent data loss, so I checked: two 400-character titles produced the
SAME filename. clampFilenameBytes truncated the assembled title-hash
from the end and ate the hash. The title is now clamped with the hash
and extension reserved, so the disambiguator always survives. Verified
across ASCII and CJK titles at the 255-byte boundary; regression test
added.

Notes also now contain the article text and a link back to the source.
They previously held only the title -- buildBody never read description,
because the design specified how annotations render and never said the
article itself should be there.

Settings: Notes Export moved up to sit with the source and connection
sections rather than after Appearance. The Tags field would not accept a
comma -- it saved on every keystroke, which re-ran the join and rewrote
the field under the cursor, discarding the comma just typed. It commits
on Enter or focus loss now.

Tests: 640 -> 641.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Exported notes hold the feed's summary, roughly 1k characters against
10k in the article. Miniflux can extract server-side and it is the wrong
dependency: a feature that works on one backend, silently does nothing
on another and is absent in plain RSS mode is the fragmentation the
backend interface exists to prevent.

No DOM in QML's JS engine, so this is string processing -- but with a
real tokenizer, not regex. Nested tags, attributes containing '>' and
unclosed <p> defeat pattern matching in ways that fail silently on
exactly the pages that matter.

Records the honest limitation: this will not match Firefox Reader View,
so the note says how its body was obtained and falls back to the summary
when extraction underperforms it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
A tokenizer, not regex: a quote-aware char scanner emitting a token
stream, then a tree, then one bottom-up stats pass, then scoring on
charCount + commas + paragraphs multiplied by (1 - linkDensity) squared
with a bonus for article/main. Link density is what separates prose from
navigation, and squaring it makes that decisive.

Quality is measured rather than asserted. tests/oracle/ runs Mozilla
Readability -- Firefox Reader View's algorithm -- in headless Chromium
over 20 real articles and compares: mean 91.1%, median 91.6%, worst 81%,
several 98-99%. So the zero-dependency extractor stays; neither a
runtime browser nor a vendored Readability plus DOM shim buys enough to
justify itself. Nothing in tests/oracle/ ships with the plugin.

An earlier run scored 71% and the fault was mine: the URL list mixed in
section fronts, where Readability correctly returns nothing because an
index page is not an article. Real article URLs moved the mean 20 points
with no code change -- but it surfaced a genuine limitation, that we
over-extract on index pages where Readability refuses. Recorded for 4c-b
to guard.

Bounded against pathological input: 20MB of 400k paragraph tags
terminates in 184ms via a token cap rather than hanging.

Fixtures are redistributable only -- Wikipedia (CC BY-SA), Gutenberg
(public domain) and synthetic structures. News pages are the real target
but not ours to vendor, so the oracle fetches them at run time into a
gitignored cache.

Tests: 641 -> 664.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Rewritten on Brendon's principle: let the text editor do the text
editing. Notes export as markdown and open in Obsidian, Neovim or VS
Code; highlighting happens there, in the file.

That removes the hard parts rather than deferring them. The earlier
design called fuzzy anchoring the whole risk of the phase -- re-locating
a highlight in an article refetched with different whitespace or an
inserted banner. With highlights living in a file the editor owns, there
is no pointer into refetched text to maintain, so Anchor.js, the
annotation store and the orphaned-highlight logic are unnecessary.

What remains was never the risk: showing prose well. The window renders
the markdown HtmlExtract already produces -- Qt does Markdown natively
via textFormat: Text.MarkdownText, verified on 6.11, so no library -- and
earns its place on typography the widget structurally cannot offer,
starting with a 60-75 character measure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Exporting now fetches the article and extracts it, behind a toggle that
defaults OFF -- it makes one outbound request per article to a
third-party site, which is a reasonable thing to want and an
unreasonable thing to do unasked. Frontmatter records extracted:
true|false, so a mangled extraction is distinguishable from a deliberate
summary. A fetch failure degrades that one note to its summary; the batch
and its single toast are unchanged.

The index-page guard is the interesting part, and the oracle is what made
it safe. Two independent signals reject a result: link density above 50%,
and character-weighted short-unpunctuated-line fraction above 70%.

The first attempt counted fragment LINES and dropped the oracle mean from
91.1% to 62.6% -- real Wikipedia and MDN articles false-positived,
because trailing navboxes contribute hundreds of tiny link lines beneath
a few long paragraphs. Character-weighting fixed it. A second false
positive, an LWN article that is mostly a quoted email in a pre block,
needed fenced code excluded from the tally, since preformatted
key-colon-value lines are legitimately short and unpunctuated.

Neither false positive would have been visible without measurement:
falling back to a summary looks exactly like working software. Mean and
median are back at 91.1% and 91.6%, matching the pre-guard baseline, and
a synthetic 30-headline index page is rejected with its reason.

Tests: 664 -> 680.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Reported from a real export: the note was full of links that looked
real and went nowhere. They were genuine anchors in the article --
site-relative ones, /news/articles/x, which resolve on the BBC's site
and to nothing in a markdown file. Worse than no link, because the
reader tries them.

Hrefs now resolve against the article's own URL, which the widget passes
as baseUrl. Site-relative, protocol-relative, document-relative and ../
all become absolute; in-page anchors and anything unresolvable degrade
to plain text, keeping the words and dropping the dead target. String
work, not the URL class, since this runs in QML's engine too.

Threading the new argument through the emitters missed emitList, and the
whole suite stayed green while the extractor threw 'opts is not defined'
on 14 of 20 real pages. The oracle caught it; 689 unit tests did not,
because they exercised paragraphs and links and nothing exercised a LIST
with the new argument in play -- a ReferenceError only fires on the
branch that touches the missing binding.

So the guard is now per-fixture rather than per-feature: every fixture
must extract without throwing, with options, with empty options, and
with none. Mutation-checked by reverting the emitList fix, which fails
it. Oracle back up at 92.4% mean over 18 pages.

Tests: 680 -> 701.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Two fixes from reading real exported notes.

Tags were emitted under the article as [[rss]] as well as in the
frontmatter. Obsidian and every other markdown tool read them from
there, so the body line was only ever something to delete in every note.
THREE tests asserted the old behaviour -- two in Node, one in the QML
smoke suite -- and all three are rewritten to assert the new intent
rather than deleted, so the decision stays visible.

'Recommended stories' widgets are spliced between paragraphs, inside the
article container, with no class the boilerplate filter catches. Two
shapes, found in real exports, handled on different evidence:

Al Jazeera labels the section: '## Recommended Stories' then the list.
That heading is the publisher stating outright that what follows is not
the article -- stronger evidence than any heuristic -- so it and
everything under it goes until the next heading of the same or higher
level.

The BBC emits a bare list of headline links with no label. All three
conditions must then hold: every item link-only, at most five items, and
prose on both sides. A long run of link-only blocks is a reference list
and real content: danluu's input-lag article has 56 and every one
belongs, which is why 'a block that is only a link' was not safe alone.

Measured before keeping. Oracle mean stays at 92.4% over 18 real
articles, so the removal costs nothing while clearing both pages.

Tests: 701 -> 709.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Adding editors is one field, not five integrations. The file written is
identical in every case; the only editor-specific behaviour is the
command that opens it, so the open action becomes a {path} template with
presets for VS Code, Zed, Emacs, Neovim, Helix and Vim.

The real distinction is GUI versus terminal rather than brand: VS Code
and Zed ship a launcher that takes a path, while Helix and Vim need a
terminal wrapped around them and which terminal is the user's business.
A hardcoded provider would have to guess and be wrong for most people.

{path} is substituted as its own argv element, never concatenated into a
shell string, and environment variables are not expanded -- doing so
would mean reimplementing shell semantics on attacker-adjacent input for
one preset's convenience.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Adding editors was one field, not five integrations. The file written is
identical in every case, so only the command that opens it differs: the
open action is now a {path} template with presets for VS Code, Zed,
Emacs, Neovim (running instance and terminal), Helix, Vim, Obsidian and
None, plus Custom. A preset fills the field; the field stays editable,
which is the difference between supporting an editor and supporting the
user's setup.

The real split is GUI versus terminal rather than brand. VS Code and Zed
ship a launcher that takes a path; Helix and Vim need a terminal wrapped
around them, and which terminal is the user's business. The terminal
presets say kitty because that is what this machine runs, and the field
description says to edit it.

{path} is substituted as its own argv element and never concatenated
into a shell string -- verified against a filename containing a
semicolon, quotes and spaces, which lands as exactly one argument.
Environment variables are deliberately not expanded: the Neovim-remote
preset works where the caller's environment already has , and
expanding it ourselves would mean reimplementing shell semantics on
attacker-adjacent input for one preset's convenience.

Existing settings migrate rather than reset -- obsidian and neovim map
to their equivalent presets, and a config that already has the new key
is left alone. Presence of the key, not its value, is what distinguishes
the two, since an object literal always has the key.

Obsidian keeps its URI form and its wikilink capability: it is a URL
handler rather than an executable, and wikilinks change the note's
content rather than how it opens.

Tests: 709 -> 733.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
A DankFloatingWindow showing one article, opened with 'v' or the row's
book icon. No highlighting, no annotation store, no anchoring -- that
lives in a real text editor after 'e', which is the whole point of the
rewritten design.

The measure is the reason this window exists, and it is computed rather
than hardcoded: FontMetrics.averageCharacterWidth * 68, so 60-75
characters holds at any Theme font scale or family, centred and capped
against the window width so dragging it wider adds margin instead of
line length. That is the one thing the widget structurally cannot do,
since a widget is sized for a corner of a desktop and prose is not.

Text.MarkdownText renders the same markdown HtmlExtract already
produces, so headings are headings and lists are lists -- no library.
Qt gives no control over inter-paragraph spacing inside one Markdown
block, so the body is split on blank lines into one Text per block
inside a ColumnLayout whose spacing IS the paragraph gap, with fenced
code guarded so a blank line in a sample does not split it. That undoes
a join HtmlExtract already made rather than parsing anything twice.

Fetch and extract reuse the export path exactly -- same
buildArticleFetchRequest, same Proc, same extractArticle -- so there is
no second fetcher to drift. A failed extraction shows the feed summary
quietly, with no toast.

Tests: 733 -> 737.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
The terminal editor closed itself a few seconds after opening. It was
launched through Proc.runCommand, which applies a default timeout and
kills what it spawned when that expires -- right for a command that
returns output, wrong for an editor, which is a long-lived process the
widget has no further interest in. Quickshell.execDetached instead, as
DMS itself uses for this. Still argv only, never a shell string.

Closing the reader left keyboard focus nowhere, so Esc did not land the
user back on the list: acceptsKeyboardFocus fell back to hover alone and
j/k did nothing until the pointer happened to be over the widget. The
window now hands focus back on close. Worth stating plainly in the
comment that this asks rather than guarantees -- under layer-shell
OnDemand the compositor decides, so if it does not restore focus to the
previously focused surface a click is still needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
From a real screenshot. The title rendered twice -- once in the window
header, once as the body's H1, the second enormous because Qt scales h1
hard and it wrapped over five lines at a 68-character measure. Heading
and body metrics disagree because lineHeight applies inside a block
while Qt's per-level heading metrics do not follow it.

Two jobs kept separate: normalise the content (drop the duplicate H1,
demote remaining headings, drop byline links before the first
paragraph), and own the typography (classify each block and apply
explicit metrics rather than letting MarkdownText decide).

The monospace body was not a bug -- it is Theme.fontFamily and this
user's DMS font is Maple Mono by choice, so it stays the default. Adds
an optional readerFontFamily override for people whose UI font is not
reading-friendly.

Records colour theming as a later item, framed as accessibility rather
than decoration: a generated palette has no reason to respect contrast
between hues a colour-blind user cannot distinguish, and red/green state
colours are exactly where that bites. Notes that not relying on hue
alone helps regardless of palette and is cheaper than palettes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
From a screenshot of a real article. Three fixes, plus a font override.

The title appeared twice -- once in the window header, once as the
body's H1 -- and the second was enormous, because Qt scales h1 hard and
it wrapped over five lines at a 68-character measure. normalizeForReader
drops a leading H1 only when it matches the title, demotes the rest so
the body's top level is H2, and drops byline and section links before
the first paragraph. It runs in the READER only: an exported note has no
window header, so it keeps its H1. Verified both ways, and the oracle is
unchanged at 92.4% because extraction never sees it.

Typography is now ours. MarkdownText's per-heading font scaling ignores
the item's own pixelSize -- setting font.pixelSize on a Text containing
'## Heading' does nothing, which is exactly what the screenshot showed.
So heading blocks have their leading #s stripped before they reach
MarkdownText, making the text a plain string our size and weight fully
control, while MarkdownText still handles inline emphasis and links.
Each block is classified and given explicit metrics relative to
bodyFontSize, so the scale moves together.

The monospace body was never a bug: it is Theme.fontFamily and this
user's DMS font is Maple Mono by choice. Default unchanged; adds an
optional readerFontFamily for anyone whose UI font is not
reading-friendly.

Tests: 737 -> 749.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Closing the reader left keyboard focus nowhere, so continuing to the
next article needed a click. The forceActiveFocus() added for this never
worked and could not: it sets Qt's own focus item, not Wayland keyboard
focus. Under layer-shell OnDemand the compositor decides, focus arrives
on a click, and when a floating window closes niri hands focus to a
regular window rather than a layer surface. That dead call is replaced
with a comment saying so, since a call that implies it does something is
worse than none.

The fix is to stop crossing the boundary: n/p (and Shift+J/K) move to
the next and previous article inside the reader, clamped at the ends,
routed through the same viewItem/openArticle path as v and the row
button so there is no second fetch path. The header shows 'N of M' so
position is visible.

positionIndex and positionCount are bindings rather than a copy taken at
open, because j/k or a filter change can move the cursor or resize the
list while the reader is open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
…n file

README 330 -> 111 lines: screenshot, features, a keyboard cheatsheet,
install, requirements, one paragraph of configuration, honest
limitations, and links out. Everything else moves to docs/wiki/ as
drafts a human pushes to the wiki repo -- Architecture, Sources and
Sync, Notes Export, Data and Persistence, Development, CI, Roadmap,
Related Plugins.

Authoring wiki pages in-repo rather than in GitHub's editor is
deliberate: the wiki is a separate repo with no review and no CI, so
pages edited there drift from the code silently. In-repo, a behaviour
change and its documentation land in one reviewable commit and pushing
is a publish step.

docs/keyboard.svg draws the bindings as keycaps. Presentation attributes
on every element, no style block -- GitHub strips those -- and it paints
its own panel rather than assuming a background, so it reads in both
light and dark themes.

The changelog moves to CHANGELOG.md. THIS BREAKS CI UNTIL THE STAGED
WORKFLOW IS COPIED ACROSS: the manifest job greps README.md for the
version heading, and README.md no longer has one.
docs/ci/tests.yml.proposed points it at CHANGELOG.md.

Corrects thirteen stale Status: headers across docs/plans, and records
six places where the docs had drifted from the code -- among them that
Phase 5 lost its annotation half, that tags are no longer repeated in
the note body, and that 3b is partly built rather than not started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Everything agreed but unbuilt, with the reasoning that shaped each item
rather than just a title: why summaries must be on demand, why interest
ranking ships default-off, why the redundancy rule matters more than
colour palettes, and why Miniflux's server-side extraction stays unused
despite working.

Records that the former good-first-issues are ours now, so the README's
contributor framing is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
n and p are awkward to reach and duplicated a binding that already
existed. Shift+J and Shift+K stay, since j/k already mean move-by-one in
the list and the shifted pair reads as the same gesture at a larger
scale. Removes Key_N and Key_P, the position line now says what the keys
are, and the cheatsheet keycaps are redrawn and widened for the glyphs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Home.md is what the Wiki tab shows first; without it the landing page is
empty. Indexes the eight pages and records that they are authored in
docs/wiki/ and published here, so an edit made directly in the wiki gets
overwritten by the next publish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRyF2uQvkQkcjbGHBsa7nS
Two strands that were deliberately sequenced together. The backlog argued
accessibility should land before the AI work, because summary and ranking
indicators would each become another thing to retrofit afterwards. They share
files, so they share a commit.

**Summaries.** `AiProvider.js` was built and measured but wired to nothing;
this gives it a UI. Connection settings and an on/off toggle in the settings
panel, with a Test Connection that tells apart an unreachable runtime, a
reachable one missing the configured model, and success. The widget owns the
provider, a bounded cache persisted under `summaries`, and a generation
counter kept separate from `fetchGeneration` -- sharing one would let an
ordinary background refresh discard a summary the user is waiting on. The
reader displays and never fetches, the same split already used for export and
starring.

`i` means the same thing wherever it is pressed: summarise the article in
front of you. From the reader that is the open article. From the list it opens
the reader showing only the summary and does not fetch the article's page --
the point of that mode is deciding whether the article is worth opening, so
fetching it anyway would defeat it. A "Load full article" button runs the same
fetch-and-extract path `v` does; `openArticle`'s fetch half was split into
`loadFullText()` so there is one path rather than two that drift.

Summarising from the list does not mark the item read. Reading a summary is
not reading the article, and an item skimmed and passed over must still be
there when you filter to unread. It is a row action, never a selection action,
unlike m/s/e: forty selected items would be forty GPU jobs from one keystroke,
which is the one outcome every other decision here is shaped to avoid.

The design doc's "never automatic" argument was rebuilt rather than patched.
It rested on ~4.8s being too slow to ever be implicit; llama3.2:3b measures
0.5s warm, so that reasoning no longer holds. Summaries stay on demand for a
better reason -- a GPU job per scrolled article is rude regardless of speed --
and the doc says so plainly instead of keeping a conclusion whose premise
moved.

`AiProvider.resolveBaseUrl` exists because of a real bug found in testing. The
settings panel filled the base URL from the preset dropdown's change handler,
which on a fresh install never fires: the dropdown loads its default, which
equals the default it already holds, so nothing is emitted and nothing is
written. The field then showed a placeholder indistinguishable from a value,
so the form looked complete while Test Connection correctly reported it empty.
A default has to be resolvable without an event having fired. The resolver is
pure, tested, and shared by the panel and the widget so the two cannot
disagree.

**Accessibility.** The backlog's claim that state was signalled by hue alone
was stale: feed status already pairs colour with distinct icon shapes and
differing text, and read state is opacity plus greyscale, which survives total
colour loss. That item is closed out with the evidence rather than answered
with a refactor of working code. The real gap was that no `Accessible.*`
property existed anywhere, so every icon-only control was unnamed -- their
only labels are hover-revealed and width-gated. Sixty-four bindings now name
them, with dynamic names tracking state and `Accessible.checked` carrying
checkbox state rather than being folded into the name.

The toggle shipped global rather than per-instance, as the design doc
specified. Every setting here goes through `savePluginData`, which is keyed by
plugin and not by instance; per-instance would mean adopting the DMS
plugin-variant system for one boolean. Recorded as a deviation, not done
quietly.

772 node tests pass, all touched QML passes qmlformat, and the manual
checklist was exercised against a live ollama. There is still no automated
coverage of QML runtime behaviour: qmllint cannot resolve the `qs.*` namespace
outside Quickshell, so that gap is unchanged and stated rather than implied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
The accessible-names item still said "not yet implemented" — it shipped in the
previous commit. Corrected, and the claim sharpened while it is being touched:
the names are known to be present, not known to be good. No screen reader was
ever driven against them, and that is the kind of gap that quietly never gets
closed unless it is written down as an open one.

The 3b entry still led with "~4.8s ... which ruled out anything automatic",
the premise its own design doc has already retired. It now matches the doc:
on demand for the hardware-courtesy reason, not the latency one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
The modules first, all of them pure and tested, none wired to the UI yet --
that integration is QML work and serialises where this does not.

**FeedParser**: OPML export (`buildOpml`), the inverse of the import that
already existed, with a round-trip property test because the failure mode is
silent -- a feed titled "Tom & Jerry" that survives export but not re-import
is a backup that looks fine until you need it. Feed autodiscovery
(`discoverFeeds`, `buildDiscoveryRequest`) ranks comments feeds last: handing
someone /comments/feed when they asked for the site is the actual thing that
goes wrong here, not parsing.

**ReaderState**: oldest-first sort -- which turned out to be inline in the
widget, so it moved here to be testable and gained an id tie-break, since
equal timestamps arrive in batches and the list was free to reshuffle on every
refresh. Per-source snooze with an expiry prune so the persisted map cannot
grow forever. Rule-based notifications reusing the existing search matcher
rather than a second one, and mirroring `evaluateSeen`'s first-run behaviour
so switching on a broad rule against a backlog does not fire once per
historical article. Mark-read-on-scroll bookkeeping, which only marks on a
settled forward move -- a fast fling to the bottom and back marks nothing.

**ExportProvider**: images in exported notes, downloaded to an attachments
folder and referenced by relative path. The backlog left hotlink-vs-download
open; wanting images to render live settles it, because Neovim's image
plugins render local files and not remote URLs. A failed image keeps its
original URL rather than pointing at a file that does not exist.

**AiProvider**: embeddings for 3d. `canEmbed()` is deliberately separate from
`isConfigured()`, and the embedding model deliberately does not fall back to
the chat model -- they are different models, and guessing would silently embed
with something that produces meaningless vectors. Responses are reindexed by
the API's own `index` field rather than array position.

**Palette.js**: colour-blind-safe presets. Values are Okabe-Ito (deuteranopia,
protanopia) and Paul Tol bright (tritanopia), cited, not invented -- inventing
hex codes by intuition is the exact failure this feature exists to correct.
`simulate()` uses the Machado 2009 matrices so the tests *prove* error and
success stay distinguishable under each condition rather than asserting it;
they clear the threshold by six to eight times. It never writes to `Theme`,
which is a process-wide singleton.

**Ranking.js**: interest ranking. Cold start is a hard gate, not a confidence
score, because the backlog asks for ranking to be *off* when it cannot be
trusted. `blendWithRecency` at weight 0 short-circuits to a timestamp sort
rather than relying on a multiplication vanishing, so the way back to
reverse-chronological is guaranteed rather than emergent.

**And four defects, found by review of the previous commit:**

The worst was destructive. `pruneSummaries` was called from `applyFilter()`,
which also runs immediately after `allItems` is emptied -- every feed
disabled, the last feed deleted, a backend switch. Pruning against an empty
dataset means deleting every summary, and it was persisted. Each entry is a
real model run, so that loss is unrecoverable. The prune is a refresh concern
and now lives only on the refresh path; separately, the module now treats an
empty list as "no information" rather than "delete everything", because that
is the half a Node test can actually pin.

Summary *errors* could render against the wrong article: the success path
checked article identity, the three error paths checked only the generation
counter, which does not advance when you merely navigate away. A completed
summary was also discarded when superseded, so asking again re-ran the model
for an answer already paid for -- it is keyed by id and is now cached
unconditionally, with only the rendering guarded. `i` from the list had no
in-flight guard, so key-repeat spawned a model run per keypress.

Finally, eight `Accessible.onPressAction` handlers carried a verbatim copy of
their `onClicked` body. One had already drifted. Assistive tech activates via
the press action rather than a synthesised click, so the two must be identical
by construction; they now call one shared function.

982 tests pass, 1 skipped (the opt-in live ollama case). All QML passes
qmlformat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
…fications

**Images in exported notes.** The export pipeline grows a stage between
extraction and the write: collect the note's images, fetch each into the
attachments folder, then build the note with a url->path map. Off by default,
and skipped entirely when a note has no images, so the common path is
untouched.

The note is built twice on that path, deliberately: attachment names derive
from the note's own path, so the note must be built once (no I/O, just string
assembly) to learn its relPath before the images can be named. That is what
keeps the names deterministic -- re-exporting an article overwrites its
pictures instead of accumulating -1, -2, -3 copies.

A failed image is not a failed note. Whatever downloaded gets rewritten to a
local path; whatever did not keeps its original URL, so that one image
degrades to today's behaviour rather than pointing at a file that was never
written.

**Sorting moved to ReaderState.** It was three inline comparators here, and
the newest/oldest ones broke ties on timestamp alone. Feed items arrive in
batches sharing a timestamp to the second, so equal-timestamp runs were free
to come back in a different order on every refresh and the list quietly
reshuffled under the cursor. `sortItems` breaks ties on id, and being in the
module it is now actually tested.

**Rule-based notifications** replace the plain new-item count when any rule is
set, rather than adding to it -- the point of a rule is to hear about
interesting items instead of merely new ones, and firing both would mean two
toasts per refresh, which is how a useful notification becomes one you learn
to dismiss unread. Rule matches are tracked in their own `notifiedIds` list
rather than reusing `seenIds`: an item is usually seen long before a
newly-added rule first matches it, and conflating the two would either
re-announce on every refresh or swallow the first match.

**Test Connection stops blaming the network for an HTTP error.** `aiCurlArgv`
now sends `--fail-with-body`, the same lesson `Backends.js` learned in 2.3.3:
curl exits 0 on a 4xx, so a runtime that answered but refused -- wrong API
key, gated proxy, missing model -- was indistinguishable from a dead socket,
and the user was told "could not reach" about a host just reached. The summary
path now parses the body on a nonzero exit and prefers the runtime's own error
text over a guess.

**Sibling merging in the extractor**, with a measured result that corrects the
backlog rather than confirming it. Against the 18-article Readability oracle:
root does NOT win by default on any Wikipedia page -- mw-parser-output wins
outright each time -- and suppressing infoboxes and reference lists, which
sounds obviously right, measures *worse* (mean 92.4% -> 90.9%, Wikipedia RSS
83% -> 58%) because Readability's own output keeps them. That experiment was
reverted. The real driver of the ~81% floor is markdown link syntax
fragmenting the word-overlap tokenisation, which is substantially a
measurement artefact.

Sibling merging ships anyway, because the mechanism was genuinely absent: a
winner's qualifying siblings are folded in. Measured byte-identical across all
18 articles -- proven neutral, not proven beneficial. It is there for the
pages that will need it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
…d it

**Digest (3c).** "d" opens a summary of the last 24 hours across every feed,
rendered in the reading window. Cursor-independent like "r", because it is a
question about the whole list rather than the row under the cursor -- it has
to work at rest, which is the one thing about the binding worth a test.

The reading window renders it through the SAME block-typesetting path as an
article body rather than a second one, so the two cannot drift apart. Digest
mode hides the article-only controls and turns the article-only keys into
no-ops rather than letting them act on a stale `_article` -- the bug class the
file already guards elsewhere.

Undated items are included rather than dropped from "the last 24 hours": a
timestamp of 0 means the feed gave no usable date, which is far more often a
feed with sloppy dates than a genuinely ancient article, and silently omitting
those is the sort of gap nobody notices until they miss something.

**Interest ranking (3d).** Ships off, and stays off until it can actually
work: it needs an embedding model configured and at least five starred
articles to learn from. Every gate fails closed and says why, because the
backlog is explicit that a ranking which feels wrong is worse than none --
an unexplained order is indistinguishable from a broken one.

Ranking reorders what the filter chose and never changes what is shown.
Keeping those separate is what makes it reversible: switch it off and the same
rows are simply in their old order. Items with no vector keep their place at
the end rather than vanishing. The weight slider's zero is an exact
reverse-chronological short-circuit in the module, so sliding it down really
is off rather than nearly off.

Vectors are held in memory and deliberately not persisted. One embedding is a
few hundred floats; a few hundred items of them is megabytes of JSON written
into a state file shared with the rest of the shell, to save a single batch
request that takes about a second. Recomputing per session is much the cheaper
side of that trade.

Both cheatsheets carry the new binding, and both gate it on AI being
configured, exactly as "e" is gated on an export folder -- documenting a key
that silently does nothing is worse than not documenting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
…d colour

Every module that landed in the last two commits needed somewhere to be turned
on. Notable choices:

OPML export writes through a one-shot FileView with atomicWrites, the same
mechanism the notes export already uses -- no shell, and a half-written OPML
file is never visible. Autodiscovery's per-result "Add" button and the manual
Add Feed form now share one `commitFeedForm()`, rather than a second copy of
validate-and-save that would drift from the first.

The embedding-model field resolves rather than stores, and is deliberately
never wired to the preset dropdown's change event -- that is the exact bug
`resolveBaseUrl` exists to fix, and a fresh install never fires that event.

The colour section carries a live three-swatch preview of error, success and
primary under the selected preset. For a feature whose entire purpose is that
two of those colours are indistinguishable to its user, a preview is not
decoration -- it is the only way to confirm the thing works without shipping
it and finding out.

Interest ranking's copy states plainly that it needs starred articles and an
embedding model, and that a weight of zero is exactly reverse-chronological.
The backlog asks for "a visible reason and an obvious way back"; in a settings
panel that requirement is met by the wording or not at all.

Test Connection now reads the runtime's own error text when there is one,
completing the `--fail-with-body` fix from the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
BrendonJL and others added 27 commits September 13, 2026 08:45
**Images never downloaded.** curl refuses to write into a directory that does
not exist and exits 23; the attachments folder never exists on a first export.
So every image failed, no folder appeared, and the note kept its remote URLs --
indistinguishable from the feature being switched off, which is why it
survived a manual test. `--create-dirs` fixes it.

**Interest ranking did nothing, silently.** Two faults stacked. The widget read
`pluginData.aiEmbedModel` raw while the settings panel resolved a preset
default only for display, so a user who never typed a model left it undefined,
`canEmbed()` returned false and ranking disabled itself. That is the second
time this project has shipped a default that exists only in the half of the
app that renders the form -- `resolveBaseUrl` was the first -- so the resolver
is now exported and shared, exactly like that one.

Worse, `rankingReason` had six carefully written explanations and was rendered
NOWHERE. The code knew precisely why it had declined to rank and told nobody,
which is the one thing the backlog explicitly asked not to happen. It now
appears above the list, and ranking re-runs when its settings change instead of
waiting for the next refresh.

(Note: renaming that resolver caught a live bug of its own. A second
`function resolveEmbedModel` with a different signature silently replaced the
existing internal one through hoisting, breaking `canEmbed()` with no error
anywhere. Renamed, and the reason recorded above it.)

**The digest was being truncated by ollama.** Measured: llama3.2:3b advertises
131072 tokens of context but ollama allocates 4096 at runtime. Thirty articles
with descriptions came to ~5000 tokens, so the digest was cut off before the
model answered -- no error, just a summary that appeared to ignore most of the
user's feeds. It was not choosing; it never saw them.

The prompt now has a hard character budget spent in two passes: every headline
first, descriptions only with what is left. Coverage beats detail for a digest
-- naming every story briefly is more use than describing the first forty and
never mentioning the rest. The digest also reads from its own pool rather than
`allItems`, which is capped at maxItems, a *display* limit that had quietly
become the digest's horizon too.

**The failed-feed indicator was an anxiety light** -- it said something was
wrong and offered no way to learn what. It now names the feeds and their
errors.

**Test Connection blamed the JSON when nothing answered.** A nonzero exit with
an empty body is a dead socket, not a malformed response; reporting "Parse
failed" sent the user looking in the wrong place entirely.

**OPML export rejected a folder**, which is the obvious thing to type, with an
error that read like a permissions problem. A path with no extension now gets
a filename.

**The reader could not be copied from**, which is a strange property for a
reading window. Body blocks are selectable, and "f" loads the full article in
summary-only mode.

Finally, the article title now takes the primary colour and the source the
muted one, so the title is what the eye lands on. Requested, and right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
The widget stopped loading entirely. Changing the reader's body blocks from
Text to TextEdit for selection parsed cleanly, passed qmlformat and passed CI
-- then failed at load: TextEdit has no lineHeight or lineHeightMode. One bad
property makes the type unavailable, which made ReaderWindow unavailable,
which made the whole widget refuse to load.

Reverted to Text, and the trade is now recorded in place rather than left to
be rediscovered: TextEdit offers selection but has no line-height control at
all, and explicit line height is the core of this window's typography. Giving
up the measure to gain selection is the wrong way round.

Copying is served properly instead: "c" or a toolbar button copies the whole
article (title, body, link) or the digest. Quickshell exposes no clipboard
type and DMS's ClipboardService only re-copies entries already in its history,
so neither takes arbitrary text -- wl-copy does, invoked as argv with "--" so
a body containing quotes or a leading dash is safe, and detached because
wl-copy stays alive to serve the selection and would die with a timeout.

**The check.** qmlformat is a syntax check; it never had an opinion about
whether a property exists. qmllint does, and works locally despite being
unable to resolve the qs.* namespace -- it resolves plain QtQuick types fine,
which is exactly where this bug lived. Confirmed by reintroducing the fault on
a scratch copy and watching qmllint name it. Both files now pass, and the
technique is written into the development notes with the filter needed to see
past the unavoidable qs.* noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
…ttes

**Ranking looked broken because nothing recomputed it.** Starring is the only
input it learns from, and starring did not trigger a re-rank -- so the reason
text went stale: star five more articles, nothing recalculates, and the widget
keeps insisting you have not starred enough. Reported as the feature being
broken, which is a fair reading. Now debounced off the bookmark map, because
starring several in a row is normal and each one would otherwise queue an
embedding pass.

The embedding path itself was verified end to end against the live runtime
while chasing this: thirty items, thirty vectors, 768 dimensions, 0.6 seconds.
It was never the problem.

**The lead image never reached the note.** Most feed items carry a thumbnail
and no inline images at all, and rewriteImageLinks only rewrites markdown that
is already present -- so the file downloaded, landed in the attachments
folder, and the note never mentioned it. On disk and invisible, which reads as
half-working. The lead image is now embedded after the title, skipped when the
body already shows it, and skipped when it was not downloaded.

**"Saved" is "Starred" throughout**, with a star rather than a bookmark, since
the ranking copy has always said starred and two names for one thing is one
too many.

**The row puts the title on its own line** and folds the source in beside the
timestamp below it, so the title is what the eye lands on first.

**Five more palettes**: Nord, Gruvbox, Catppuccin Mocha, Dracula and
Solarized, values taken from each project's published specification rather
than sampled. They are ordinary themes, not colour-vision palettes, and are
only safe to offer because this widget never signals state by hue alone.

Measured, not assumed: Solarized's error and success are **43 apart under
simulated deuteranopia against a threshold of 50** -- genuinely confusable for
this project's owner. It is still offered, but `isCvdSafe` computes that from
the simulated colours rather than any palette declaring itself safe, and a
test pins the result so a future tweak cannot silently flip the claim. Every
aesthetic preset is also checked for WCAG AA body text on its own surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
`buildInterestProfile` takes `{ vector, starredAt }` objects. The widget passed
the raw vectors. Every entry then failed its validity check, valid.length came
back 0, and a user with eight starred articles was told indefinitely that they
had not starred enough -- while the embedding path underneath worked perfectly
the whole time (verified live: thirty items, thirty 768-dimension vectors,
0.6s).

Both shapes are arrays of plausible length, so nothing threw and nothing
logged. The only symptom was a feature that never turned on, which is why two
rounds of fixing the wiring around it changed nothing. tests/ranking.test.js
now calls it exactly as the widget does, and separately asserts that raw
vectors are REJECTED, so the mistake cannot be made quietly again.

Chasing it turned up a second fault in the module: `isVector` accepted
anything with a numeric length, so a plain string counted as an embedding.
`cosineSimilarity` was safe about it -- returning 0 rather than NaN -- which is
exactly why nobody noticed: the ranking silently got slightly worse rather than
failing. Tightened to require a real array of numbers.

**Attachment names are slugged.** The image downloaded correctly, landed in
the right folder, and still did not display, because the filename inherited
the note's title: `![](attachments/Reform's £72m donations, 'in line with
law'-cdadf470-0.jpg)`. Spaces, apostrophes and commas are all legal in a
filename and none of them survive being a markdown link target. Escaping at
the point of use would have fixed rendering and left the folder full of
awkward names; slugging fixes both, and the note's own filename is untouched.

Two existing tests asserted the old spaced names and have been updated -- they
were pinning the bug. The replacement asserts the stronger property: the path
must be safe to drop into `![](...)` with no escaping at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
Images landed in a subfolder the user never chose, which reads as the setting
being ignored even though a subfolder is the tidier answer and what most
vaults expect. The description now states the default and says how to opt out.

Clearing the field is now a real choice rather than falling back to the
default: an empty attachmentDir means 'beside the notes'. Previously the
default applied to both 'not set' and 'deliberately cleared', so the setting
had a value that could not be selected. Traversal in the folder name is still
refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
It still listed 3c and 3d as design-only and most of Phase 6 as pending, when
both cleared over the last two days. What remains is two items, each recorded
with the reason it remains rather than just its name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
The remaining work spans more than one session, so it needs somewhere durable
to live. Handover note first, plan second: where things stand, what verifies
what (including the qmllint filter that catches type errors qmlformat cannot),
the two outstanding features with the reasoning that has kept them outstanding,
and a place for the review findings to land that is not a chat log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
The reason this sat in the backlog was that skipping a feed's request means
`finalizeFetch` rebuilds `allItems` without that feed's articles, so they
vanish. Everything here is shaped by that.

Skipped feeds' articles are put back before the dedupe, from a retention pool,
so a feed that was not due keeps exactly what it had. The all-skipped case is
handled separately and explicitly: `descriptors.length === 0` used to mean
"clear the list", which is right when every feed is disabled and catastrophic
when every feed is merely inside its own interval. Those two now have
different outcomes.

Skipped feeds also carry their PREVIOUS status forward rather than getting a
fresh "loading" row that nothing will ever resolve.

The decision itself lives in `ReaderState.isFeedDue` so it can be tested, and
its bias is deliberate and documented: every malformed, missing or nonsensical
input answers *due*. The asymmetry is the whole point -- fetching slightly too
often costs one request, fetching too rarely loses content. That includes a
clock that has moved backwards, which would otherwise make every feed look
freshly fetched until real time caught up.

Opt-in throughout: a feed with no positive `intervalMinutes` is always due and
keeps the global cycle, so this changes nothing until it is configured.
Timestamps are recorded on ATTEMPT rather than success, so a failing feed backs
off to its own interval instead of being retried every global cycle.

**Also fixes the digest pool, which never worked.** It was captured *after*
the maxItems slice, making it identical to `allItems` -- so the fix that was
supposed to let the digest see past the display cap did nothing at all. It is
now taken before the cap, and doubles as the retention pool above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
The roadmap was brought in line with reality in 9188291 and the feature landed
in e490027 -- in that order, so the doc update was already stale when it was
written. Three files then told a contributor to re-litigate a solved problem,
which is exactly the failure docs/plans/README.md warns about.

Also marks Ranking.explainRank as deliberately unwired. It is a complete,
tested API surface for a 'why is this ranked here' affordance that does not
exist yet, and the reason string the widget shows is about something else
entirely -- why ranking is unavailable. Without the note a future reader would
reasonably assume it is load-bearing.

Both found by an architecture review, which otherwise came back clean on the
two failure classes this project has repeated: dual-runtime violations and two
places deciding the same setting. Zero new instances of either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
…d with them

The settings panel was one ~2700-line scroll. It is now eleven
DankCollapsibleSections, with Feed Management expanded and the rest collapsed --
that is what the panel is usually opened for. Section-level `visible:` bindings
replace per-child ones where a whole section is conditional (Miniflux and
Google Reader credentials), and the dividers that used to separate sections are
gone, since the component provides that separation itself.

**One real bug caught before it shipped.** Tidying moved `addPresetFeed` out of
the root and onto the Feed Management section, alongside the sixteen Quick Add
buttons that call it. That is wrong, and wrong silently: QML resolves an
unqualified name against the calling object and the COMPONENT ROOT, and does
not walk intermediate ancestors. Every Quick Add button would have thrown
"addPresetFeed is not defined" at click time -- no error until someone clicked,
and nothing in qmlformat, qmllint or the test suite would have said a word.

Verified rather than argued: a nine-line QML file under the real headless
engine confirms a function on an intermediate object is unreachable from a
nested child (exit 10), and reachable when it sits on the component root (exit
55). The function is back on the root with that reasoning recorded above it.

That check took about thirty seconds and is the second time this week the QML
runtime has settled a question that reading could not. It was sitting unused
in tests/qml/run.sh the whole time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
Exporting a selection spawned everything at once: one curl per article for
full text, then one per image per article, all in the same tick. "Select all"
across thirty articles with a couple of pictures each is over a hundred
concurrent processes -- inside the shell's own process, where a stall takes
the bar and popups with it. Found by a performance review, which cleared
everything else it was pointed at as correctly bounded at this scale.

A small bounded queue now fronts the three export fetches (Miniflux fast path,
local full-text, images), four at a time. Deliberately NOT applied to the AI or
backend requests: those are single calls, and putting them behind an export
batch would make a summary wait on a hundred image downloads.

Two details that matter more than they look. The job is captured through an
IIFE, because `var` is function-scoped and every callback in the loop would
otherwise close over the last job. And the pump runs in a `finally`, so a
callback that throws cannot strand every job behind it forever.

Both verified by simulating the queue against a fake async Proc rather than
reasoning about them: 120 jobs, peak concurrency never above the cap, each job
run exactly once under out-of-order completion, and a throwing callback still
letting the remaining nine through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
Four of five reviews returned. Architecture and performance findings are acted
on; CI recommendations are recorded with the one that needs a human, since
Claude cannot write to .github/workflows/.

The CI review's top finding was wrong: it reported the manifest job's changelog
grep as a live outage, having trusted docs/ci/README.md's PENDING section
rather than the live workflow, and said so. The live file and the proposed one
are byte-identical and both read CHANGELOG.md. That doc is corrected -- a
PENDING left in place after the change lands is a claim about the present, and
it cost a reviewer a finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
**A stale full-text fetch could overwrite the wrong article.** The reader's
`_fetchGeneration` was bumped only inside `loadFullText()`, which
`openArticle()` does not reach when the article opens summary-only or has no
link. Open A (fetch in flight), move to B by either of those routes, and A's
extracted text lands in B's body -- under B's title, B's star, B's export
action -- with nothing to indicate it. The counter is now bumped at the top of
`openArticle()` and `openDigest()`, before any early return can skip it, and
the callback additionally checks the article id, because "was this superseded"
and "is this still the article on screen" are different questions. The summary
path already learned that.

**OPML import bypassed URL validation entirely.** A hand-typed feed goes
through `validateFeedUrl`; an imported one went straight into the feed list,
and every enabled feed's url becomes a curl argument on every refresh --
unattended, repeatedly. `parseOpml` now applies `isSafeUrl` at the parse
boundary, where `imageUrl` and `audioUrl` already were. Tested against
`file:`, `javascript:`, `data:` and values shaped like curl flags.

**Links inside rendered article bodies reached `Qt.openUrlExternally`
unchecked.** The item's own link has always been gated; the body's links were
not, which was an inconsistency rather than a decision -- that markdown comes
from the feed, or from a page the extractor fetched. Both now go through the
same check and the same warning.

**Every outbound curl now ends option parsing before the URL.** All six
builders -- standard feed fetch, the Miniflux/Google Reader API builder, the
AI builder, article fetch, image fetch, discovery -- emit `--` immediately
before their URL, so a value beginning with a dash cannot be read as a flag.
`isSafeUrl` remains the primary gate; this costs one argv element and removes
the need to be sure about every path that reaches one.

The review confirmed as solid, having tried to break them: path traversal in
the export (unicode, encoded separators, overlong names, `..` in the
configured attachment folder, absolute paths -- no bypass), apiKey and token
isolation across all four AI builders plus both backends, the deliberate
absence of `-L` on credentialed calls, and every resource bound.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
…opy in

**Per-feed intervals are now reachable.** The field lives in the add/edit feed
form rather than on the feed rows -- those already carry a status line, a
toggle and four buttons, and a numeric field on each would not survive a narrow
panel. Empty means "follow the global interval", and the value is floored at
zero so a nonsense entry falls back rather than throttling by accident. Hidden
for server-backed backends, which have one logical stream and nothing per-feed
to schedule; showing it there would be a control that silently does nothing.

**MPRIS ownership is not possible, and that is the answer.** Settled from
Quickshell's own type metadata: Quickshell.Services.Mpris exports four types
and every one is isCreatable: false. It is a consumer API -- a shell can read
the players on the bus, it cannot instantiate one -- and quickshell-core
exposes no D-Bus service-export type either. Registering this plugin as an
MPRIS player would mean shipping a separate daemon that owns playback, which is
a different product.

So the honest version ships instead: a Podcast Audio settings section naming
which players publish MPRIS natively (VLC, Strawberry, Audacious, Rhythmbox)
and which does not without help (mpv, absent mpv-mpris), and stating that
without one the episode still plays and simply will not appear in the media
widget. The backlog item as written should be closed, not re-attempted.

**Two CI jobs are staged in docs/ci/tests.yml.proposed** for a human to copy
across, since Claude cannot write into .github/workflows/.

qml-types is the one that matters: it closes the exact gap that took the widget
down, where qmlformat passed a TextEdit with a Text-only property. qmllint
catches it; it cannot resolve qs.* and never will, but it resolves plain
QtQuick types, which is where that class of bug lives. It fails only on four
message classes and treats the rest as the unavoidable noise it is. It carries
the same self-test discipline as the existing syntax job -- it feeds qmllint a
known-bad property first and fails if that is NOT flagged, because the filter
is a regex over free-text warnings and a Qt reword would otherwise disarm it
silently. Verified locally: the fixture trips, all nine real .qml files pass.

qml-smoke wires in the six-test headless harness. Noted in the docs that it
skips cleanly when it cannot find a runtime, so a green tick alone does not
prove it ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
**Custom colours.** `Palette.applyOverrides` has existed since the palettes
landed and nothing ever called it. A "Custom" preset now exposes three colour
pickers -- primary, error and success. Only those three, deliberately:
exposing all thirteen roles would be a wall of pickers for values nobody wants
to change, and leaving the surface and text colours following the system theme
keeps the widget sitting in its desktop rather than beside it. An invalid or
half-finished entry is ignored, so the palette degrades to its base rather
than to undefined colours.

**A settings layout fix, offered with less confidence than the rest.** The
collapsible refactor put DMS's setting components (SliderSetting,
ToggleSetting and friends) inside a ColumnLayout. Those components internally
bind `width: parent.width`, which fights a layout's implicit sizing when no
Layout.fillWidth is declared -- the component paints full width while the
layout believes it is only as wide as its implicit size. Twenty-eight direct
children now declare it.

That is structurally correct regardless, but I could not reproduce the
reported visual fault in three separate synthetic cases under the real QML
engine (a ColumnLayout child without a Layout hint still measured full width;
a ColumnLayout inside a plain Column still got its height). So this is the
most plausible cause rather than a proven one, and if the panel still renders
wrong the honest next step is to revert the collapsible sections and ship the
long scroll, which was known-good.

**Version 3.0.0**, with the matching changelog heading CI enforces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
Evidence quality is recorded per item deliberately. 'Two independent projects
have had this open for a decade' is a different signal from 'someone mentioned
it once', and a list where those look alike is worth nothing.

Two things worth noting beyond the list. Ranking.explainRank -- flagged last
week as built, tested and unwired -- turns out to be the feature other readers'
users are asking for: NewsBlur's trainer is praised specifically for not being
a black box. And LDAP auth is FreshRSS's most-reacted open issue while being
completely irrelevant here, which is a useful reminder that reaction counts do
not transfer between products.

Recorded honestly: Reddit was unreachable during the research, so duplicate
complaints, feed spam and paywall frustration are NOT independently validated
and should not be treated as checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
Reported as jumbled, and it was the one section that deserved to be. Feed
Management is a thousand lines across seven blocks -- an add/edit form, feed
discovery, the feed list, OPML import and export, quick-add presets and the
server-side subscription list -- and the refactor had merged the subscription
list in and relocated markup on top of that. Wrapping that much structure in a
clipped, height-animated container was the wrong trade.

It is also the section the panel is usually opened for, and was already set to
start expanded, so collapsing it was never buying anything. It is now a plain
heading and content again, like it was before. The ten sections that really are
a handful of controls each stay collapsible, and those were reported as working
well.

The file is also run through qmlformat -i. The wrap had left mixed four- and
eight-space indentation, which is half of what "jumbled" meant. Verified the
reformat changed presentation only: settingKey, loadValue, saveValue, onClicked,
visible and id counts are all identical before and after.

This also answers "where is the per-feed interval" -- it is in the add/edit
feed form, inside Feed Management, which is precisely the section that was not
rendering. The field itself has one visibility condition (hidden for
server-backed backends, which have no per-feed polling), and nothing else
gating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
The 3.0.0 section was missing accessible names, colour themes and custom
colours, per-feed intervals, categories, the Miniflux fast path, podcast
enclosures, reader copy, collapsible settings, and four fixes -- including two
security gaps and a stale fetch that could render one article's text under
another's title.

Notable in what was added: the podcast entry says plainly that reaching the DMS
media widget depends on the player rather than on this plugin, because
Quickshell's MPRIS API is read-only. And the Solarized palette is documented as
measured-unsafe for deuteranopia (43 against a threshold of 50) rather than
quietly offered, since the whole point of that feature is that its user cannot
check by looking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
The README described none of v3 despite being the front door, and three wiki
pages described pre-v3 reality. All now cover the AI features (opt-in, local),
accessibility and colour themes, categories, per-feed intervals, snooze,
notification rules, autodiscovery, OPML export, note images and the Miniflux
fast path.

Three things were WRONG rather than merely missing, which is the more useful
half of this:

- Sources-and-Sync said OPML export was "not yet". It shipped.
- Data-and-Persistence omitted feedLastFetch, snoozes and notifiedIds
  entirely, so the state tier was documented as smaller than it is. It now
  carries all eight keys, with the reasoning that matters: why notifiedIds is
  separate from seenIds, why feedLastFetch stamps on attempt rather than
  success, and that ranking embeddings are deliberately NOT persisted.
- Both that page and the README warned that deleting the state file clears
  bookmarks, without mentioning it now also takes summaries, snoozes and fetch
  timestamps. A partial warning about data loss is worse than none.

The cheatsheet was missing "f" and "c" -- real bindings, never pictured. Caught
by the doc pass rather than by me, having been told the image already had them.
Added, rendered and checked, and every one of the sixteen KeyMap bindings is
now cross-verified as present.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
SQLite is viable -- QtQuick.LocalStorage is present in both the system and Nix
Qt and round-trips under the headless engine. Recorded with the reason it is
worth doing: every cap in the widget exists because persistence is a whole-file
rewrite, and the biggest prize is persisting ranking embeddings, which are
currently recomputed every session because megabytes of floats do not belong in
a shared JSON file.

Also recorded with its cost, which is not tidiness: LocalStorage is QML-only, so
anything expressed as SQL leaves the tested half of the codebase and enters the
half where most of this project's shipped bugs have lived.

Fact-checking is written down as NOT worth building in its obvious form. A
local 3B model has no retrieval and a frozen knowledge cutoff; asked whether a
claim is true it will be confidently wrong often enough to matter, and a wrong
'verified' leaves the reader less informed than no check at all. Every other AI
feature here fails visibly -- a bad summary looks like a bad summary. A bad
fact-check does not.

The adjacent feature that is genuinely good is recorded instead: related
coverage across the user's own feeds, using the embeddings that already exist.
It surfaces corroboration without asserting it, and shares almost all its
machinery with cross-feed deduplication.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
The job I wrote passes only `-I .`. Without the Qt QML import path qmllint
cannot resolve QtQuick at all, so `Qt.rgba`, `Qt.openUrlExternally` and
`Qt.PointingHandCursor` all read as missing properties -- 120 matches across
this repo, every one of them noise.

My own fault, and a specific kind: I verified the filter locally with
`-I <qt>/lib/qt-6/qml -I .` and then wrote the workflow without the first half.
The command I tested was not the command I shipped.

Reproduced before fixing (120 matches without the path, 0 with it), and the
self-test still trips with the path present, so the job can still fail when it
should.

Two guards added so this cannot recur quietly:

- The locate step resolves the import path via qtpaths and FAILS THE JOB if it
  cannot find one. A missing path now stops the run with a clear message
  instead of producing a wall of noise that looks like a real regression.
- A second sanity step at the other end, because a filter that matches
  everything and a filter that matches nothing both look like "0 errors" from
  the outside.

Copy across again to pick this up:

    cp docs/ci/tests.yml.proposed .github/workflows/tests.yml

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
qt6-declarative-dev-tools ships qmllint itself but not the QML module tree, so
the import path resolved to a directory that does not contain QtQuick. Every
Qt.rgba and Qt.openUrlExternally then reads as a missing property -- the same
120 false positives as before, from a different cause. qml-smoke already
installed the module packages; qml-types did not.

This repo had already learned that, and I did not read it: the comment above
qml-syntax records two earlier attempts at qmllint in CI failing on exactly
this, down to the "Failed to find the following builtins" symptom. That comment
is the reason qml-syntax uses qmlformat at all.

Given it has now bitten three times, the job is continue-on-error until it has
been green across a few pushes. It exists to catch a bug class, not to gate a
release before anyone knows how it behaves, and a false red on release day is
worse than a missed warning. The check still runs and still reports; it just
cannot block a merge. Flip the flag off once it has earned it.

Copy across again:

    cp docs/ci/tests.yml.proposed .github/workflows/tests.yml

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
Ubuntu ships qmllint without a usable QML module tree, so it cannot resolve
QtQuick -- and on some versions cannot load its own builtins. Everything after
that is a cascade: 120 false positives across this repo, with Qt.rgba and
Qt.openUrlExternally reading as missing properties. Installing
qml6-module-qtquick and friends did not fix it.

The self-test I attached to the last attempt -- feed it a known-bad property,
fail if that is NOT flagged -- did exactly what it was built to do and reported
that the checker is useless in that environment. That is a good outcome from a
check that cost nothing, and the right response is to believe it. A permanently
red job is worse than no job: a check nobody can act on is a check everybody
learns to ignore.

This repo had already recorded the same conclusion twice, in the comment above
qml-syntax, which is why that job uses qmlformat. I re-derived it a third time
rather than reading it. The reasoning now sits in docs/ci/README.md where
someone deciding whether to try again will actually look.

qmllint stays a LOCAL check, where it works and has already earned its keep by
catching lineHeightMode on a TextEdit -- the bug that stopped the widget
loading. Its invocation and filter are in docs/wiki/Development.md.

Copy across:

    cp docs/ci/tests.yml.proposed .github/workflows/tests.yml

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ERBH6uBEVyvNEhT2tj1soK
@BrendonJL
BrendonJL marked this pull request as ready for review September 13, 2026 19:21
@BrendonJL
BrendonJL merged commit c658804 into main Sep 13, 2026
6 checks passed
@BrendonJL
BrendonJL deleted the develop branch September 13, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant