Commit Graph
101 Commits
Author SHA1 Message Date
thalladaandClaude Fable 5.1 c971fd9317 Register the [discovery] section in settings and document it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
2026-09-07 04:17:29 +00:00
thalladaandClaude Fable 5.1 4a2fc6d1c4 Add the [discovery] config section for feed discovery
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
2026-09-07 04:09:37 +00:00
thalladaandClaude Fable 5.1 e69940fac8 Fix 404 on issue downloads and make the BookOrbit link admin-only
Download hrefs were built with form encoding, which turns the spaces in
"The Daily EPUB - <date>.epub" into "+"; in a URL path that is a literal
plus, so /files/epub/<name> never matched the file. They now use the
same path-segment percent-encoder the OPDS feed already uses, and the
download-menu test follows the rendered link and expects 200.

The Read in BookOrbit button renders only for admins, and the
/issues/{date}/read redirect answers 403 to other signed-in users.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
2026-09-07 02:39:06 +00:00
thalladaandClaude Fable 5.1 78d9ef5959 Serialize the config tests that touch the process environment
`toml_then_env_layering` sets DAILY_EPUB_* variables through figment's
Jail while other tests call `Config::load`, which reads them; under the
parallel test runner that intermittently failed
`registry_validation_rejects_bad_roles_kinds_and_efforts`. Every test on
either side of that race now holds one mutex.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
2026-09-07 02:31:25 +00:00
thallada 5079d9c008 Merge branch 'followups': password-change redirect and requested usernames 2026-09-07 02:28:16 +00:00
thalladaandClaude Fable 5.1 e56acfb9dc Carry next through the password change and let requesters pick a username
A forced or voluntary password change now redirects to the validated
`next` target (the page the user was heading for, or the home page)
instead of landing back on the account form. Login and the
must-change-password middleware pass the destination along as
/account?change=1&next=... and the form carries it as a hidden field.

The request-access form gains a required Username field validated with
the account rules and rejected when an account or another open request
already holds it (case-insensitive). Migration 0009 stores it on the
request; the notification email and the Users dashboard show it, and
the Approve form is prefilled with it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
2026-09-07 02:28:09 +00:00
thallada a3a041dc06 Merge branch 'approve': dashboard account approval and forced password change 2026-09-07 01:43:20 +00:00
thalladaandClaude Fable 5.1 49a2de14ae Approve access requests from the dashboard with an emailed temporary password
Each open access request on /dashboard/users gains an Approve form with
a suggested username. Approving creates a user-role account with a
20-character random temporary password, emails it to the requester with
the sign-in link, and marks the request done; if the email fails the
account is deleted so the admin can retry. Approval refuses when mail is
not configured.

Migration 0008 adds users.must_change_password. A middleware on the
signed-in routers sends flagged users to /account?change=1 until they
set a new password; login honours the flag regardless of `next`, and the
CLI's `users passwd` clears it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
2026-09-07 01:43:10 +00:00
thallada 502ba38607 Merge branch 'mail': SMTP mail, access-request notifications, request throttle 2026-09-07 01:24:39 +00:00
thalladaandClaude Fable 5.1 df76c81b59 Add SMTP mail, access-request notifications, and a shared request throttle
A new [mail] section configures an SMTP relay (SES-style STARTTLS or
implicit TLS; the password is environment-only via
DAILY_EPUB_MAIL__SMTP_PASS) and src/mail.rs wraps lettre in a small
plain-text Mailer built once at server start. Each stored access request
now emails mail.notify_to in a spawned task with the address, reason,
time, and a link to /dashboard/users. POST /request-access shares the
login endpoint's per-IP limiter (server.login_attempts per window).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
2026-09-07 01:24:27 +00:00
thallada feb887a8af Merge branch 'fix-request-access': public account request flow
# Conflicts:
#	src/web/static/app.css
2026-09-06 18:04:19 +00:00
thalladaandClaude Fable 5.1 87ff3d92c8 Let public visitors request a reader account
New public page GET/POST /request-access explains what a signed-in
reader account gets (complete issues online, EPUB/XTC downloads, no
ratings or admin tools) and takes an email plus an optional reason. A
honeypot field drops bots and a partial unique index keeps one open
request per email, updating it on resubmit. Open requests show on
/dashboard/users with a "Mark done" button and as a tile on the
overview; accounts are still created with the daily-epub users CLI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
2026-09-06 18:02:18 +00:00
thallada 8c821527e8 Merge branch 'fix-neighbours': any-article neighbours and wider enums 2026-09-06 17:52:47 +00:00
thalladaandClaude Fable 5.1 f0529d2d01 Show any-article nearest neighbours and widen the format/kind vocabularies
The article dashboard page gains a "Nearest articles (any)" table: the
ten closest stored embeddings by cosine, regardless of rating or run,
via a brute-force scan of article_embeddings. The deep-assessment format
facet grows from 5 to 14 values (code_repository, documentation_reference,
tool_or_product_page, discussion_thread, paper_or_report,
interview_or_transcript, video_or_podcast, fiction_or_humor, other) and
the triage kind from 10 to 16 (repo, docs, discussion, paper, media,
fiction), so a GitHub repository is no longer forced into analysis_essay.
Both prompt versions bump to 2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
2026-09-06 17:52:37 +00:00
thallada af8850e948 Merge branch 'fix-import': historical rating imports 2026-09-06 17:31:47 +00:00
thalladaandClaude Fable 5.1 d94cefcbd7 Import historical ratings from arbitrary URLs as a background job
The Ratings page gains an "Import ratings" form: paste URLs (one per
line), choose a verdict and an optional note. Rows land in the new
rating_imports table and the new import-ratings job (same systemd job
template as the rest of the catalogue) canonicalizes each URL, reuses or
fetches + extracts the article, embeds it with Voyage when enabled, and
appends an explicit rating event with source "import". Per-URL status
shows on the Ratings page; the job page's journal is the live log.

Imported articles have no entry row (best_entry_id NULL, feed "Imported")
and no sources, so they act as rated neighbours without touching the
feed prior. The CLI's rating-event construction moves to rate::record_explicit
and the dashboard job start path is shared as jobs::start_job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
2026-09-06 17:31:41 +00:00
thallada 7036fd347e Merge branch 'fix-ui': dashboard links and download menu 2026-09-06 17:18:31 +00:00
thalladaandClaude Fable 5.1 71665c293c Add admin dashboard links on article pages and a single download menu
Signed-in issue and article pages now carry an admin-only link to the
article's dashboard page. The separate Download EPUB / X4 EPUB / XTC
buttons become one "Download EPUB" button (Standard edition first) with a
no-JS <details> menu listing every available format and its size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
2026-09-06 17:18:23 +00:00
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