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
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
/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
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
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
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
- 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
- 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
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
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
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
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
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
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
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
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
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
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
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