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
This commit is contained in:
2026-09-03 20:01:50 +00:00
co-authored by Claude Fable 5.1
parent 9b1c137c80
commit 61ed37fc20
7 changed files with 826 additions and 0 deletions
+2
View File
@@ -3,3 +3,5 @@ target/
*.sqlite *.sqlite
out/ out/
.env .env
dev/
node_modules/
@@ -0,0 +1,188 @@
# Shared brief — web dashboard v2 (design pass + fixes)
The web site and operator dashboard planned in `docs/plans/2026-09-03-web-dashboard.md`
is implemented and deployed (see `docs/plans/briefs/web-dashboard/handoff-step*.md`).
This second pass, requested by the operator on 2026-09-03, does six things:
1. Replaces the hand-written `src/web/static/app.css` with a **Tailwind CSS v4**
setup and a fresh, more refined design that keeps the simple newspaper feel.
2. Adds a **light/dark theme** that follows the system by default and can be
overridden by the reader (persisted in `localStorage`).
3. **Public** issue pages now show each article's AI summary and its
"Why it's here" line (a deliberate reversal of plan §7.1's "no generated text";
article bodies, The Brief, the World Briefing and comments stay private).
4. Signed-in issue pages get a **table-of-contents sidebar** (chapters = articles)
that collapses into a hamburger menu on mobile and shows where the reader is.
5. Signed-in issue pages always offer the **World Briefing** and **Behind the paper**
chapters, including for issues published before `issues.issue_json` existed.
6. The **Colophon** on those legacy issues shows real numbers instead of zeros.
Items 56 have one root cause: `web::issue::load` falls back to a reduced
`Issue` (default `Colophon`, no `world_briefing`, default `BehindThePaper`,
`from_json == false`) whenever `issues.issue_json` is NULL — which is every
issue published before migration `0004_web.sql` ran (2026-09-03).
Each step below has its own brief (`01``04`). Read the original plan's §4, §7,
§8 and §16 and `docs/plans/2026-08-15-implementation-notes.md` for conventions:
runtime sqlx queries with manual row mapping, `jiff` timestamps, `thiserror`/`anyhow`
errors, askama 0.16 templates, no network in tests, rustfmt defaults, no `unwrap()`
outside tests.
## Ground rules
- You work in the git worktree named in your brief. **Never touch the main
checkout** at `/home/thallada/workspace/the-daily-epub`. Finish by committing
**one** commit on your worktree's branch (message prefix `Web dashboard v2 step N:`).
- Do not edit migrations. Do not change routes, auth, or the security headers
(`web::security_headers`) — in particular the CSP stays
`default-src 'self'; img-src * data:; style-src 'self'; script-src 'self'; …`,
so **no inline `<style>` or `<script>`, no `style=""` attributes, no CDN**.
Fonts and scripts are served from `/static/…` (`include_str!`/`include_bytes!`).
- Every `|safe` in a template must be one of the sanitized inputs listed in plan
§16 (plus, after step 1, the world chapter body recovered from our own EPUB).
- Keep every hook `src/web/static/app.js` relies on: `form.rating`,
`button[data-label]`, the `.active` class and `aria-pressed` on rating
buttons, `form[data-confirm]`, `details[id]`, `table[data-filter]`,
`.table-filter`, `button[data-reset]`/`data-default`, `[data-refresh]`,
`.scroll-x`. Keep all `id`, `name`, `action` and `aria-*` attributes that
handlers or tests use. Router tests assert on text and some class names; when
a test breaks only because a purely cosmetic class was renamed, update the test.
- Tests: unit tests inline; router tests with `tower::ServiceExt::oneshot`
against `server::router(...)` over a temp DB, never binding a socket.
- Keep `cargo fmt`, `cargo clippy --all-targets -- -D warnings` and `cargo test`
green. Finish with those three commands and report their output.
- **Sandbox note:** inside the Codex sandbox `bind()` on 127.0.0.1 is forbidden,
so exactly these pre-existing tests fail there and must be ignored:
`curate::llm::tests::anthropic_*` (4), `extract::tests::relative_urls_resolve_against_the_url_we_landed_on`,
`server::tests::*` (5 that bind a listener), and `tests/m7_server.rs`. Do not
"fix" them. The orchestrator runs the full suite outside the sandbox. If you
cannot run the dev server for the same reason, rely on router tests and on
rendering templates in tests; say so in the handoff.
- This host has 4 cores and 7 GB; another agent builds Rust at the same time.
Prefer `cargo test --lib web::` style narrow runs while iterating and one full
`cargo test` at the end.
- Don't gold-plate. Where this brief and the code disagree, follow the code and
note the deviation in your handoff.
- When you finish, write `docs/plans/briefs/web-dashboard-v2/handoff-step<N>.md`:
what landed, deviations and why, anything left for later, test counts, and
(for design steps) which pages you screenshotted.
## Previewing the site locally
`examples/seed_dev_db.rs` builds a throwaway database with two issues (No. 18 on
2026-09-01 is a *legacy* issue without `issue_json`; No. 19 on 2026-09-02 has the
full snapshot), nine articles across four sections, an `admin` and a `reader`:
```text
cargo run --example seed_dev_db -- ./dev # ./dev is git-ignored
DAILY_EPUB_SERVER__HMAC_SECRET=$(head -c 48 /dev/urandom | base64) \
cargo run -- --config ./dev/config.toml serve # http://127.0.0.1:3599
```
Accounts: `admin` / `adminpassword123` (dashboard, ratings), `reader` /
`readerpassword123` (full issues only). Anonymous visitors get the public pages.
Port 3499 belongs to the production server on this host — never use it.
Screenshots: a Playwright script that signs in, sets the theme and shoots a
list of paths lives at
`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/e963db53-510f-4312-ac87-460291a92781/scratchpad/shoot/shoot.mjs`
(`node shoot.mjs <outdir> [--user admin|reader|none] [--theme light|dark] [--mobile] [--full] /path …`,
`BASE=http://127.0.0.1:3599`). If that path is unreadable from your sandbox, the
bare headless Chromium at
`~/.cache/ms-playwright/chromium_headless_shell-1208/chrome-headless-shell-linux64/chrome-headless-shell --headless --no-sandbox --screenshot=out.png --window-size=1280,1400 URL`
works for anonymous pages. **Look at your screenshots** (the Read tool renders
PNGs) before calling a design step done.
## Design language (steps 2, 3 and 4 must follow this)
The look: a quiet, well-set morning paper. Warm off-white paper, near-black ink,
one red accent, hairline rules instead of boxes, generous whitespace, real
typographic hierarchy. Reader pages are serif; the dashboard is a sans-serif
instrument panel that shares the same paper, ink, rules and accent so the two
halves feel like one publication.
**Tailwind v4 conventions.** One source file `src/web/tailwind.css`:
`@import "tailwindcss"; @source "../web/templates"; @source "./static/app.js";`
(paths relative to the file — check they resolve), design tokens in `@theme`,
the dark variant as `@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));`.
Colours are **semantic CSS variables swapped per theme**, exposed to Tailwind
through `@theme inline`, so templates write `bg-paper text-ink border-rule`
once and both themes work without `dark:` on every element:
```css
:root { --paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#7a7568;
--rule:#d8d2c4; --rule-strong:#1c1b18; --accent:#a3231f; --accent-hover:#7c1a16;
--loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#8a6d1f; color-scheme:light; }
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { /* dark values */ } }
:root[data-theme="dark"] { --paper:#151513; --paper-2:#1e1d1a; --ink:#ebe6da; --ink-2:#bfb9aa; --muted:#8f8a7d;
--rule:#3a3833; --rule-strong:#ebe6da; --accent:#ef8c82; --accent-hover:#f7aaa2;
--loved:#7fcb92; --good:#7fb8d8; --down:#ea8f84; --warn:#d9b45a; color-scheme:dark; }
@theme inline { --color-paper: var(--paper); --color-paper-2: var(--paper-2); --color-ink: var(--ink); }
```
Put the dark values in **both** the media block and the attribute block (a
small `@mixin`-free duplication is fine). Tinted badge backgrounds use
`color-mix(in oklab, var(--loved) 14%, transparent)`.
**Type.** Reader pages: *Newsreader* (variable, optical size axis, self-hosted
from `src/web/static/fonts/Newsreader.woff2` + `Newsreader-italic.woff2`,
`font-display: swap`, fallback `"Iowan Old Style", "Palatino Linotype", Georgia, serif`,
`font-optical-sizing: auto`). Body `1.0625rem/1.65`, max measure `max-w-[68ch]`.
Headlines weight 600, `leading-[1.1]`, `tracking-[-0.01em]`: article h1
`text-4xl md:text-5xl`; index headlines `text-2xl`, lead story `text-3xl`.
UI chrome (nav, datelines, bylines, meta lines, labels, buttons, forms, the
whole dashboard): the system sans stack `font-sans` (`ui-sans-serif, system-ui,
-apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`), small
labels `text-[0.72rem] uppercase tracking-[0.12em] text-muted`. Code:
`ui-monospace, SFMono-Regular, Menlo, monospace`. Tabular numbers in tables.
**Masthead and chrome.** Centred masthead "The Daily EPUB" in Newsreader 600,
`text-5xl` (`text-3xl` on phones), `tracking-[-0.02em]`, a 1px rule above and a
3px double rule below. An "ears" row above it (sans, tiny): the dateline /
issue number on the left, the theme toggle and sign-in/account on the right.
Primary nav below the masthead as a rule-bounded row of uppercase tracked
links; the active link (`aria-current="page"`) carries a 2px accent underline.
The admin nav is a second, denser row in the same style. Footer: hairline rule,
tiny muted sans. The theme toggle is one button cycling *System → Light → Dark*
(icon + short label, `aria-label`), no dropdown.
**Reader pages.** Section headings are tracked uppercase sans labels sitting on
a rule, not big serif headings. The Brief gets a drop cap on its first paragraph
(`first-letter:` utilities, Newsreader 600, ~3 lines tall) — and nothing else
gets a drop cap. Each index entry: headline (link, no underline, hover accent),
`source · N min` meta in sans, the summary in serif at body size, then the
"Why it's here" line in italic with a 2px accent bar on its left (`border-l-2
border-accent pl-3`), then the rating control for admins. Entries are separated
by hairlines; the lead story is set larger. Article pages: h1 linking to the
source, byline/meta in sans, the summary as an italic standfirst, the body with
comfortable paragraph spacing, images `rounded-sm` with a caption-style alt
fallback, code blocks on `bg-paper-2`, blockquotes with a rule on the left,
`prev/next` as two bordered cards with "Previous"/"Next" labels. Download links
are `.btn` buttons. Links inside prose: ink text, `underline decoration-rule
underline-offset-4 hover:decoration-accent`.
**Controls.** `.btn` (sans `text-sm`, 1px `border-rule-strong`, `px-3 py-1.5`,
hover fills ink/paper, `focus-visible:ring-2 ring-accent ring-offset-2
ring-offset-paper`), `.btn-primary` filled ink. The rating widget is a
segmented control: buttons joined edge to edge, the active verdict filled ink
on paper, "clear" a muted text button. Inputs: `bg-paper border-rule rounded-sm
px-2.5 py-1.5 focus:border-ink`. Flash: `border-l-4 border-accent bg-paper-2`.
Badges: pill, tinted background + coloured text per state. Transitions 150ms on
colour only; honour `prefers-reduced-motion`.
**Dashboard.** `max-w-7xl`, sans throughout, a page header (title `text-2xl
font-semibold`, one-line description, actions on the right), stat tiles (big
tabular number, small label, optional muted delta), tables `text-sm` with sticky
`thead` on `bg-paper`, hairline row rules, `hover:bg-paper-2`, right-aligned
`tabular-nums` numerics, every table inside `.scroll-x`; filters as one inline
form of labelled controls; funnels and sparklines keep their SVG classes.
Settings keep their sticky save bar. Nothing boxed unless it is a card with a
purpose (stat tiles, job cards, settings groups).
**Layout components shared by reader and dashboard** live in
`@layer components` in `tailwind.css`: `.btn`, `.btn-primary`, `.badge` and its
state colours, `.kv` (definition-list grid), `.scroll-x`, `.prose-body`
(article/world/discussion body styling), base `table`/`th`/`td`, form controls.
Prefer utilities in templates; use a component class only when the same
composite repeats in three or more places or when server-rendered HTML we do
not control (article bodies, discussions, world chapter) needs styling.
@@ -0,0 +1,98 @@
# Step 1 — backend data: public summaries, legacy issue fallback
Worktree: `/home/thallada/workspace/the-daily-epub-v2a` (branch `v2-backend`).
Read `00-shared.md` first. This step is Rust plus tiny template edits; a design
step runs in parallel in another worktree and will restyle every template, so
keep your template changes minimal and semantic (no new CSS, no new classes
beyond what you need for tests).
## A. Public issue pages show summary and "why" (shared brief item 3)
- `src/web/public.rs`: add `summary: Option<String>` and `why: Option<String>`
to `PublicEntry`, filled by `PublicIssue::from(&Issue)` the same way the
signed-in index does (`web::issue::summary_for`-style: `pick.summary`, else
`editorial.summaries[article_id]`, trimmed, empty → `None`; `why = pick.why`).
- `issue_public.html`: after the byline, render `<p class="summary">…</p>` when
present and `<p class="why"><em>Why it's here: …</em></p>` when present.
`feed_entry.html` (the Atom `<content>`): include the summary and the why line
per entry too, so feed readers see the same public presentation.
- Tests: rewrite `public_issue_carries_no_generated_text` (and any sibling
asserting summaries/why are absent, including in the feed tests) into
`public_issue_shows_summaries_and_why_but_no_bodies`: summaries and why lines
present; The Brief text, every article body sentence, the World Briefing text
and every comment string still absent. Keep the existing assertions that
titles, authors, sources and comment links are present.
- Docs: in `docs/plans/2026-09-03-web-dashboard.md` §7.1 and §21 add a dated
note ("2026-09-03: the operator chose to publish summaries and why lines;
bodies/Brief/World/comments remain private"). Update `README.md` if it
describes the public page.
## B. Legacy issues: rebuild colophon, Behind the paper and the World Briefing (items 56)
Context: `web::issue::load` (src/web/issue.rs) takes the fallback branch when
`issues.issue_json` is NULL. Production has such issues (everything before
2026-09-03). What *does* exist for them:
- `issues.report_json` (column since `0001_init.sql`) — the run's
`report::RunReport` (counts, `provider_costs`, `cost_usd`, `voyage_cost_usd`,
`config_json` from `pipeline::resolved_run_config`, `started_at`/`finished_at`,
timings). Verify which model names `resolved_run_config` records and read
them from there; if it lacks a name, fall back to the current `Config`
(`state.config`) the way `pipeline.rs` builds `Models` (look at how
`curator.llms.bulk/editor` and `summary_model` are resolved) and say so in a
`generator_version`-style note only if unavoidable.
- `runs` row(s) for the date (`entries_fetched`, `candidates`, `selected`,
tokens, `cost_usd`, `status`, `provider_costs_json`, `config_json`) — use the
latest finished run for the date when `issues.report_json` is NULL.
- `candidate_runs` rows for that run (curation v2) — `curate::telemetry::paper_near_misses(db, run_id, 10)`
gives the near misses exactly as the pipeline does (`pipeline::behind_the_paper`).
- The EPUB on disk (`issues.epub_path`, else `publish::issue_filename(date, Edition::Standard, "epub")`
under `config.publish.epub_dir`) contains `OEBPS/world.xhtml` when the issue
had a World Briefing, and `OEBPS/colophon.xhtml` / `OEBPS/behind.xhtml`.
Implement, in the fallback branch of `load` (or a helper module
`src/web/legacy.rs` called from it):
1. **Colophon**: `Colophon { provider_costs, models, entries_fetched, feeds_seen,
candidates, cost_usd, generator_version }` from the report (or run row). Where
a number is genuinely unknown leave 0 but make the template say "n/a" rather
than "0 from 0 feeds" — add `Option`s to `ColophonView` where needed and
render "n/a". `generated_at` already comes from the `issues` row.
2. **Behind the paper**: `BehindThePaper` built like `pipeline::behind_the_paper`
from the report counts + `paper_near_misses` + models + total cost +
`generation_secs` from `started_at`/`finished_at`. Set `has_behind` in
`issue_full` to "a report or run exists", not `from_json`, and make
`GET /issues/{date}/behind` render for legacy issues.
3. **World Briefing**: when `world_briefing` is `None` and the EPUB exists, open
it with the `zip` crate (already a dependency), read `OEBPS/world.xhtml`, and
take the inner HTML of `<body>` minus the chapter's own leading `<h1>` (the
template supplies the heading). Store it on `IssueView` as
`world_html: Option<String>` (or an enum `WorldSource { Briefing(WorldBriefing), Recovered(String) }`)
and have the `world` handler render either `world::render_xhtml(&briefing)` or
the recovered body `|safe` — it is our own sanitized XHTML, note that in the
§16 list in the plan. Set `has_world` accordingly. Missing/corrupt EPUB → no
world chapter, never an error (log at debug).
4. Keep the JSON branch untouched. Do not backfill `issue_json` in the database.
Also make the dev seed mirror production: in `examples/seed_dev_db.rs` the
legacy issue (2026-09-01) must end with `issues.issue_json = NULL`, an
`issues.report_json` present, and — so the world fallback can be exercised — a
real EPUB for it written into `dev/epubs/` with the fixture World Briefing
(`epub::build_edition`/`build_all` or whatever the builder's public entry is;
read `src/epub/mod.rs`) and its path stored in `issues.epub_path`. Add a few
`candidate_runs` rows for the legacy run via `db` helpers or SQL so near
misses render (look at `web::dashboard::tests::seed()` for the shape of
`signals_json`).
Tests (router tests over a temp DB; see the existing `seeded_issue(false)`
harness in `src/web/issue.rs`): fallback colophon shows the report's counts and
models and no "0 from 0 feeds"; `/issues/{date}/behind` renders for the legacy
issue with the near misses; `/issues/{date}/world` renders the recovered
chapter from a small EPUB built in the test with `epub::build` on the fixture
issue; `/issues/{date}` links to both chapters; `PublicIssue` never gains a
body/brief/world field (extend the existing negative test).
## Handoff
`docs/plans/briefs/web-dashboard-v2/handoff-step1.md`. Commit once on
`v2-backend`: `Web dashboard v2 step 1: public summaries, legacy issue fallback`.
@@ -0,0 +1,100 @@
# Step 2 — Tailwind v4 design system, theme toggle, reader pages
Worktree: `/home/thallada/workspace/the-daily-epub-v2b` (branch `v2-frontend`).
Read `00-shared.md` first, especially the design language — it is the spec.
You own the design system and the **reader-facing** templates. Do not restyle
`src/web/templates/dashboard/*` (step 3 does that, on top of your system); they
may look plain until then, which is expected. Do not implement the sidebar
table of contents (step 4).
## 1. Tailwind toolchain (no Node at build or run time)
- `package.json` at the repo root with `devDependencies`
`tailwindcss` and `@tailwindcss/cli` pinned to `4.3.3`, scripts
`css` (`tailwindcss -i src/web/tailwind.css -o src/web/static/app.css --minify`),
`css:watch`, and `css:check` (build to a temp file and `diff` against the
committed `app.css`, non-zero on drift). `package-lock.json` committed;
`node_modules/` and `dev/` added to `.gitignore`.
- The compiled `src/web/static/app.css` is **committed** and embedded with
`include_str!` exactly as today, so `cargo build` and the server never need
Node. Document the workflow (edit templates/`tailwind.css``npm run css`
commit both) in `README.md` and in `docs/runbooks/web-dashboard-rollout.md`.
- Fonts: `src/web/static/fonts/Newsreader.woff2`, `Newsreader-italic.woff2` and
`OFL.txt` are already in your worktree. Serve them from
`web::static_asset` (`include_bytes!`, `font/woff2`, same ETag/304 logic,
long cache) — extend the route so `/static/fonts/Newsreader.woff2` or
`/static/Newsreader.woff2` works (pick one, keep `safe` matching: a fixed
match arm per file, no path joins). `@font-face` in `tailwind.css` with
`font-weight: 200 800; font-stretch: normal; font-display: swap;` and the
`unicode-range` for latin from Google's CSS
(`U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD`).
Add a `<link rel="preload" as="font" type="font/woff2" crossorigin>` for the
regular face in the layout.
## 2. Theme toggle (item 2)
- `src/web/static/theme.js` (new, served like `app.js`, `include_str!`): a
handful of lines run **synchronously in `<head>`** before paint: read
`localStorage.theme` (`"light"`/`"dark"`, anything else = system), set
`document.documentElement.dataset.theme` accordingly (remove the attribute for
system). Wrap storage access in try/catch. No inline script (CSP).
- The toggle button in the layout's "ears" row: `<button type="button"
data-theme-toggle aria-label="Theme: system">` with an inline SVG icon (sun /
moon / auto) and a short visible label. `app.js` handles clicks: cycle
system → light → dark → system, persist (`removeItem` for system), update the
attribute, icon and label, and re-evaluate on `matchMedia("(prefers-color-scheme: dark)")`
changes while in system mode. The three icons can all be in the markup with
`hidden` toggled — remember `[hidden]` must win over any `display` utility.
- Everything themed goes through the semantic tokens; `color-scheme` follows
the theme so native controls and scrollbars match. Check both themes in
screenshots: contrast of muted text on paper, accent on dark paper, rating
buttons, flash, focus rings, images in dark mode.
## 3. Design system + layout
`src/web/tailwind.css` per the shared brief (tokens, dark variant, fonts,
`@layer base` for body/typography defaults, `@layer components` for `.btn`,
`.btn-primary`, `.badge` + states (`selected loved good down excluded
shortlisted admitted eligible cleared assessed triaged reason running requested
ok degraded failed dry_run`), `.kv`, `.scroll-x`, `.prose-body`, base tables
and form controls — step 3 will consume these). Rewrite `layout.html`: ears
row, masthead, primary nav with `aria-current="page"` derived from
`page.active_nav` (check what values handlers set), the admin nav row, flash,
`<main>`, footer, scripts. Add an accessible "skip to content" link.
## 4. Reader templates (restyle in place; keep every handler field)
`issue_public.html`, `issue_full.html`, `issue_list.html`, `article.html`,
`world.html`, `behind.html`, `login.html`, `account.html`, `error.html`,
`_rating_widget.html`. The public and full issue pages are the showcase — set
them like a front page: dateline, stats line, The Brief with its drop cap,
download buttons, sections as tracked labels on rules, entries per the spec,
lead story larger, the chapter links (World Briefing · Behind the paper) as a
centred rule-bounded row, the colophon as a two-column `.kv` in small sans.
Note: the backend step adds `summary`/`why` to the public entries and it will
merge after you; leave a clear place for them in `issue_public.html` (the same
summary/why treatment as the full issue) guarded with `{% if let Some(...) %}`
**only if the field exists in your tree** — otherwise just leave a comment
`{# step 1 adds summary/why here #}` so the merge is trivial.
Article page: h1 → source link, byline/meta, "Why it's here" callout, social
line, the summary as standfirst, the body in `.prose-body`, discussion with
nested comment styling (`blockquote.reply` indent, `.comment-meta` small
sans), rating control, "Read online ↗" button, prev/next cards, back link.
Login/account: a narrow centred card-free form, labelled inputs, primary button.
Archive: months as tracked labels, issues as a hairline list with date, number,
article count.
Phones (390px): masthead scales down, nav wraps as a centred row, everything
single-column, tap targets ≥ 40px, tables scroll inside `.scroll-x`.
## 5. Verify
Run the seeded dev server (shared brief), screenshot `/`, `/issues`,
`/issues/2026-09-02`, `/issues/2026-09-02/articles/1`, `/issues/2026-09-02/world`,
`/issues/2026-09-02/behind`, `/login`, `/account` as anonymous, `reader` and
`admin`, in light and dark, desktop and mobile, and **look at them**. Fix what
looks off. Make sure `npm run css:check` passes on the committed CSS, `cargo
fmt`, clippy `-D warnings` and `cargo test` are green (minus the sandbox-only
failures), then write `handoff-step2.md` and commit once on `v2-frontend`:
`Web dashboard v2 step 2: Tailwind design system, theme toggle, reader pages`.
@@ -0,0 +1,44 @@
# Step 3 — dashboard restyle on the Tailwind system
Worktree and branch: given in your launch prompt. Read `00-shared.md`
(design language, "Dashboard" and "Controls" paragraphs) and
`handoff-step2.md` (what the design system provides: tokens, `.btn`, `.badge`,
`.kv`, `.scroll-x`, `.prose-body`, table and form defaults in
`src/web/tailwind.css`). Step 2 deliberately left `src/web/templates/dashboard/*`
unstyled; you restyle every one of them with Tailwind utilities plus those
components, then rebuild `src/web/static/app.css` (`npm run css`) and commit it.
Templates: `overview`, `runs`, `run`, `articles`, `article`, `ratings`,
`profile`, `stats`, `settings`, `settings_history`, `jobs`, `job`, `users`,
`_pager`, `_sparkline`, and the shared partials `_candidate_row.html`,
`_signals_table.html`, `_pagination.html` (the reader-side `_rating_widget.html`
is styled already; the dashboard variant with the note field must look right
inside tables and on the article page).
Rules of the road:
- Keep every handler field, form `action`/`name`/`id`, `data-*` hook and the
SVG classes the sparkline/funnel code emits (`.spark`, `.s0``.s5`, `.line`,
`.funnel`). Read `src/web/static/app.js` before touching anything it queries.
- Old CSS class names that tests assert on (grep `tests/` and the `#[cfg(test)]`
modules under `src/web/dashboard/` for `class=` and for names like `badge`,
`num`, `muted`, `card`, `superseded`) may stay as harmless extra classes or be
updated together with the test — never leave a test asserting a class the
template no longer emits.
- Page header pattern on every page: title, one-line description, actions
(buttons/links) right-aligned, then filters, then content. Stat tiles on the
overview and stats pages. Tables per the shared brief; long text cells
(`message`, `editor_why`) wrap with `overflow-wrap:anywhere`; numeric columns
right-aligned `tabular-nums`; sticky `thead` on `bg-paper`.
- Settings: grouped cards with monospace section keys, a two-column setting row
(label + help left, control right) collapsing on phones, locked (env-overridden)
fields visibly disabled with a small lock note, the sticky save bar, danger
actions in `text-down`. Profile: editor/preview split that stacks on phones.
Jobs: cards with a status badge, last run, and the start form; the job page's
journal in a scrolling monospace block. Users: a simple table.
- Both themes, desktop and mobile (390px), every page: screenshot as `admin`
with the seeded dev server and **look** before finishing.
Finish: `npm run css` + `npm run css:check`, `cargo fmt`, clippy `-D warnings`,
`cargo test`; write `handoff-step3.md`; commit once:
`Web dashboard v2 step 3: dashboard restyle`.
@@ -0,0 +1,69 @@
# Step 4 — table-of-contents sidebar for signed-in issue pages
Worktree and branch: given in your launch prompt. Read `00-shared.md`,
`handoff-step1.md` (legacy world/behind availability) and `handoff-step2.md`
(design system). Item 4 of the shared brief: on every signed-in issue page —
`/issues/{date}`, `/issues/{date}/articles/{id}`, `/issues/{date}/world`,
`/issues/{date}/behind` — a sidebar lists the issue's chapters, marks where the
reader is, and lets them jump anywhere. Public pages get nothing.
## Data (Rust, `src/web/issue.rs`)
One helper `issue_toc(&IssueView, current: TocPosition) -> Toc` used by all
four handlers, passed to their templates as `toc`:
```rust
struct Toc { date: Date, display_date: String, issue_number: i64,
items: Vec<TocItem>, position: usize, total: usize, issue_href: String }
enum TocKind { Brief, Section, Chapter, World, Behind, Colophon }
struct TocItem { kind: TocKind, label: String, href: String, number: Option<usize>,
minutes: Option<i64>, current: bool }
```
Chapters are the picks in issue order (numbered 1..n across sections, section
labels interleaved as non-links), followed by World Briefing and Behind the
paper when available (`has_world`/`has_behind` as computed after step 1) and a
Colophon anchor (`/issues/{date}#colophon`). `position`/`total` count
navigable chapters only (articles + world + behind), so the article page can say
"Chapter 4 of 11"; on the issue page position is 0 ("Front page").
## Markup and behaviour
- Desktop (`lg:` and up): a two-column grid, `aside` on the left `w-64 xl:w-72`,
`sticky top-4`, `max-h-[calc(100vh-2rem)] overflow-y-auto`, thin scrollbar.
Header: issue number + date (link to the issue), a progress line "Chapter 4
of 11" and a 2px progress bar (width = position/total). Then the list:
section labels as tracked uppercase sans, chapters as numbered links in
Newsreader `text-[0.95rem]` with the read-time in muted sans, the current one
bold with a 2px accent bar on the left and `aria-current="page"`; World
Briefing / Behind the paper / Colophon after a hairline.
- Mobile (below `lg:`): the aside becomes a sticky bar under the site header:
a hamburger `<button type="button" aria-controls="toc" aria-expanded="false"
aria-label="Contents">` with the current chapter title truncated beside it and
"4 / 11" on the right. Tapping toggles a panel (`id="toc"`, `hidden`) that
drops down under the bar with the same list; close on link tap, on Escape,
and on tap outside. Body scroll stays enabled. Implement in `app.js` (no
inline handlers), progressive: without JS the panel is simply open below the
bar. Use the same `<nav>` markup for both breakpoints (one element, styled
responsively), not two copies.
- On article pages `app.js` also updates the progress bar with scroll progress
within the chapter (position-1 + scrolled fraction over total), throttled with
`requestAnimationFrame`, and honours `prefers-reduced-motion` (no transition).
- The reading column keeps its `max-w-[68ch]`; the grid must not push it
narrower than ~60ch at `lg`. At `xl` the aside gets its full width.
- Sidebar links to the current article do nothing surprising (`href` still
set). Keyboard: Tab order is bar → panel → content; the toggle is reachable.
## Tests
Router tests: the toc appears for `reader` on all four pages with the right
`aria-current` item and "Chapter N of M"; it is absent on public pages and in
the anonymous issue page; the legacy issue (no `issue_json`) lists World
Briefing and Behind the paper when step 1's fallback finds them and omits
World when there is no EPUB. Unit test for `issue_toc` numbering across
sections.
Finish: rebuild CSS (`npm run css` + `css:check`), `cargo fmt`, clippy
`-D warnings`, `cargo test`; screenshots of the four pages at desktop and
mobile in both themes (panel closed and open); `handoff-step4.md`; one commit:
`Web dashboard v2 step 4: table-of-contents sidebar`.
+325
View File
@@ -0,0 +1,325 @@
//! Seed a throwaway development database so the web site can be browsed
//! locally with realistic data — two issues (one with the full `issue_json`
//! snapshot, one "legacy" issue without it), their runs, an admin and a
//! reader account. For design work and manual smoke tests only; never run it
//! against the production database.
//!
//! ```text
//! cargo run --example seed_dev_db -- ./dev
//! DAILY_EPUB_SERVER__HMAC_SECRET=$(head -c 48 /dev/urandom | base64) \
//! cargo run -- --config ./dev/config.toml serve
//! ```
//!
//! Then open http://127.0.0.1:3599 and sign in as `admin` / `adminpassword123`
//! or `reader` / `readerpassword123`.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use daily_epub::db::Db;
use daily_epub::epub::fixtures;
use daily_epub::report::{RunReport, RunStatus};
use daily_epub::types::{Article, Entry, Issue, Pick};
use daily_epub::web::users;
use jiff::Timestamp;
use jiff::civil::Date;
const ADMIN_PASSWORD: &str = "adminpassword123";
const READER_PASSWORD: &str = "readerpassword123";
/// (section, title, feed, summary, why, words)
const STORIES: &[(&str, &str, &str, &str, &str, i64)] = &[
(
"Top Stories",
"How a Forty-Year-Old Filesystem Quietly Rewrote Its Write Path",
"Systems Weekly",
"A long, careful account of the redesign, with the benchmarks that justified it and the two regressions that nearly sank it.",
"The systems story with enough operational detail to matter to you",
3100,
),
(
"Top Stories",
"The Case Against Feature Flags",
"Alice on Software",
"Argues that flags outlive their purpose and proposes a retirement discipline, with examples from three codebases.",
"A contrarian take on tooling you use every day",
1850,
),
(
"Top Stories",
"What the Latest Battery Chemistry Actually Changes",
"Ars Technica",
"Separates the press-release claims from the measurable improvements in energy density and cycle life.",
"You keep an eye on energy storage; this one is unusually sober",
2200,
),
(
"Deep Reads",
"A Field Guide to Distributed Consensus, Told Through One Outage",
"The Morning Paper",
"Walks through a real incident to explain leader election, log replication and why the fix was a config change.",
"Distributed systems explained with an actual outage rather than diagrams",
4200,
),
(
"Deep Reads",
"Why Every Map Is a Lie & How Cartographers Choose Which One to Tell",
"Longreads",
"A history of projections and the politics behind them, from Mercator to the maps in your phone.",
"Long-form nonfiction outside the technical orbit you asked for",
5100,
),
(
"Tools & Craft",
"Notes on Writing a Rust Linter That People Actually Enable",
"Rust Blog",
"Design notes on false-positive budgets, fix suggestions and the social side of shipping a lint.",
"Rust tooling with a practical bent",
1600,
),
(
"Tools & Craft",
"The Terminal Is the Best UI We Have and It Is Getting Better",
"Julia's Notebook",
"A tour of modern terminal features (hyperlinks, images, synchronized output) and which tools use them.",
"Terminal ergonomics, one of your recurring interests",
1300,
),
(
"Niche Corner",
"A Small-Town Bakery's Sourdough Starter Turns One Hundred",
"Saveur",
"A charming profile of a starter kept alive across four generations and the bread it still makes.",
"A small-scene delight outside the usual technical orbit",
900,
),
(
"Niche Corner",
"Inside the Community Keeping 1990s Synthesizers Alive",
"Sound on Sound",
"Repair collectives, replacement parts and the odd economics of vintage gear.",
"Music hardware, for the weekend",
1700,
),
];
fn story_article(index: usize, story: &(&str, &str, &str, &str, &str, i64)) -> Article {
let id = index as i64 + 1;
let entry_id = 1000 + id;
let (_, title, feed, _, _, words) = *story;
let mut article = fixtures::article(id, entry_id, title);
article.feed_title = feed.to_string();
article.feed_id = 7 + index as i64;
article.word_count = words;
for source in &mut article.sources {
source.feed_title = feed.to_string();
source.feed_id = article.feed_id;
}
article.content_html = body_html(title, words);
if index % 3 != 0 {
article.social.clear();
}
if index == 4 {
article.author = None;
}
article
}
fn body_html(title: &str, words: i64) -> String {
let paragraphs = (words / 120).clamp(4, 40);
let mut html = format!("<p>Body of <em>{title}</em>. ");
html.push_str("This paragraph exists so the article page has something to lay out: a first sentence with a claim, a second with the evidence, and a third that hedges just enough to be honest.</p>");
html.push_str("<h2>Where it gets interesting</h2>");
for index in 0..paragraphs {
if index == 2 {
html.push_str("<img src=\"https://picsum.photos/seed/daily-epub/900/500\" alt=\"A chart of the daily figures\">");
}
if index == 5 {
html.push_str("<blockquote><p>A pull quote that the author would rather you remembered than the rest.</p></blockquote>");
}
if index == 7 {
html.push_str(
"<pre><code>fn main() {\n println!(\"hello, reader\");\n}</code></pre>",
);
}
html.push_str("<p>Paragraph ");
html.push_str(&(index + 1).to_string());
html.push_str(" keeps the argument moving. Long sentences alternate with short ones. Then a list of considerations that the author says nobody weighs properly, followed by the observation that everyone weighs them and simply disagrees about the weights.</p>");
}
html.push_str("<ul><li>First consideration</li><li>Second consideration</li><li>A third, longer consideration that wraps onto another line on narrow screens</li></ul>");
html
}
fn dev_issue(date: Date, issue_number: i64, generated_at: Timestamp) -> Issue {
let mut issue = fixtures::issue();
issue.meta.date = date;
issue.meta.issue_number = issue_number;
issue.meta.generated_at = generated_at;
issue.meta.display_date = date.strftime("%A, %B %-d, %Y").to_string();
issue.lineup.date = date;
let mut section_order = Vec::new();
let mut picks = Vec::new();
let mut summaries = BTreeMap::new();
for (index, story) in STORIES.iter().enumerate() {
let (section, _, _, summary, why, _) = *story;
if !section_order.iter().any(|s| s == section) {
section_order.push(section.to_string());
}
let position = picks
.iter()
.filter(|pick: &&Pick| pick.section == section)
.count() as i64;
let article = story_article(index, story);
summaries.insert(article.id, summary.to_string());
picks.push(Pick {
discussion: (index == 0)
.then(|| fixtures::discussion(article.id, article.best_entry_id)),
article,
section: section.to_string(),
position,
is_lead: index == 0,
why: Some(why.to_string()),
summary: Some(summary.to_string()),
llm: None,
});
}
issue.meta.article_count = picks.len() as i64;
issue.meta.section_count = section_order.len() as i64;
issue.meta.total_words = picks.iter().map(|pick| pick.article.word_count).sum();
issue.meta.reading_minutes = daily_epub::types::reading_minutes(issue.meta.total_words);
issue.lineup.picks = picks;
issue.lineup.section_order = section_order;
issue.editorial.summaries = summaries;
issue.editorial.front_page_html = "<p>Nine stories today. The filesystem rewrite leads because it is the rare systems piece that shows its benchmarks and its mistakes; the consensus outage walkthrough is the long read to save for the train.</p><p>Two contrarian pieces on tooling sit in the middle, and the back pages are for the weekend: a hundred-year-old sourdough starter and the people who keep old synthesizers humming.</p>".into();
if let Some(world) = issue.world_briefing.as_mut() {
world.date = date;
}
issue.behind.selected = issue.meta.article_count;
issue
}
async fn seed_issue(db: &Db, issue: &mut Issue, with_snapshot: bool) -> anyhow::Result<()> {
for pick in &mut issue.lineup.picks {
let article = &pick.article;
db.upsert_entry(&Entry {
id: article.best_entry_id,
feed_id: article.feed_id,
feed_title: Some(article.feed_title.clone()),
category: article.category.clone(),
title: article.title.clone(),
url: article.url.clone(),
canonical_url: Some(article.canonical_url.clone()),
author: article.author.clone(),
published_at: article.published_at,
comments_url: article.comments_url.clone(),
raw_content: article.content_html.clone(),
fetched_at: article.first_seen,
})
.await?;
let id = db.upsert_article(article).await?;
pick.article.id = id;
for social in &mut pick.article.social {
social.article_id = id;
db.upsert_social(social).await?;
}
}
let snapshot = with_snapshot
.then(|| {
let mut snapshot = issue.clone();
for pick in &mut snapshot.lineup.picks {
pick.article.content_html.clear();
}
serde_json::to_string(&snapshot)
})
.transpose()?;
let run_id = db
.start_run(issue.meta.date, issue.meta.generated_at)
.await?;
let mut report = RunReport::new(issue.meta.date, issue.meta.generated_at);
report.finished_at = Some(issue.meta.generated_at);
report.status = RunStatus::Ok;
report.counts.entries_fetched = issue.colophon.entries_fetched;
report.counts.feeds_seen = issue.colophon.feeds_seen;
report.counts.articles = issue.behind.considered;
report.counts.eligible = issue.behind.eligible;
report.counts.triaged = issue.behind.triaged;
report.counts.assessed = issue.behind.read_closely;
report.counts.shortlisted = issue.behind.shortlisted;
report.counts.candidates = issue.colophon.candidates;
report.counts.selected = issue.meta.article_count;
report.counts.admitted_by = issue.behind.admitted_by.clone();
report.counts.rated_with_embeddings = issue.behind.rated_with_embeddings;
report.counts.knn_gate = issue.behind.knn_gate;
report.counts.feed_gate = issue.behind.feed_gate;
report.counts.embedded = 300;
report.cost_usd = issue.colophon.cost_usd;
report.config_json = serde_json::json!({
"llm": {"bulk": {"model": issue.colophon.models.bulk}, "editor": {"model": issue.colophon.models.editor}},
"voyage": {"model": "voyage-4"},
});
db.finish_run(run_id, &report).await?;
db.upsert_issue(
issue.meta.date,
issue.meta.issue_number,
issue.meta.generated_at,
None,
None,
None,
Some(&issue.editorial.front_page_html),
Some(&serde_json::to_string(&report)?),
snapshot.as_deref(),
)
.await?;
db.replace_issue_articles(issue.meta.date, &issue.lineup.picks)
.await?;
Ok(())
}
fn write_config(dir: &Path) -> anyhow::Result<PathBuf> {
let path = dir.join("config.toml");
let dir = dir.canonicalize()?;
let toml = format!(
"database_path = \"{db}\"\n\n[publish]\nepub_dir = \"{epubs}\"\nxtc_dir = \"{xtc}\"\n\n[server]\nbind = \"127.0.0.1:3599\"\npublic_url = \"http://127.0.0.1:3599\"\njobs_enabled = false\n",
db = dir.join("daily-epub.db").display(),
epubs = dir.join("epubs").display(),
xtc = dir.join("xtc").display(),
);
std::fs::write(&path, toml)?;
std::fs::create_dir_all(dir.join("epubs"))?;
std::fs::create_dir_all(dir.join("xtc"))?;
Ok(path)
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let dir = std::env::args()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("dev"));
std::fs::create_dir_all(&dir)?;
let db_path = dir.join("daily-epub.db");
if db_path.exists() {
anyhow::bail!("{} already exists; delete it first", db_path.display());
}
let config_path = write_config(&dir)?;
let db = Db::open_and_migrate(&db_path).await?;
users::add(&db, "admin", ADMIN_PASSWORD, true).await?;
users::add(&db, "reader", READER_PASSWORD, false).await?;
// Legacy issue: published before `issue_json` existed, so the site must
// rebuild the colophon and back matter from the run instead.
let mut legacy = dev_issue("2026-09-01".parse()?, 18, "2026-09-01T05:31:00Z".parse()?);
seed_issue(&db, &mut legacy, false).await?;
let mut latest = dev_issue("2026-09-02".parse()?, 19, "2026-09-02T05:29:00Z".parse()?);
seed_issue(&db, &mut latest, true).await?;
println!("seeded {}", db_path.display());
println!(
"run: DAILY_EPUB_SERVER__HMAC_SECRET=$(head -c 48 /dev/urandom | base64) cargo run -- --config {} serve",
config_path.display()
);
println!("open: http://127.0.0.1:3599 (admin / {ADMIN_PASSWORD}, reader / {READER_PASSWORD})");
Ok(())
}