Commit Graph
83 Commits
Author SHA1 Message Date
thalladaandClaude Fable 5.1 142a8d9905 Merge branch 'bookorbit': Read in BookOrbit link on the issue page
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-05 05:20:34 +00:00
thalladaandClaude Fable 5.1 9aff78ebf1 Add the Read in BookOrbit button and /issues/{date}/read redirect
The signed-in issue page shows a Read in BookOrbit button (only when the
integration is active and the Standard EPUB exists) that hits
/issues/{date}/read. The route redirects to the cached BookOrbit reader
URL, or resolves the ids through OPDS on first click and caches them;
?refresh=1 re-resolves. Not indexed yet is a 503, upstream failures 502.
Includes the implementation plan.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-05 05:20:34 +00:00
thallada e370fcaeb2 Merge branch 'bookorbit-b' into bookorbit 2026-09-05 05:20:34 +00:00
thalladaandClaude Fable 5.1 0f45c34fdb Add the BookOrbit OPDS client module
Searches BookOrbit's OPDS catalog for an issue's Standard edition and
extracts the book/file ids the web reader route needs. Hand-rolled Atom
parsing, no new dependencies; unit-tested against the real feed shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-05 05:20:34 +00:00
thalladaandClaude Opus 5 a72f036bd1 Fix a speculation rule that excluded every URL
The query-string exclusion was written as the pattern string "/*\?*",
meaning "any path, then a literal ?, then anything". It never meant that.
A URL pattern string is split into components before escapes are resolved,
so the ? still ends the pathname: the pattern parses as pathname `/*` with
search `*`, and search `*` matches the empty query too. Sitting inside a
`not`, it excluded every same-origin URL, so both rules reduced to
`(matches) AND NOT (everything)` and no link was ever speculated.

Filter on the query with the component form instead, whose parts are
separate by construction. Name `pathname` alongside `search`: an object
that omits it inherits `/` from the document URL and matches nothing —
the same silent failure wearing different clothes.

Also prerender /issues, which `/issues/*` does not cover.

The MDN example this was modelled on, "/*\?*(^|&)add-to-cart=*", does
work, because what follows the ? lands in the search component and says
something there. Only the bare * collapsed.

Verified by evaluating the shipped JSON through URLPattern: /, /issues,
/issues/<date> and its chapters prerender; ?page=2, /logout, /rate and
/static/* do not; /dashboard* prefetches. Covered by a test that rejects
a ? in any href_matches string and a `search` component without its
`pathname`; it fails on the old rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164rQrMUZkf7uV1VGYuCFy4
2026-09-05 04:42:25 +00:00
thalladaandClaude Fable 5.1 0851d56459 Add [bookorbit] config, settings section and cached reader ids
New BookorbitConfig section (enabled, URLs, OPDS user; password via
DAILY_EPUB_BOOKORBIT__OPDS_PASS), its settings-dashboard group and secret
masking, migration 0005 adding bookorbit_book_id/bookorbit_file_id to
issues with Db::set_bookorbit_ids, and README rows for the new keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-05 04:36:08 +00:00
thalladaandClaude Opus 5 6c2d74c734 Stop routine excerpt fallbacks from degrading every run
Every issue since 2026-08-15 landed as `degraded`, because the extraction
stage warned on any fetch failure at all and a few always fail: the worst
of the 21 published days was 15% (33 of 220 articles), the median under
11%. A status every run carries says nothing, so warn only past a 30%
share -- roughly twice the worst day seen -- and log the rest. The exact
count was already in `counts.excerpt_only` either way.

`rebuild()` also stored the bumped profile version before reading the
interests OPML, so a rebuild that failed on a missing file would mark the
profile fresh for another week having never rewritten its text. Read the
prompt inputs first, ahead of both the model call and the writes, so a
failure stays due and costs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0164rQrMUZkf7uV1VGYuCFy4
2026-09-05 04:08:06 +00:00
thalladaandClaude Fable 5.1 17a88043ff Break Server-Timing into sess, db and tpl metrics
`app` alone said how long the origin took; it could not say where. The
header now also carries `sess` (the session/auth layers around the
routed stack), `db` (SQLite statement time, statement count in `desc`)
and `tpl` (askama rendering), each only where it was measured.

`db` needs a tracing layer: sqlx-sqlite runs statements on a worker
thread and only reports elapsed time there, inside the span the caller
handed it. The new `web::timing` module hangs the request's metrics on a
per-request span and reads them back off sqlx's `sqlx::query` event.
The layer's filter admits every span up to DEBUG, which is what lets it
see the request span past the middleware spans below it; the module docs
spell out the process-wide cost of that.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-05 03:12:02 +00:00
thalladaandClaude Fable 5.1 9c4964ae27 cargo fmt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-05 02:02:44 +00:00
thalladaandClaude Fable 5.1 1e362fee3e Batch the ratings page's per-row queries
The Current tab ran three statements per rated article: two for
`get_article` (the article and its social rows) and one for the newest
explicit `rating_events` source. That is fine on the dev seed and
linear in a production ratings table, and per-statement overhead, not
SQLite's work, was most of the page's origin time.

Collect the article ids up front, load them with the batched
`Db::get_articles` added for the issue page, and resolve every source
in one windowed query per chunk of 500 ids. Semantics are unchanged:
ties still break on `id DESC`, and an article with no explicit event
still renders an empty source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-05 02:02:04 +00:00
thalladaandClaude Fable 5.1 f6b2d3ea9a README: HTTP/3 listeners in the reverse-proxy config
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-05 01:35:22 +00:00
thalladaandClaude Fable 5.1 e727d53b85 Cut per-request origin work: batched article loads, no session write per page
The origin was 3–8 ms per page, almost all of it per-statement overhead:
an issue page ran two statements per pick (~55 on a 25-article issue) and
every signed-in page wrote its session row back because `take_flash`
called `Session::remove`, which marks the session modified even when the
key is absent.

- `Db::get_articles` loads an issue's articles and their social rows in
  two statements; both branches of `web::issue::load` use it. The
  single-id and batch queries share one projection via a macro.
- `take_flash` reads before removing, and touches a signed-in session at
  most once a day so the inactivity expiry still slides. Anonymous
  requests never create a session.
- `Server-Timing: app;dur=<ms>` on every response, outermost layer.
- `reject_early_data`: 425 for a non-safe method that arrived as TLS 0-RTT
  data, so nginx `ssl_early_data on` is safe (RFC 8470 §5.2).
- `[profile.release]`: fat LTO, one codegen unit (binary 46 → 29 MB).

Docs: the Cloudflare proxy was retired on 2026-09-05 after measuring
+43 ms per signed-in page from Boston; README reverse-proxy section is
now the direct setup (upstream keepalive, 0-RTT lines) and the CDN
runbook carries a retired-status banner.

Dev seed, app-side: `/` 21 → 5 statements, 3.7 → 1.0 ms; `/feed.xml`
44 → 12, 9.1 → 3.0 ms; session writes per signed-in page 1 → 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-05 01:34:39 +00:00
thalladaandClaude Fable 5.1 bc40773964 Runbook: cache rules resolve last-match-wins, so bypass goes last
The first rollout put the cookie bypass rule above the cache rule, as
the runbook said to, and signed-in requests were served the cached
anonymous page. Cloudflare applies the last matching rule for each
setting. Fold the cookie exclusion into the cache rule's expression so
order stops mattering, and explain the symptom in the verify step.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-04 22:32:45 +00:00
thalladaandClaude Fable 5.1 f634f06bf1 Settle on a five-minute public cache and drop the purge rationale
The edge-purge integration was reverted in the previous commit; this
finishes the job. Public pages, the feed and /issues.json now say
`public, max-age=300` for browsers and shared caches alike, the code
comments and README explain why the longer s-maxage plus purge design
was dropped, and the rollout runbook loses its purge step, marks the
HTML cache rule optional, and warns to keep the record DNS-only until
the Universal SSL certificate is active.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-04 22:04:07 +00:00
thallada de332cd4b3 Revert "Add a [cdn] section and purge the edge after publishing"
This reverts commit 41fe61a691.
2026-09-04 22:00:06 +00:00
thalladaandClaude Fable 5.1 e616638c36 Document the CDN rollout and refresh the reverse-proxy section
The README's nginx snippet had drifted from the box: it was missing http2, the
security-headers include, the gzip block, the keepalive bump and the trusted
certificate. Replace it with what actually runs, then add the two CDN changes.

`set_real_ip_from` for Cloudflare's ranges plus `real_ip_header
CF-Connecting-IP` has to come first, or every request looks like it came from
Cloudflare and the login throttle becomes global. Then `X-Forwarded-For` is
*set* from `$remote_addr` rather than appended to: the throttle keys on the
first entry, so a client-supplied header must never survive into the app.
The ranges are listed for reference but the reader is told to regenerate them
from cloudflare.com/ips-v4 and /ips-v6, with a one-liner that does it.

The gzip comment claimed the stylesheet carries 445 KB of base64 fonts; it has
not since the faces moved back out to their own URLs.

The new runbook covers the order that matters: the DNS move (Free plan means a
full nameserver change, so the rest of the zone has to survive it), the zone
settings that are quietly wrong by default — Browser Cache TTL *raises* the
origin's max-age unless set to "Respect Existing Headers", and Rocket Loader and
Email Obfuscation both inject scripts the site's `script-src 'self'` CSP blocks
— the two cache rules, locking the origin to Cloudflare, wiring the purge token,
and the `cf-cache-status` checks that prove each row of the header matrix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-04 21:29:10 +00:00
thalladaandClaude Fable 5.1 7d12f53314 Make the origin's Cache-Control headers CDN-safe
An audit of the live site ahead of the Cloudflare move found three ways a
cache-everything rule would have gone wrong.

First, several routes named no policy at all — `/login`, `/issues.json`,
`/files/epub/*`, `/files/xtc/*`, `/robots.txt`, the error pages — so the edge
would have applied its own default TTL (Cloudflare: two hours on a 200). For
`/files/*` that means an authenticated download becoming a publicly cached one.
Those routes now say what they mean, and `security_headers` fails closed: a
response that set no `Cache-Control` gets `no-store`, so a route added later
cannot silently inherit the CDN's default. Everything cookie- or Basic-auth
gated (`/files/*`, `/opds*`) is `private, no-store` on every response, 401s and
404s included.

Second, the public pages said `max-age=300` alone. They now say
`public, max-age=300, s-maxage=86400`: five minutes for the browser, a day for
the edge, which is safe because publishing purges the edge. A request carrying
a `daily_session=` cookie still gets `private, no-store`.

Third, `/static/favicon.svg` and `/static/speculation.json` were referenced
without `?v=` while being served `immutable` for a year — editing either one
could never have reached a browser again. Both are now in the `ASSET_VERSION`
hash and referenced with the version, and `static_asset` only promises a year
when the URL actually carries `?v=`; a bare `/static/…` URL gets an hour and
revalidates against the same ETag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-04 21:28:59 +00:00
thalladaandClaude Fable 5.1 41fe61a691 Add a [cdn] section and purge the edge after publishing
The site is about to sit behind Cloudflare, where the public pages will be
allowed to live at the edge for a day (`s-maxage`, next commit). That is only
correct if the edge is emptied the moment a new issue lands, so the origin now
does the emptying itself rather than leaving it to an operator to remember.

`[cdn]` is inert by default: with no `provider` nothing is called and no token
is needed, so an origin with no CDN behaves exactly as before. Setting
`provider = "cloudflare"` without both a zone id and `DAILY_EPUB_CDN__API_TOKEN`
is a config error — a half-configured purge would publish into a stale edge and
say nothing.

The purge is `purge_everything` on purpose. A new issue changes more than its
own page: `/`, `/issues`, `/feed.xml`, `/issues.json`, and the previous issue's
page too, whose "latest" nav marker moves. A per-URL list of that set is exactly
the kind of thing that silently rots, and everything expensive at the edge is
content-hashed, so refilling it costs one origin fetch.

A purge failure is logged at warn and never fails the run: the paper is already
published and recorded by then, and a few stale hours are not worth failing over.
`daily-epub cdn purge` runs the same code by hand; it takes no run lock because
it touches neither the database nor the publish directories.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
2026-09-04 21:28:48 +00:00
thalladaandClaude Fable 5.1 2388d7bd02 Drop the font preload and defer app.js
Measured on a quiet machine behind a gzip proxy, three interleaved rounds
per variant: with the preload FCP 1.8 s, LCP 2.6 s, perf 0.95-0.96;
without it FCP 0.97 s, LCP 0.97 s, perf 1.00.

Lantern splits bandwidth between in-flight requests, so preloading the
132 KB regular face starved the 12 KB render-blocking stylesheet — and
the stylesheet, not the font, is what first paint waits on. The
metric-matched fallbacks already make the wait for the real face
invisible, so the preload bought nothing that first paint could see.

app.js gets `defer` for the same reason: Lighthouse counts the sync tag
among the render-blocking requests. It stays at the end of body, and it
is plain top-level DOM code with no readyState or DOMContentLoaded
dependence, so execution order is unchanged.

theme.js keeps its place: still synchronous, still after the stylesheet
link, so Gecko cannot paint unstyled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 20:08:42 +00:00
thalladaandClaude Fable 5.1 62803d73a6 Merge branches psi-fonts and psi-a11y-seo: Lighthouse fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 20:02:13 +00:00
thalladaandClaude Fable 5.1 4c8eeda147 Stop inlining the Newsreader faces into the render-blocking stylesheet
/static/app.css was 305 KB because APP_CSS base64-embedded both woff2
faces into it at runtime, and the browser cannot paint until it has the
whole sheet. Lighthouse mobile put FCP and LCP at 2.4 s for it.

The inlining was justified by a Firefox font-flash theory that the later
FOUC investigation (239125a) disproved: the flash came from theme.js
running ahead of the stylesheet, not from fonts arriving late. So point
the faces back at /static/Newsreader*.woff2, versioned with ASSET_VERSION
so the immutable one-year max-age stays safe. Gzipped, the sheet goes
from 304 KB to 12 KB.

That leaves first paint waiting on a font, so switch both faces from
font-display: block to swap and give them metric-matched local fallbacks
(Georgia, Times New Roman, Noto Serif, DejaVu Serif). The overrides are
computed from Newsreader's own metrics at the body optical size against
@capsizecss/metrics widths, so the fallback occupies the same box and the
swap changes glyph shapes and nothing else: every block on the page lands
at the same y-position either way, and CLS stays at 0.001.

Preload the regular face so it starts with the stylesheet rather than
after it parses; italic stays on demand. theme.js keeps its place after
the stylesheet link.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 19:58:28 +00:00
thalladaandClaude Fable 5.1 04bf14f710 Fix the two Lighthouse findings: muted contrast and meta description
Accessibility (95 -> 100). Light `--muted` #7a7568 was 4.14:1 on `--paper`
and 3.72:1 on `--paper-2`, under the 4.5:1 AA floor for normal text — and
muted is the ears line, the nav, bylines, the footer, table headers and
placeholders, so it was most of the page's small type. An audit of every
text token against both surfaces turned up one more: light `--warn`
#8a6d1f at 4.42 / 3.97.

Both are darkened just enough to clear 4.5 on the darker of the two
surfaces, at constant hue and saturation, so the warm grey and the ochre
read the same:

  --muted  #7a7568 -> #6b665a   4.14/3.72 -> 5.16/4.63
  --warn   #8a6d1f -> #7d631c   4.42/3.97 -> 5.16/4.63

Everything else already passed on both surfaces (light ink 15.54/13.96,
ink-2 7.83/7.03, accent 6.73/6.04, loved 5.44/4.89, good 5.29/4.76, down
5.96/5.36), as did the whole dark set against #151513 / #1e1d1a (muted
5.31/4.90 is its floor), so the dark blocks are untouched and stay
identical to each other. `.badge` tints sit on near-paper, so the new
token values carry it.

SEO (91 -> 100). The layout had no `<meta name="description">`. `Page`
now carries one — `DEFAULT_DESCRIPTION` for the site, so all ~26
`Page::new` callers keep their signature — with `with_description` for
the pages worth writing one for: the issue page (issue number, date and
the counts the masthead already prints), the archive, the empty-latest
landing page and sign-in. Dashboard pages keep the default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 19:43:56 +00:00
thalladaandClaude Fable 5.1 239125a8e5 Put theme.js after the stylesheet so Gecko cannot paint unstyled
Root cause of the Firefox-only flash of blue links on the first click after
an idle spell (Bugzilla 1459305, still open): Gecko does not block the
parser on a pending stylesheet, it only suppresses painting, and any layout
flush in that window (an extension content script, a ResizeObserver, a
getComputedStyle) initialises layout and paints the unstyled body. With
theme.js *before* the <link>, nothing stalled the parser while app.css was
coming back from the disk cache, so the whole body was parsed unstyled and
one flush painted it. Chrome blocks rendering on the sheet outright, which
is why it never showed this.

Gecko refuses to run a parser-blocking script while stylesheets are
pending, so a synchronous script placed after the <link> stalls the parser
inside <head>: there is nothing to paint. theme.js still runs before any
body content exists, so has-js and the saved theme are applied pre-paint
exactly as before.

Reproduced with Playwright Firefox, a proxy delaying app.css and an init
script forcing offsetHeight on every mutation: a 1.2 s unstyled paint with
the old order, none with the new order, in both Firefox and Chromium.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 19:08:52 +00:00
thalladaandClaude Fable 5.1 1047cf4633 Remove everything that can change after first paint
Firefox users saw a flash on the first navigation after an idle spell, and
Chrome occasionally flashed white. Nothing in the network path explained it
(assets are immutable, the stylesheet is render-blocking, a cold Firefox
load paints fully styled), so this removes every remaining way a page could
look different between its first paint and its final state:

- Both Newsreader faces are embedded in the served stylesheet as data: URIs
  (CSP gains `font-src 'self' data:`; the preloads go away). A font fetched
  by URL is applied after first paint whenever the browser has to bring it
  back from disk, which is exactly the "first click after a while" case.
- The cross-document view transition is gone; the operator wants snappy.
- A color-scheme meta, kept in step with the saved theme, so the canvas the
  browser paints before the stylesheet is the right shade.
- The theme toggle's icon and label are chosen by CSS from html[data-theme]
  (set pre-paint by theme.js) instead of being rewritten by app.js.
- Dashboard table filters are rendered by the templates (shown under
  `.has-js`) rather than inserted by app.js, so tables no longer jump.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 18:37:44 +00:00
thalladaandClaude Fable 5.1 9acd1b0800 Rebuild app.css after merging the UI pass branches
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 17:45:14 +00:00
thallada b299b4ba05 Merge branch 'ui-rating': rating widget, issue section headers, ratings verdict column 2026-09-04 17:43:23 +00:00
thalladaandClaude Fable 5.1 4248e48a8b UI pass: rating widget, issue section headers, ratings verdict column
Finishes the Codex partial (0cdc302) after seeing it rendered.

Rating widget
- Inactive segments go text-ink-2 (ink on hover) so the group recedes
  beside serif body copy; the per-verdict tint stays the only colour.
- Hit area: the ::after overlay is positioned against the padding box,
  so -inset-y-1/-1.5 only gave 37/41 px. Now -inset-y-1.5/-2 -> 41 px
  desktop, 45 px phone.
- focus-visible hugs the segment (z-20, ring-offset-0); the inherited
  ring-offset-2 painted a detached box over the neighbouring buttons.
- The note field is a quiet bottom-hairline input everywhere (rule at
  rest, ink on focus) instead of a box on reader pages and an invisible
  border in tables.

Issue section headers
- The Brief, every section name and Colophon now use one serif
  uppercase, letter-spaced mark on a double rule. A sentence-case
  text-2xl header was screenshotted first and rejected: it reads as
  another headline, which is the problem being fixed.

Ratings table verdict column
- New .rating-cell block: a one-column 15rem grid, segmented group with
  clear/cleared flush right, note field beneath on the same width, group
  top-aligned with the article title. 13rem cannot hold the labels plus
  the wider "cleared" state, so rows wrapped and overlapped the Article
  column.
- Drop the dead CurrentRow::verdict field left over from removing the
  badge (clippy -D warnings).

Also: reader-page on world/behind so the text-wrap: balance polish item
covers those chapters.

Verified on the dev server at 127.0.0.1:3602 in light and dark, desktop
and 390 px: rating clicks toggle .active/aria-pressed with no navigation
and no console output; note text round-trips; cargo fmt, clippy
--all-targets -D warnings, cargo test (445 lib + integration) and
npm run css:check are clean. See docs/plans/briefs/ui-pass/handoff-rating.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 17:42:21 +00:00
thallada bcbe1583af Merge branch 'main' into ui-rating
# Conflicts:
#	src/web/templates/issue_full.html
2026-09-04 17:09:06 +00:00
thallada 0cdc302f84 WIP: rating widget / section headers (Codex partial, quota cut-off) 2026-09-04 17:08:48 +00:00
thallada da24e7c7c5 Merge branch 'ui-shell': nav state, ears, feeds table, font flash, faster navigation
# Conflicts:
#	src/web/static/app.css
2026-09-04 17:08:10 +00:00
thalladaandClaude Fable 5.1 5cc4ac4bd3 UI pass: nav state, ears, feeds table, font flash, faster navigation
- Issue pages highlight Archive instead of Latest unless the issue is the
  newest one (IssueView::is_latest), with a router test.
- The ears row is empty by default and says "Dashboard" on dashboard pages;
  "Morning edition" only survives on the empty-state hero.
- Run page: the Feeds card table is fixed-layout and truncates long feed
  names with the full title on hover instead of pushing the entries column
  out of the card.
- Static assets are content-hashed, so serve them immutable for a year;
  preload both Newsreader faces and use font-display: block so a cache
  revalidation never paints the fallback serif first. Persisted details
  state is restored pre-paint from theme.js.
- Cross-document view transitions (140 ms fade, masthead held still) and
  Speculation Rules (prerender reader pages, prefetch dashboard pages on
  hover) served from /static/speculation.json via the Speculation-Rules
  header, which the CSP would otherwise block inline. No client router.
- Dashboard nav gets the same accent underline as the site nav; 40 px
  targets for the theme toggle, account link and dashboard nav.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 17:06:48 +00:00
thallada fafb737e53 Merge branch 'ui-toc': table-of-contents fixes and scroll sync 2026-09-04 17:06:26 +00:00
thalladaandClaude Fable 5.1 ad2e99eeb9 UI pass: table-of-contents sidebar fixes and scroll sync
- The open hamburger panel now fills the viewport below the sticky bar
  (100dvh, measured bar offset) and locks the page behind it. Locking only
  the root element: locking body too made it its own scroll container and
  un-stuck the bar.
- Front-page scroll-spy: the current chapter marker, mobile label/counter,
  desktop "Chapter N of M" line and both progress bars follow the reader
  through "In This Issue"; passed chapters read in ink. The sidebar
  auto-scrolls to keep the current chapter in view.
- The sidebar scrollbar sits in a reserved gutter instead of over the text.
- Hamburger cross-fades to a close icon; 44px tap targets on phones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 17:05:39 +00:00
thalladaandClaude Fable 5.1 560bbc13af Add briefs for the UI polish pass
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 16:33:58 +00:00
thalladaandClaude Fable 5.1 78e7e4aa2c Static assets: bust caches with a content hash instead of the crate version
/static/app.css, app.js and theme.js URLs now carry the first 12 hex digits
of a SHA-256 over the embedded assets, so any CSS or JS change reaches
browsers that cached the previous build without a version bump.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 22:29:13 +00:00
thalladaandClaude Fable 5.1 6b0fa51e87 Bump version to 0.3.0 for the web dashboard v2 release
The static asset URLs carry ?v=<version> and are cached for a day, so the
redesigned CSS needs a new version to reach browsers that cached 0.2.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 22:27:06 +00:00
thalladaandClaude Fable 5.1 e719e031cf Merge branch 'v2-toc': table-of-contents sidebar
Keeps both the dashboard and sidebar component blocks in tailwind.css and
rebuilds the compiled app.css.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 21:53:06 +00:00
thalladaandClaude Fable 5.1 ca910d99e5 Web dashboard v2 step 4: table-of-contents sidebar
Signed-in issue pages (issue, article, world, behind) get a chapter list
that marks where the reader is. One `issue_toc` helper builds it for all
four handlers: the picks in issue order numbered across sections, then
World Briefing, Behind the paper and a colophon anchor, with
position/total over the navigable chapters.

At `lg` and up it is a sticky left column with its own sticky header
(issue number, "Chapter N of M", a 2px progress bar); below `lg` the same
`<nav>` collapses behind a sticky hamburger bar and drops down as a
panel that closes on link tap, Escape and outside tap. Without JS the
panel is simply visible; `theme.js` marks the document scripted before
paint so it never flashes open. Article pages advance the progress bar
with scroll position through `requestAnimationFrame`, and the bar is a
`<progress>` element because a percentage width would need an inline
style the CSP forbids.

Also fixes the phone "ears" row from the step 2 review: the row no
longer wraps at 390px, the dateline has a short form below `sm:`, and
the toggle and account link stay on one line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 21:50:05 +00:00
thalladaandClaude Fable 5.1 16244a6a70 Run page: label the funnel bar column 'share' instead of a second 'reached'
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 21:31:46 +00:00
thalladaandClaude Fable 5.1 a4c338a9f9 Web dashboard v2 step 3: dashboard restyle
Restyle every template under src/web/templates/dashboard/ plus the shared
partials _candidate_row.html, _signals_table.html and _pagination.html with
Tailwind utilities and the design system step 2 built, so the dashboard reads
as the sans-serif instrument-panel half of the same publication as the reader
pages: a page header on every page, stat tiles, compact tables with sticky
heads inside .scroll-x, tinted badges, one inline filter form per page,
settings groups with a sticky save bar, job cards, a scrolling journal block
and a rating widget that fits inside a table cell.

tailwind.css gains the dashboard vocabulary (.page-head/.page-desc/
.page-actions, .tiles/.tile*, .cell-wrap, .scroll-x.tall, .filters,
.pager/.tabs, .setting*/.save-bar, .disclosure, .sparklines/.spark-figure,
td .rating overrides, tr.superseded) plus a styled <meter> and a disabled
control state. Renames class="inline" to form-inline: Tailwind emits an
.inline display utility that was beating form.inline's layout.

Three small backend changes: job cards show the status and time of their last
run (attach_last_runs, no extra query, unit-tested); the Stats page passes
"stats" as its nav key so the admin nav no longer highlights Overview; and the
settings load-error banner drops its "! " prefix, with its assertion updated.

cargo test: 477 passed. npm run css:check clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 21:30:09 +00:00
thalladaandClaude Fable 5.1 29c66da865 Merge branch 'v2-frontend': Tailwind design system, theme toggle, reader pages
Resolves the step 1 / step 2 overlap in the issue and world templates:
public entries carry the summary and why lines in the new styling, and the
legacy colophon renders n/a for unknown counts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 20:56:00 +00:00
thalladaandClaude Fable 5.1 12134f3596 Web dashboard v2 step 2: Tailwind design system, theme toggle, reader pages
Replaces the hand-written stylesheet with a Tailwind CSS v4 source
(src/web/tailwind.css) whose minified build is committed as
src/web/static/app.css and still embedded with include_str!, so the binary
and the test suite never need Node. package.json pins tailwindcss and
@tailwindcss/cli to 4.3.3 and adds css, css:watch and css:check.

Colours are semantic CSS variables swapped per theme and exposed to Tailwind
through @theme inline, so templates write bg-paper/text-ink/border-rule once
and both themes work. theme.js runs synchronously in <head> to set
data-theme before paint; app.js cycles the toggle system -> light -> dark and
persists the choice. Newsreader is self-hosted and served from
web::static_asset with the same ETag/304 logic as the other assets.

Every reader template is restyled: ears row, centred masthead with its rules,
tracked section labels, the drop cap on The Brief, "Why it's here" with its
accent bar, the segmented rating control, prev/next cards, and the colophon
as a two-column definition grid. Dashboard templates are untouched (step 3).

Also fixes a .block collision with Tailwind's display utility, the SVG
`hidden` property that never toggled the theme icon, and points the dev
seed's profile_path at ./dev so previewing cannot rewrite data/profile.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 20:51:08 +00:00
thalladaandClaude Fable 5.1 f410df47c0 Web dashboard v2 step 1: public summaries, legacy issue fallback
Public issue pages and the Atom feed now carry each pick's summary and
'Why it's here' line (bodies, The Brief, World Briefing and comments stay
private). Issues stored before issues.issue_json existed rebuild their
colophon and Behind the paper from issues.report_json / the run row and
candidate_runs, and recover the World Briefing chapter from the EPUB on
disk. The dev seed mirrors that legacy shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 20:28:16 +00:00
thalladaandClaude Fable 5.1 61ed37fc20 Web dashboard v2: dev seed example and agent briefs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 20:01:50 +00:00
thalladaandClaude Fable 5.1 9b1c137c80 Session store: implement create with id-collision retry
Silences tower-sessions' warning about the default create and never
overwrites an existing row on a session id collision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 18:22:41 +00:00
thalladaandClaude Fable 5.1 eb960f1b57 Web dashboard step 7: docs, users page, polish
Users page, README and config.example updates, implementation notes, the
rollout runbook, site-layout 404/500 pages, human-readable download sizes,
dark-mode and narrow-screen polish, and a smoke test over every dashboard
route; also removes zdiff3 ancestor markers left by earlier merges.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 18:19:08 +00:00
thalladaandClaude Fable 5.1 10cbe9ed79 Web dashboard: step 7 brief follow-ups from reviews
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 18:03:50 +00:00
thallada f6e317a9fe Merge branch 'step6' into web-dashboard
# Conflicts:
#	src/web/static/app.css
2026-09-03 18:01:36 +00:00
thalladaandClaude Fable 5.1 aa3de51d9c Web dashboard step 6: jobs and stats
Job catalogue and daily-epub job run, the systemd job unit and polkit rule,
SystemdRunner/MockRunner, the Jobs pages with status and journal tail, the
stats_data refactor with a byte-identical CLI, the stats page and the
overview sparklines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 18:01:21 +00:00
thalladaandClaude Fable 5.1 d404946c78 Settings test: inject the env probe instead of mutating the environment
The env-detection test briefly set a real DAILY_EPUB_* variable, which raced
with the config test that loads the shipped example through figment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 17:53:47 +00:00
thallada 073a85f3c7 Merge branch 'step5' into web-dashboard
# Conflicts:
#	src/web/static/app.css
#	src/web/static/app.js
2026-09-03 17:50:02 +00:00
thalladaandClaude Fable 5.1 572056ce3b Web dashboard step 5: settings page with toml_edit writer
Schema derived from Config::default() and the live config, help text for every
shipped key, env locks and presence-only secrets, typed saves that preserve
comments and validate through Config::load before an atomic rename, provider
add/remove, the change history, and config reload on mtime.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 17:49:48 +00:00
thallada d36f203e4c Merge branch 'step3' into web-dashboard
# Conflicts:
#	src/web/static/app.css
2026-09-03 05:27:28 +00:00
thalladaandClaude Fable 5.1 63eee6bbd3 Web dashboard step 3: overview, runs and articles pages
Fill in the dashboard read pages of the web dashboard plan (§9): the
overview card set (last run from report_json, budget today per provider,
ratings this week, unrated picks with the rating widget, jobs summary,
config-on-disk warnings), the runs list and run detail (funnel, admission
mix, preference state, timings, provider usage, warnings, top feeds,
config diff against the previous non-dry run, near misses, and the
candidates table with allow-listed filters/sorts and per-row signals),
and the articles list and article detail (assessments including
provider rejections, run history, neighbours and interests, embedding
metadata, rating events, and the text explain).

Dynamic list queries go through AssertSqlSafe with SQL built only from
constants and allow-listed fragments; every user value is bound. Adds
the shared signals-table, candidate-row and pager partials, a CSS block
and the data-filter table behaviour in app.js, plus the step 3 handoff.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 05:26:52 +00:00
thalladaandClaude Fable 5.1 da065437c0 Web dashboard step 4: ratings page and profile editor
Add /dashboard/ratings with the preference-state header, the "How ratings
enter the algorithm" explainer, the Current tab (decayed neighbour weight,
feed credit, prompt/rebuild membership, last-run neighbour usage, inline
rating widget with note) and the paginated, filterable Events tab with
superseded marking.

Add /dashboard/profile: profile.md editor with atomic mode-preserving
writes and profile_versions history plus restore, the parsed preview,
OPML interests by theme, learned adjustments with staleness, the stored
system prompt, and the profile-rebuild job form.

Make profile::MAX_RATINGS_IN_REBUILD and profile::stored_version pub.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 05:21:33 +00:00
thalladaandClaude Fable 5.1 849231e49a Web dashboard: dashboard module scaffold for parallel steps
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 04:59:29 +00:00
thalladaandClaude Fable 5.1 4995a8bd5d Web dashboard step 2: full issue pages, article chapters, POST /rate
Signed-in issue page with the Brief, downloads, index and colophon; article,
World Briefing and Behind the paper pages under the login guard; the rating
widget with its fetch enhancement; the admin-only POST /rate writing
dashboard-attributed rating events; the HMAC confirmation page links to the
site.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 04:56:29 +00:00
thalladaandClaude Fable 5.1 69cd736455 Web dashboard step 1: migration, sessions, users CLI, public site
Migration 0004 (users, sessions, config_changes, profile_versions, jobs,
rating_events.user_id, runs.report_json, issues.issue_json), the issue
snapshot writer and loader, the web module skeleton with layout and static
assets, axum-login/tower-sessions over a sqlx session store, password-auth
users with a CLI, the login throttle, the origin check, security headers,
and the public issue pages, archive, Atom feed and robots.txt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 04:38:22 +00:00
thalladaandClaude Fable 5.1 a70de4af06 Web dashboard: briefs for steps 2-7
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 04:03:11 +00:00
thalladaandClaude Fable 5.1 bf2ab8441e Web dashboard: shared and step 1 agent briefs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 04:01:25 +00:00
thalladaandClaude Fable 5.1 89423f01fa Add web site and operator dashboard plan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 04:00:23 +00:00
thalladaandClaude Fable 5.1 af2a3ceb49 Bisect provider-rejected batches in triage and deep assessment
DeepSeek's content filter rejects a whole request (400 "Content Exists
Risk") when one article trips it, which cost the other articles in the
batch their assessment and retried them every run. New curate/batch.rs
runs both stages through a bisecting runner: a rejected batch is split
until the offending article is isolated, that article is retried once on
the editor provider when it is a different one, and a still-rejected
article is recorded as a provider_rejected assessment row so it is not
retried for assessment_reuse_days. Cache reuse accepts rows from either
configured model. Each stage logs reused/requested/rejected counts, the
curation: line shows rejections when non-zero, explain prints them, and
the llm_assess span reports the deep-set size.

Implemented by a Claude agent from an orchestrator brief; verified
fmt/clippy(-W dead_code)/test green (354 lib tests).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 20:44:28 +00:00
thallada 7a842b61b6 Bump version to 0.2.0 2026-09-02 19:12:21 +00:00
thalladaandClaude Fable 5.1 99d1338890 Provider-agnostic LLM registry, config check, migration runbook
[llm] assigns the bulk and editor roles by name over a [providers.*]
registry (kind = openai | anthropic, per-provider model, effort, daily
ceiling and price table); DeepseekBackend becomes OpenAiCompatibleBackend
(reasoning_effort passthrough), AnthropicBackend builds from the same
ProviderConfig, meters and provider_costs are keyed by provider name.
Gemini 3.8 Flash is declared via Google's OpenAI-compatible endpoint so
switching the editor is one line (or DAILY_EPUB_LLM__EDITOR=gemini for an
A/B dry run). Stale [deepseek]/[anthropic] tables, the top-level
max_daily_usd and the old key env vars fail loudly.

daily-epub config check validates and prints the resolved roles, models,
key presence and paths without opening the database.

docs/runbooks/curation-v2-migration.md walks the server upgrade from v1.

Registry implemented by a Claude agent from an orchestrator brief;
verified fmt/clippy(-W dead_code)/test green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 18:44:57 +00:00
thalladaandClaude Fable 5.1 5edbeb509f Curation v2: session 2 handoff (steps 4-7 done, briefs preamble updated)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 16:32:18 +00:00
thalladaandClaude Fable 5.1 d403c51edf Curation v2 step 7: cleanup, prune paths, implementation notes
Dead code and stale v1 comments removed (clippy -W dead_code clean, the
three world.rs warnings fixed), the Brief chapter's TOC title renamed from
"From the Editor", features prune now also sweeps article_assessments and
generate runs the sweep once after publishing, the example config is
tested key-for-key against Config::default(), README commands match
--help, and docs/plans/2026-08-15-implementation-notes.md records the
Anthropic and Voyage facts, the new tables, the budget-day rule and the
lock.

Implemented by a Claude agent from docs/plans/curation-v2-briefs/step7.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 16:32:18 +00:00
thalladaandClaude Fable 5.1 d261cd485d Curation v2 step 6: Behind the paper, stats, run report block, lock
Behind-the-paper chapter (behind.xhtml, both editions) built from the run's
StageCounts and candidate_runs near misses; daily-epub stats [--days N];
StageCounts gains knn/feed gates and verdicts_in_prompt, timings split
into summaries + brief, the four-line §15.4 info block logged once per
run and printed by print_report; src/lock.rs flock guard on
<database_path>.lock for generate, profile rebuild, features backfill and
backfill-social; README updated for the new CLI, env vars and costs.

Implemented by a Claude agent from docs/plans/curation-v2-briefs/step6.md;
reviewed against plan §5, §15.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 16:21:06 +00:00
thalladaandClaude Fable 5.1 05a74a0dcf Curation v2 step 5: deep assessment, utility, diversified shortlist
assess.rs replaces score.rs (DEEP_INSTRUCTIONS, representative sample
with [BEGINNING]/[MIDDLE]/[END], facets, cached deep rows with --rescore
bypass), rank.rs adds the utility blend over present signals with gate
ramps and the leader-clustered shortlist (cap 2 → 3 → uncapped, protected
top-N, exploration reserve), editor.rs replaces select.rs with the §13
rendering and utility-ordered fallbacks. ScoredArticle is gone; Candidate
is the only flow type. deep_batch_size replaces score_batch_size.

Started by Codex (cut off by its usage limit mid-verification) and
finished by a Claude agent from docs/plans/curation-v2-briefs/step5.md;
reviewed against plan §12–§13.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 16:04:43 +00:00
thalladaandClaude Fable 5.1 10f4afd091 Curation v2 step 4: triage replaces the gate
DeepSeek triage over every eligible article (plan §10) with cached
assessments in article_assessments, the union admission with quotas and
exploration slots (§11), hygiene moved to admit.rs with the churn rule
reading assessments, prefilter.rs reduced to hygiene and text heuristic,
prefilter_keep removed in favour of curation.ranking.deep_keep, the
scores table dropped (migration 0003), and --rescore on generate.

Implemented by Codex (gpt-5.4, high effort) from
docs/plans/curation-v2-briefs/step4.md; reviewed against plan §10–§11.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 15:25:04 +00:00
thalladaandClaude Fable 5.1 76527e3821 Curation v2: progress handoff and per-step agent briefs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 04:16:59 +00:00
thalladaandClaude Fable 5.1 8b51454c20 Merge curation-v2-step3 into curation-v2 (steps 2 and 3 united)
Union merge of the Claude editor (step 2) and the embeddings/signals/telemetry
work (step 3): both config trees, per-provider LLM budgets plus the Voyage
meter, one startup provider log, runs.config_json with ranking and voyage
settings, and selected picks' why lines landing in candidate_runs.editor_why.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 04:16:12 +00:00
thalladaandClaude Fable 5.1 ea3b141373 Curation v2 step 3: Voyage embeddings, cheap signals, candidate telemetry
- embedding.rs: EmbeddingBackend + VoyageBackend, batched bounded-concurrency
  client with its own UsageMeter, f32 BLOB codec, article/interest embedding
  cache keyed by model, dimension and sha256 of the embedded text.
- signals.rs: z-scored interest match, decayed rated-neighbour preference
  with the knn gate, feed affinity with the feed gate, social, text heuristic
  without social terms, mid-rank percentile normalizer, preliminary blend.
- telemetry.rs: candidate_runs writer with §7.5 signals_json, explain and
  near-misses renderers, prune.
- [voyage] and the full [curation.ranking] config with validation.
- CLI: explain, features backfill|prune, generate --skip-embeddings.
- Pipeline: hygiene rows, embed and signals stages before the old prefilter;
  same-date regeneration no longer excludes its own picks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 04:08:45 +00:00
thalladaandClaude Fable 5.1 57efbb49b4 Curation v2 step 2: Claude Opus 5 editor, the Brief, per-provider budgets
- AnthropicBackend (Messages API, cached system block, output_config.effort,
  server-side fallbacks, refusal surfaced as an error); Llms { bulk, editor }
  with editor_or_bulk(); PriceTable-based UsageMeter per provider.
- [anthropic], [editorial], deepseek.max_concurrent_requests and
  curation.max_article_count config; startup logs resolved providers.
- Budget day is the UTC date of started_at, preloaded from
  runs.provider_costs_json; finish_run writes provider_costs_json and
  config_json. Stage A batches run concurrently with per-batch budget checks.
- Editor prompt with one-line "why" per pick; no minimum lineup size;
  --max-articles is a ceiling; top-up branch deleted; why stored on picks and
  issue_articles.why and rendered in chapters and In this issue.
- Summaries on the editor client (3k-token input, concurrency 4, bulk then
  excerpt fallback); "The Brief" replaces From the Editor; section intros gone.
- Colophon carries per-provider costs and models.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 03:56:14 +00:00
thalladaandClaude Fable 5.1 3a9f4b99e0 Curation v2 step 1: three-way feedback, rating events, reader profile
- Migration 0002: rating_events, article_embeddings, interest_embeddings,
  article_assessments, candidate_runs, runs.config_json/provider_costs_json,
  issue_articles.why; copies ratings into rating_events and drops ratings and
  feed_priors (scores stays until step 4).
- Vote is Loved | Good | NotForMe; legacy `up` links still verify as Loved.
- Footer offers Loved it / Good / Not for me; the confirmation page offers the
  other two so a mis-tap can be corrected. handle_rating appends one event.
- db::current_ratings implements the latest-explicit-event rule with summaries
  and facets; `ratings list|set|clear` CLI appends source='cli' events.
- data/profile.md replaces the hard-coded reader prose; the system prompt is
  rebuilt every run in the §8.4 order with a recent-verdicts block.
- Weekly rebuild reads summaries, facets and notes; feed priors removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 03:23:39 +00:00
thalladaandClaude Fable 5.1 d0560afa1a Add personalized curation v2 plan; archive superseded plan and reviews
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A1rCLQeKBgnBo3oTgHuTMe
2026-09-02 02:45:11 +00:00
thallada a5997454ec Increase max size of downloaded article images
- Download cap raised from 5 MiB to 100 MiB in src/images/fetch.rs:14.
- Added 16,384×16,384 source dimension limits.
- Added an explicit 512 MiB per-image decoder allocation limit in
src/images/encode.rs:15.
- Existing output resizing and 25 MiB final issue budget remain
unchanged.
- Added regression coverage for a large image source size and oversized
image dimensions.
- Improved overflow safety when accumulating streamed image bytes.
2026-08-19 04:48:54 +00:00
thallada 4e1cf20800 Fix crash with utf8 truncation 2026-08-18 16:15:48 +00:00
thalladaandClaude Opus 5 db19d08257 Break up the image and EPUB god files
The image fixes left two problems of navigation. Image handling was
spread across `extract.rs` (300 lines of normalization) and
`epub/images.rs` (download, re-encode, markup rewriting, plus generic
HTML helpers that comments, world and x4 were all reaching into a
module named "images" to borrow). And `epub/build.rs` had grown to
1,148 lines of code covering cover rendering, ten askama templates,
every chapter renderer and the zip assembly.

New homes:

- `src/html.rs` — markup helpers that do not care what the markup is
  about: tag scanning, attribute parsing, entity decoding, escaping,
  XHTML fixups, reading a fragment as text. Previously scattered between
  `epub/images.rs` and `extract.rs`.
- `src/images/` — one module per stage of an article's images, in the
  order they run: `normalize` (make `<img>` usable, pre-readability),
  `refs` (what an article references), `fetch` + `encode` (download and
  re-encode per edition), `embed` (point the markup at what shipped).
- `src/epub/{cover,chapters,build}.rs` — the cover, the chapter
  renderers, and the ordering plus assembly that puts them together.
  `epub/fixtures.rs` takes the shared test issue, which was a public
  module wedged inside `build.rs`.
- `src/curate/profile/themes.rs` — a 260-line keyword table that sat in
  the middle of the profile logic.

`curate::html_to_text` is renamed `prompt_text`: it is a different
function from `html::html_to_text` (collapses whitespace, no DOM, sized
for prompt budgets) and sharing a name with it was a trap.

Largest module drops from 1,148 code lines to 828, and no file mixes
two subjects. Behaviour is unchanged: 231 lib tests plus 25 integration
tests green, and the real-world audit over issues 1–3 still reports 214
images referenced, 214 shown, 0 placeholders, 0 orphaned assets.

`image_audit` gains `--epub-out DIR`, which writes a readable EPUB of
the audited articles so images can be checked on a device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 03:35:21 +00:00
thalladaandClaude Opus 5 254eaeb713 Fix the six ways article images went missing
Across the first three issues, 145 of 208 referenced images reached the
page; 38 that had been downloaded, re-encoded and written into the EPUB
were never referenced by any chapter, and 69 became `[image: …]` lines.
Six independent causes, each verified against the real articles:

- **Entity-encoded URLs never matched their asset.** `prepare_body`
  re-runs `ammonia::clean`, which writes `&` as `&amp;`, and the tag
  scanner that looks the asset up compared raw attribute text against a
  URL a real parser had decoded. Every image with a query string lost.
  `parse_attrs` now decodes entities.
- **Readability clobbers a working `src`.** Its lazy-image heuristic
  copies any attribute containing `.jpg` over `src`, which on NPR meant
  a `{width}` template (the CDN answers those with a grey square reading
  "Image"), on Substack a JSON blob, on dfarq an entire `srcset` string.
  Pages are now normalized before readability sees them: every `<img>`
  is reduced to `src`/`alt`/`title` with the best candidate from the
  lazy attributes, `src`, `srcset` and `<picture><source>`, so there is
  nothing left for the heuristic to substitute. Candidates that cannot
  resolve — braces, whitespace, quotes, `data:` — are rejected by shape
  rather than by publisher.
- **Readability deletes `<button>` and its subtree**, taking lightbox
  images with it and leaving the captions behind. Image-only wrappers
  are unwrapped first.
- **SVG was undecodable**, so vector charts became placeholders. They
  are rasterized with resvg, which the cover already depends on.
- **Relative URLs resolved against the pre-redirect URL**, 404ing every
  image on an article reached through a shortener. `fetch_readable` now
  reports where it landed.
- **The 12-image cap** silently truncated photo essays, and the images
  past it were advertised as failures. Removed; the issue-wide byte
  budget is the real backstop.

An image that still cannot be embedded is now dropped rather than
announced, unless its alt text is a real description — decorative rules,
spacers and dead links were generating most of the placeholder noise.

`examples/image_audit.rs` replays the pipeline over the articles of
published issues and reports what reaches the page. On issues 1–3:
208→214 images referenced, 145→214 shown, 69→0 placeholders, 38→0
orphaned assets. The three images still not embedded are a 14×14 favicon
and a 650×2 divider — correctly declined, and no longer announced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 02:51:28 +00:00
thallada 6ba3c3d525 Halve page margins in standard edition EPUBs 2026-08-18 01:36:40 +00:00
thallada 38fb493989 Fix world briefing & add LLM enrichment, colophon fix, x4 cover fix
- World Briefing now starts with yesterday, preserves complete nested
event hierarchies and Wikipedia links, fetches deduplicated article
leads concurrently, and adds stable-ID summaries plus a daily overview.
- Enrichment runs after normal editorial work and degrades safely with
report warnings.
- Colophon facts now use X4-safe block paragraphs.
- Standard cover remains 1200×1600 RGB PNG.
- X4 cover is a 480×800 baseline RGB JPEG with consistent XHTML and OPF
declarations.
- Added CrossInk cache-clearing guidance to README.md.
- Worked around an epub-builder duplicate XML-ID defect discovered by
validation.
2026-08-15 21:13:56 +00:00
thallada 9e27a32fb6 Serve EPUBs instead XTC in daily OPDS
It turns out it is currently impossible for the Xteink X4 to download
XTC files from an OPDS server. So I switched the built-in OPDS server in
this binary (daily.hallada.net/opds) to serve the EPUB files instead of
the XTC files. The X4 is pretty capable of reading the X4 edition of the
EPUB that was optimized for it anyways, and I prefer the flexibility of
EPUB, so I might end up eventually deleting the XTC conversion. For now,
I kept the conversion step (in case I ever want to ever try manually
copying them to the device) and I keep a limited number of XTC issues on
the server since they are quite big in filesize. The BookOrbit
integration is now optional since the built-in OPDS server is able to
serve the EPUBs. In my installation, I serve the EPUBs through both. The
daily.hallada.net/opds server is just a little quicker to navigate and
download the EPUBs on the X4 since the BookOrbit OPDS requires diving
into a couple layers of folders before you get to the files.
2026-08-15 20:24:20 +00:00
thallada 9e30c1dcdf Initial commit: The Daily EPUB full implementation
Full implementation of a personalized daily newspaper delivered as an
EPUB.

Articles are pulled from a local self-hosted Miniflux instance, enriched
with comments, summarized and filtered by DeepSeek AI, and then
assembled into two EPUB editions: standard and optimized for the Xteink
X4 e-ink reader. Both are served by the local self-hosted BookOrbit OPDS
server in a separate library. Then the X4 edition is futher converted to
XTC format and served over a separate OPDS server hosted by the Rust
binary. Runs are tracked in a local SQLite database so runs are
idempotent per date.

Full documentation of the plan is in docs/plans and setup and install
instructions are in the README.md file.
2026-08-15 17:46:19 +00:00