Each pick's web page now lists up to five related articles: the nearest
embedded articles across the whole archive (cosine >= 0.55), excluding the
rest of the issue, excerpt-only articles and same-title copies. They are
computed once per issue after publishing and stored in issue_related
(migration 0014), since the full embedding scan is too slow per page view;
`features related [--date]` and the related-refresh job backfill older issues.
Related articles that ran in an issue link to that issue page; the rest link
to a new signed-in /articles/{id} page rendering the stored body with no
summary. /articles/{id} for a published article redirects to its issue page.
embedding_retention_days = 0 now means keep every vector (previously it would
have deleted every unrated, unpublished embedding) and is the new default, so
related lists can reach the whole archive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeJmv3WED4w8S3GV7mMjS7
CrossPoint and CrossInk print an image's alt text instead of drawing it
inside a table cell, and CrossInk drops all text of a nested table.
Layout tables (The Space Review's figures) lost every image on the X4:
59 images across 8 articles in the published issues.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builds both editions around one article (a URL through real extraction, or
an HTML file), runs the XTC converter on the X4 edition and decodes every
page to PNG. --convert re-renders an existing EPUB, for iterating on a
stylesheet. The decoder follows the converter's encoder, which writes the
level's low bit to the first plane, rather than the spec's table.
The device itself reads the X4 EPUB through its firmware, which lays out
differently. Check those layouts with the firmware's own host tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`daily-epub rebuild --date D` (and the rebuild-YYYY-MM-DD job) takes the
issues.issue_json snapshot and restores each article's body from its row.
It then builds both editions and the XTC, publishes them under the same
names and records the issue as a run would. Nothing is curated again or
refreshed from live rows, so headlines, summaries, social lines and rating
links stay as published; only images are downloaded again. It takes the
run lock, opens the database without migrating, and the job is marked
dangerous like generate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The X4 firmwares (CrossPoint and forks such as CrossInk) ignore
white-space and treat <pre> as inline. Its margins never applied, so the
paragraph after a listing started on the next line. CrossInk also glued
the first code line onto the paragraph before it. The simplifier now makes
each listing line a <p class="code-line">, the one path both firmwares lay
out identically. The first and last lines carry the block's margins.
Inline tags open across a line break are closed and reopened on the next
line.
A blank source line used to be a paragraph of its own. With the firmware's
default paragraph spacing that read as a double newline, so blank lines
now become a half-line gap (p.code-gap) on the line before.
The X4 theme matches the standard one but uses only the device's exact
levels (#000, #555, #aaa), so glyphs are not dithered. Inline code is
display: inline for the XTC converter's CREngine.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Following zenbones and lackluster, the theme keeps what the code names in
ink (functions bold, types italic) and moves the language's own syntax to
grey (keywords bold grey, literals and punctuation grey). Comments are the
lightest. Weight and slant carry the distinctions, so nothing depends on
colour.
Code blocks stay left-aligned inside a <figure>, which centres for image
captions, and have explicit margins. Inline code is display: inline because
CREngine readers (KOReader, PocketBook) make it a block.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dev server now shows both highlighting paths: a Rust block marked by
its source, and a Python block left to detection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The web uses ink with a few print colours: oxblood keywords, bottle-green
strings, umber literals, slate types, muted italic comments. Each is
defined as an --hl-* token, and all reach at least 4.5:1 on paper-2 in
both themes. The EPUBs rely on weight and slant (bold keywords, grey
italic comments), using flat selectors for the X4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
arborium (tree-sitter) renders each token as a flat <span class="hl-…">,
which the X4's no-descendant-selector stylesheet can still target.
The sanitizer strips class attributes, so language hints never reached
the database. annotate_hints now copies them onto <pre data-lang> before
readability and sanitizing: pre/code classes, Rouge and GitHub wrapper
divs, SyntaxHighlighter's brush:, and data-lang style attributes.
ammonia keeps only canonical values.
Highlighting runs at render time for the web article page and both EPUB
editions, so stored articles benefit too. Unhinted blocks are guessed
from content (JSON that parses, shebangs, leading <script>/<style>, then
betlang at >= 0.5). A borderline block also takes the article's prevailing
language when that language is among its top candidates. Anything else
stays plain, since wrong colours are worse than none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the profile page's version history keeps the 70vh scrolling island;
it has three sections below it. Everything else is paginated or last on
its page, so the page itself scrolls.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The wrapper spanned the whole wide track while the table sat centred inside
it, so the empty flanks were part of the scroll container and caught the
wheel. A centred grid item is sized fit-content, so the wrapper now hugs the
table; the page-column floor and viewport cap move onto the wrapper.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Grid items never collapse margins, so the row-filter input and the rating
form pushed the next child down by their bottom margins.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With the why line gone the accent-bar callout held nothing but "Matches: a · b",
which no longer earned its quoting. Every surface now puts one row directly
under the rubric: a small-caps "Matches" label and one pill chip per interest.
On the web that is one `.interest-chip` component (admins still get links to
the interest's article filter); the EPUB gets `.matches`/`.interest` rules in
both stylesheets, and `Understanding::interests_line` goes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESJbbzQ1b8fs5AA65tKw2q
After a rewrite of the prompt rule and a few more live issues the line still
read as a second abstract, so it goes entirely: the rule and the "why" key in
the editor's JSON, the parsing and 14-word cap, the Pick field, the
issue_articles and candidate_runs writes, the Brief prompt's per-pick why,
the explain output, the dashboard "editor" columns, and the rendering on the
web index, article and public pages, the Atom feed and both EPUB pages.
The "Matches: …" interests line stays (restyled in the next commit). The
issue_articles.why and candidate_runs.editor_why columns remain in the
schema unused; stored issue snapshots that still carry "why" deserialize
because Pick ignores unknown fields.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESJbbzQ1b8fs5AA65tKw2q
Every interest name in the web UI links admins to the Articles page
filtered and sorted by that interest's stored matches; the Interests page
lists weights derived from current ratings and manages categories.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9PrjtUS16PAQve8D4bHgc
The signals stage upserts each eligible article's matched interests; an
LLM batch files uncategorized interests before the prompt is built (and on
demand as the interests-categorize job); `interests import` and
`interests backfill` load the OPML and seed match rows once.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9PrjtUS16PAQve8D4bHgc
An interest matches an article only when it is in the top three by z and
z >= 1.0, so the Matches line, the stored rows and the weights agree. The
new bounded affinity signal blends each matched interest's rating-derived
weight, gated on attributable ratings like feed affinity.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9PrjtUS16PAQve8D4bHgc
Standing interests get a table of their own plus article_interests, the
per-run top-3 matches, and a pure rates() that derives each interest's
Beta-smoothed weight from current ratings the way feed affinity does.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9PrjtUS16PAQve8D4bHgc
The web index, the article page and both EPUB pages each ordered the same
metadata differently, and the "understanding" line was one wall of text
with the why line above it on one page and below it on another. Every
surface now reads title, meta, rubric, why block, summary:
- the rubric is a newspaper kicker (topic group and format in small caps)
followed by the specific topics; depth and technicality stay on the
dashboard article page only
- the accent-bar why block holds the editor's line and "Matches: …" (the
matched interests) beneath it
- the social line moves after the summary on the article page and chapter,
matching the index pages, and the EPUB chapter footer no longer carries
the understanding line
`understanding_line` becomes an `Understanding { kicker, topics, interests }`
struct shared by the web view models and the EPUB, and the three web
templates include one `_understanding.html` partial.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxctvrEPQZ9pX58LRgGYNZ
Across 96 picks in four live issues the "Why it's here" line was a 12-word
restatement of the summary printed next to it: one used the second person,
five mentioned the reader at all. Rule 8 now says what the line is for, what
it must not do (describe the piece, list the interests shown beneath it), and
gives a bad/good pair.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxctvrEPQZ9pX58LRgGYNZ
The summary prompt told the model "if the text is a truncated excerpt,
summarize only what is there and say it is an excerpt". Any article cut
to the 3k-token budget tripped that clause, and the model complied with
a terse "(Excerpt.)" label on the abstract. Bodies the extractor could
only excerpt also carried the reader-facing "(excerpt only — read
online)" note into the prompt, which the model mirrored.
Separate the two cases: text truncated for length gets no caveat, and
genuine feed-teaser bodies (the article's excerpt_only flag, now passed
in as SummarySource) are labelled "opening excerpt only" with a request
for one plain closing clause. Strip the extractor's note from the prompt
text, forbid labelling the abstract outright, and scrub a short trailing
"(Excerpt…)" parenthetical from the response as a guard.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KnYokPekNmU7LNSf6rdYAk
Every list of articles now reads "Author · Feed · Publication · N min":
the signed-in issue page, the public issue page, the RSS feed and the
EPUB's In This Issue page. `author_label` drops an author that is empty
or only repeats the feed's title, as a dozen personal blogs do. Article
pages keep their own byline.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj
Scour interest feeds link every entry through
https://scour.ing/r/rss/<percent-encoded article URL>, so the wrapper
became the article's canonical URL: the same post arriving via Hacker
News and Scour never merged, and the publication fallback showed
"scour.ing" instead of the site's own domain. Resolve the wrapper like
the Google News redirector. `publication_label` re-canonicalizes the
stored URL so the 477 articles saved with the wrapper show the real
domain too.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj
Readers see "Feed · Publication" on the issue, article, public and RSS
pages and in the EPUB index and chapter headers; the publication is the
page's site name, else its domain, and is dropped when it would repeat
the feed's title. Admins get the feed name linked to its Miniflux page.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWmCpUojfHXhSZ2129Z7Nv
Readability already reads og:site_name and JSON-LD publisher.name; carry
that through Page and Extracted and store it in articles.publication, the
same way the page byline is kept. `publication_label` is what the readers
will see after the feed name: the site name, else the domain, and nothing
when it would only repeat the feed's own title.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWmCpUojfHXhSZ2129Z7Nv
The pipeline talks to Miniflux on loopback, so links meant for a person
need their own base. `[miniflux].public_url` defaults to base_url, and
`feed_url` builds the web UI's `/feed/{id}/entries` page, which the feeds
dashboard now uses instead of the non-existent `/feeds/{id}` route.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWmCpUojfHXhSZ2129Z7Nv
Every navigation is a full document load, so the sidebar came back at
scrollTop 0 and revealCurrent() smooth-scrolled the current chapter into
view: a jump on every click even though the link was already in view.
app.js saves the panel's scrollTop to sessionStorage (large screens only,
keyed by the issue) on link clicks and pagehide; theme.js, which already
runs before first paint, restores it as soon as the parser has closed the
nav. The load-time reveals (initial and the first scroll-tracking paint on
the full issue page) are now instant; opening the mobile panel and
tracking while scrolling stay smooth.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj
The operator reads the site at 110% browser zoom; make that the default
scale. Tailwind v4 utilities are rem-based, so `html { font-size:110% }`
scales type, spacing, columns and controls together the way zoom does,
while media queries keep firing at the same viewport widths.
Things that did not scale with the root size and had to move:
- the `lg` reader grid is now 15rem + gap-x-8 (was 16rem + gap-x-10) so
the TOC sidebar and the 68ch article still fit at 1024-1100px;
- the TOC reveal margin in app.js is 1.5rem of the computed root size
instead of a fixed 24px, and the run funnel SVG height is `h-3`;
- long unbreakable tokens on the phone-width dashboard (settings group
ids and headings, config warnings, run warnings) now wrap with
`overflow-wrap:anywhere`, and the feed candidates table sits in the
same `.scroll-x` wrapper as the other dashboard tables. Overview and
feeds already overflowed a 390px viewport before this change; settings
started to. All three fit now.
Verified with before/after screenshots of 27 pages at 390-1440px on the
seeded dev database: no page body scrolls horizontally, the reader
layout, TOC sidebar and dashboard match the old 110%-zoom rendering.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj
A fourth explicit verdict, "AI slop" (Vote::Slop, label `slop`), joins the
rating widget on the web, the EPUB footer links, the /r/ confirmation page,
the CLI, imports and the dashboard filters. It counts as a full negative
(curation.feedback.slop_value, -1.0) in the neighbour and affinity signals.
Beyond that, each run loads the authors whose current verdict is slop, with
no lookback, and multiplies the preliminary blend and the utility of every
candidate by that author by 1 - curation.ranking.slop_author_penalty (0.75),
so they sink before triage. The flag is recorded in signals_json, shown by
`explain` and the dashboard signals table, and the confirmation names the
author (or says no author is known, in which case only the rating applies).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj