Commit Graph
70 Commits
Author SHA1 Message Date
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