Compare commits

..
10 Commits
Author SHA1 Message Date
thalladaandClaude Fable 5.1 78e7e4aa2c Static assets: bust caches with a content hash instead of the crate version
/static/app.css, app.js and theme.js URLs now carry the first 12 hex digits
of a SHA-256 over the embedded assets, so any CSS or JS change reaches
browsers that cached the previous build without a version bump.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 20:28:16 +00:00
thalladaandClaude Fable 5.1 61ed37fc20 Web dashboard v2: dev seed example and agent briefs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
2026-09-03 20:01:50 +00:00
63 changed files with 3864 additions and 545 deletions

No files matched your search

+2
View File
@@ -3,3 +3,5 @@ target/
*.sqlite
out/
.env
dev/
node_modules/
Generated
+1 -1
View File
@@ -884,7 +884,7 @@ dependencies = [
[[package]]
name = "daily-epub"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"ammonia",
"anyhow",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "daily-epub"
version = "0.2.0"
version = "0.3.0"
edition = "2024"
[dependencies]
@@ -59,10 +59,10 @@ tower_governor = { version = "0.8.0", features = ["axum"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
url = { version = "2.5.8", features = ["serde"] }
zip = { version = "6", default-features = false, features = ["deflate"] }
[dev-dependencies]
figment = { version = "0.10.19", features = ["test", "toml", "env"] }
roxmltree = "0.21.1"
tempfile = "3.27.0"
tower = { version = "0.5.3", features = ["util"] }
zip = { version = "6", default-features = false, features = ["deflate"] }
+10 -2
View File
@@ -111,6 +111,13 @@ cargo test # everything is offline; no keys needed
sudo install -m0755 target/release/daily-epub /usr/local/bin/
```
The web UI uses Tailwind CSS v4, but Node is only a development dependency: the
compiled stylesheet is committed and embedded in the Rust binary. After editing
`src/web/tailwind.css`, a web template, or `src/web/static/app.js`, run
`npm install` once and then `npm run css`. Commit both the source changes and
`src/web/static/app.css`; `npm run css:check` verifies that the committed output
is current.
### Commands
```
@@ -189,8 +196,9 @@ the database or takes the lock, so it is safe to run next to a live `generate`.
## Web site and dashboard
The server is both the public newspaper index and the private operator UI. An
anonymous visitor sees only titles, authors, sources, metadata and outbound
comment links; generated and scraped text stays private. A signed-in `user`
anonymous visitor sees titles, authors, sources, metadata, AI summaries, why
lines and outbound comment links; article bodies, the Brief, the World Briefing
and comments stay private. A signed-in `user`
sees complete issues and article chapters and can download artifacts. An
`admin` can additionally rate articles and use every `/dashboard/*` page,
including settings and jobs. Personalization is shared across accounts for now.
+13 -9
View File
@@ -19,7 +19,7 @@ These are settled. Do not reopen them during implementation.
| Frontend | Server-rendered **askama** templates plus one hand-written CSS file and a few hundred lines of plain JavaScript. No node build, no SPA, no framework; assets are embedded in the binary with `include_str!`. |
| Auth | Username + password, sessions in a SQLite table, one `HttpOnly; Secure; SameSite=Lax` cookie. Users are created from the CLI (`daily-epub users add`). No third-party identity, no passkeys. **Use the ecosystem, not hand-rolled code**: `axum-login` over `tower-sessions` for login/logout/session lifecycle and route guards, `password-auth` for argon2 hashing, `tower_governor` for the login throttle. axum-login is taken from **git at the pinned rev `151c72d7a1b4646830f86b4332e6bd6e34d719a7`** (`main`, 2026-05-07: tower-sessions 0.15 and the finalized `Require` API), not from the 0.18.0 crates.io release; §2 records why and what was verified. The only auth code we write is a ~60-line `SessionStore` over our own sqlx pool (the official store pins sqlx 0.8; we are on 0.9) and the `AuthnBackend` glue (§6). |
| Roles | `user` and `admin`. **Signed-in users of either role** see every issue in full HTML (Brief, summaries, article bodies, discussions, World Briefing, Behind the paper) and can download the EPUB/XTC files. **Admins** additionally rate, browse the dashboard, edit settings and the profile, and start jobs. |
| Copyright boundary | Anonymous visitors never see generated or scraped text: no Brief, no summaries, no `why` lines, no article bodies, no comments, no World Briefing. Sections, reading time and word count are fine. The public renderer takes a dedicated `PublicIssue` type that cannot carry the private fields. |
| Copyright boundary | Anonymous visitors see titles, metadata, AI summaries and `why` lines, but never the Brief, article bodies, comments or World Briefing. The public renderer takes a dedicated `PublicIssue` type that cannot carry those private fields. |
| Personalization | One shared algorithm for now. `rating_events` gains a nullable `user_id` so a per-user algorithm is possible later; nothing else is per user. HMAC links from the EPUB stay unattributed (`user_id NULL`, `source = 'epub'`). |
| Settings | Every key of `config.toml`, grouped by table, editable from the UI and written back **in place with comments preserved** (`toml_edit`). A key overridden by a `DAILY_EPUB_*` environment variable is shown locked. API keys and the HMAC secret are shown as present/absent only. The page re-reads the file on every view, so hand edits show up. `data/profile.md` gets an editor with version history. |
| Actions | A Jobs page starts pipeline work through **systemd**: `systemctl start daily-epub-job@<name>.service`, permitted by a polkit rule for the `daily-epub` user. The server never runs the pipeline in-process (its unit has `MemoryDenyWriteExecute=yes`, which the XTC converter's Node JIT cannot live with). |
@@ -438,12 +438,15 @@ pub struct PublicEntry {
pub source: String, // feed_title
pub domain: String, // host of `url`, "www." stripped
pub reading_minutes: i64, pub word_count: i64,
pub summary: Option<String>, pub why: Option<String>,
pub comment_links: Vec<CommentLink>, // {label, url, meta}
pub is_lead: bool,
}
```
`PublicIssue::from(&Issue)` is the only constructor, and it copies exactly these fields. There is deliberately no `summary`, `why`, `body`, `brief`, or `world` field, so the template cannot render them. Test `public_issue_carries_no_generated_text` (§17) renders the fixture issue publicly and asserts that the Brief, every summary, every `why`, every article body sentence and every comment string are absent from the HTML.
`PublicIssue::from(&Issue)` is the only constructor, and it copies exactly these fields. There is deliberately no `body`, `brief`, or `world` field, so the template cannot render them. Test `public_issue_shows_summaries_and_why_but_no_bodies` (§17) renders the fixture issue publicly and asserts that summaries and `why` lines are present while the Brief, every article body sentence and every comment string are absent from the HTML.
**2026-09-03:** the operator chose to publish summaries and why lines; bodies/Brief/World/comments remain private.
`comment_links`: one per `SocialRef` with an `item_url` — label `Hacker News` / `Lobsters` / `Reddit`, meta `"342 points · 210 comments"` — plus `Comments` for `entries.comments_url` when it is set and differs from the article URL. Links carry `rel="noopener"` and `target="_blank"`; article title links are plain `<a href>` (the aggregator's purpose is to send readers to the source, so no `nofollow`).
@@ -461,12 +464,12 @@ Public pages send `Cache-Control: public, max-age=300` only when no session cook
## 8. Full issue views for signed-in users
`web::issue::load(state, date) -> Result<Option<IssueView>>` (§5.2). `IssueView { issue: Issue, downloads: Vec<Download>, from_json: bool }` where `Download { label, href, size_bytes }` lists the standard EPUB, the X4 EPUB and the XTC file **only when the file at `issues.*_path` (or `publish::issue_filename` in `epub_dir`) exists**; `href` is the existing `/files/epub/{name}` or `/files/xtc/{name}`.
`web::issue::load(state, date) -> Result<Option<IssueView>>` (§5.2). `IssueView` carries the reconstructed `Issue`, downloads, snapshot provenance, and an optional trusted World Briefing HTML fragment recovered from a legacy EPUB. `Download { label, href, size_bytes }` lists the standard EPUB, the X4 EPUB and the XTC file **only when the file at `issues.*_path` (or `publish::issue_filename` in `epub_dir`) exists**; `href` is the existing `/files/epub/{name}` or `/files/xtc/{name}`.
- `GET /issues/{date}` (signed in) — `issue_full.html`: masthead and dateline; **The Brief** (`editorial.front_page_html`, already sanitized XHTML, rendered `|safe`); download buttons; the index exactly like the EPUB's In-this-issue page: per section, title (→ `/issues/{date}/articles/{id}`), `source · N min read`, summary, `Why it's here`, the rating widget for admins; then links to World Briefing and Behind the paper; the colophon facts (models, cost, counts) in a footer block.
- `GET /issues/{date}/articles/{article_id}` — `article.html`: `article-header` (title linking to the source, byline, meta line `feed · 1,850 words · ~8 min`, `Why it's here`, social line via `chapters::social_line`, summary), the body (`ammonia::clean(articles.content_html)` — reuse the same ammonia configuration the EPUB uses; do **not** run `to_xhtml`; `<img>` tags keep their remote `src` and get `loading="lazy" referrerpolicy="no-referrer"`), the discussion (`comments::render_xhtml(discussion, title)` when `pick.discussion` is present; it is XHTML and renders fine as HTML), prev/next links in issue order, "Read online ↗", and the rating widget for admins. 404 when the article is not in that issue.
- `GET /issues/{date}/world` — `world::render_xhtml` in the layout; 404 when the issue has no briefing (fallback issues).
- `GET /issues/{date}/behind` — the same lines as the EPUB chapter (`chapters::behind_*_line`, near misses linking to `/dashboard/articles/{id}` for admins).
- `GET /issues/{date}/world` — `world::render_xhtml` in the layout for snapshots, or trusted `OEBPS/world.xhtml` body content recovered from our own sanitized legacy EPUB; 404 when neither source has a briefing.
- `GET /issues/{date}/behind` — the same lines as the EPUB chapter (`chapters::behind_*_line`, near misses linking to `/dashboard/articles/{id}` for admins), reconstructed from the stored report/run for legacy issues.
`/files/epub/{name}` and `/files/xtc/{name}`: `serve_file` accepts either a valid session (any role) **or** the existing Basic auth; when neither is present and Basic auth is configured, it challenges as today; when Basic auth is not configured it redirects HTML clients to `/login?next=` and returns 401 to others. OPDS clients are unaffected.
@@ -716,7 +719,7 @@ New dependencies: `axum-login = { git = "https://github.com/maxcountryman/axum-l
- Login: `tower_governor` per-IP limit on `POST /login` (§6.5); equal-timing dummy verification for unknown users; no username enumeration in messages ("invalid username or password").
- Authorization: `login_required!`/`permission_required!` route layers on whole sub-routers (§6.3), so a forgotten check in one handler still redirects or 403s; `POST /rate` sits under the admin layer.
- Headers on every app response (via a `tower-http` `SetResponseHeader` layer or a small middleware): `Content-Security-Policy: default-src 'self'; img-src * data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; form-action 'self'`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`. (`img-src *` because article pages show remote images for signed-in users.) nginx keeps adding its own.
- All user text is escaped by askama; the only `|safe` inputs are `front_page_html` (already sanitized by the pipeline), `ammonia::clean` output, `comments::render_xhtml` and `world::render_xhtml` (built from sanitized comment/portal HTML), and the server-rendered SVG sparklines.
- All user text is escaped by askama; the only `|safe` inputs are `front_page_html` (already sanitized by the pipeline), `ammonia::clean` output, `comments::render_xhtml`, `world::render_xhtml` (built from sanitized comment/portal HTML), a recovered World Briefing body read from our own sanitized EPUB XHTML, and the server-rendered SVG sparklines.
- Settings: secrets never rendered, never written; validation through `Config::load` before rename; permissions preserved; every change attributed. `Path` fields are written as given — the operator is the admin, and the config validates paths on load.
- Jobs: unit names come only from the fixed catalogue regex; the polkit rule allows `start` only, on that regex only, for that user only.
- Files: `safe_join` unchanged. `robots.txt` disallows private paths. Public caching only without a cookie.
@@ -735,8 +738,8 @@ No test touches the network or systemd. Router-level tests use `tower::ServiceEx
- **Guards**: anonymous `/dashboard` → 302 `/login?next=/dashboard`; signed-in `user` role → 403 site page; admin → 200; `POST /rate` follows the same three outcomes.
- **Origin check**: POST with `Sec-Fetch-Site: cross-site` → 403; absent fetch metadata and a foreign `Origin` → 403; same-origin passes; the HMAC rating route (GET) is untouched.
- **Throttle**: with a test governor config of burst 3, the fourth `POST /login` from one address → 429; another address still passes.
- **Public rendering**: `public_issue_carries_no_generated_text` (§7.1); comment links built from social refs and `comments_url`; the World Briefing is absent publicly; the feed validates as XML, has one entry per issue, and its content is the public list; `robots.txt` content; `Cache-Control` public without a cookie, `no-store` with one.
- **Full rendering**: signed-in `/issues/{date}` contains the Brief, summaries and `why`; article page contains the body and the discussion; the fallback loader (no `issue_json`) renders an issue from rows with no world/discussion links; downloads listed only for files that exist; `/files/epub` accepts a session, still challenges Basic auth when configured and no session.
- **Public rendering**: `public_issue_shows_summaries_and_why_but_no_bodies` (§7.1); comment links built from social refs and `comments_url`; the Brief, article bodies, comments and World Briefing are absent publicly; the feed validates as XML, has one entry per issue, and its content is the public list with summaries and why lines; `robots.txt` content; `Cache-Control` public without a cookie, `no-store` with one.
- **Full rendering**: signed-in `/issues/{date}` contains the Brief, summaries and `why`; article page contains the body and the discussion; the fallback loader (no `issue_json`) reconstructs colophon/Behind facts and recovers the World chapter from an available EPUB while discussion links remain unavailable; downloads listed only for files that exist; `/files/epub` accepts a session, still challenges Basic auth when configured and no session.
- **issue_json**: `record_issue` writes it with empty bodies; the loader rehydrates bodies; `runs.report_json` and `issues.report_json` written; `/issues.json` returns real reports.
- **Rating**: `POST /rate` as admin appends a `dashboard` event with `user_id`; as user → 403; anonymous → redirect/401; `cleared` writes value 0; JSON and form variants; `next` validated.
- **Dashboard queries**: funnel counts match a seeded `candidate_runs` set; candidate filters and sorts are allow-listed (an unknown sort falls back, never errors); config diff finds changed dotted keys and ignores unchanged; articles list filters; article detail shows assessments, run history and rating events.
@@ -806,6 +809,7 @@ Each step is a shippable commit or small series; `cargo fmt --check`, `cargo cli
| Search over article bodies (FTS5) | Title/URL `LIKE` feels slow or insufficient. |
| Live log streaming (SSE) on the job page | The 5-second refresh feels slow. |
| Passkeys / WebAuthn | A second admin or a phishing concern. |
| Public per-article "why" lines | The operator decides the second-person tone reads fine publicly (one field added to `PublicEntry`, one template line, one test change). |
| Backfilling `issue_json` for old issues from the EPUB files on disk | Only the last `retention_days` of EPUBs exist; the fallback renderer covers the rest. |
| Charts beyond sparklines | `stats` grows a question the tables cannot answer. |
**2026-09-03:** the operator chose to publish summaries and why lines; bodies/Brief/World/comments remain private. Public per-article why lines are therefore no longer deferred.
@@ -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 5–6 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 5–6)
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`.
@@ -0,0 +1,64 @@
# Web dashboard v2 — step 1 handoff
## What landed
- Public issue pages and Atom entries now show each pick's trimmed summary and
its “Why it's here” line. The public view model still has no article body,
Brief, World Briefing, or discussion/comment content, and the public tests
assert those fields do not leak.
- Legacy `issues.issue_json IS NULL` loads now recover their colophon and
Behind the paper data from `issues.report_json`, matching the report to its
run by `started_at` for near misses. If the issue report is absent, the loader
uses the latest finished run for that issue date. Missing historical values
render as `n/a` in the web colophon rather than misleading zeroes.
- Historical model names come from the resolved run config. Summary-provider
resolution follows `editorial.summary_model`; missing model metadata falls
back to the current config and is called out in the generator note.
- Legacy World Briefings are recovered from `OEBPS/world.xhtml` in the stored
standard EPUB (or its canonical publish path). The EPUB body is used after
removing its own leading heading; missing or corrupt files are debug-logged
and treated as “no World chapter,” not as request errors.
- The development seed's 2026-09-01 issue now has a real report, a real EPUB
path with fixture World Briefing content, `issue_json = NULL`, and three
`candidate_runs` near misses. The 2026-09-02 issue retains a full snapshot.
- README and the dashboard plan now document the operator's 2026-09-03 public
summary/why decision and list recovered EPUB XHTML among the trusted `|safe`
inputs. `zip` moved from dev-only to runtime dependencies.
## Tests
- Focused web issue tests: **10 passed**, including report and run-only
colophon fallback, recovered World/Behind routes, missing EPUB behavior, and
public/feed privacy assertions.
- Public view-model test: **1 passed**.
- Development seed smoke test: successfully generated the database and EPUB;
SQL verification showed the legacy row has a null snapshot, present report
and EPUB path, plus **3** near-miss rows.
- `cargo fmt`: clean.
- `cargo clippy --all-targets -- -D warnings`: clean.
- Unfiltered `cargo test`: library result **427 passed, 13 failed**. Every
failure was a pre-existing loopback-listener test rejected by the sandbox.
- Filtered sandbox run: **461 passed, 0 failed, 15 filtered** across library,
binary, integration, and doc-test targets. The filters were the 13 loopback
unit tests and both `tests/m7_server.rs` TCP tests.
## Deviations and follow-up
- `00-shared.md` lists four Anthropic listener tests, one extraction listener
test, five server listener tests, and `m7_server` as sandbox-only failures.
The code also has three pre-existing OpenAI mock-server tests that bind the
same forbidden loopback listener; they failed with the identical
`PermissionDenied` error. `docs/plans/2026-08-15-implementation-notes.md`
already lists those three. They were therefore filtered for the complete
sandbox verification and should run normally in the orchestrator.
- No migrations, routes, auth behavior, or security headers changed. No
database backfill is performed.
- This sandbox mounts the worktree's shared Git metadata under
`/home/thallada/workspace/the-daily-epub/.git/worktrees/the-daily-epub-v2a`
read-only. The required `git add` failed while creating `index.lock`, so the
verified changes remain unstaged and the single requested commit still needs
to be created by the orchestrator with message `Web dashboard v2 step 1:
public summaries, legacy issue fallback`. No source or data in the forbidden
main checkout was changed.
- Beyond that commit and the orchestrator's unsandboxed full-suite run, nothing
remains for step 1. This was not a design step, so no screenshots were taken.
@@ -0,0 +1,151 @@
# Step 2 handoff — Tailwind design system, theme toggle, reader pages
Branch `v2-frontend`, worktree `/home/thallada/workspace/the-daily-epub-v2b`, one commit.
Step 3 (dashboard restyle) and step 4 (TOC sidebar) build on what is described below.
## Landed
### Toolchain
- `package.json` (root, `private`) pins `tailwindcss` and `@tailwindcss/cli` to `4.3.3` with
scripts `css`, `css:watch` and `css:check`. `package-lock.json` is committed.
`.gitignore` already carried `node_modules/` and `dev/`.
- `src/web/tailwind.css` is the single source; `src/web/static/app.css` is the **committed,
minified build** and is still embedded with `include_str!`, so `cargo build`, `cargo test`
and the deployed binary never need Node.
- `npm run css:check` rebuilds to a temp file and `diff -u`s it against the committed
`app.css`, exiting non-zero on drift (verified in both directions).
- Workflow documented in `README.md` and `docs/runbooks/web-dashboard-rollout.md`
(`npm ci && npm run css:check` added to the rollout build step).
### Assets
- `web::static_asset` gained fixed match arms (no path joins) for `theme.js`,
`Newsreader.woff2` and `Newsreader-italic.woff2`. The asset tuple is now
`(&str, &'static [u8])` so text assets use `include_str!(...).as_bytes()` and the fonts use
`include_bytes!`; the SHA-256 ETag / 304 / long-cache path is unchanged and its test still
passes. URLs are flat: `/static/Newsreader.woff2`, not `/static/fonts/…`.
- `@font-face` for both faces (`font-weight: 200 800`, `font-stretch: normal`,
`font-display: swap`, Google's latin `unicode-range`), plus a
`<link rel="preload" as="font" type="font/woff2" crossorigin>` for the regular face.
`document.fonts.check('600 48px Newsreader')` is true on a loaded page and the server log
shows no 404s.
### Theme
- `src/web/static/theme.js` runs synchronously in `<head>` (external file, CSP-safe): reads
`localStorage.theme`, sets or removes `document.documentElement.dataset.theme` before paint.
- `app.js` drives `[data-theme-toggle]`: cycles **system → light → dark → system**, persists
(`removeItem` for system), updates the attribute, the SVG icon and the visible label, keeps
`aria-label="Theme: <state>"`, and re-renders on `matchMedia` changes while in system mode.
- Colours are semantic CSS variables on `:root`, duplicated into
`@media (prefers-color-scheme: dark) :root:not([data-theme="light"])` and
`:root[data-theme="dark"]`, exposed to Tailwind via `@theme inline`, with
`@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *))`.
`color-scheme` follows the theme.
### Reader templates
`layout.html` (ears row + centred masthead with the 1px rule above and the 3px double rule
below + primary nav with `aria-current="page"` + admin nav row + flash + `<main id="content">`
+ footer + skip link), `issue_public.html`, `issue_full.html`, `issue_list.html`,
`article.html`, `world.html`, `behind.html`, `login.html`, `account.html`, `error.html` and
`_rating_widget.html` are all restyled with utilities. Every hook listed in the shared brief
(`form.rating`, `button[data-label]`, `.active`, `aria-pressed`, `form[data-confirm]`,
`details[id]`, `table[data-filter]`, `.table-filter`, `[data-refresh]`, `.scroll-x`, ids,
names, actions) is intact.
## What step 3 can use
Tokens (`@theme inline`, so both a CSS variable and a Tailwind colour utility exist):
| token | utilities |
| --- | --- |
| `--paper`, `--paper-2` | `bg-paper`, `bg-paper-2` |
| `--ink`, `--ink-2`, `--muted` | `text-ink`, `text-ink-2`, `text-muted`, `bg-ink` |
| `--rule`, `--rule-strong` | `border-rule`, `border-rule-strong` |
| `--accent`, `--accent-hover` | `text-accent`, `border-accent`, `bg-accent`, `hover:text-accent-hover` |
| `--loved`, `--good`, `--down`, `--warn` | `text-loved`, `text-good`, `text-down`, `text-warn` |
| `--font-serif` / `--font-sans` / `--font-mono` | `font-serif`, `font-sans`, `font-mono` |
Component classes in `@layer components`: `.btn`, `.btn-primary`, `.badge` + state modifiers
(`selected loved good down excluded shortlisted admitted eligible cleared assessed triaged
reason running requested ok degraded failed dry_run`), `.kv`, `.scroll-x`, `.prose-body`,
`.rating`/`.rating-prompt`/`.rating-note`, `.flash`, `.error`, `.notice`, `.dashboard`,
`.cards`/`.card`, `.muted`/`.meta`/`.num`, `.filters`, `.table-filter`, `.pager`/`.run-nav`/
`.tabs`, `pre.block`/`pre.preview`/`pre.journal`, `.funnel`, `.spark` (+ `.s0`–`.s5`, `.line`).
`@layer base` styles `table`/`thead`/`th`/`td` (sticky head on `bg-paper`, hairline rules,
`hover:bg-paper-2`, tabular numerals) and all form controls.
Repeated inline recipes step 3 will probably want: small label
`font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted`; headline
`font-semibold leading-[1.1] tracking-[-0.01em]`; reader measure
`mx-auto max-w-[68ch] px-4 sm:px-6`; dashboard shell `.dashboard` (`max-w-7xl`, sans).
## Deviations and fixes beyond the brief
- **`.block` collided with Tailwind's `block` display utility.** The legacy component was
renamed to `pre.block`, which keeps both `<pre class="block">` dashboard usages working and
frees `class="block"` for real layout. Before this, the article prev/next cards rendered as
grey monospace boxes.
- **`icon.hidden = …` does not work on SVG elements** (`hidden` is an `HTMLElement` IDL
attribute); the theme icon never changed. `app.js` now uses
`icon.toggleAttribute("hidden", …)`. `[hidden] { display:none !important }` stays in
`@layer base` so it beats display utilities.
- `.prose-body` lists had no markers after Preflight; added `list-disc` / `list-decimal` and a
muted `::marker`.
- The flash bar was flush to the viewport edge while the header used a gutter; `.flash` lost
its own `mx-auto max-w-7xl` and the layout wraps it in `mx-auto max-w-7xl px-4 sm:px-6`.
- The article prev/next grid rendered an empty first cell when there is no previous article;
the placeholder `<span>` is gone and the next card takes `sm:col-start-2`.
- `examples/seed_dev_db.rs` now writes `profile_path` into the throwaway `./dev` directory and
seeds it from `data/profile.md`. Without this, saving on `/dashboard/profile` while running
the dev server **rewrites the repository's `data/profile.md`** and breaks
`curate::profile::tests::shipped_profile_and_opml_parse`. It also fixes a pre-existing
`clippy::manual_is_multiple_of` error in that example (`cargo clippy --all-targets
-D warnings` did not pass on this branch before).
- `Page::new` for the Jobs and Users dashboard pages now passes `"jobs"` / `"users"` instead of
`"dashboard"`, so the admin nav highlights the right entry.
- `FullEntry` gained `is_lead` so the full issue's lead story is set larger (`text-3xl` vs
`text-2xl`), matching what `issue_public.html` already did.
- Fonts are served flat from `/static/<name>.woff2` (the brief allowed either shape).
- `issue_public.html` carries `{# step 1 adds summary/why here #}` at the point where the
backend step's summary/`why` markup should go; use the same treatment as `issue_full.html`
(summary as a body-size serif paragraph, then `border-l-2 border-accent pl-3 italic`).
- The dashboard templates are deliberately unstyled beyond what the base/component layers give
them. They render without errors (all nine routes smoke-tested) but look plain — step 3.
- The archive month label is still the raw `YYYY-MM` produced by `web::public`; changing it is
a backend concern and was left alone.
## Verification
- `npm run css` / `npm run css:check` — clean; drift is detected (exit 1) when `app.css` is
edited by hand.
- `cargo fmt`, `cargo clippy --all-targets -- -D warnings` — clean.
- `cargo test` — **475 passed, 0 failed** (439 lib + 9 + 2 + 3 + 4 + 7 + 9 + 2 integration,
one binary with 0 tests). Run outside the Codex sandbox, so the socket-binding tests ran too.
No test needed a cosmetic-class update.
- Seeded dev server on `127.0.0.1:3599`. All of `/`, `/issues`, `/issues/2026-09-01` (legacy),
`/issues/2026-09-02`, `/issues/2026-09-02/articles/1`, `/…/world`, `/…/behind`, `/account`,
`/feed.xml` and the nine `/dashboard/*` routes return 200 with **no console errors and no
asset 404s**; `/issues/1999-01-01` returns the styled 404 page.
- Theme toggle asserted with Playwright: system → light → dark → system, `localStorage.theme`
written/removed, `data-theme` set/removed, icon and label follow, state survives a reload.
- Focus order checked: the skip link is the first tab stop and becomes visible; the toggle
shows the accent focus ring.
### Screenshots
`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/e963db53-510f-4312-ac87-460291a92781/scratchpad/step2-shots/`
— 83 PNGs. `/`, `/issues`, `/issues/2026-09-02`, `/issues/2026-09-02/articles/1`, `/…/world`,
`/…/behind`, `/account` and `/login` × {anonymous, reader, admin} × {light, dark} ×
{1280px desktop, 390px mobile}, full page. Named
`<path>-<user>-<theme>[-mobile].png`. Plus `toggle-1..4.png` (the toggle cycle),
`flash-light/dark.png`, `rating-active-light.png`, `focus-skiplink.png`, `focus-toggle.png`
and `issues_1999-01-01-none-{light,dark}.png` (404 page).
## Left for later
- Step 1 adds `summary`/`why` to the public entries (marker comment in place).
- Step 3 restyles `src/web/templates/dashboard/*` on top of this system.
- Step 4 adds the collapsing table-of-contents sidebar to the signed-in issue pages.
@@ -0,0 +1,132 @@
# Step 3 handoff — dashboard restyle
Branch `v2-dashboard`, worktree `/home/thallada/workspace/the-daily-epub-v2c`, one commit
on top of the merged `v2-frontend` work (step 1 + step 2).
## Landed
Every template under `src/web/templates/dashboard/` plus the shared partials
`_candidate_row.html`, `_signals_table.html` and `_pagination.html` is restyled with
Tailwind utilities and the component layer step 2 built. `src/web/static/app.css` is
rebuilt and committed; `npm run css:check` is clean.
### New/changed pieces in `src/web/tailwind.css`
`@layer base` gained: a disabled state for every control, `summary { cursor:pointer }`,
and a styled `<meter>` (webkit + moz pseudo-elements, `--rule` track, `--good` value,
`--down` when `.over`) for the overview budget bars.
`@layer components` gained the dashboard vocabulary the templates now use:
| class | what it is |
| --- | --- |
| `.page-head`, `.page-desc`, `.page-actions`, `.page-eyebrow` | the page header on all 13 pages: eyebrow/breadcrumb, `h1`, one-line description, right-aligned actions, hairline under it |
| `.tiles`, `.tile`, `.tile-label`, `.tile-num`, `.tile-delta` | stat tiles (overview, stats, run detail) |
| `.cell-wrap`, `.cell-tight` | long text cells (`message`, `editor_why`, notes, reasons) wrap with `overflow-wrap:anywhere` and a max width; dates/badges stay on one line |
| `.scroll-x.tall` | opt-in `max-h-[70vh] overflow-y-auto`, which is what actually makes the base layer's `thead { sticky top-0 bg-paper }` do something. Applied to the long tables (runs, candidates, articles, ratings, jobs history, users, settings history, stats) and deliberately not to the small tables inside cards |
| `.filters` / `.filter-actions` / `.form-inline` | one inline filter form per page: rule-bounded row, tiny uppercase labels above each control, buttons grouped at the end |
| `.pager`, `.tabs`, `.pagination` | pagination row and the ratings tab strip (accent underline on `.active`) |
| `.setting`, `.setting-label`, `.setting-input`, `.help`, `.default`, `.secret`, `button.reset`, `.save-bar` | settings: two-column rows collapsing to one on phones, monospace keys, disabled env-locked fields with a `locked` badge and a lock note, sticky save bar with a soft top shadow |
| `.disclosure` (`details.disclosure` + `.disclosure-body`) | the framed `<details>` blocks (ratings "How ratings enter the algorithm", stats "As text", article "Explain") |
| `.sparklines`, `.spark-figure`, `.spark-axis`, `.spark-legend` | sparkline cards; the SVG classes (`.spark`, `.s0`…`.s5`, `.line`) are untouched |
| `.funnel`, `.funnel-cell` | the funnel bar gets a `bg-paper-2` track; `class="funnel"` is unchanged because a test asserts it verbatim |
| `td .rating` / `.picks .rating` overrides | the rating widget compacted for table cells and the overview picks list: prompt hidden, `min-h-8` buttons, small note field, capped width |
| `tr.superseded` | muted row (the class stays exactly `superseded`; a test counts it) |
| `.badge` default | bare `<span class="badge">` now has a neutral tint, plus `.badge.restart` / `.badge.warn` |
`.dashboard` gained a vertical rhythm (`> * + * { mt-4 }`), section headings as tracked
uppercase labels on a rule (`.dashboard > h2`), muted underlined table links, and
`code` chips on `bg-paper-2`.
### Per page
- **overview** — five stat tiles (last run, warnings, verdicts this week, unrated picks,
active jobs) above the existing cards; budget rows are now provider + spend + a meter bar;
jobs/picks lists are hairline-separated with ink links.
- **runs** — page header with Stats/Jobs actions, inline status filter with a Reset that
only appears when a filter is set, numeric columns right-aligned, empty-state row.
- **run** — breadcrumb + status badge header, prev/issue/next as buttons in the header,
four tiles (date, duration, cost, warnings), funnel with a track, cards for the report,
empty-state row for candidates.
- **articles / article** — filter form, wrapped title cells; the article page splits its
facts into "Article" and "Provenance" cards, gives the rating widget its own
"Your verdict" section and keeps everything else as tables.
- **ratings** — description as the page header, tab strip, per-tab filter form, the
in-cell rating widget, muted superseded rows.
- **profile** — editor/preview split (`lg:grid-cols-2`, stacked on phones), monospace
textarea, previews as capped `pre.preview` blocks, history table, `.kv` theme list.
- **stats** — window buttons in the header, `summary` rendered as six stat tiles,
sparkline cards, cards, two filterable tables, text output in a disclosure.
- **jobs / job** — job cards now carry a status badge and a "last started" line linking
to that job (see the one backend change below); the job page is breadcrumb + two cards
+ a scrolling monospace journal.
- **settings / settings history / users** — as described in the table above.
### Backend changes (three, all small)
1. `JobCard` gained `last_status` / `last_requested` / `last_id`, filled by a new
`attach_last_runs` from the job rows the page already loads (matching `name` or
`name-<date>` for the dated `generate` card). No extra query. Unit-tested.
2. `stats::stats` passed `"dashboard"` as its nav key, so the admin nav highlighted
*Overview* on the Stats page; it now passes `"stats"`.
3. `settings.html` dropped the `! ` prefix in front of the "config.toml on disk does not
load" banner (the `.error` bar already says it); the assertion in
`settings_pages_render_save_and_show_hand_edits` was updated with it. That is the only
test touched.
## Deviations
- **`class="inline"` was a latent bug**, the same shape as step 2's `.block` collision:
Tailwind emits an `.inline { display:inline }` utility, which beats `form.inline`'s
`@apply flex` in the components layer, so those forms were laid out inline. The class
is renamed to `form-inline` in `jobs.html`, `profile.html` and `settings.html` and in
`tailwind.css`. Nothing asserted on it.
- **Sticky table heads need a scroll box.** `thead { sticky top-0 }` from step 2 is inert
inside a plain `overflow-x:auto` container, so the long tables opt into
`.scroll-x.tall` (`max-h-[70vh]`). Small tables inside cards keep the plain `.scroll-x`.
- The brief asks for "last run" on the job cards; the data was not in `JobCard`, so it was
added (item 1 above) rather than skipped.
- `_rating_widget.html` itself is untouched — two tests assert its exact attribute order
(`value="loved" data-label="loved" class="active"`), so the table-cell treatment is done
purely with `td .rating …` rules.
- `.dashboard > h2` renders section headings as small tracked uppercase labels on a rule
rather than as large sans headings; `.card h2`/`h3` stay normal-weight `text-base`.
## Hooks and asserted classes (all preserved)
`form.rating`, `button[data-label]`, `.active` + `aria-pressed`, `form[data-confirm]`,
`details[id]` (`ratings-how`, `profile-prompt`, plus new `stats-text` and `article-explain`),
`table[data-filter]`, `.table-filter`, `button[data-reset]`/`data-default`, `[data-refresh]`,
`.scroll-x`, `.spark`/`.s0`–`.s5`/`.line`, `class="funnel"`, `class="superseded"`,
`badge <state>`, `<h1>Users</h1>`, every form `action`/`name`/`id` and every `aria-*`.
## Verification
- `npm run css` + `npm run css:check` — clean.
- `cargo fmt`, `cargo clippy --all-targets -- -D warnings` — clean.
- `cargo test` — **477 passed, 0 failed** (441 lib + 9 + 2 + 3 + 4 + 7 + 9 + 2 integration,
one binary with 0 tests). Run outside the Codex sandbox, so the socket-binding tests ran.
- Dev server on `127.0.0.1:3601` (port 3599/3602 belong to other agents, 3499 to
production). Every dashboard route returned 200, with **no console errors and no asset
404s** in the server log.
### Screenshots
`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/e963db53-510f-4312-ac87-460291a92781/scratchpad/step3-shots/`
— 58 PNGs, all signed in as `admin`: `/dashboard`, `/dashboard/runs`, `/dashboard/runs/2`,
`/dashboard/articles`, `/dashboard/articles/1`, `/dashboard/ratings`,
`/dashboard/ratings?tab=events`, `/dashboard/profile`, `/dashboard/stats`,
`/dashboard/jobs`, `/dashboard/jobs/1`, `/dashboard/settings`,
`/dashboard/settings/history`, `/dashboard/users` × {light, dark} × {1280px desktop,
390px mobile}, full page, plus viewport shots of the settings save bar
(`*-top.png`, desktop light/dark and mobile).
The throwaway `./dev` database was extended by hand for the shoot (four `jobs` rows, eight
`rating_events`, four `config_changes`) so the jobs, ratings and settings-history pages had
content; `dev/` is git-ignored and `examples/seed_dev_db.rs` was **not** changed. A future
design pass may want those rows in the seeder.
## Left for later
- Step 4 adds the collapsing table-of-contents sidebar to the signed-in issue pages.
- `examples/seed_dev_db.rs` still seeds no jobs, ratings or config changes.
@@ -0,0 +1,151 @@
# Step 4 handoff — table-of-contents sidebar
Branch `v2-toc`, worktree `/home/thallada/workspace/the-daily-epub-v2d`, one commit.
Builds on step 2's design system (`handoff-step2.md`) and step 1's legacy fallback
(`handoff-step1.md`). Also folds in one reader-side fix from the step-2 review: the
phone "ears" row.
## Landed
### Data (`src/web/issue.rs`)
- `issue_toc(&IssueView, TocPosition) -> Toc`, one helper feeding all four signed-in
handlers (`render_full`, `article`, `world`, `behind`) as the template field `toc`.
- `TocPosition` (`FrontPage`, `Article(ArticleId)`, `World`, `Behind`) and `TocKind`
(`Brief`, `Section`, `Chapter`, `World`, `Behind`, `Colophon`) as the brief specifies.
- Chapters are the picks in `chapters::section_names` × `Lineup::section_picks` order —
the same order the issue index and the EPUB use — numbered `1..n` across sections with
the section names interleaved as non-links. World Briefing and Behind the paper follow
when available (`world_briefing.is_some() || world_html.is_some()`, and `has_behind`,
both as computed after step 1), then a `#colophon` anchor on the issue page.
- `position` / `total` count navigable chapters only (articles + world + behind), so an
article page says "Chapter 4 of 11"; the issue page has `position == 0` and reads
"Front page".
- New small helpers: `issue_href(date)` (replaces four inline `format!`s) and
`crate::pipeline::short_display_date(date)` → `"Wed, Sep 2"`.
### Markup (`src/web/templates/_toc.html`, included by the four reader templates)
- One `<nav id="toc" data-toc-panel>` for both breakpoints, wrapped with the article in a
`lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[18rem_minmax(0,1fr)]` shell
inside `max-w-7xl`. The reading column keeps `max-w-[68ch]`; at `lg` (1024px viewport)
it still gets ~680px, comfortably above the ~60ch floor the brief asks for.
- Desktop: `lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto`, thin
scrollbar. Header (issue number + short date linking to the issue, "Chapter N of M",
2px progress bar) is `lg:sticky lg:top-0` **inside** the scrollable nav, so it stays put
while the chapter list scrolls under it. Section labels are tracked uppercase sans;
chapters are numbered Newsreader `text-[0.95rem]` links with the read time in muted
sans; the current one is bold with a 2px accent bar and `aria-current="page"`. World
Briefing / Behind the paper / Colophon follow a hairline.
- Below `lg`: a sticky bar (`sticky top-0 z-30` on the wrapper, `lg:static`) with the
hamburger `<button data-toc-toggle aria-controls="toc" aria-expanded>`, the current
chapter title truncated, "4 / 11", and the progress bar as its bottom edge. The same
`<nav>` drops under it as an absolutely positioned panel.
- No inline `<style>`/`<script>` and no `style=""`; every hook step 2 listed is intact.
### Behaviour (`src/web/static/app.js`, `theme.js`)
- The panel toggles by `data-open` on the nav; `@media (width < 64rem) .has-js
[data-toc-panel]:not([data-open="true"]) { display:none }` in `tailwind.css`. `theme.js`
(synchronous, in `<head>`) adds `has-js` to `<html>`, so **without JS the panel is simply
visible** under the bar and **with JS there is no open-then-collapse flash**.
- Closes on link tap, on Escape (focus returns to the toggle), on a tap outside, and when
crossing to `lg`. Body scroll is never locked.
- `revealCurrent()` scrolls the panel — never the page — the minimum needed to bring the
current chapter into view, on load and on open. Long issues no longer hide the reader's
position behind the fold.
- Article pages (`<article data-toc-scroll>`) update every `[data-toc-progress]` with
`position - 1 + scrolled fraction`, throttled through `requestAnimationFrame` on a
passive scroll listener.
### Progress bar and CSP
The bar is a real `<progress class="toc-progress">`: a percentage width would need a
`style=""` attribute, which the CSP forbids, and Tailwind cannot generate a class for a
runtime number. It is styled in `@layer components` (2px, `--rule` track, `--accent`
value) and marked `aria-hidden="true"` because the adjacent text already says
"Chapter N of M". Reduced motion: the 150ms transition on the value is dropped both under
`prefers-reduced-motion: reduce` and while JS drives the bar (`[data-live]`), so scrolling
never animates.
### Ears row (step-2 review follow-up)
`layout.html`: the row is `flex-nowrap` with `gap-2 sm:gap-4`; the left cell is
`min-w-0 truncate whitespace-nowrap`; the right group and the theme toggle are
`shrink-0 whitespace-nowrap`; "Sign in" is `whitespace-nowrap` and a long username is
`max-w-32 truncate`. The four issue pages and `issue_public.html` now show
`short_display_date` below `sm:` and the full dateline + issue number above it, so at
390px the whole row is one line for anonymous, reader and admin.
## Tests
- `cargo test` — **478 passed, 0 failed** (442 lib + 9 bin + 2 + 3 + 4 + 7 + 9 + 2
integration, 0 doc-tests). Run outside the Codex sandbox, so the socket-binding tests
ran too.
- New: `web::issue::tests::toc_numbers_chapters_across_sections_and_tracks_the_reader`
(unit — full item shape across two sections, `total == 4`, `position` for front page /
article / world / behind, the single back-matter divider, `current_label`, short date)
and `web::issue::tests::the_sidebar_marks_the_current_chapter_on_every_signed_in_issue_page`
(router — all four pages carry the bar and panel, the right "Chapter N of M", the right
`aria-current="page"` href and the colophon anchor; the anonymous issue page has none).
- Extended: the public-page test now asserts no `data-toc-toggle` / `id="toc"` leaks; the
legacy tests assert the sidebar offers World Briefing and Behind the paper, and that it
omits World when there is no EPUB to recover it from.
- Browser checks (Playwright, `shoot/toc-behaviour.mjs`): 14/14 — collapsed on load,
`aria-expanded`/`aria-controls`, open on tap, close on Escape with focus return, close on
outside tap, close + navigate on link tap, `aria-current` after navigation, desktop panel
always visible with the bar hidden, progress 4 → 5 on scrolling a chapter, no toc on the
public page, no console errors.
- `cargo fmt`, `cargo clippy --all-targets -- -D warnings`, `npm run css`,
`npm run css:check` — all clean.
## Deviations
- **`TocItem` has a seventh field, `divider: bool`.** The brief's hairline "after the
chapters" cannot be expressed in askama without knowing which entry starts the
back-matter group; the helper marks it instead. Everything else matches the brief's
shapes. `Toc` also carries `short_date` (for the header and the ears row) and gains one
method, `current_label()`, for the mobile bar.
- **The toggled element is the `<nav id="toc">` itself**, and the issue header lives inside
it (hidden below `lg`, where the sticky bar already shows the same information). There is
still exactly one copy of the list, as the brief requires.
- **The progress bar is a `<progress>` element**, not a div with a percentage width — see
above; a width would need an inline style.
- **Visibility is a CSS rule keyed on `.has-js` + `data-open`**, not the `hidden`
attribute: `[hidden]` would also hide the desktop sidebar, and a server-rendered `hidden`
would break the no-JS case.
- **`theme.js` gained one line** (`classList.add("has-js")`). It is the only script that
runs before paint, and without it the mobile panel flashes open on every load.
- Article top margins were normalised to `mt-10 lg:mt-12` across the four pages so the
sidebar and the reading column start on the same line.
- `web::public::PublicIssue` gained `short_date` (no private data; the public template
needed the phone dateline).
## Screenshots
`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/e963db53-510f-4312-ac87-460291a92781/scratchpad/step4-shots/`
— 137 PNGs. Eight paths (`/issues/2026-09-02`, `…/articles/1`, `…/articles/5` — mid-issue,
`…/world`, `…/behind`, and the legacy `/issues/2026-09-01` with its `/world` and `/behind`)
× {reader, admin} × {light, dark} × {1280, 1024, 390 mobile closed, 390 mobile open}, named
`<path>-<user>-<theme>-<width|mobile>[-open].png`. Plus `-390-ears` crops of the phone ears
row for anonymous, reader and admin, `-1280-mid` (scrolled, progress bar advanced) and
`-1280-tall` (1500px, the whole sidebar including the back-matter group).
The Playwright helper is `shoot/shoot4.mjs` (adds `--width/--height`, `--click`, `--scroll`
and `--tag` to step 2's `shoot.mjs`, and aborts loudly if the sign-in is throttled).
## Notes for whoever runs the dev server next
- `src/web/static/app.css`, `app.js`, `theme.js` and the templates are all compiled into
the binary (`include_str!` / askama), so **the server must be rebuilt and restarted after
every CSS or template edit** — a browser reload alone shows the old assets.
- `server.login_attempts` defaults to 10 per 15 minutes per IP. A screenshot sweep signs in
once per invocation and trips the throttle after ten runs, which silently yields
screenshots of the sign-in page. The throwaway `./dev/config.toml` was given
`login_attempts = 2000`; `dev/` is git-ignored and `seed_dev_db` rewrites it.
## Left for later
- Nothing from step 4. On a very long issue the sidebar is taller than the viewport at
1280×900, so the back-matter group needs a scroll of the sidebar (by design — the brief's
`max-h`); `revealCurrent` handles the reader's own position.
+10 -1
View File
@@ -6,7 +6,17 @@ already be complete.
## 1. Build and install
The deployed binary does not require Node. For UI development, edit
`src/web/tailwind.css`, the templates, or `src/web/static/app.js`, then run
`npm run css` and commit the regenerated `src/web/static/app.css` alongside the
source. The `/static/*.css|js` URLs carry a hash of the embedded assets
(`web::ASSET_VERSION`), so a new build busts browser caches on its own — no
hard refresh and no version bump needed. CI and release preparation should run `npm run css:check` to detect
stylesheet drift.
```sh
npm ci
npm run css:check
cargo test
cargo build --release
sudo install -m0755 target/release/daily-epub /usr/local/bin/daily-epub
@@ -134,4 +144,3 @@ Candidate and assessment history is pruned after
`curation.ranking.telemetry_retention_days` (180 by default). Raise it now if the dashboard
should retain a longer article/run history, then reload `/dashboard/settings` and verify the
effective value. This changes future pruning only; it cannot restore rows already deleted.
+438
View File
@@ -0,0 +1,438 @@
//! 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::config::Config;
use daily_epub::curate::telemetry::{self, CandidateRun};
use daily_epub::db::Db;
use daily_epub::epub::fixtures;
use daily_epub::report::{ProviderUsage, RunReport};
use daily_epub::types::{Article, Edition, Entry, Issue, Pick, TokenUsage};
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.is_multiple_of(3) {
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,
epub_path: Option<&Path>,
) -> anyhow::Result<i64> {
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 started_at = issue
.meta
.generated_at
.checked_sub(jiff::Span::new().minutes(22))
.unwrap_or(issue.meta.generated_at);
let run_id = db.start_run(issue.meta.date, started_at).await?;
let mut report = RunReport::new(issue.meta.date, started_at);
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.provider_costs = issue
.colophon
.provider_costs
.iter()
.map(|(provider, cost_usd)| {
(
provider.clone(),
ProviderUsage {
usage: TokenUsage::default(),
cost_usd: *cost_usd,
},
)
})
.collect();
report.config_json = serde_json::json!({
"models": {
"bulk": issue.colophon.models.bulk,
"editor": issue.colophon.models.editor,
"embedding": issue.behind.embedding_model,
},
"editorial": {"summary_model": "editor"},
"voyage": {"model": issue.behind.embedding_model},
});
report.finish(issue.meta.generated_at);
db.finish_run(run_id, &report).await?;
let epub_path = epub_path.map(|path| path.to_string_lossy().into_owned());
db.upsert_issue(
issue.meta.date,
issue.meta.issue_number,
issue.meta.generated_at,
epub_path.as_deref(),
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(run_id)
}
async fn seed_near_misses(db: &Db, run_id: i64) -> anyhow::Result<()> {
for (index, title, quality, fit, utility) in [
(
0,
"The Database Migration That Almost Made the Cut",
8.4,
7.2,
81.5,
),
(
1,
"An Oral History of the First E-Ink Hackers",
7.7,
8.1,
79.8,
),
(2, "A Very Good Essay About Municipal Trees", 8.0, 6.8, 76.2),
] {
let mut article = fixtures::article(0, 9001 + index, title);
article.feed_title = "The Near-Miss Review".into();
let 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,
};
db.upsert_entry(&entry).await?;
let article_id = db.upsert_article(&article).await?;
let signals = serde_json::json!({
"v": 1,
"raw": {"quality": quality, "fit": fit},
"norm": {"quality": quality / 10.0, "fit": fit / 10.0},
"present": {"quality": true, "fit": true},
"weights": {"quality": 0.6, "fit": 0.4},
})
.to_string();
telemetry::write(
db,
&CandidateRun {
run_id,
article_id,
stage: "shortlisted",
excluded_reason: Some("not_selected"),
admitted_by: Some("[\"blend\"]"),
signals_json: &signals,
utility: Some(utility),
rank_utility: Some(index + 10),
cluster_id: Some(index),
cluster_rank: Some(1),
editor_why: None,
},
)
.await?;
}
Ok(())
}
fn write_config(dir: &Path) -> anyhow::Result<PathBuf> {
let path = dir.join("config.toml");
let dir = dir.canonicalize()?;
// The profile lives inside the throwaway directory: saving it from
// /dashboard/profile must never rewrite the repository's data/profile.md.
let profile = dir.join("profile.md");
let toml = format!(
"database_path = \"{db}\"\nprofile_path = \"{profile}\"\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(),
profile = profile.display(),
epubs = dir.join("epubs").display(),
xtc = dir.join("xtc").display(),
);
std::fs::write(&path, toml)?;
if !profile.exists() {
let shipped = Path::new("data/profile.md");
let body = std::fs::read_to_string(shipped)
.unwrap_or_else(|_| "# Reader profile\n\n## Who he is\n\nA developer.\n".to_string());
std::fs::write(&profile, body)?;
}
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()?);
let mut epub_config = Config::default();
epub_config.publish.epub_dir = dir.join("epubs");
let legacy_epub = daily_epub::epub::build_edition_with_images(
&legacy,
Edition::Standard,
&epub_config,
&epub_config.publish.epub_dir,
&[],
)?;
let legacy_run = seed_issue(&db, &mut legacy, false, Some(&legacy_epub.path)).await?;
seed_near_misses(&db, legacy_run).await?;
let mut latest = dev_issue("2026-09-02".parse()?, 19, "2026-09-02T05:29:00Z".parse()?);
seed_issue(&db, &mut latest, true, None).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(())
}
+313
View File
@@ -0,0 +1,313 @@
{
"name": "the-daily-epub-v2b",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"@tailwindcss/cli": "4.3.3",
"tailwindcss": "4.3.3"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"dev": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"dev": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"dev": true
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"dev": true
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"dev": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
"micromatch": "^4.0.5",
"node-addon-api": "^7.0.0"
},
"optionalDependencies": {
"@parcel/watcher-linux-x64-glibc": "2.5.1",
"@parcel/watcher-linux-x64-musl": "2.5.1"
}
},
"node_modules/@parcel/watcher-linux-x64-glibc": {
"version": "2.5.1",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@parcel/watcher-linux-x64-musl": {
"version": "2.5.1",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@parcel/watcher/node_modules/detect-libc": {
"version": "1.0.3",
"dev": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
}
},
"node_modules/@tailwindcss/cli": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz",
"integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==",
"dev": true,
"dependencies": {
"@parcel/watcher": "2.5.1",
"@tailwindcss/node": "4.3.3",
"@tailwindcss/oxide": "4.3.3",
"enhanced-resolve": "^5.24.1",
"mri": "^1.2.0",
"picocolors": "^1.1.1",
"tailwindcss": "4.3.3"
},
"bin": {
"tailwindcss": "dist/index.mjs"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
"dev": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.24.1",
"jiti": "^2.7.0",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.3"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
"dev": true,
"optionalDependencies": {
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
"@tailwindcss/oxide-linux-x64-musl": "4.3.3"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/braces": {
"version": "3.0.3",
"dev": true,
"dependencies": {
"fill-range": "^7.1.1"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"dev": true
},
"node_modules/enhanced-resolve": {
"version": "5.24.2",
"dev": true,
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"dev": true
},
"node_modules/is-extglob": {
"version": "2.1.1",
"dev": true
},
"node_modules/is-glob": {
"version": "4.0.3",
"dev": true,
"dependencies": {
"is-extglob": "^2.1.1"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"dev": true
},
"node_modules/jiti": {
"version": "2.7.0",
"dev": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"dev": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
"optionalDependencies": {
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/magic-string": {
"version": "0.30.21",
"dev": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"dev": true,
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
}
},
"node_modules/mri": {
"version": "1.2.0",
"dev": true
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"dev": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"dev": true
},
"node_modules/picomatch": {
"version": "2.3.1",
"dev": true
},
"node_modules/source-map-js": {
"version": "1.2.1",
"dev": true
},
"node_modules/tailwindcss": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
"dev": true
},
"node_modules/tapable": {
"version": "2.3.3",
"dev": true
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"dev": true,
"dependencies": {
"is-number": "^7.0.0"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"private": true,
"scripts": {
"css": "tailwindcss -i src/web/tailwind.css -o src/web/static/app.css --minify",
"css:watch": "tailwindcss -i src/web/tailwind.css -o src/web/static/app.css --watch",
"css:check": "tmp=$(mktemp) && trap 'rm -f \"$tmp\"' EXIT && tailwindcss -i src/web/tailwind.css -o \"$tmp\" --minify && diff -u src/web/static/app.css \"$tmp\""
},
"devDependencies": {
"@tailwindcss/cli": "4.3.3",
"tailwindcss": "4.3.3"
}
}
+11
View File
@@ -139,6 +139,17 @@ pub fn display_date(date: Date) -> String {
format!("{weekday}, {month} {}, {}", date.day(), date.year())
}
/// "Fri, Aug 15" — the same dateline abbreviated for narrow screens (web only).
pub fn short_display_date(date: Date) -> String {
const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let weekday = WEEKDAYS[(date.weekday().to_monday_zero_offset() as usize).min(6)];
let month = MONTHS[(date.month() as usize).clamp(1, 12) - 1];
format!("{weekday}, {month} {}", date.day())
}
/// Materialize the [`Issue`] the EPUB builder consumes (§3.10).
///
/// Pure: every count is derived from the lineup, so the same inputs always give
+73 -4
View File
@@ -45,6 +45,12 @@ struct JobCard {
/// The `generate` card carries the date input for `generate-YYYY-MM-DD`.
dated: bool,
lock: Option<&'static str>,
/// Status of the most recent job started from this card, when there is one.
last_status: Option<String>,
/// When that job was requested, for the card's "last run" line.
last_requested: Option<String>,
/// Its id, so the card links straight to the job page.
last_id: Option<i64>,
}
#[derive(Debug, Clone)]
@@ -95,10 +101,29 @@ fn cards() -> Vec<JobCard> {
dangerous: job.dangerous(),
dated: matches!(job, Job::Generate { .. }),
lock: job.takes_lock(),
last_status: None,
last_requested: None,
last_id: None,
})
.collect()
}
/// `rows` is newest first, so the first row whose name is the card's name (or
/// `<name>-<date>` for the dated `generate` card) is that card's last run.
fn attach_last_runs(cards: &mut [JobCard], rows: &[JobLine]) {
for card in cards.iter_mut() {
let prefix = format!("{}-", card.name);
if let Some(row) = rows
.iter()
.find(|row| row.name == card.name || row.name.starts_with(&prefix))
{
card.last_status = Some(row.status.clone());
card.last_requested = Some(row.requested.clone());
card.last_id = Some(row.id);
}
}
}
fn job_line(row: &JobRow, config: &Config) -> JobLine {
JobLine {
id: row.id,
@@ -129,7 +154,7 @@ async fn jobs_template(
) -> Result<JobsTemplate, WebError> {
let config = state.config();
let rows = jobs::list(&state.db, TABLE_ROWS).await.map_err(db_err)?;
let mut page = Page::new("Jobs", viewer, "dashboard");
let mut page = Page::new("Jobs", viewer, "jobs");
page.flash = flash;
let today = config
.tz()
@@ -140,11 +165,14 @@ async fn jobs_template(
.date()
.to_string()
});
let jobs: Vec<JobLine> = rows.iter().map(|row| job_line(row, &config)).collect();
let mut cards = cards();
attach_last_runs(&mut cards, &jobs);
Ok(JobsTemplate {
page,
jobs_enabled: config.server.jobs_enabled,
cards: cards(),
jobs: rows.iter().map(|row| job_line(row, &config)).collect(),
cards,
jobs,
today,
})
}
@@ -314,7 +342,7 @@ async fn show(
Ok(log) => (log, None),
Err(error) => (String::new(), Some(error)),
};
let mut page = Page::new(format!("Job {id} · {}", row.name), viewer, "dashboard");
let mut page = Page::new(format!("Job {id} · {}", row.name), viewer, "jobs");
page.flash = take_flash(&session).await?;
Ok(Html(JobTemplate {
page,
@@ -352,6 +380,47 @@ mod tests {
use crate::web::MockRunner;
use crate::web::dashboard::tests::{assert_admin_only, get, login_cookie, response_text};
fn job_line_named(id: i64, name: &str, status: &str) -> JobLine {
JobLine {
id,
name: name.into(),
requested_by: "admin".into(),
requested: "2026-09-03 01:00".into(),
started: String::new(),
finished: String::new(),
duration: String::new(),
status: status.into(),
message: None,
run_id: None,
}
}
#[test]
fn cards_take_their_last_run_from_the_newest_matching_job() {
let mut cards = cards();
// Newest first, the way `jobs::list` returns them.
let rows = vec![
job_line_named(4, "generate-2026-09-03", "running"),
job_line_named(3, "profile-rebuild", "ok"),
job_line_named(2, "generate-2026-09-02", "ok"),
];
attach_last_runs(&mut cards, &rows);
let generate = cards.iter().find(|card| card.name == "generate").unwrap();
assert_eq!(generate.last_id, Some(4), "the dated job matches its card");
assert_eq!(generate.last_status.as_deref(), Some("running"));
let rebuild = cards
.iter()
.find(|card| card.name == "profile-rebuild")
.unwrap();
assert_eq!(rebuild.last_id, Some(3));
let never = cards
.iter()
.find(|card| card.last_id.is_none())
.expect("a catalogue job with no recorded run");
assert!(never.last_status.is_none());
}
async fn app_with_runner(
config: Config,
runner: Arc<dyn crate::web::JobRunner>,
+1 -1
View File
@@ -2495,7 +2495,7 @@ mod tests {
)
.await;
assert!(
page.contains("! config.toml on disk does not load:"),
page.contains("config.toml on disk does not load:"),
"{page}"
);
assert!(page.contains("value=\"31\""), "{page}");
+1 -1
View File
@@ -406,7 +406,7 @@ async fn stats(
})
.collect();
let mut page = Page::new("Stats", viewer, "dashboard");
let mut page = Page::new("Stats", viewer, "stats");
page.flash = take_flash(&session).await?;
Ok(Html(StatsTemplate {
page,
+1 -1
View File
@@ -56,7 +56,7 @@ async fn index(
open_sessions: row.open_sessions,
})
.collect();
let mut page = Page::new("Users", viewer, "dashboard");
let mut page = Page::new("Users", viewer, "users");
page.flash = take_flash(&session).await?;
Ok(Html(UsersTemplate { page, users }))
}
+996 -32
View File
File diff suppressed because it is too large. Load diff
+56 -6
View File
@@ -6,7 +6,7 @@ pub mod session;
pub mod users;
use std::fmt;
use std::sync::Mutex;
use std::sync::{LazyLock, Mutex};
use std::time::SystemTime;
use askama::Template;
@@ -204,8 +204,21 @@ pub struct Page {
pub flash: Option<Flash>,
pub active_nav: String,
pub version: &'static str,
/// Cache-busting token for `/static/*.css|js` URLs: a content hash, so
/// any stylesheet or script change reaches browsers that cached the
/// previous build (they are served with a one-day `max-age`).
pub asset_version: &'static str,
}
/// First 12 hex digits of the SHA-256 over the embedded CSS and JS assets.
pub static ASSET_VERSION: LazyLock<String> = LazyLock::new(|| {
let mut hasher = Sha256::new();
hasher.update(include_str!("static/app.css"));
hasher.update(include_str!("static/app.js"));
hasher.update(include_str!("static/theme.js"));
hex::encode(hasher.finalize())[..12].to_string()
});
impl Page {
pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self {
Self {
@@ -214,6 +227,7 @@ impl Page {
flash: None,
active_nav: active_nav.to_string(),
version: crate::VERSION,
asset_version: ASSET_VERSION.as_str(),
}
}
@@ -502,16 +516,34 @@ async fn static_asset(
axum::extract::Path(file): axum::extract::Path<String>,
headers: axum::http::HeaderMap,
) -> Response {
let asset = match file.as_str() {
"app.css" => ("text/css; charset=utf-8", include_str!("static/app.css")),
let asset: (&str, &'static [u8]) = match file.as_str() {
"app.css" => (
"text/css; charset=utf-8",
include_str!("static/app.css").as_bytes(),
),
"app.js" => (
"application/javascript; charset=utf-8",
include_str!("static/app.js"),
include_str!("static/app.js").as_bytes(),
),
"theme.js" => (
"application/javascript; charset=utf-8",
include_str!("static/theme.js").as_bytes(),
),
"favicon.svg" => (
"image/svg+xml",
include_str!("static/favicon.svg").as_bytes(),
),
"Newsreader.woff2" => (
"font/woff2",
include_bytes!("static/fonts/Newsreader.woff2"),
),
"Newsreader-italic.woff2" => (
"font/woff2",
include_bytes!("static/fonts/Newsreader-italic.woff2"),
),
"favicon.svg" => ("image/svg+xml", include_str!("static/favicon.svg")),
_ => return WebError::NotFound.into_response(),
};
let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1.as_bytes())));
let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1)));
if headers
.get(header::IF_NONE_MATCH)
.and_then(|value| value.to_str().ok())
@@ -977,6 +1009,24 @@ mod tests {
assert_eq!(session.status(), StatusCode::OK);
}
#[test]
fn asset_urls_carry_a_content_hash_not_the_crate_version() {
let page = Page::new("t", None, "latest");
assert_eq!(page.asset_version.len(), 12);
assert!(page.asset_version.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(page.asset_version, crate::VERSION);
let html = ErrorTemplate {
page,
heading: "h".into(),
message: "m".into(),
}
.render()
.unwrap();
let expected = format!("/static/app.css?v={}", ASSET_VERSION.as_str());
assert!(html.contains(&expected), "{html}");
assert!(!html.contains(&format!("/static/app.css?v={}", crate::VERSION)));
}
#[tokio::test]
async fn static_assets_use_content_hash_etags() {
let (_dir, state) = test_state(Config::default()).await;
+24 -3
View File
@@ -16,6 +16,7 @@ pub struct PublicIssue {
pub date: Date,
pub issue_number: i64,
pub display_date: String,
pub short_date: String,
pub article_count: i64,
pub reading_minutes: i64,
pub stats_line: String,
@@ -38,6 +39,8 @@ pub struct PublicEntry {
pub domain: String,
pub reading_minutes: i64,
pub word_count: i64,
pub summary: Option<String>,
pub why: Option<String>,
pub comment_links: Vec<CommentLink>,
pub is_lead: bool,
}
@@ -102,6 +105,20 @@ impl From<&Issue> for PublicIssue {
domain: domain(&article.canonical_url),
reading_minutes: article.reading_minutes(),
word_count: article.word_count,
summary: pick
.summary
.as_deref()
.or_else(|| {
issue
.editorial
.summaries
.get(&article.id)
.map(String::as_str)
})
.map(str::trim)
.filter(|summary| !summary.is_empty())
.map(str::to_string),
why: pick.why.clone(),
comment_links,
is_lead: pick.is_lead,
}
@@ -113,6 +130,7 @@ impl From<&Issue> for PublicIssue {
date: issue.meta.date,
issue_number: issue.meta.issue_number,
display_date: issue.meta.display_date.clone(),
short_date: crate::pipeline::short_display_date(issue.meta.date),
article_count: issue.meta.article_count,
reading_minutes: issue.meta.reading_minutes,
stats_line: issue.meta.stats_line(),
@@ -343,6 +361,7 @@ fn empty_issue() -> PublicIssue {
date: "1970-01-01".parse().expect("valid epoch date"),
issue_number: 0,
display_date: String::new(),
short_date: String::new(),
article_count: 0,
reading_minutes: 0,
stats_line: String::new(),
@@ -356,16 +375,18 @@ mod tests {
use super::*;
#[test]
fn public_issue_carries_no_generated_text() {
fn public_issue_shows_summaries_and_why_but_no_bodies() {
let source = crate::epub::fixtures::issue();
let public = PublicIssue::from(&source);
let html = FeedEntryTemplate { issue: &public }.render().unwrap();
assert!(html.contains("The Lead Story"));
assert!(html.contains("Hacker News"));
assert!(html.contains("What it argues, and why it is worth the time."));
assert!(html.contains("A short abstract for the second piece."));
assert!(html.contains("The systems story with enough operational detail to matter"));
assert!(html.contains("A small-scene delight outside the usual technical orbit"));
for private in [
"Two stories today",
"What it argues",
"systems story",
"Body of",
"write path",
"Agreed",
+2 -157
View File
@@ -1,157 +1,2 @@
:root { --bg:#fbfaf6; --fg:#171713; --muted:#68665f; --rule:#c9c5b9; --accent:#8b1e1e; --loved:#286a3b; --good:#34688a; --down:#943b35; color-scheme:light dark; }
@media (prefers-color-scheme:dark) { :root { --bg:#171714; --fg:#eeeae0; --muted:#aaa69b; --rule:#4c4a44; --accent:#ef8c82; --loved:#75c58a; --good:#75aed0; --down:#e5867d; } }
* { box-sizing:border-box; }
body { margin:0 auto; padding:0 1rem; background:var(--bg); color:var(--fg); font:1rem/1.55 Georgia,serif; }
a { color:var(--accent); }
.masthead { max-width:72ch; margin:1.5rem auto .4rem; border-block:3px double var(--fg); padding:.45rem 0; text-align:center; font-size:2rem; font-weight:700; }
.masthead a { color:inherit; text-decoration:none; }
.primary,.admin,footer { max-width:72ch; margin:.7rem auto; text-align:center; color:var(--muted); }
.admin { max-width:1200px; font-family:system-ui,sans-serif; }
.flash { max-width:72rem; margin:1rem auto; padding:.75rem 1rem; border:1px solid var(--rule); }
main { min-height:70vh; }
.reading { max-width:72ch; margin:2rem auto; }
.narrow { max-width:34rem; }
.dateline,.stats,.byline,.comments,.strap,.meta,.social,.index-meta { color:var(--muted); }
.issue section { border-top:1px solid var(--rule); margin-top:2rem; }
.issue article { border-bottom:1px solid var(--rule); padding:.4rem 0 .8rem; }
.issue .lead h3 { font-size:1.45rem; }
.comments a,.downloads a { margin-right:.8rem; }
.downloads { display:flex; flex-wrap:wrap; gap:.6rem; margin:1.5rem 0; }
.button { border:1px solid currentColor; padding:.45rem .7rem; text-decoration:none; }
.index-list { list-style:none; padding:0; }
.index-entry { border-bottom:1px solid var(--rule); padding:.5rem 0 1rem; }
.index-entry h3 { margin-bottom:.1rem; }
.index-entry p { margin:.35rem 0; }
.issue-chapters { border-block:1px solid var(--rule); margin:2rem 0; padding:1rem 0; text-align:center; font-size:1.15rem; }
.colophon { max-width:none; margin:2rem 0; text-align:left; }
.rule { border:0; border-top:1px solid var(--rule); margin:1.5rem 0; }
.article-header h1 a { color:inherit; }
.article-body img { display:block; max-width:100%; height:auto; margin:1rem auto; }
.article-body pre { max-width:100%; overflow-x:auto; }
.discussion { border-top:1px solid var(--rule); margin-top:2rem; padding-top:1rem; }
.discussion blockquote { border-left:2px solid var(--rule); margin-left:.5rem; padding-left:1rem; }
.discussion blockquote.reply { margin-left:1.5rem; }
.comment-meta { color:var(--muted); font-size:.9rem; }
.prev-next { display:grid; grid-template-columns:1fr 1fr; gap:1rem; margin:1.5rem 0; }
.prev-next a:last-child { text-align:right; }
form { display:grid; gap:.8rem; margin:1.5rem 0; }
label { display:grid; gap:.25rem; }
input,textarea,button { font:inherit; padding:.45rem; }
.error { color:var(--down); }
.dashboard { max-width:1200px; margin:2rem auto; font-family:system-ui,sans-serif; }
.scroll-x { max-width:100%; overflow-x:auto; overscroll-behavior-inline:contain; }
table { width:100%; border-collapse:collapse; font:0.9rem/1.35 system-ui,sans-serif; }
th,td { border-bottom:1px solid var(--rule); padding:.35rem .5rem; text-align:left; }
thead { position:sticky; top:0; background:var(--bg); }
.badge { border:1px solid currentColor; border-radius:999px; padding:.1rem .45rem; }
.badge.selected,.badge.loved { color:var(--loved); } .badge.good,.badge.assessed,.badge.triaged { color:var(--good); } .badge.down,.badge.excluded { color:var(--down); }
.badge.shortlisted,.badge.admitted,.badge.eligible,.badge.cleared { color:var(--muted); }
.kv { display:grid; grid-template-columns:minmax(10rem,1fr) 3fr; } .kv dt { color:var(--muted); }
.funnel > * { background:var(--good); min-width:1px; margin:.2rem 0; }
.spark { max-width:100%; height:auto; }
.rating { display:flex; flex-wrap:wrap; align-items:center; gap:.3rem; }
.rating button { border:1px solid var(--rule); background:transparent; color:var(--fg); cursor:pointer; }
.rating button.active { background:var(--fg); color:var(--bg); border-color:var(--fg); }
.rating button.clear { border:0; color:var(--muted); padding-inline:.25rem; text-decoration:underline; }
.rating button.clear.active { background:transparent; color:var(--fg); font-weight:700; }
.rating-prompt { margin-right:.25rem; }
.rating-note { flex-basis:100%; }
@media (max-width:40rem) { .masthead { font-size:1.55rem; } .kv { display:block; } }
/* step 4: ratings and profile pages */
.preference-summary { font-size:1.05rem; }
.notice { border-left:3px solid var(--accent); padding:.4rem .8rem; }
.how ol { padding-left:1.2rem; } .how li { margin:.4rem 0; }
.tabs { margin:1rem 0; } .tabs a { padding:.3rem .7rem; border:1px solid var(--rule); text-decoration:none; } .tabs a.active { background:var(--fg); color:var(--bg); border-color:var(--fg); }
form.filters { display:flex; flex-wrap:wrap; align-items:end; gap:.8rem; margin:1rem 0; } form.filters label { gap:.15rem; }
form.inline { display:inline-flex; flex-wrap:wrap; align-items:center; gap:.6rem; margin:.8rem 0; }
.contributions td { vertical-align:top; } .contributions .rating { margin-top:.3rem; font-size:.85rem; } .contributions .rating-prompt { display:none; }
tr.superseded td { color:var(--muted); }
.pager { display:flex; gap:1rem; align-items:center; margin:1rem 0; }
.profile-grid { display:grid; grid-template-columns:3fr 2fr; gap:1.5rem; }
.profile-form textarea { width:100%; font:.9rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; resize:vertical; }
pre.preview { white-space:pre-wrap; overflow-wrap:anywhere; font:.85rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; border:1px solid var(--rule); padding:.6rem; max-height:28rem; overflow:auto; margin:0; }
.versions pre.preview { max-height:6rem; border:0; padding:0; }
.versions form { margin:0; }
@media (max-width:60rem) { .profile-grid { display:block; } }
/* step 3: dashboard reads (overview, runs, articles) */
.dashboard h1 { font-size:1.5rem; margin:.5rem 0; }
.dashboard h1 a { color:inherit; }
.dashboard h2 { font-size:1.1rem; margin:1.75rem 0 .5rem; }
.dashboard h3 { font-size:1rem; margin:.5rem 0; }
.dashboard table td { vertical-align:top; }
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(20rem,1fr)); gap:1rem; margin:1rem 0; }
.card { border:1px solid var(--rule); padding:.75rem 1rem; min-width:0; }
.card h2 { margin-top:0; }
.block { white-space:pre-wrap; overflow-wrap:anywhere; font:.85rem/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; margin:.5rem 0; }
.muted { color:var(--muted); }
.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
.filters { display:flex; flex-wrap:wrap; gap:.5rem 1rem; align-items:end; margin:1rem 0; }
.filters label { font-size:.85rem; }
.filters input,.filters select { padding:.3rem; font-size:.9rem; }
.run-nav { display:flex; flex-wrap:wrap; gap:1.5rem; margin:.5rem 0 1rem; }
.funnel-cell { min-width:12rem; width:40%; }
.funnel svg { display:block; }
.funnel-table .reasons { color:var(--muted); font-size:.8rem; }
.badge.reason,.badge.running,.badge.requested { color:var(--muted); }
.badge.ok { color:var(--loved); } .badge.degraded { color:var(--good); } .badge.failed,.badge.dry_run { color:var(--down); }
.pager { display:flex; flex-wrap:wrap; gap:1rem; margin:1rem 0; align-items:center; }
details.signals summary { cursor:pointer; }
details.signals summary a { display:inline; }
.signals-body { margin:.5rem 0 .5rem 1rem; font-size:.85rem; }
.signals-body table { width:auto; }
.signals-body tr.muted td { color:var(--muted); }
details.explain { margin:1rem 0; }
.diff .before { color:var(--down); } .diff .after { color:var(--loved); }
.picks,.jobs,.warnings { list-style:none; padding:0; }
.picks li { border-bottom:1px solid var(--rule); padding:.5rem 0; }
.budget meter { width:60%; max-width:14rem; height:.8rem; vertical-align:middle; margin-right:.5rem; }
.table-filter { margin:.5rem 0; padding:.3rem; width:20rem; max-width:100%; }
/* step 5: settings */
.settings .card { border:1px solid var(--rule); padding:.75rem 1rem; margin:1rem 0; }
.settings .card h2 { margin:.2rem 0 .6rem; font-size:1.1rem; font-family:ui-monospace,monospace; }
.settings-form { display:block; }
.setting { display:grid; grid-template-columns:minmax(14rem,1fr) 2fr; gap:.5rem 1rem; padding:.5rem 0; border-top:1px solid var(--rule); }
.setting .help,.setting .default,.settings .meta { color:var(--muted); font-size:.85rem; margin:.2rem 0; }
.setting-input input,.setting-input select,.setting-input textarea { width:100%; max-width:36rem; }
.setting-input textarea { min-height:5rem; font-family:ui-monospace,monospace; }
.setting-input input:disabled,.setting-input textarea:disabled { opacity:.6; }
.setting .lines { white-space:pre-line; }
.settings .banner { padding:.6rem .8rem; border:1px solid var(--down); margin:1rem 0; }
.settings .reset,.settings .danger { font:inherit; font-size:.85rem; border:0; background:transparent; color:var(--accent); text-decoration:underline; padding:0; cursor:pointer; }
.settings .actions { position:sticky; bottom:0; background:var(--bg); padding:.7rem 0; border-top:1px solid var(--rule); }
.settings form.inline { display:none; }
.settings .add-provider { max-width:36rem; }
.history pre { margin:0; white-space:pre-wrap; font-size:.8rem; }
@media (max-width:40rem) { .setting { display:block; } }
/* step 6: jobs and stats */
.sparklines { display:grid; grid-template-columns:repeat(auto-fit,minmax(16rem,1fr)); gap:1rem 1.5rem; margin:1rem 0; }
.spark-figure { margin:0; min-width:0; } .spark-figure figcaption { font-size:.9rem; margin-bottom:.2rem; }
.spark { display:block; width:100%; max-width:26rem; height:auto; border-bottom:1px solid var(--rule); }
.spark .s0 { fill:var(--good); } .spark .s1 { fill:var(--loved); } .spark .s2 { fill:var(--down); } .spark .s3 { fill:var(--accent); } .spark .s4 { fill:var(--muted); } .spark .s5 { fill:var(--fg); }
.spark .line { stroke:var(--accent); }
.spark-axis { display:flex; justify-content:space-between; max-width:26rem; font-size:.75rem; }
.spark-legend { list-style:none; padding:0; margin:.3rem 0 0; display:flex; flex-wrap:wrap; gap:.2rem .8rem; font-size:.8rem; }
.spark-legend svg { vertical-align:middle; }
.job-cards form.inline { margin:.4rem 0 0; } .job-cards label { display:inline-grid; }
td.message,dd.message { overflow-wrap:anywhere; max-width:32rem; }
pre.journal { max-height:40rem; }
/* step 7: dark-mode and narrow-screen polish */
.primary,.admin { display:flex; flex-wrap:wrap; justify-content:center; gap:.35rem 1rem; }
.masthead { line-height:1.15; overflow-wrap:anywhere; }
.scroll-x > table { min-width:max-content; }
input,textarea,select,button { max-width:100%; border-color:var(--rule); background:var(--bg); color:var(--fg); }
button { border-style:solid; }
input:focus-visible,textarea:focus-visible,select:focus-visible,button:focus-visible,a:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
code,pre,.message { overflow-wrap:anywhere; }
.prev-next a { overflow-wrap:anywhere; }
@media (max-width:40rem) {
body { padding-inline:.75rem; }
.masthead { margin-top:.75rem; padding-inline:.25rem; }
.primary,.admin { gap:.25rem .75rem; line-height:1.35; }
.dashboard,.reading { margin-block:1.25rem; }
.cards { grid-template-columns:minmax(0,1fr); }
.card { padding:.65rem .75rem; }
.prev-next { display:flex; flex-wrap:wrap; justify-content:space-between; }
.rating { align-items:stretch; }
.rating-prompt,.rating-note { flex-basis:100%; }
}
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--font-serif:"Newsreader", "Iowan Old Style", "Palatino Linotype", Georgia, serif;--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-2xl:42rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-normal:0em;--leading-tight:1.25;--leading-snug:1.375;--leading-normal:1.5;--leading-relaxed:1.625;--radius-sm:.25rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;--default-mono-font-family:ui-monospace, SFMono-Regular, Menlo, monospace;--color-paper:var(--paper);--color-paper-2:var(--paper-2);--color-ink:var(--ink)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgrouLine truncated
+107
View File
@@ -1,3 +1,44 @@
const themeToggle = document.querySelector("[data-theme-toggle]");
if (themeToggle) {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const readTheme = () => {
try {
const saved = localStorage.getItem("theme");
return saved === "light" || saved === "dark" ? saved : "system";
} catch (_) {
return "system";
}
};
const renderTheme = (theme) => {
themeToggle.setAttribute("aria-label", `Theme: ${theme}`);
themeToggle.querySelector("[data-theme-label]").textContent = theme[0].toUpperCase() + theme.slice(1);
themeToggle.querySelectorAll("[data-theme-icon]").forEach((icon) => {
// SVGElement has no `hidden` IDL attribute; toggle the content attribute.
icon.toggleAttribute("hidden", icon.getAttribute("data-theme-icon") !== theme);
});
};
const setTheme = (theme) => {
if (theme === "system") document.documentElement.removeAttribute("data-theme");
else document.documentElement.dataset.theme = theme;
try {
if (theme === "system") localStorage.removeItem("theme");
else localStorage.setItem("theme", theme);
} catch (_) {}
renderTheme(theme);
};
renderTheme(readTheme());
themeToggle.addEventListener("click", () => {
const current = readTheme();
setTheme(current === "system" ? "light" : current === "light" ? "dark" : "system");
});
media.addEventListener("change", () => {
if (readTheme() === "system") {
document.documentElement.removeAttribute("data-theme");
renderTheme("system");
}
});
}
document.addEventListener("submit", (event) => {
const form = event.target;
if (form.matches("form.rating") && event.submitter) {
@@ -70,3 +111,69 @@ document.querySelectorAll("[data-refresh]").forEach((element) => {
const seconds = Number(element.dataset.refresh);
if (seconds > 0) setTimeout(() => window.location.reload(), seconds * 1000);
});
/* step 4: table-of-contents panel (below `lg`) and reading-progress bar */
const tocPanel = document.querySelector("[data-toc-panel]");
const tocToggle = document.querySelector("[data-toc-toggle]");
if (tocPanel) {
// Long issues overflow the sidebar; scroll just enough to show where we are.
const revealCurrent = () => {
const current = tocPanel.querySelector("a[aria-current=page]");
if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return;
const margin = 24;
const top = current.offsetTop - margin;
const bottom = current.offsetTop + current.offsetHeight + margin;
if (bottom > tocPanel.scrollTop + tocPanel.clientHeight) {
tocPanel.scrollTop = bottom - tocPanel.clientHeight;
} else if (top < tocPanel.scrollTop) {
tocPanel.scrollTop = Math.max(0, top);
}
};
const setOpen = (open) => {
tocPanel.dataset.open = open ? "true" : "false";
if (tocToggle) tocToggle.setAttribute("aria-expanded", open ? "true" : "false");
if (open) revealCurrent();
};
setOpen(false);
revealCurrent();
if (tocToggle) {
tocToggle.addEventListener("click", () => setOpen(tocPanel.dataset.open !== "true"));
document.addEventListener("click", (event) => {
if (tocPanel.dataset.open !== "true" || tocToggle.contains(event.target)) return;
// A tap on a chapter closes the panel; so does a tap anywhere outside it.
if (!tocPanel.contains(event.target) || event.target.closest("a")) setOpen(false);
});
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape" || tocPanel.dataset.open !== "true") return;
setOpen(false);
tocToggle.focus();
});
window.matchMedia("(min-width: 64rem)").addEventListener("change", () => setOpen(false));
}
}
const tocBars = document.querySelectorAll("[data-toc-progress]");
const tocChapter = document.querySelector("[data-toc-scroll]");
if (tocBars.length && tocChapter) {
// "Chapter N" is worth position N once finished; show N-1 plus how far down we are.
const base = Math.max(0, Number(tocBars[0].getAttribute("value")) - 1);
let queued = false;
const paint = () => {
queued = false;
const start = window.scrollY + tocChapter.getBoundingClientRect().top;
const end = start + tocChapter.offsetHeight - window.innerHeight;
const read = end > start ? (window.scrollY - start) / (end - start) : 1;
const value = base + Math.min(1, Math.max(0, read));
tocBars.forEach((bar) => {
// The CSS transition is for navigation, not for tracking a finger.
bar.dataset.live = "true";
bar.value = value;
});
};
const schedule = () => {
if (queued) return;
queued = true;
window.requestAnimationFrame(paint);
};
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
schedule();
}
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Newsreader Project Authors (http://github.com/productiontype/Newsreader)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+12
View File
@@ -0,0 +1,12 @@
(() => {
// Marks the document as scripted before first paint so progressively enhanced
// widgets (the contents panel) can start collapsed without a flash.
document.documentElement.classList.add("has-js");
try {
const theme = localStorage.getItem("theme");
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
else document.documentElement.removeAttribute("data-theme");
} catch (_) {
document.documentElement.removeAttribute("data-theme");
}
})();
+273
View File
@@ -0,0 +1,273 @@
@import "tailwindcss";
@source "../web/templates";
@source "./static/app.js";
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@font-face {
font-family: "Newsreader";
font-style: normal;
font-weight: 200 800;
font-stretch: normal;
font-display: swap;
src: url("/static/Newsreader.woff2") format("woff2");
unicode-range: 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;
}
@font-face {
font-family: "Newsreader";
font-style: italic;
font-weight: 200 800;
font-stretch: normal;
font-display: swap;
src: url("/static/Newsreader-italic.woff2") format("woff2");
unicode-range: 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;
}
: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"]) {
--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;
}
}
: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);
--color-ink-2:var(--ink-2); --color-muted:var(--muted); --color-rule:var(--rule);
--color-rule-strong:var(--rule-strong); --color-accent:var(--accent);
--color-accent-hover:var(--accent-hover); --color-loved:var(--loved); --color-good:var(--good);
--color-down:var(--down); --color-warn:var(--warn);
--font-serif:"Newsreader", "Iowan Old Style", "Palatino Linotype", Georgia, serif;
--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono:ui-monospace, SFMono-Regular, Menlo, monospace;
}
@layer base {
[hidden] { display:none !important; }
html { @apply bg-paper text-ink antialiased; }
body { @apply m-0 min-h-screen bg-paper font-serif text-[1.0625rem] leading-[1.65] text-ink; font-optical-sizing:auto; }
::selection { background:color-mix(in oklab, var(--accent) 24%, transparent); }
a { @apply text-accent decoration-rule underline-offset-4 transition-colors duration-150 hover:text-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
h1, h2, h3 { @apply text-ink; font-optical-sizing:auto; }
button, input, select, textarea { font:inherit; @apply rounded-sm border border-rule bg-paper px-2.5 py-1.5 text-ink transition-colors duration-150 placeholder:text-muted focus:border-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
button { @apply cursor-pointer; }
button:disabled, input:disabled, select:disabled, textarea:disabled { @apply cursor-not-allowed border-rule bg-paper-2 text-muted; }
textarea { @apply min-h-28; }
summary { @apply cursor-pointer; }
table { @apply w-full border-collapse font-sans text-sm leading-snug; font-variant-numeric:tabular-nums; }
thead { @apply sticky top-0 z-10 bg-paper; }
th, td { @apply border-b border-rule px-2 py-2 text-left align-top; }
th { @apply text-[0.72rem] font-semibold uppercase tracking-[0.1em] text-muted; }
tbody tr { @apply transition-colors duration-150 hover:bg-paper-2; }
code, pre { @apply font-mono; }
meter { @apply block h-2 w-full rounded-sm; -webkit-appearance:none; appearance:none; background:var(--rule); }
meter::-webkit-meter-bar { background:var(--rule); border:0; border-radius:2px; height:0.5rem; }
meter::-webkit-meter-optimum-value, meter::-webkit-meter-suboptimum-value, meter::-webkit-meter-even-less-good-value { background:var(--good); border-radius:2px; }
meter.over::-webkit-meter-optimum-value, meter.over::-webkit-meter-suboptimum-value, meter.over::-webkit-meter-even-less-good-value { background:var(--down); }
meter::-moz-meter-bar { background:var(--good); border-radius:2px; }
meter.over::-moz-meter-bar { background:var(--down); }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior:auto !important; transition-duration:0.01ms !important; } }
}
@layer components {
/* ---------- shared controls ---------- */
.btn, .button { @apply inline-flex min-h-10 items-center justify-center rounded-sm border border-rule-strong bg-transparent px-3 py-1.5 font-sans text-sm font-medium text-ink no-underline transition-colors duration-150 hover:bg-ink hover:text-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
.btn-primary { @apply inline-flex min-h-10 items-center justify-center rounded-sm border border-ink bg-ink px-3 py-1.5 font-sans text-sm font-medium text-paper no-underline transition-colors duration-150 hover:border-accent hover:bg-accent hover:text-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; }
.btn-quiet { @apply inline-flex min-h-9 items-center gap-1 rounded-sm border-0 bg-transparent px-2 py-1 font-sans text-sm text-muted underline decoration-rule underline-offset-4 hover:text-ink hover:decoration-accent; }
.btn:disabled, .btn-primary:disabled, .btn-danger:disabled { @apply cursor-not-allowed border-rule bg-paper-2 text-muted hover:bg-paper-2 hover:text-muted; }
.btn-danger, button.danger { @apply inline-flex min-h-9 items-center justify-center rounded-sm border border-down bg-transparent px-2.5 py-1 font-sans text-sm font-medium text-down transition-colors duration-150 hover:bg-down hover:text-paper; }
.badge { @apply inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 font-sans text-xs font-medium leading-5; color:var(--ink-2); background:color-mix(in oklab, var(--ink) 8%, transparent); }
.badge.selected, .badge.loved, .badge.ok { color:var(--loved); background:color-mix(in oklab, var(--loved) 14%, transparent); }
.badge.good, .badge.assessed, .badge.triaged, .badge.degraded { color:var(--good); background:color-mix(in oklab, var(--good) 14%, transparent); }
.badge.down, .badge.excluded, .badge.failed, .badge.dry_run { color:var(--down); background:color-mix(in oklab, var(--down) 14%, transparent); }
.badge.shortlisted, .badge.admitted, .badge.eligible, .badge.cleared, .badge.reason, .badge.running, .badge.requested { color:var(--muted); background:color-mix(in oklab, var(--muted) 14%, transparent); }
.badge.restart, .badge.warn { color:var(--warn); background:color-mix(in oklab, var(--warn) 16%, transparent); }
.kv { @apply grid grid-cols-[minmax(9rem,1fr)_2fr] gap-x-5 gap-y-1.5; }
.kv dt { @apply text-sm text-muted; }
.kv dd { @apply m-0 min-w-0 text-sm; overflow-wrap:anywhere; }
.scroll-x { @apply max-w-full overflow-x-auto; overscroll-behavior-inline:contain; }
.scroll-x > table { min-width:max-content; }
.scroll-x.tall { @apply max-h-[70vh] overflow-y-auto; overscroll-behavior-block:contain; }
.prose-body { @apply text-[1.0625rem] leading-[1.72] text-ink; }
.prose-body > * + * { @apply mt-5; }
.prose-body h2 { @apply mt-10 border-t border-rule pt-4 text-2xl font-semibold leading-[1.15] tracking-[-0.01em]; }
.prose-body h3 { @apply mt-8 text-xl font-semibold leading-tight; }
.prose-body a { @apply text-ink underline decoration-rule underline-offset-4 hover:text-ink hover:decoration-accent; }
.prose-body img { @apply mx-auto my-7 block h-auto max-w-full rounded-sm text-center font-sans text-xs italic text-muted; }
.prose-body pre { @apply max-w-full overflow-x-auto rounded-sm bg-paper-2 p-4 text-sm leading-relaxed; }
.prose-body :not(pre) > code { @apply rounded-sm bg-paper-2 px-1 py-0.5 text-[0.9em]; }
.prose-body blockquote { @apply my-7 border-l-2 border-rule-strong pl-5 text-ink-2; }
.prose-body ul { @apply list-disc pl-6; }
.prose-body ol { @apply list-decimal pl-6; }
.prose-body li::marker { color:var(--muted); }
.prose-body li + li { @apply mt-2; }
.rating { @apply my-5 flex flex-wrap items-center font-sans text-sm; }
.rating-prompt { @apply mr-3 text-[0.72rem] uppercase tracking-[0.1em] text-muted; }
.rating button[data-label]:not(.clear) { @apply -ml-px min-h-10 rounded-none border-rule bg-transparent px-3 py-1.5 text-ink first:ml-0 first:rounded-l-sm last:rounded-r-sm hover:z-10 hover:border-ink hover:bg-paper-2; }
.rating button[data-label]:not(.clear).active { @apply z-10 border-ink bg-ink text-paper; }
.rating button.clear { @apply ml-2 min-h-10 border-0 bg-transparent px-2 text-muted underline decoration-rule underline-offset-4 hover:text-ink; }
.rating button.clear.active { @apply font-semibold text-ink; }
.rating-note { @apply mt-2 flex w-full max-w-md basis-full items-center gap-2 text-muted; }
.rating-note input { @apply flex-1; }
.flash { @apply my-4 border-l-4 border-accent bg-paper-2 px-4 py-3 font-sans text-sm; }
.error { @apply border-l-4 border-down bg-paper-2 px-4 py-3 font-sans text-sm text-down; }
.notice { @apply border-l-4 border-warn bg-paper-2 px-4 py-3 font-sans text-sm text-ink-2; }
ul.error, ul.notice { @apply list-disc pl-8; }
/* ---------- dashboard shell ---------- */
.dashboard { @apply mx-auto my-8 max-w-7xl px-4 pb-6 font-sans text-base leading-normal sm:px-6; }
.dashboard > * + * { @apply mt-4; }
.dashboard h1 { @apply text-2xl font-semibold leading-tight tracking-[-0.01em]; }
.dashboard h2 { @apply text-lg font-semibold; }
.dashboard h3 { @apply text-sm font-semibold; }
.dashboard > h2 { @apply mt-9 border-t border-rule pt-3 text-[0.78rem] font-semibold uppercase tracking-[0.14em] text-muted; }
.dashboard > p, .dashboard > ul, .dashboard > ol { @apply text-sm; }
.dashboard code { @apply rounded-sm bg-paper-2 px-1 py-0.5 text-[0.85em]; }
.dashboard pre code { @apply bg-transparent p-0; }
.dashboard table a { @apply text-ink underline decoration-rule underline-offset-2 hover:text-ink hover:decoration-accent; }
.page-head { @apply flex flex-wrap items-end justify-between gap-x-6 gap-y-3 border-b border-rule pb-4; }
.page-head > div:first-child { @apply min-w-0; }
.dashboard > .page-head + * { @apply mt-6; }
.page-desc { @apply mt-1.5 max-w-[74ch] text-sm text-muted; }
.page-desc a { @apply text-accent; }
.page-actions { @apply flex flex-wrap items-center gap-2 font-sans text-sm; }
.page-eyebrow { @apply text-[0.7rem] uppercase tracking-[0.14em] text-muted; }
.tiles { @apply grid gap-3; grid-template-columns:repeat(auto-fit,minmax(min(10.5rem,100%),1fr)); }
.tile { @apply min-w-0 border border-rule px-3.5 py-3; }
.tile-label { @apply block text-[0.68rem] font-medium uppercase tracking-[0.12em] text-muted; }
.tile-num { @apply mt-1 block text-[1.55rem] font-semibold leading-tight tracking-[-0.01em]; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
.tile-delta { @apply mt-1 block text-xs text-muted; overflow-wrap:anywhere; }
.cards { @apply grid gap-4; grid-template-columns:repeat(auto-fit,minmax(min(20rem,100%),1fr)); }
.card { @apply min-w-0 border border-rule p-4; }
.card > * + * { @apply mt-3; }
.card h2 { @apply text-base font-semibold; }
.card h3 { @apply text-base font-semibold; }
.card > h2:first-child, .card > h3:first-child { @apply mt-0; }
.card p, .card li { @apply text-sm; }
.muted, .meta { @apply text-muted; }
.jobs a, .picks > li > a:first-child { @apply text-ink no-underline hover:text-accent; }
.num { @apply whitespace-nowrap text-right tabular-nums; }
.cell-wrap { @apply min-w-[10rem] max-w-[24rem] whitespace-normal; overflow-wrap:anywhere; }
.cell-tight { @apply whitespace-nowrap; }
.filters { @apply flex flex-wrap items-end gap-x-4 gap-y-3 border-y border-rule py-3 font-sans text-sm; }
.filters label { @apply grid gap-1 text-[0.66rem] font-medium uppercase tracking-[0.1em] text-muted; }
.filters input, .filters select, .filters textarea { @apply text-sm normal-case tracking-normal text-ink; }
.filters select, .filters input[type="date"] { @apply min-w-32; }
.filters .filter-actions { @apply flex items-center gap-3; }
.form-inline { @apply flex flex-wrap items-center gap-3 font-sans text-sm; }
.form-inline label { @apply flex items-center gap-2 text-sm; }
.table-filter { @apply my-2 w-80 max-w-full font-sans text-sm; }
.pager { @apply flex flex-wrap items-center justify-between gap-x-6 gap-y-2 py-1 font-sans text-sm text-muted; }
.pager a { @apply text-ink no-underline hover:text-accent; }
.run-nav { @apply flex flex-wrap items-center justify-between gap-x-6 gap-y-2 font-sans text-sm; }
.run-nav a { @apply text-ink no-underline hover:text-accent; }
.tabs { @apply flex flex-wrap items-center gap-x-6 border-b border-rule font-sans text-[0.72rem] font-medium uppercase tracking-[0.12em]; }
.tabs a { @apply -mb-px flex min-h-10 items-center border-b-2 border-transparent text-muted no-underline hover:text-ink; }
.tabs a.active { @apply border-accent text-ink; }
.pagination { @apply font-sans text-sm text-muted; }
tr.superseded > td { @apply text-muted; }
tr.superseded a { @apply text-muted; }
/* the rating widget has to fit inside a table cell */
td .rating, .picks .rating { @apply my-1.5 max-w-[15rem] gap-y-1 text-xs; }
td .rating-prompt, .picks .rating-prompt { @apply hidden; }
td .rating button[data-label]:not(.clear), .picks .rating button[data-label]:not(.clear) { @apply min-h-8 px-2.5 py-1 text-xs; }
td .rating button.clear, .picks .rating button.clear { @apply ml-2 min-h-8 px-0 text-xs; }
td .rating-note { @apply mt-1 max-w-[15rem] gap-1.5 text-[0.7rem]; }
td .rating-note input { @apply px-1.5 py-0.5 text-xs; }
.picks .rating { @apply max-w-none; }
pre.block, pre.preview, pre.journal { @apply overflow-auto whitespace-pre-wrap rounded-sm bg-paper-2 p-3 font-mono text-[0.8125rem] leading-relaxed; overflow-wrap:anywhere; }
pre.block { @apply max-h-64; }
pre.preview { @apply max-h-56; }
pre.journal { @apply max-h-[34rem] p-4; }
pre.prompt, pre.learned { @apply max-h-[32rem]; }
td > pre.preview { @apply max-h-24 max-w-md bg-transparent p-0 text-xs text-muted; }
details.disclosure { @apply border border-rule; }
details.disclosure > summary { @apply px-4 py-3 font-sans text-sm font-medium; }
details.disclosure[open] > summary { @apply border-b border-rule; }
details.disclosure > .disclosure-body { @apply px-4 py-3 text-sm; }
details.disclosure > .disclosure-body > * + * { @apply mt-3; }
details.signals > summary { @apply min-w-0; }
.signals-body { @apply mt-2 max-w-2xl border-l-2 border-rule pl-3 text-xs; }
.signals-body > * + * { @apply mt-2; }
.signals-body ul { @apply list-disc pl-5; }
.funnel { @apply w-full min-w-[6rem] rounded-sm bg-paper-2; }
.funnel > * { @apply block min-w-px rounded-sm bg-good; }
.funnel-cell { @apply w-[14rem] min-w-[8rem] align-middle; }
.sparklines { @apply grid gap-4; grid-template-columns:repeat(auto-fit,minmax(min(18rem,100%),1fr)); }
.spark-figure { @apply m-0 min-w-0 border border-rule p-3; }
.spark-figure figcaption { @apply mb-2 text-[0.7rem] font-semibold uppercase tracking-[0.1em] text-ink; }
.spark-figure figcaption .muted { @apply font-normal normal-case tracking-normal; }
.spark { @apply h-auto w-full max-w-full; }
.spark .s0 { fill:var(--good); } .spark .s1 { fill:var(--loved); } .spark .s2 { fill:var(--down); } .spark .s3 { fill:var(--accent); } .spark .s4 { fill:var(--muted); } .spark .s5 { fill:var(--ink); } .spark .line { stroke:var(--accent); }
.spark-axis { @apply mt-1 flex justify-between text-[0.66rem] uppercase tracking-[0.08em]; }
.spark-legend { @apply mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted; }
.spark-legend li { @apply flex items-center gap-1.5; }
/* ---------- settings ---------- */
.settings-form > * + * { @apply mt-5; }
.setting { @apply grid gap-2 border-t border-rule py-3 md:grid-cols-[minmax(0,19rem)_minmax(0,1fr)] md:gap-6; }
.setting:first-of-type { @apply border-t-0 pt-1; }
.setting-label { @apply min-w-0; }
.setting-label label { @apply inline-block; }
.setting-label code { @apply bg-transparent p-0 text-[0.85rem] font-medium text-ink; overflow-wrap:anywhere; }
.setting-input { @apply min-w-0; }
.setting-input > input, .setting-input > select, .setting-input > textarea { @apply w-full max-w-lg; }
.help, .default { @apply mt-1 text-xs text-muted; overflow-wrap:anywhere; }
.setting-label .help { @apply max-w-[42ch]; }
.secret { @apply text-sm text-ink-2; }
code.lines { @apply mt-1 block whitespace-pre-wrap; }
button.reset { @apply ml-1 inline min-h-0 rounded-none border-0 bg-transparent p-0 text-xs text-muted underline decoration-rule underline-offset-2 hover:text-ink; }
.save-bar { @apply sticky bottom-0 z-20 -mx-4 mt-6 flex flex-wrap items-center justify-end gap-x-4 gap-y-2 border-t border-rule bg-paper px-4 py-3 sm:-mx-6 sm:px-6; box-shadow:0 -10px 18px -14px color-mix(in oklab, var(--ink) 55%, transparent); }
.save-bar .meta { @apply mr-auto text-xs; }
/* step 4: table-of-contents sidebar */
.toc-progress { @apply block h-0.5 w-full appearance-none border-0 p-0 align-middle; background:var(--rule); }
.toc-progress::-webkit-progress-bar { background:var(--rule); }
.toc-progress::-webkit-progress-value { background:var(--accent); transition:width 150ms linear; }
.toc-progress::-moz-progress-bar { background:var(--accent); }
/* While JS drives the bar from scroll position it must track the finger exactly. */
.toc-progress[data-live]::-webkit-progress-value { transition:none; }
@media (prefers-reduced-motion: reduce) { .toc-progress::-webkit-progress-value { transition:none; } }
[data-toc-panel] { scrollbar-width:thin; scrollbar-color:var(--rule) transparent; }
/* Without JS the panel is simply visible under the bar; `has-js` is set in the head. */
@media (width < 64rem) {
.has-js [data-toc-panel]:not([data-open="true"]) { display:none; }
}
@media (max-width:40rem) {
.kv { @apply block; } .kv dt { @apply mt-2; }
.rating { @apply items-stretch; } .rating-prompt, .rating-note { @apply basis-full; } .rating-prompt { @apply mb-2; }
.filters label { @apply w-full; }
.filters input, .filters select { @apply w-full; }
}
}
+7 -7
View File
@@ -1,16 +1,16 @@
<tr>
<td><details class="signals"><summary><a href="{{ candidate.href }}">{{ candidate.title }}</a></summary>{% let signals = candidate.signals %}{% include "_signals_table.html" %}</details></td>
<td>{{ candidate.feed }}</td>
<td class="cell-wrap"><details class="signals"><summary><a href="{{ candidate.href }}">{{ candidate.title }}</a></summary>{% let signals = candidate.signals %}{% include "_signals_table.html" %}</details></td>
<td class="cell-tight text-muted">{{ candidate.feed }}</td>
<td class="num">{{ candidate.words }}</td>
<td><span class="badge {{ candidate.stage }}">{{ candidate.stage }}</span></td>
<td>{% if let Some(reason) = candidate.reason %}<span class="badge reason">{{ reason }}</span>{% endif %}</td>
<td>{% if let Some(first) = candidate.admitted_first %}{{ first }}{% if !candidate.admitted_rest.is_empty() %} <span class="muted">({{ candidate.admitted_rest }})</span>{% endif %}{% endif %}</td>
<td class="cell-tight"><span class="badge {{ candidate.stage }}">{{ candidate.stage }}</span></td>
<td class="cell-tight">{% if let Some(reason) = candidate.reason %}<span class="badge reason">{{ reason }}</span>{% endif %}</td>
<td class="cell-tight text-muted">{% if let Some(first) = candidate.admitted_first %}{{ first }}{% if !candidate.admitted_rest.is_empty() %} <span class="muted text-xs">({{ candidate.admitted_rest }})</span>{% endif %}{% endif %}</td>
<td class="num">{{ candidate.utility }}</td>
<td class="num">{{ candidate.rank }}</td>
<td class="num">{{ candidate.cluster }}</td>
<td class="num">{{ candidate.triage }}</td>
<td class="num">{{ candidate.quality }}</td>
<td class="num">{{ candidate.fit }}</td>
<td>{% if candidate.exploration %}<span class="badge">exploration</span>{% endif %}{% if candidate.auto_include %}<span class="badge">auto</span>{% endif %}</td>
<td>{% if let Some(why) = candidate.editor_why %}{{ why }}{% endif %}</td>
<td class="cell-tight">{% if candidate.exploration %}<span class="badge">exploration</span>{% endif %}{% if candidate.auto_include %}<span class="badge">auto</span>{% endif %}</td>
<td class="cell-wrap text-muted">{% if let Some(why) = candidate.editor_why %}{{ why }}{% endif %}</td>
</tr>
+1 -1
View File
@@ -1 +1 @@
{% if pagination.pages() > 1 %}<nav class="pagination">Page {{ pagination.page }} of {{ pagination.pages() }}</nav>{% endif %}
{% if pagination.pages() > 1 %}<nav class="pagination" aria-label="Pagination">Page <span class="text-ink">{{ pagination.page }}</span> of {{ pagination.pages() }}</nav>{% endif %}
+5 -5
View File
@@ -1,8 +1,8 @@
{% if signals.empty %}<p class="muted">No signals recorded for this row (hygiene exclusion or thin telemetry).</p>{% else %}<div class="signals-body">
{% if signals.empty %}<p class="muted text-xs">No signals recorded for this row (hygiene exclusion or thin telemetry).</p>{% else %}<div class="signals-body">
<div class="scroll-x"><table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead>
<tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}absent{% endif %}</td></tr>{% endfor %}</tbody></table></div>
<p class="muted">Preliminary blend {{ signals.blend }}{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine {{ cos }}{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}</p>
{% if !signals.top_interests.is_empty() %}<p><strong>Top interests</strong></p><ul>{% for interest in signals.top_interests %}<li>{{ interest.name }} <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %}
{% if !signals.neighbours.is_empty() %}<p><strong>Nearest rated neighbours</strong></p><ul>{% for neighbour in signals.neighbours %}<li><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span> <a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a> <span class="muted">· cos {{ neighbour.cos }}</span></li>{% endfor %}</ul>{% endif %}
<tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}<span class="muted">absent</span>{% endif %}</td></tr>{% endfor %}</tbody></table></div>
<p class="muted">Preliminary blend <span class="tabular-nums text-ink">{{ signals.blend }}</span>{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine <span class="tabular-nums">{{ cos }}</span>{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}</p>
{% if !signals.top_interests.is_empty() %}<p class="page-eyebrow">Top interests</p><ul>{% for interest in signals.top_interests %}<li>{{ interest.name }} <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %}
{% if !signals.neighbours.is_empty() %}<p class="page-eyebrow">Nearest rated neighbours</p><ul>{% for neighbour in signals.neighbours %}<li><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span> <a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a> <span class="muted">· cos {{ neighbour.cos }}</span></li>{% endfor %}</ul>{% endif %}
{% if !signals.notes.is_empty() %}<ul class="muted">{% for note in signals.notes %}<li>{{ note }}</li>{% endfor %}</ul>{% endif %}
</div>{% endif %}
+20
View File
@@ -0,0 +1,20 @@
<div class="sticky top-0 z-30 lg:static lg:z-auto">
<div class="relative flex min-h-12 items-center gap-3 border-b border-rule bg-paper px-4 sm:px-6 lg:hidden">
<button class="-ml-2 flex h-10 w-10 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-ink hover:text-accent" type="button" data-toc-toggle aria-controls="toc" aria-expanded="false" aria-label="Contents">
<svg aria-hidden="true" viewBox="0 0 24 24" class="h-5 w-5 fill-none stroke-current" stroke-width="1.75"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
</button>
<span class="min-w-0 flex-1 truncate font-sans text-sm leading-tight text-ink">{{ toc.current_label() }}</span>
{% if toc.position > 0 %}<span class="shrink-0 font-sans text-xs tabular-nums text-muted">{{ toc.position }} / {{ toc.total }}</span>{% endif %}
<progress class="toc-progress absolute inset-x-0 -bottom-px" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
</div>
<nav id="toc" data-toc-panel class="absolute inset-x-0 top-full z-30 max-h-[72vh] overflow-y-auto border-b border-rule bg-paper px-4 pb-7 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:px-6 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100vh-3rem)] lg:border-0 lg:px-0 lg:pb-10 lg:pt-0 lg:shadow-none" aria-label="Contents">
<div class="hidden lg:sticky lg:top-0 lg:z-10 lg:block lg:bg-paper lg:pb-3">
<a class="block font-sans text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted no-underline hover:text-ink" href="{{ toc.issue_href }}">No. {{ toc.issue_number }} <span class="text-rule" aria-hidden="true">·</span> {{ toc.short_date }}</a>
<p class="mt-2 font-sans text-[0.72rem] text-ink-2">{% if toc.position > 0 %}Chapter {{ toc.position }} of {{ toc.total }}{% else %}Front page{% endif %}</p>
<progress class="toc-progress mt-2.5" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
</div>
<ul class="m-0 mt-1 list-none p-0 lg:mt-4">
{% for item in toc.items %}{% if item.is_section() %}<li class="mt-5 border-t border-rule pt-2 font-sans text-[0.66rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ item.label }}</li>{% else %}<li{% if item.divider %} class="mt-4 border-t border-rule pt-3"{% endif %}><a class="flex items-baseline gap-2 border-l-2 py-2.5 pl-3 no-underline transition-colors duration-150 lg:py-1.5 {% if item.current %}border-accent font-semibold text-ink{% else %}border-transparent text-ink-2 hover:border-rule-strong hover:text-accent{% endif %}" href="{{ item.href }}"{% if item.current %} aria-current="page"{% endif %}>{% match item.number %}{% when Some with (number) %}<span class="w-4 shrink-0 font-sans text-[0.66rem] tabular-nums text-muted">{{ number }}</span>{% when None %}<span class="w-4 shrink-0" aria-hidden="true"></span>{% endmatch %}<span class="min-w-0 flex-1 font-serif text-[0.95rem] leading-snug">{{ item.label }}</span>{% match item.minutes %}{% when Some with (minutes) %}<span class="shrink-0 font-sans text-[0.66rem] tabular-nums text-muted">{{ minutes }} min</span>{% when None %}{% endmatch %}</a></li>{% endif %}{% endfor %}
</ul>
</nav>
</div>
+1 -1
View File
@@ -1 +1 @@
{% extends "layout.html" %}{% block content %}<section class="reading narrow"><h1>Account</h1>{% if !error.is_empty() %}<p class="error">{{ error }}</p>{% endif %}<form method="post" action="/account/password"><label>Current password <input type="password" name="current_password" required></label><label>New password <input type="password" name="new_password" minlength="12" required></label><label>Confirm password <input type="password" name="confirm_password" minlength="12" required></label><button>Change password</button></form><form method="post" action="/account/logout-all" data-confirm="Sign out everywhere?"><button>Sign out everywhere</button></form><form method="post" action="/logout"><button>Sign out</button></form></section>{% endblock %}
{% extends "layout.html" %}{% block ears %}<span>Reader account</span>{% endblock %}{% block content %}<section class="mx-auto mt-12 max-w-md px-4 sm:mt-16 sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Reader account</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">Account</h1>{% if !error.is_empty() %}<p class="error mt-6">{{ error }}</p>{% endif %}<form class="mt-8 grid gap-5 border-b border-rule pb-9 font-sans text-sm" method="post" action="/account/password"><label class="grid gap-1.5 font-medium">Current password <input class="min-h-11 w-full" type="password" name="current_password" autocomplete="current-password" required></label><label class="grid gap-1.5 font-medium">New password <input class="min-h-11 w-full" type="password" name="new_password" autocomplete="new-password" minlength="12" required></label><label class="grid gap-1.5 font-medium">Confirm password <input class="min-h-11 w-full" type="password" name="confirm_password" autocomplete="new-password" minlength="12" required></label><button class="btn-primary mt-1 w-full">Change password</button></form><div class="mt-7 flex flex-wrap gap-3 font-sans text-sm"><form class="m-0" method="post" action="/account/logout-all" data-confirm="Sign out everywhere?"><button class="btn">Sign out everywhere</button></form><form class="m-0" method="post" action="/logout"><button class="btn">Sign out</button></form></div></section>{% endblock %}
+8 -20
View File
@@ -1,20 +1,8 @@
{% extends "layout.html" %}{% block content %}<article class="reading article-page">
<header class="article-header">
<h1><a href="{{ source_url }}">{{ title }}</a></h1>
{% match byline %}{% when Some with (byline) %}<p class="byline">{{ byline }}</p>{% when None %}{% endmatch %}
<p class="meta">{{ meta_line }}</p>
{% match why %}{% when Some with (why) %}<p class="why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}
{% match social_line %}{% when Some with (social) %}<p class="social">{{ social }}</p>{% when None %}{% endmatch %}
{% match summary %}{% when Some with (summary) %}<p class="summary">{{ summary }}</p>{% when None %}{% endmatch %}
{% if excerpt_only %}<p class="notice">(excerpt only — read online)</p>{% endif %}
</header>
<hr class="rule">
<div class="article-body">{{ body_html|safe }}</div>
{% match discussion_html %}{% when Some with (discussion) %}<section class="discussion"><h2>Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %}
<footer class="article-footer">
{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}
<p><a href="{{ read_online_url }}">Read online ↗</a></p>
<nav class="prev-next">{% match previous %}{% when Some with (previous) %}<a rel="prev" href="{{ previous.href }}">← {{ previous.title }}</a>{% when None %}<span></span>{% endmatch %}{% match next %}{% when Some with (next) %}<a rel="next" href="{{ next.href }}">{{ next.title }} →</a>{% when None %}{% endmatch %}</nav>
<p><a href="{{ issue_href }}">← Back to this issue</a></p>
</footer>
</article>{% endblock %}
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
<article class="mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0" data-toc-scroll>
<header><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Article</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl"><a class="text-ink no-underline hover:text-accent" href="{{ source_url }}">{{ title }}</a></h1>{% match byline %}{% when Some with (byline) %}<p class="mt-5 font-sans text-sm font-medium text-ink-2">{{ byline }}</p>{% when None %}{% endmatch %}<p class="mt-1 font-sans text-sm text-muted">{{ meta_line }}</p>{% match why %}{% when Some with (why) %}<p class="mt-6 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match social_line %}{% when Some with (social) %}<p class="mt-4 font-sans text-sm text-muted">{{ social }}</p>{% when None %}{% endmatch %}{% match summary %}{% when Some with (summary) %}<p class="mt-7 text-xl italic leading-relaxed text-ink-2">{{ summary }}</p>{% when None %}{% endmatch %}{% if excerpt_only %}<p class="notice mt-6">Excerpt only — continue reading at the original site.</p>{% endif %}</header>
<div class="prose-body mt-10 border-t border-rule pt-8">{{ body_html|safe }}</div>
{% match discussion_html %}{% when Some with (discussion) %}<section class="discussion prose-body mt-12 border-t border-rule pt-6 [&_.comment-meta]:font-sans [&_.comment-meta]:text-sm [&_.comment-meta]:text-muted [&_blockquote.reply]:ml-6"><h2 class="mb-6 border-0 pt-0 text-2xl">Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %}
<footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6"><a class="btn" href="{{ read_online_url }}">Read online ↗</a></p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer>
</article>
</div>{% endblock %}
+3 -7
View File
@@ -1,7 +1,3 @@
{% extends "layout.html" %}{% block content %}<article class="reading behind">
<h1>Behind the paper</h1>
<p class="fact-line">{{ summary_line }}</p><p class="fact-line">{{ admitted_line }}</p><p class="fact-line">{{ learned_line }}</p>
<h2>Near misses</h2><p><em>Highest utility not selected.</em></p>
{% if near_misses.is_empty() %}<p>None recorded for this run.</p>{% else %}<ul class="near-misses">{% for near_miss in near_misses %}<li>{% if page.is_admin() %}<a href="/dashboard/articles/{{ near_miss.article_id }}">{{ near_miss.line }}</a>{% else %}{{ near_miss.line }}{% endif %}</li>{% endfor %}</ul>{% endif %}
<p class="fact-line">{{ models_line }}</p><p><a href="{{ issue_href }}">← Back to this issue</a></p>
</article>{% endblock %}
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
<article class="mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0"><header class="mb-9"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Edition notes</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">Behind the paper</h1></header><section class="border-y border-rule py-6"><p>{{ summary_line }}</p><p class="mt-3">{{ admitted_line }}</p><p class="mt-3">{{ learned_line }}</p></section><section class="mt-10"><h2 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">Near misses</h2><p class="mt-5 italic text-ink-2">Highest utility not selected.</p>{% if near_misses.is_empty() %}<p class="mt-4 text-muted">None recorded for this run.</p>{% else %}<ol class="mt-5 list-decimal space-y-3 pl-6">{% for near_miss in near_misses %}<li>{% if page.is_admin() %}<a class="text-ink decoration-rule hover:decoration-accent" href="/dashboard/articles/{{ near_miss.article_id }}">{{ near_miss.line }}</a>{% else %}{{ near_miss.line }}{% endif %}</li>{% endfor %}</ol>{% endif %}</section><p class="mt-10 border-l-2 border-rule pl-4 italic text-ink-2">{{ models_line }}</p><p class="mt-10 border-t border-rule pt-5 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></article>
</div>{% endblock %}
+1 -1
View File
@@ -1 +1 @@
{% if pager.pages > 1 %}<nav class="pager">{% if let Some(href) = pager.prev_href %}<a href="{{ href }}">← Previous</a>{% endif %}<span>Page {{ pager.page }} of {{ pager.pages }} · {{ pager.total }} rows</span>{% if let Some(href) = pager.next_href %}<a href="{{ href }}">Next →</a>{% endif %}</nav>{% else %}<p class="muted">{{ pager.total }} row{% if pager.total != 1 %}s{% endif %}</p>{% endif %}
{% if pager.pages > 1 %}<nav class="pager" aria-label="Pagination"><span>Page <span class="text-ink">{{ pager.page }}</span> of {{ pager.pages }} · {{ pager.total }} rows</span><span class="flex items-center gap-2">{% if let Some(href) = pager.prev_href %}<a class="btn" href="{{ href }}">← Previous</a>{% endif %}{% if let Some(href) = pager.next_href %}<a class="btn" href="{{ href }}">Next →</a>{% endif %}</span></nav>{% else %}<p class="pager">{{ pager.total }} row{% if pager.total != 1 %}s{% endif %}</p>{% endif %}
+2 -2
View File
@@ -1,8 +1,8 @@
<figure class="spark-figure"><figcaption>{{ spark.title }}{% if !spark.caption.is_empty() %} <span class="muted">· {{ spark.caption }}</span>{% endif %}</figcaption>
{% if spark.empty %}<p class="muted">No data yet.</p>{% else %}<svg class="spark" viewBox="0 0 {{ spark.width }} {{ spark.height }}" width="{{ spark.width }}" height="{{ spark.height }}" role="img" aria-label="{{ spark.title }}">
{% if spark.empty %}<p class="muted text-sm">No data yet.</p>{% else %}<svg class="spark" viewBox="0 0 {{ spark.width }} {{ spark.height }}" width="{{ spark.width }}" height="{{ spark.height }}" role="img" aria-label="{{ spark.title }}">
{% for bar in spark.bars %}<rect class="s{{ bar.series }}" x="{{ bar.x }}" y="{{ bar.y }}" width="{{ bar.w }}" height="{{ bar.h }}"><title>{{ bar.label }}</title></rect>
{% endfor %}{% if !spark.points.is_empty() %}<polyline class="line" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" points="{{ spark.points }}"/>{% endif %}
</svg>
<div class="spark-axis muted"><span>{{ spark.first_label }}</span><span>{{ spark.last_label }}</span></div>
{% if !spark.legend.is_empty() %}<ul class="spark-legend">{% for item in spark.legend %}<li><svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><rect class="s{{ item.series }}" width="10" height="10"/></svg> {{ item.name }}</li>{% endfor %}</ul>{% endif %}
{% if !spark.legend.is_empty() %}<ul class="spark-legend">{% for item in spark.legend %}<li><svg class="spark h-2.5 w-2.5 shrink-0" width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><rect class="s{{ item.series }}" width="10" height="10"/></svg> {{ item.name }}</li>{% endfor %}</ul>{% endif %}
{% endif %}</figure>
+42 -30
View File
@@ -1,69 +1,81 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<h1><a href="{{ url }}">{{ title }}</a></h1>
<p class="muted">Article {{ id }} · <a href="/dashboard/articles?feed={{ feed_id }}">{{ feed }}</a>{% if let Some(category) = category %} · {{ category }}{% endif %}{% if let Some(author) = author %} · {{ author }}{% endif %}</p>
<header class="page-head"><div>
<p class="page-eyebrow"><a class="text-muted no-underline hover:text-accent" href="/dashboard/articles">Articles</a> › {{ id }}</p>
<h1 class="mt-1"><a class="text-ink no-underline hover:text-accent" href="{{ url }}">{{ title }}</a></h1>
<p class="page-desc"><a href="/dashboard/articles?feed={{ feed_id }}">{{ feed }}</a>{% if let Some(category) = category %} · {{ category }}{% endif %}{% if let Some(author) = author %} · {{ author }}{% endif %}</p>
</div><div class="page-actions">{% if let Some(rating) = rating %}<span class="badge {{ rating_class }}">{{ rating }}</span>{% else %}<span class="badge">unrated</span>{% endif %}<a class="btn" href="{{ url }}">Open source</a></div></header>
<h2>Article</h2>
<div class="cards">
<section class="card"><h2>Article</h2>
<dl class="kv">
<dt>Canonical URL</dt><dd><a href="{{ canonical_url }}">{{ canonical_url }}</a></dd>
<dt>Published</dt><dd>{{ published_at }}</dd>
<dt>First seen</dt><dd>{{ first_seen }}</dd>
<dt>Words</dt><dd>{{ words }}{% if excerpt_only %} <span class="badge">excerpt only</span>{% endif %}</dd>
<dt>Images</dt><dd>{{ image_count }}</dd>
<dt>Current rating</dt><dd>{% if let Some(rating) = rating %}<span class="badge {{ rating_class }}">{{ rating }}</span>{% else %}<span class="muted">unrated</span>{% endif %}</dd>
</dl>
</section>
<section class="card"><h2>Provenance</h2>
<dl class="kv">
<dt>Sources</dt><dd>{% if sources.is_empty() %}<span class="muted">none recorded</span>{% else %}{% for source in sources %}{{ source.kind }} · {{ source.feed }}{% if let Some(category) = source.category %} ({{ category }}){% endif %}{% if !loop.last %}<br>{% endif %}{% endfor %}{% endif %}</dd>
<dt>Social</dt><dd>{% if social.is_empty() %}<span class="muted">none</span>{% else %}{% for item in social %}{% if let Some(url) = item.url %}<a href="{{ url }}">{{ item.source }}</a>{% else %}{{ item.source }}{% endif %} · {{ item.score }} points · {{ item.comments }} comments{% if !loop.last %}<br>{% endif %}{% endfor %}{% endif %}</dd>
<dt>In issues</dt><dd>{% if in_issues.is_empty() %}<span class="muted">never published</span>{% else %}{% for issue in in_issues %}<a href="{{ issue.href }}">{{ issue.date }}</a> · {{ issue.section }} · position {{ issue.position }}{% if issue.is_lead %} · lead{% endif %}{% if !loop.last %}<br>{% endif %}{% endfor %}{% endif %}</dd>
<dt>Current rating</dt><dd>{% if let Some(rating) = rating %}<span class="badge {{ rating_class }}">{{ rating }}</span>{% else %}<span class="muted">unrated</span>{% endif %}</dd>
</dl>
</section>
</div>
<h2>Your verdict</h2>
{% include "_rating_widget.html" %}
{% if let Some(text) = explain %}<details class="explain"><summary>Explain (text)</summary><pre class="block">{{ text }}</pre></details>{% endif %}
{% if let Some(text) = explain %}<details class="explain disclosure" id="article-explain"><summary>Explain (text)</summary><div class="disclosure-body"><pre class="block">{{ text }}</pre></div></details>{% endif %}
<h2>Assessments</h2>
{% if assessments.is_empty() %}<p class="muted">No LLM assessment stored.</p>{% else %}{% for assessment in assessments %}<section class="card">
<h3>{{ assessment.stage }}{% if assessment.rejected %} <span class="badge down">rejected by provider</span>{% endif %}</h3>
{% if assessment.rejected %}<p>{{ assessment.rationale }}</p><p class="muted">{{ assessment.model }} · prompt v{{ assessment.prompt_version }} · {{ assessment.assessed_at }}</p>{% else %}<dl class="kv">
{% if assessment.stage == "deep" %}<dt>Quality</dt><dd>{{ assessment.score }}</dd><dt>Fit</dt><dd>{{ assessment.fit }}</dd><dt>Category</dt><dd>{{ assessment.category }}</dd><dt>Format</dt><dd>{{ assessment.kind }}</dd><dt>Paywalled guess</dt><dd>{% if assessment.paywalled %}yes{% else %}no{% endif %}</dd>{% else %}<dt>Interest</dt><dd>{{ assessment.score }}</dd><dt>Kind</dt><dd>{{ assessment.kind }}</dd>{% endif %}
{% if assessments.is_empty() %}<p class="muted text-sm">No LLM assessment stored.</p>{% else %}<div class="cards">{% for assessment in assessments %}<section class="card">
<h3 class="flex flex-wrap items-center gap-2">{{ assessment.stage }}{% if assessment.rejected %} <span class="badge down">rejected by provider</span>{% endif %}</h3>
{% if assessment.rejected %}<p>{{ assessment.rationale }}</p><p class="muted text-xs">{{ assessment.model }} · prompt v{{ assessment.prompt_version }} · {{ assessment.assessed_at }}</p>{% else %}<dl class="kv">
{% if assessment.stage == "deep" %}<dt>Quality</dt><dd class="tabular-nums">{{ assessment.score }}</dd><dt>Fit</dt><dd class="tabular-nums">{{ assessment.fit }}</dd><dt>Category</dt><dd>{{ assessment.category }}</dd><dt>Format</dt><dd>{{ assessment.kind }}</dd><dt>Paywalled guess</dt><dd>{% if assessment.paywalled %}yes{% else %}no{% endif %}</dd>{% else %}<dt>Interest</dt><dd class="tabular-nums">{{ assessment.score }}</dd><dt>Kind</dt><dd>{{ assessment.kind }}</dd>{% endif %}
<dt>Rationale</dt><dd>{{ assessment.rationale }}</dd>
<dt>Model</dt><dd>{{ assessment.model }} · prompt v{{ assessment.prompt_version }} · profile v{{ assessment.profile_version }}</dd>
<dt>Assessed</dt><dd>{{ assessment.assessed_at }}</dd>
</dl>
{% if !assessment.facets.is_empty() %}<div class="scroll-x"><table class="facets"><thead><tr><th>facet</th><th>value</th></tr></thead><tbody>{% for facet in assessment.facets %}<tr><td>{{ facet.name }}</td><td>{{ facet.value }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% endif %}
</section>{% endfor %}{% endif %}
{% if !assessment.facets.is_empty() %}<div class="scroll-x"><table class="facets"><thead><tr><th>facet</th><th>value</th></tr></thead><tbody>{% for facet in assessment.facets %}<tr><td>{{ facet.name }}</td><td class="cell-wrap">{{ facet.value }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% endif %}
</section>{% endfor %}</div>{% endif %}
<h2>Run history</h2>
{% if history.is_empty() %}<p class="muted">Never considered by a run.</p>{% else %}<div class="scroll-x"><table class="history">
{% if history.is_empty() %}<p class="muted text-sm">Never considered by a run.</p>{% else %}<div class="scroll-x"><table class="history">
<thead><tr><th>run</th><th>date</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th>editor</th></tr></thead>
<tbody>{% for row in history %}<tr>
<td><details class="signals"><summary><a href="{{ row.run_href }}">run {{ row.run_id }}</a> <span class="badge {{ row.status }}">{{ row.status }}</span></summary>{% let signals = row.signals %}{% include "_signals_table.html" %}</details></td>
<td>{{ row.date }}</td>
<td><span class="badge {{ row.stage }}">{{ row.stage }}</span></td>
<td>{% if let Some(reason) = row.reason %}{{ reason }}{% endif %}</td>
<td>{% if let Some(first) = row.admitted_first %}{{ first }}{% if !row.admitted_rest.is_empty() %} <span class="muted">({{ row.admitted_rest }})</span>{% endif %}{% endif %}</td>
<td class="cell-wrap"><details class="signals"><summary><a href="{{ row.run_href }}">run {{ row.run_id }}</a> <span class="badge {{ row.status }}">{{ row.status }}</span></summary>{% let signals = row.signals %}{% include "_signals_table.html" %}</details></td>
<td class="cell-tight text-muted">{{ row.date }}</td>
<td class="cell-tight"><span class="badge {{ row.stage }}">{{ row.stage }}</span></td>
<td class="cell-tight text-muted">{% if let Some(reason) = row.reason %}{{ reason }}{% endif %}</td>
<td class="cell-tight text-muted">{% if let Some(first) = row.admitted_first %}{{ first }}{% if !row.admitted_rest.is_empty() %} <span class="muted text-xs">({{ row.admitted_rest }})</span>{% endif %}{% endif %}</td>
<td class="num">{{ row.utility }}</td>
<td class="num">{{ row.rank }}</td>
<td class="num">{{ row.cluster }}</td>
<td>{% if let Some(why) = row.editor_why %}{{ why }}{% endif %}</td>
<td class="cell-wrap text-muted">{% if let Some(why) = row.editor_why %}{{ why }}{% endif %}</td>
</tr>{% endfor %}</tbody></table></div>{% endif %}
<h2>Neighbours and interests</h2>
{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted">The latest run recorded no interests or neighbours for this article.</p>{% else %}<div class="cards">
{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted text-sm">The latest run recorded no interests or neighbours for this article.</p>{% else %}<div class="cards">
<section class="card"><h3>Top interests</h3>{% if signals.top_interests.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>interest</th><th class="num">z</th><th class="num">cos</th></tr></thead><tbody>{% for interest in signals.top_interests %}<tr><td>{{ interest.name }}</td><td class="num">{{ interest.z }}</td><td class="num">{{ interest.cos }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
<section class="card"><h3>Nearest rated neighbours</h3>{% if signals.neighbours.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>label</th><th class="num">cos</th><th>article</th></tr></thead><tbody>{% for neighbour in signals.neighbours %}<tr><td><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span></td><td class="num">{{ neighbour.cos }}</td><td><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
</div>{% endif %}{% else %}<p class="muted">No signals recorded.</p>{% endif %}
<section class="card"><h3>Nearest rated neighbours</h3>{% if signals.neighbours.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>label</th><th class="num">cos</th><th>article</th></tr></thead><tbody>{% for neighbour in signals.neighbours %}<tr><td><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span></td><td class="num">{{ neighbour.cos }}</td><td class="cell-wrap"><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
</div>{% endif %}{% else %}<p class="muted text-sm">No signals recorded.</p>{% endif %}
<h2>Embedding</h2>
{% if let Some(embedding) = embedding %}<dl class="kv"><dt>Model</dt><dd>{{ embedding.model }}</dd><dt>Dimension</dt><dd>{{ embedding.dimension }}</dd><dt>Created</dt><dd>{{ embedding.created_at }}</dd><dt>Input hash</dt><dd><code>{{ embedding.input_hash }}</code></dd></dl>{% else %}<p class="muted">No embedding stored.</p>{% endif %}
{% if let Some(embedding) = embedding %}<dl class="kv"><dt>Model</dt><dd>{{ embedding.model }}</dd><dt>Dimension</dt><dd class="tabular-nums">{{ embedding.dimension }}</dd><dt>Created</dt><dd>{{ embedding.created_at }}</dd><dt>Input hash</dt><dd><code>{{ embedding.input_hash }}</code></dd></dl>{% else %}<p class="muted text-sm">No embedding stored.</p>{% endif %}
<h2>Rating events</h2>
{% if events.is_empty() %}<p class="muted">No rating events.</p>{% else %}<div class="scroll-x"><table class="events">
{% if events.is_empty() %}<p class="muted text-sm">No rating events.</p>{% else %}<div class="scroll-x"><table class="events">
<thead><tr><th>when</th><th>label</th><th class="num">value</th><th>kind</th><th>source</th><th>user</th><th>issue</th><th>note</th></tr></thead>
<tbody>{% for event in events %}<tr>
<td>{{ event.event_at }}</td>
<td><span class="badge {{ event.label_class }}">{{ event.label }}</span></td>
<td class="cell-tight text-muted">{{ event.event_at }}</td>
<td class="cell-tight"><span class="badge {{ event.label_class }}">{{ event.label }}</span></td>
<td class="num">{{ event.value }}</td>
<td>{{ event.kind }}</td>
<td>{{ event.source }}</td>
<td>{% if let Some(user) = event.user %}{{ user }}{% else %}<span class="muted">—</span>{% endif %}</td>
<td>{% if let Some(date) = event.issue_date %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
<td>{% if let Some(note) = event.note %}{{ note }}{% endif %}</td>
<td class="cell-tight text-muted">{{ event.kind }}</td>
<td class="cell-tight text-muted">{{ event.source }}</td>
<td class="cell-tight">{% if let Some(user) = event.user %}{{ user }}{% else %}<span class="muted">—</span>{% endif %}</td>
<td class="cell-tight">{% if let Some(date) = event.issue_date %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
<td class="cell-wrap text-muted">{% if let Some(note) = event.note %}{{ note }}{% endif %}</td>
</tr>{% endfor %}</tbody></table></div>{% endif %}
</section>{% endblock %}
+14 -11
View File
@@ -1,7 +1,10 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<header class="page-head"><div>
<h1>Articles</h1>
<p class="page-desc">Every article the crawler has ever seen, with the last stage it reached, its scores and its verdict.</p>
</div><div class="page-actions"><a class="btn" href="/dashboard/ratings">Ratings</a><a class="btn" href="/dashboard/runs">Runs</a></div></header>
<form class="filters" method="get" action="/dashboard/articles">
<label>Title or URL <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}"></label>
<label>Title or URL <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}" placeholder="contains…"></label>
<label>Feed id <input type="number" name="feed" value="{{ feed_value }}" min="1"></label>
<label>Stage <select name="stage"><option value="">any</option>{% for name in stages %}<option value="{{ name }}"{% if let Some(current) = filters.stage %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
<label>Reason <select name="reason"><option value="">any</option>{% for name in reasons %}<option value="{{ name }}"{% if let Some(current) = filters.reason %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
@@ -11,24 +14,24 @@
<label>to <input type="date" name="to" value="{% if let Some(to) = filters.to %}{{ to }}{% endif %}"></label>
<label>Triage kind <select name="kind"><option value="">any</option>{% for name in kinds %}<option value="{{ name }}"{% if let Some(current) = filters.kind %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
<label>Sort <select name="sort">{% for name in sorts %}<option value="{{ name }}"{% if filters.sort == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
<button type="submit">Apply</button> <a href="/dashboard/articles">Reset</a>
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/articles">Reset</a></div>
</form>
{% include "dashboard/_pager.html" %}
<div class="scroll-x"><table class="articles" data-filter>
<div class="scroll-x tall"><table class="articles" data-filter>
<thead><tr><th>first seen</th><th>title</th><th>feed</th><th class="num">words</th><th>last stage</th><th>reason</th><th class="num">utility</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>rating</th><th>published</th></tr></thead>
<tbody>{% for article in articles %}<tr>
<td>{{ article.first_seen }}</td>
<td><a href="{{ article.href }}">{{ article.title }}</a></td>
<td>{% if let Some(feed_id) = article.feed_id %}<a href="/dashboard/articles?feed={{ feed_id }}">{{ article.feed }}</a>{% else %}{{ article.feed }}{% endif %}</td>
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
<td class="cell-wrap"><a href="{{ article.href }}">{{ article.title }}</a></td>
<td class="cell-tight">{% if let Some(feed_id) = article.feed_id %}<a href="/dashboard/articles?feed={{ feed_id }}">{{ article.feed }}</a>{% else %}{{ article.feed }}{% endif %}</td>
<td class="num">{{ article.words }}</td>
<td>{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted">never considered</span>{% endif %}</td>
<td>{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}</td>
<td class="cell-tight">{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted text-xs" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted text-xs">never considered</span>{% endif %}</td>
<td class="cell-tight text-muted">{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}</td>
<td class="num">{{ article.utility }}</td>
<td class="num">{{ article.triage }}</td>
<td class="num">{{ article.quality }}</td>
<td class="num">{{ article.fit }}</td>
<td>{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td>
<td>{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
</tr>{% endfor %}</tbody></table></div>
<td class="cell-tight">{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td>
<td class="cell-tight">{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
</tr>{% endfor %}{% if articles.is_empty() %}<tr><td colspan="12" class="text-muted">No articles match this filter.</td></tr>{% endif %}</tbody></table></div>
{% include "dashboard/_pager.html" %}
</section>{% endblock %}
+9 -6
View File
@@ -1,6 +1,9 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard"{% if refresh %} data-refresh="5"{% endif %}>
<h1><a href="/dashboard/jobs">Jobs</a> › Job {{ job.id }} · {{ job.name }} <span class="badge {{ job.status }}">{{ job.status }}</span></h1>
{% if !description.is_empty() %}<p class="muted">{{ description }}</p>{% endif %}
<header class="page-head"><div>
<p class="page-eyebrow"><a class="text-muted no-underline hover:text-accent" href="/dashboard/jobs">Jobs</a> › Job {{ job.id }}</p>
<h1 class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1"><span class="font-mono">{{ job.name }}</span> <span class="badge {{ job.status }}">{{ job.status }}</span></h1>
{% if !description.is_empty() %}<p class="page-desc">{{ description }}</p>{% endif %}
</div><div class="page-actions"><a class="btn" href="/dashboard/jobs">All jobs</a>{% if let Some(run_id) = job.run_id %}<a class="btn" href="/dashboard/runs/{{ run_id }}">Run {{ run_id }}</a>{% endif %}</div></header>
<div class="cards">
<section class="card"><h2>Job</h2>
<dl class="kv">
@@ -12,7 +15,7 @@
{% if let Some(message) = job.message %}<dt>Message</dt><dd class="message">{{ message }}</dd>{% endif %}
{% if let Some(run_id) = job.run_id %}<dt>Run</dt><dd><a href="/dashboard/runs/{{ run_id }}">Run {{ run_id }}</a></dd>{% endif %}
</dl>
{% if refresh %}<p class="muted">This page reloads every 5 seconds while the job is requested or running.</p>{% endif %}
{% if refresh %}<p class="muted text-xs">This page reloads every 5 seconds while the job is requested or running.</p>{% endif %}
</section>
<section class="card"><h2>Unit</h2>
{% if let Some(status) = status %}<dl class="kv">
@@ -22,11 +25,11 @@
{% if let Some(exited) = status.exited %}<dt>Main exited</dt><dd>{{ exited }}</dd>{% endif %}
</dl>{% endif %}
{% if let Some(error) = status_error %}<p class="error">Unit status unavailable: {{ error }}</p>{% endif %}
<p class="muted">From <code>systemctl show {{ unit }}</code>.</p>
<p class="muted text-xs">From <code>systemctl show {{ unit }}</code>.</p>
</section>
</div>
<h2>Log</h2>
<h2>Journal</h2>
{% if let Some(error) = log_error %}<p class="error">Journal unavailable: {{ error }}</p>{% endif %}
<p class="muted">Last {{ log_lines }} lines of <code>journalctl -u {{ unit }}</code>.</p>
<p class="muted text-sm">Last {{ log_lines }} lines of <code>journalctl -u {{ unit }}</code>.</p>
<pre class="preview journal">{{ log }}</pre>
</section>{% endblock %}
+25 -19
View File
@@ -1,28 +1,34 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<header class="page-head"><div>
<h1>Jobs</h1>
{% if jobs_enabled %}<p class="muted">Each job starts <code>daily-epub-job@&lt;name&gt;.service</code> through systemd; the unit runs <code>daily-epub job run &lt;name&gt;</code> and records itself here. Jobs that take the run lock wait for nothing: a second <code>generate</code> while one is running fails immediately.</p>{% else %}<p class="notice">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run the commands by hand instead.</p>{% endif %}
<div class="cards job-cards">{% for card in cards %}<section class="card">
<h2>{{ card.name }}</h2>
<p>{{ card.description }}</p>
{% if let Some(lock) = card.lock %}<p class="muted">Takes the <code>{{ lock }}</code> lock.</p>{% endif %}
<form method="post" action="/dashboard/jobs/{{ card.name }}" class="inline"{% if card.dangerous %} data-confirm="Start {{ card.name }}? This republishes the issue of that date."{% endif %}>
{% if card.dated %}<label>Date <input type="date" name="date" value="{{ today }}"></label>{% endif %}
<button type="submit"{% if !jobs_enabled %} disabled{% endif %}>Start</button>
<p class="page-desc">{% if jobs_enabled %}Each job starts <code>daily-epub-job@&lt;name&gt;.service</code> through systemd; the unit runs <code>daily-epub job run &lt;name&gt;</code> and records itself here.{% else %}Jobs are disabled on this server; the catalogue below is read-only.{% endif %}</p>
</div><div class="page-actions"><a class="btn" href="/dashboard/runs">Runs</a><a class="btn" href="/dashboard/settings">Settings</a></div></header>
{% if jobs_enabled %}<p class="muted text-sm">Jobs that take the run lock wait for nothing: a second <code>generate</code> while one is running fails immediately.</p>{% else %}<p class="notice">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run the commands by hand instead.</p>{% endif %}
<div class="cards job-cards">{% for card in cards %}<section class="card flex flex-col">
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h2 class="font-mono text-base">{{ card.name }}</h2>
{% if let Some(status) = card.last_status %}<span class="badge {{ status }}">{{ status }}</span>{% else %}<span class="muted text-xs uppercase tracking-[0.1em]">never run</span>{% endif %}
</div>
<p class="text-muted">{{ card.description }}</p>
<p class="text-xs text-muted">{% if let Some(id) = card.last_id %}Last started <a href="/dashboard/jobs/{{ id }}">{% if let Some(when) = card.last_requested %}{{ when }}{% else %}job {{ id }}{% endif %}</a>{% else %}No run recorded yet.{% endif %}{% if let Some(lock) = card.lock %} · takes the <code class="whitespace-nowrap">{{ lock }}</code> lock{% endif %}</p>
<form method="post" action="/dashboard/jobs/{{ card.name }}" class="form-inline mt-auto pt-1"{% if card.dangerous %} data-confirm="Start {{ card.name }}? This republishes the issue of that date."{% endif %}>
{% if card.dated %}<label class="text-xs uppercase tracking-[0.1em] text-muted">Date <input type="date" name="date" value="{{ today }}" class="text-sm normal-case tracking-normal text-ink"></label>{% endif %}
<button class="btn" type="submit"{% if !jobs_enabled %} disabled{% endif %}>Start</button>
</form>
</section>{% endfor %}</div>
<h2>History</h2>
{% if jobs.is_empty() %}<p class="muted">No jobs recorded yet.</p>{% else %}<div class="scroll-x"><table data-filter>
<thead><tr><th>job</th><th>name</th><th>requested by</th><th>requested</th><th>started</th><th>finished</th><th class="num">duration</th><th>status</th><th>message</th><th>run</th></tr></thead>
{% if jobs.is_empty() %}<p class="muted text-sm">No jobs recorded yet.</p>{% else %}<div class="scroll-x tall"><table data-filter>
<thead><tr><th class="num">job</th><th>name</th><th>requested by</th><th>requested</th><th>started</th><th>finished</th><th class="num">duration</th><th>status</th><th>message</th><th class="num">run</th></tr></thead>
<tbody>{% for job in jobs %}<tr>
<td><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
<td>{{ job.name }}</td>
<td>{{ job.requested_by }}</td>
<td>{{ job.requested }}</td>
<td>{{ job.started }}</td>
<td>{{ job.finished }}</td>
<td class="num"><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
<td class="cell-tight font-mono text-xs">{{ job.name }}</td>
<td class="cell-tight text-muted">{{ job.requested_by }}</td>
<td class="cell-tight text-muted">{{ job.requested }}</td>
<td class="cell-tight text-muted">{{ job.started }}</td>
<td class="cell-tight text-muted">{{ job.finished }}</td>
<td class="num">{{ job.duration }}</td>
<td><span class="badge {{ job.status }}">{{ job.status }}</span></td>
<td class="message">{% if let Some(message) = job.message %}{{ message }}{% endif %}</td>
<td>{% if let Some(run_id) = job.run_id %}<a href="/dashboard/runs/{{ run_id }}">{{ run_id }}</a>{% endif %}</td>
<td class="cell-tight"><span class="badge {{ job.status }}">{{ job.status }}</span></td>
<td class="message cell-wrap text-muted">{% if let Some(message) = job.message %}{{ message }}{% endif %}</td>
<td class="num">{% if let Some(run_id) = job.run_id %}<a href="/dashboard/runs/{{ run_id }}">{{ run_id }}</a>{% endif %}</td>
</tr>{% endfor %}</tbody></table></div>{% endif %}
</section>{% endblock %}
+28 -14
View File
@@ -1,41 +1,55 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<header class="page-head"><div>
<h1>Overview</h1>
<p class="page-desc">The state of the paper right now: the last run, today's spend, this week's verdicts and anything waiting on you.</p>
</div><div class="page-actions"><a class="btn" href="/dashboard/runs">Runs</a><a class="btn" href="/dashboard/jobs">Jobs</a><a class="btn" href="/dashboard/stats">Stats</a></div></header>
<div class="tiles">
<div class="tile"><span class="tile-label">Last run</span>{% if let Some(run) = last_run %}<span class="tile-num">{{ run.date }}</span><span class="tile-delta"><span class="badge {{ run.status }}">{{ run.status }}</span> · {{ run.duration }}</span>{% else %}<span class="tile-num text-muted">—</span><span class="tile-delta">no runs yet</span>{% endif %}</div>
<div class="tile"><span class="tile-label">Warnings</span>{% if let Some(run) = last_run %}<span class="tile-num{% if run.warnings > 0 %} text-warn{% endif %}">{{ run.warnings }}</span><span class="tile-delta">{% if run.warnings > 0 %}<a href="/dashboard/runs/{{ run.id }}#warnings">on run {{ run.id }}</a>{% else %}clean run{% endif %}</span>{% else %}<span class="tile-num text-muted">—</span><span class="tile-delta">no runs yet</span>{% endif %}</div>
<div class="tile"><span class="tile-label">Verdicts, 7 days</span><span class="tile-num">{{ ratings_total }}</span><span class="tile-delta"><a href="/dashboard/ratings">rating history</a></span></div>
<div class="tile"><span class="tile-label">Unrated picks</span><span class="tile-num">{{ unrated.len() }}</span><span class="tile-delta">from the last three issues</span></div>
<div class="tile"><span class="tile-label">Active jobs</span><span class="tile-num">{{ active_jobs.len() }}</span><span class="tile-delta"><a href="/dashboard/jobs">all jobs</a></span></div>
</div>
<div class="cards">
<section class="card"><h2>Last run</h2>
{% if let Some(run) = last_run %}<p><a href="/dashboard/runs/{{ run.id }}">Run {{ run.id }}</a> · {{ run.date }} · <span class="badge {{ run.status }}">{{ run.status }}</span></p>
<p class="muted">Started {{ run.started }} · {{ run.duration }}</p>
<p class="muted text-xs">Started {{ run.started }} · {{ run.duration }}</p>
<pre class="block">{% for line in run.lines %}{{ line }}
{% endfor %}</pre>
{% if run.warnings > 0 %}<p><a href="/dashboard/runs/{{ run.id }}#warnings">{{ run.warnings }} warning{% if run.warnings != 1 %}s{% endif %}</a></p>{% endif %}
{% if run.warnings > 0 %}<p class="text-sm"><a href="/dashboard/runs/{{ run.id }}#warnings">{{ run.warnings }} warning{% if run.warnings != 1 %}s{% endif %}</a></p>{% endif %}
{% if let Some(error) = run.error %}<p class="error">{{ error }}</p>{% endif %}
{% else %}<p class="muted">No runs recorded yet.</p>{% endif %}
</section>
<section class="card"><h2>Budget today</h2>
{% if budget.is_empty() %}<p class="muted">No providers configured.</p>{% else %}<dl class="kv budget">
{% for line in budget %}<dt>{{ line.provider }}</dt><dd><meter value="{{ line.value }}" max="{{ line.max }}"{% if line.over %} class="over"{% endif %}></meter> {{ line.spent }} of {{ line.ceiling }}{% if line.over %} <strong class="error">ceiling reached</strong>{% endif %}</dd>
{% if budget.is_empty() %}<p class="muted">No providers configured.</p>{% else %}<dl class="kv budget grid-cols-1 gap-y-3">
{% for line in budget %}<div><div class="flex flex-wrap items-baseline justify-between gap-2"><dt class="font-medium text-ink">{{ line.provider }}</dt><dd class="text-muted">{{ line.spent }} of {{ line.ceiling }}{% if line.over %} · <strong class="text-down">ceiling reached</strong>{% endif %}</dd></div><dd class="mt-1"><meter value="{{ line.value }}" max="{{ line.max }}"{% if line.over %} class="over"{% endif %}></meter></dd></div>
{% endfor %}</dl>{% endif %}
<p class="muted">Spend on the current UTC day across every recorded run.</p>
<p class="muted text-xs">Spend on the current UTC day across every recorded run.</p>
</section>
<section class="card"><h2>Ratings this week</h2>
{% if ratings.is_empty() %}<p class="muted">No explicit ratings in the last seven days.</p>{% else %}<p>{{ ratings_total }} verdict{% if ratings_total != 1 %}s{% endif %}: {% for count in ratings %}<span class="badge {{ count.label }}">{{ count.label }}</span> {{ count.count }}{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %}
<p><a href="/dashboard/ratings">Rating history</a></p>
{% if ratings.is_empty() %}<p class="muted">No explicit ratings in the last seven days.</p>{% else %}<p class="flex flex-wrap items-center gap-x-3 gap-y-1.5">{{ ratings_total }} verdict{% if ratings_total != 1 %}s{% endif %}{% for count in ratings %}<span class="inline-flex items-center gap-1.5"><span class="badge {{ count.label }}">{{ count.label }}</span> <span class="tabular-nums">{{ count.count }}</span></span>{% endfor %}</p>{% endif %}
<p class="text-sm"><a href="/dashboard/ratings">Rating history</a></p>
</section>
<section class="card"><h2>Jobs</h2>
{% if active_jobs.is_empty() && finished_jobs.is_empty() %}<p class="muted">No jobs recorded.</p>{% else %}
{% if !active_jobs.is_empty() %}<ul class="jobs">{% for job in active_jobs %}<li><a href="/dashboard/jobs/{{ job.id }}">{{ job.name }}</a> <span class="badge {{ job.status }}">{{ job.status }}</span> <span class="muted">requested {{ job.requested }}</span></li>{% endfor %}</ul>{% endif %}
{% if !finished_jobs.is_empty() %}<p class="muted">Recently finished</p><ul class="jobs">{% for job in finished_jobs %}<li><a href="/dashboard/jobs/{{ job.id }}">{{ job.name }}</a> <span class="badge {{ job.status }}">{{ job.status }}</span> <span class="muted">{{ job.finished }}</span>{% if let Some(message) = job.message %} <span class="muted">— {{ message }}</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
{% if !active_jobs.is_empty() %}<ul class="jobs divide-y divide-rule border-y border-rule">{% for job in active_jobs %}<li class="flex flex-wrap items-baseline gap-x-2 py-1.5"><a class="font-mono text-xs" href="/dashboard/jobs/{{ job.id }}">{{ job.name }}</a> <span class="badge {{ job.status }}">{{ job.status }}</span> <span class="muted text-xs">requested {{ job.requested }}</span></li>{% endfor %}</ul>{% endif %}
{% if !finished_jobs.is_empty() %}<p class="page-eyebrow">Recently finished</p><ul class="jobs divide-y divide-rule border-y border-rule">{% for job in finished_jobs %}<li class="flex flex-wrap items-baseline gap-x-2 py-1.5"><a class="font-mono text-xs" href="/dashboard/jobs/{{ job.id }}">{{ job.name }}</a> <span class="badge {{ job.status }}">{{ job.status }}</span> <span class="muted text-xs">{{ job.finished }}</span>{% if let Some(message) = job.message %} <span class="muted text-xs">— {{ message }}</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
{% endif %}
<p><a href="/dashboard/jobs">All jobs</a></p>
<p class="text-sm"><a href="/dashboard/jobs">All jobs</a></p>
</section>
{% if !config_warnings.is_empty() %}<section class="card"><h2>Config on disk</h2>
<ul class="warnings">{% for line in config_warnings %}<li class="error">{{ line }}</li>{% endfor %}</ul>
<p><a href="/dashboard/settings">Settings</a></p>
<ul class="warnings error list-disc pl-5">{% for line in config_warnings %}<li>{{ line }}</li>{% endfor %}</ul>
<p class="text-sm"><a href="/dashboard/settings">Settings</a></p>
</section>{% endif %}
</div>
<h2>Last 30 runs</h2>
<div class="sparklines">{% for item in sparklines %}{% let spark = item %}{% include "dashboard/_sparkline.html" %}{% endfor %}</div>
<h2>Unrated picks</h2>
{% if unrated.is_empty() %}<p class="muted">Every pick from the last three issues has a verdict.</p>{% else %}<p class="muted">Picks from the last three issues without a verdict yet.</p>
<ul class="picks">{% for pick in unrated %}<li><a href="{{ pick.issue_href }}">{{ pick.title }}</a> <span class="muted">· {{ pick.feed }} · {{ pick.issue_date }}</span>
{% if unrated.is_empty() %}<p class="muted text-sm">Every pick from the last three issues has a verdict.</p>{% else %}<p class="muted text-sm">Picks from the last three issues without a verdict yet.</p>
<ul class="picks divide-y divide-rule border-y border-rule">{% for pick in unrated %}<li class="py-3"><a class="font-medium text-ink no-underline hover:text-accent" href="{{ pick.issue_href }}">{{ pick.title }}</a> <span class="muted text-xs">· {{ pick.feed }} · {{ pick.issue_date }}</span>
{% let widget = pick.widget %}{% include "_rating_widget.html" %}</li>{% endfor %}</ul>{% endif %}
</section>{% endblock %}
+34 -30
View File
@@ -1,51 +1,55 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard profile">
<header class="page-head"><div>
<h1>Profile</h1>
<p class="page-desc">The standing taste file the curator reads before every run: what you like, what the OPML declares, and what the editor model has learned from your verdicts.</p>
</div><div class="page-actions"><a class="btn" href="/dashboard/ratings">Ratings</a><a class="btn" href="/dashboard/settings#curation.feedback">Feedback settings</a></div></header>
<div class="profile-grid">
<div class="profile-editor">
<h2>profile.md</h2>
<p class="meta"><code>{{ path }}</code>{% if !exists %} — <strong>missing</strong>; saving creates it{% else %} · {{ bytes }} bytes{% endif %} · limit {{ max_bytes }} bytes. Any <code>## Interests</code> section is parsed one interest per line; everything else goes into the system prompt verbatim.</p>
<form method="post" action="/dashboard/profile" class="profile-form">
<textarea name="content" rows="28" spellcheck="true" required>{{ content }}</textarea>
<div><button type="submit">Save</button> <span class="meta">The next run rebuilds the system prompt from the saved file.</span></div>
<div class="profile-grid grid gap-6 lg:grid-cols-2">
<div class="profile-editor min-w-0">
<h3 class="font-mono text-base">profile.md</h3>
<p class="meta mt-1 text-xs"><code>{{ path }}</code>{% if !exists %} — <strong class="text-down">missing</strong>; saving creates it{% else %} · {{ bytes }} bytes{% endif %} · limit {{ max_bytes }} bytes. Any <code>## Interests</code> section is parsed one interest per line; everything else goes into the system prompt verbatim.</p>
<form method="post" action="/dashboard/profile" class="profile-form mt-3">
<textarea name="content" rows="24" spellcheck="true" required class="w-full font-mono text-sm">{{ content }}</textarea>
<div class="mt-3 flex flex-wrap items-center gap-3"><button class="btn-primary" type="submit">Save</button> <span class="meta text-xs">The next run rebuilds the system prompt from the saved file.</span></div>
</form>
</div>
<div class="profile-preview">
<h2>What the loader parses</h2>
<h3>Passthrough sections</h3>
{% if preview_body.trim().is_empty() %}<p class="meta">Nothing passes through — the file is empty or only has an Interests section.</p>{% else %}<pre class="preview">{{ preview_body }}</pre>{% endif %}
<h3>Extracted <code>## Interests</code> lines</h3>
{% if preview_interests.is_empty() %}<p class="meta">None. The prompt uses the OPML interests alone.</p>{% else %}<ul>{% for interest in preview_interests %}<li>{{ interest }}</li>{% endfor %}</ul>{% endif %}
<div class="profile-preview min-w-0">
<h3 class="text-base">What the loader parses</h3>
<p class="meta mt-1 text-xs">A live read of the text on the left, exactly as <code>curate::profile</code> splits it.</p>
<h4 class="mt-4 page-eyebrow">Passthrough sections</h4>
{% if preview_body.trim().is_empty() %}<p class="meta mt-1 text-sm">Nothing passes through — the file is empty or only has an Interests section.</p>{% else %}<pre class="preview mt-2">{{ preview_body }}</pre>{% endif %}
<h4 class="mt-5 page-eyebrow">Extracted <code>## Interests</code> lines</h4>
{% if preview_interests.is_empty() %}<p class="meta mt-1 text-sm">None. The prompt uses the OPML interests alone.</p>{% else %}<ul class="mt-2 list-disc space-y-1 pl-5 text-sm marker:text-muted">{% for interest in preview_interests %}<li>{{ interest }}</li>{% endfor %}</ul>{% endif %}
</div>
</div>
<h2>History</h2>
{% if versions.is_empty() %}<p class="meta">No saved versions yet. The first save records the current file as version 1.</p>{% else %}
<p class="meta">Each row is the text that was replaced by a save (or a restore). Restore writes it back and records what is on disk now as a new version.</p>
<div class="scroll-x"><table class="versions">
<thead><tr><th>Version</th><th>Replaced at</th><th>By</th><th>Size</th><th>Preview</th><th></th></tr></thead>
{% if versions.is_empty() %}<p class="meta text-sm">No saved versions yet. The first save records the current file as version 1.</p>{% else %}
<p class="meta text-sm">Each row is the text that was replaced by a save (or a restore). Restore writes it back and records what is on disk now as a new version.</p>
<div class="scroll-x tall"><table class="versions">
<thead><tr><th class="num">Version</th><th>Replaced at</th><th>By</th><th class="num">Size</th><th>Preview</th><th></th></tr></thead>
<tbody>{% for version in versions %}<tr>
<td>#{{ version.id }}</td>
<td>{{ version.saved_at }}</td>
<td>{{ version.saved_by }}</td>
<td>{{ version.bytes }} bytes</td>
<td class="num">#{{ version.id }}</td>
<td class="cell-tight text-muted">{{ version.saved_at }}</td>
<td class="cell-tight">{{ version.saved_by }}</td>
<td class="num text-muted">{{ version.bytes }} bytes</td>
<td><pre class="preview">{{ version.preview }}</pre></td>
<td><form method="post" action="/dashboard/profile/restore" data-confirm="Restore version #{{ version.id }}? The current file is kept as a new version."><input type="hidden" name="version_id" value="{{ version.id }}"><button type="submit">Restore</button></form></td>
<td class="cell-tight"><form method="post" action="/dashboard/profile/restore" data-confirm="Restore version #{{ version.id }}? The current file is kept as a new version."><input type="hidden" name="version_id" value="{{ version.id }}"><button class="btn" type="submit">Restore</button></form></td>
</tr>{% endfor %}</tbody></table></div>
{% endif %}
<h2>Standing interests</h2>
<p class="meta"><code>{{ opml_path }}</code> · {{ opml_count }} interests, grouped the way the system prompt lists them. The union of these and the <code>## Interests</code> lines above is what the prompt uses; edit the OPML file to change them.</p>
{% if !opml_error.is_empty() %}<p class="error">! {{ opml_error }}</p>{% endif %}
{% if !themes.is_empty() %}<dl class="kv themes">{% for theme in themes %}<dt>{{ theme.name }} <span class="meta">({{ theme.count }})</span></dt><dd>{{ theme.members }}</dd>{% endfor %}</dl>{% endif %}
<p class="meta text-sm"><code>{{ opml_path }}</code> · {{ opml_count }} interests, grouped the way the system prompt lists them. The union of these and the <code>## Interests</code> lines above is what the prompt uses; edit the OPML file to change them.</p>
{% if !opml_error.is_empty() %}<p class="error">{{ opml_error }}</p>{% endif %}
{% if !themes.is_empty() %}<dl class="kv themes">{% for theme in themes %}<dt class="text-ink">{{ theme.name }} <span class="meta">({{ theme.count }})</span></dt><dd class="text-muted">{{ theme.members }}</dd>{% endfor %}</dl>{% endif %}
<h2>Learned adjustments</h2>
<p class="meta">Rebuilt weekly from ratings by the editor model (prompt version {{ prompt_version }}, built {{ prompt_built_at }}, {{ learned_age }}). {% if rebuild_due %}<strong>A rebuild is due</strong> — the next run performs it, or start it now.{% else %}The next scheduled rebuild is at least {{ rebuild_interval_days }} days after the last one; the next run performs it when due.{% endif %}</p>
{% if learned.trim().is_empty() %}<p class="meta">No learned adjustments stored yet.</p>{% else %}<pre class="preview learned">{{ learned }}</pre>{% endif %}
<form method="post" action="/dashboard/jobs/profile-rebuild" class="inline" data-confirm="Rebuild the learned adjustments now? This calls the editor model."><button type="submit"{% if !jobs_enabled %} disabled{% endif %}>Rebuild profile now</button>{% if !jobs_enabled %} <span class="meta">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run <code>daily-epub profile rebuild</code> instead.</span>{% else %} <span class="meta">Starts the <code>profile-rebuild</code> job through systemd.</span>{% endif %}</form>
<p class="meta text-sm">Rebuilt weekly from ratings by the editor model (prompt version {{ prompt_version }}, built {{ prompt_built_at }}, {{ learned_age }}). {% if rebuild_due %}<strong class="text-warn">A rebuild is due</strong> — the next run performs it, or start it now.{% else %}The next scheduled rebuild is at least {{ rebuild_interval_days }} days after the last one; the next run performs it when due.{% endif %}</p>
{% if learned.trim().is_empty() %}<p class="meta text-sm">No learned adjustments stored yet.</p>{% else %}<pre class="preview learned">{{ learned }}</pre>{% endif %}
<form method="post" action="/dashboard/jobs/profile-rebuild" class="form-inline" data-confirm="Rebuild the learned adjustments now? This calls the editor model."><button class="btn" type="submit"{% if !jobs_enabled %} disabled{% endif %}>Rebuild profile now</button>{% if !jobs_enabled %} <span class="meta text-xs">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run <code>daily-epub profile rebuild</code> instead.</span>{% else %} <span class="meta text-xs">Starts the <code>profile-rebuild</code> job through systemd.</span>{% endif %}</form>
<h2>System prompt</h2>
<p class="meta">Stored after the last run: version {{ prompt_version }}, {{ prompt_chars }} characters, {{ prompt_verdicts }} verdict lines. Every LLM call of a run receives exactly this text.</p>
{% if prompt.is_empty() %}<p class="meta">No prompt has been built yet.</p>{% else %}<details id="profile-prompt"><summary>Show the system prompt</summary><pre class="preview prompt">{{ prompt }}</pre></details>{% endif %}
<p class="meta text-sm">Stored after the last run: version {{ prompt_version }}, {{ prompt_chars }} characters, {{ prompt_verdicts }} verdict lines. Every LLM call of a run receives exactly this text.</p>
{% if prompt.is_empty() %}<p class="meta text-sm">No prompt has been built yet.</p>{% else %}<details id="profile-prompt" class="disclosure"><summary>Show the system prompt</summary><div class="disclosure-body"><pre class="preview prompt">{{ prompt }}</pre></div></details>{% endif %}
</section>{% endblock %}
+39 -35
View File
@@ -1,19 +1,23 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard ratings">
<header class="page-head"><div>
<h1>Ratings</h1>
<p class="preference-summary">{{ summary_line }}</p>
<p class="page-desc">{{ summary_line }}</p>
</div><div class="page-actions"><a class="btn" href="/dashboard/profile">Profile</a><a class="btn" href="/dashboard/articles?rated=unrated">Unrated articles</a></div></header>
{% if no_embedding_count > 0 %}<p class="notice">{{ no_embedding_count }} rated articles have no embedding and cannot act as neighbours — run the <code>features-backfill</code> job (<code>features backfill --rated-only</code>).</p>{% endif %}
<details id="ratings-how" class="how">
<details id="ratings-how" class="how disclosure">
<summary>How ratings enter the algorithm</summary>
<ol>
<div class="disclosure-body">
<ol class="list-decimal space-y-3 pl-5 marker:text-muted">
<li><strong>Prompt verdict block.</strong> The {{ how.verdicts_in_prompt }} most recent non-cleared verdicts, newest first, are written into every LLM call's system prompt as one line each (<code>LOVED | title | feed | summary</code>). Only the rank matters here — a verdict never ages out of this block, it is pushed out by newer ones. Tune <a href="/dashboard/settings#curation.feedback"><code>curation.feedback.verdicts_in_prompt</code></a>.</li>
<li><strong>Weekly learned adjustments.</strong> Every {{ how.rebuild_interval_days }} days the editor model rewrites the profile's "Learned adjustments" bullets from the {{ how.max_ratings_in_rebuild }} most recent non-cleared verdicts, including notes and deep-assessment facets. See the <a href="/dashboard/profile">Profile</a> page.</li>
<li><strong>Rated-neighbour signal.</strong> Each verdict with an embedding is an example with weight <code>value × 0.5^(age / {{ how.half_life_days }} days)</code>, where loved = {{ how.loved }}, good = {{ how.good }}, not for me = {{ how.not_for_me }}; ratings older than {{ how.lookback_days }} days are not loaded. A candidate's signal is the weighted mean cosine to its {{ how.neighbour_k }} nearest positive examples minus {{ how.negative_coefficient }} × the same over its nearest negative ones. The signal's preliminary weight ({{ how.knn_weight }}) is scaled by a gate that opens above {{ how.knn_floor }} embedded verdicts and is fully open at {{ how.knn_full }}. Tune <a href="/dashboard/settings#curation.ranking"><code>curation.ranking.rating_half_life_days</code>, <code>knn_floor</code>, <code>knn_full</code>, <code>neighbour_k</code></a> and <a href="/dashboard/settings#curation.ranking.weights.preliminary"><code>weights.preliminary.knn</code></a>.</li>
<li><strong>Feed affinity.</strong> The same decayed weight is credited to the rated article's direct feeds, split evenly; each feed's Beta-smoothed rate <code>(up + 1) / (up + down + 2)</code> becomes a candidate's signal (the mean over its rated direct feeds). Its weight ({{ how.feed_weight }}) is gated between {{ how.feed_floor }} and {{ how.feed_full }} attributable ratings. Tune <a href="/dashboard/settings#curation.ranking"><code>curation.ranking.feed_floor</code>, <code>feed_full</code></a> and <a href="/dashboard/settings#curation.ranking.weights.preliminary"><code>weights.preliminary.feed</code></a>.</li>
</ol>
<p>Clearing a verdict removes it from all four paths without deleting history; ratings are append-only.</p>
<p class="muted">Clearing a verdict removes it from all four paths without deleting history; ratings are append-only.</p>
</div>
</details>
<nav class="tabs"><a href="{{ current_href }}"{% if tab == "current" %} class="active"{% endif %}>Current ({{ current_total }})</a> <a href="{{ events_href }}"{% if tab == "events" %} class="active"{% endif %}>Events</a></nav>
<nav class="tabs" aria-label="Ratings view"><a href="{{ current_href }}"{% if tab == "current" %} class="active" aria-current="page"{% endif %}>Current ({{ current_total }})</a> <a href="{{ events_href }}"{% if tab == "events" %} class="active" aria-current="page"{% endif %}>Events</a></nav>
{% if tab == "events" %}
<form class="filters" method="get" action="/dashboard/ratings">
@@ -23,27 +27,27 @@
<label>User <select name="user"><option value="">any</option>{% for username in usernames %}<option value="{{ username }}"{% if filter_user == username.as_str() %} selected{% endif %}>{{ username }}</option>{% endfor %}</select></label>
<label>From <input type="date" name="from" value="{{ filter_from }}"></label>
<label>To <input type="date" name="to" value="{{ filter_to }}"></label>
<button type="submit">Filter</button>
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ events_href }}">Reset</a></div>
</form>
<p class="meta">{{ pagination.total }} events, newest first. History is append-only; a later explicit event for the same article marks the earlier one <em>superseded</em>. Use the clear verdict to retract.</p>
<div class="scroll-x"><table>
<thead><tr><th>Event</th><th>Verdict</th><th>Value</th><th>Article</th><th>Issue</th><th>When</th><th>Source</th><th>By</th><th>Note</th></tr></thead>
<p class="meta text-sm">{{ pagination.total }} events, newest first. History is append-only; a later explicit event for the same article marks the earlier one <em>superseded</em>. Use the clear verdict to retract.</p>
<div class="scroll-x tall"><table>
<thead><tr><th class="num">Event</th><th>Verdict</th><th class="num">Value</th><th>Article</th><th>Issue</th><th>When</th><th>Source</th><th>By</th><th>Note</th></tr></thead>
<tbody>
{% for event in events %}<tr{% if event.superseded %} class="superseded"{% endif %}>
<td>#{{ event.id }}{% if event.kind != "explicit" %} <span class="badge">{{ event.kind }}</span>{% endif %}{% if event.superseded %} <span class="badge cleared">superseded</span>{% endif %}</td>
<td><span class="badge {{ event.badge }}">{{ event.verdict }}</span></td>
<td>{{ event.value }}</td>
<td><a href="/dashboard/articles/{{ event.article_id }}">{{ event.title }}</a></td>
<td>{% if !event.issue_date.is_empty() %}<a href="/issues/{{ event.issue_date }}">{{ event.issue_date }}</a>{% endif %}</td>
<td>{{ event.when }}</td>
<td>{{ event.source }}</td>
<td>{{ event.username }}</td>
<td>{{ event.note }}</td>
<td class="num">#{{ event.id }}{% if event.kind != "explicit" %} <span class="badge">{{ event.kind }}</span>{% endif %}{% if event.superseded %} <span class="badge cleared">superseded</span>{% endif %}</td>
<td class="cell-tight"><span class="badge {{ event.badge }}">{{ event.verdict }}</span></td>
<td class="num">{{ event.value }}</td>
<td class="cell-wrap"><a href="/dashboard/articles/{{ event.article_id }}">{{ event.title }}</a></td>
<td class="cell-tight">{% if !event.issue_date.is_empty() %}<a href="/issues/{{ event.issue_date }}">{{ event.issue_date }}</a>{% endif %}</td>
<td class="cell-tight text-muted">{{ event.when }}</td>
<td class="cell-tight text-muted">{{ event.source }}</td>
<td class="cell-tight text-muted">{{ event.username }}</td>
<td class="cell-wrap text-muted">{{ event.note }}</td>
</tr>
{% endfor %}
{% if events.is_empty() %}<tr><td colspan="9">No rating events match.</td></tr>{% endif %}
{% if events.is_empty() %}<tr><td colspan="9" class="text-muted">No rating events match.</td></tr>{% endif %}
</tbody></table></div>
<div class="pager">{% if !prev_href.is_empty() %}<a href="{{ prev_href }}">← Newer</a>{% endif %} {% include "_pagination.html" %} {% if !next_href.is_empty() %}<a href="{{ next_href }}">Older →</a>{% endif %}</div>
<nav class="pager" aria-label="Pagination">{% include "_pagination.html" %}<span class="flex items-center gap-2">{% if !prev_href.is_empty() %}<a class="btn" href="{{ prev_href }}">← Newer</a>{% endif %}{% if !next_href.is_empty() %}<a class="btn" href="{{ next_href }}">Older →</a>{% endif %}</span></nav>
{% else %}
<form class="filters" method="get" action="/dashboard/ratings">
<input type="hidden" name="tab" value="current">
@@ -51,26 +55,26 @@
<label>Source <select name="source"><option value="">any</option>{% for source in sources %}<option value="{{ source }}"{% if filter_source == source.as_str() %} selected{% endif %}>{{ source }}</option>{% endfor %}</select></label>
<label>Feed <select name="feed"><option value="">any</option>{% for feed in feeds %}<option value="{{ feed.id }}"{% if filter_feed == feed.id.to_string() %} selected{% endif %}>{{ feed.title }}</option>{% endfor %}</select></label>
<label>Title <input type="search" name="q" value="{{ filter_q }}" placeholder="contains…"></label>
<button type="submit">Filter</button>
<div class="filter-actions"><button class="btn" type="submit">Filter</button> <a class="btn-quiet" href="{{ current_href }}">Reset</a></div>
</form>
<p class="meta">One row per rated article (its latest explicit event), newest first. {% match last_run %}{% when Some with (run) %}"Used last run" counts the candidates of run #{{ run.id }} ({{ run.date }}, {{ run.status }}) that listed the article among their nearest rated neighbours, and how many of those were selected.{% when None %}No run has happened yet, so "Used last run" is empty.{% endmatch %}</p>
<div class="scroll-x"><table class="contributions">
<thead><tr><th>Verdict</th><th>Article</th><th>When · by</th><th>Note</th><th>Age → decay</th><th>Neighbour weight</th><th>Feed credit</th><th>In prompt</th><th>In rebuild</th><th>Used last run</th></tr></thead>
<p class="meta text-sm">One row per rated article (its latest explicit event), newest first. {% match last_run %}{% when Some with (run) %}"Used last run" counts the candidates of run #{{ run.id }} ({{ run.date }}, {{ run.status }}) that listed the article among their nearest rated neighbours, and how many of those were selected.{% when None %}No run has happened yet, so "Used last run" is empty.{% endmatch %}</p>
<div class="scroll-x tall"><table class="contributions">
<thead><tr><th>Verdict</th><th>Article</th><th>When · by</th><th>Note</th><th>Age → decay</th><th class="num">Neighbour weight</th><th>Feed credit</th><th>In prompt</th><th>In rebuild</th><th>Used last run</th></tr></thead>
<tbody>
{% for row in current %}<tr>
<td><span class="badge {{ row.badge }}">{{ row.verdict }}</span>{% let widget = row.widget %}{% include "_rating_widget.html" %}</td>
<td><a href="/dashboard/articles/{{ row.article_id }}">{{ row.title }}</a><br><span class="meta">{{ row.feed_title }}{% if !row.issue_date.is_empty() %} · <a href="/issues/{{ row.issue_date }}">{{ row.issue_date }}</a>{% endif %}</span></td>
<td>{{ row.when }}<br><span class="meta">{{ row.source }} · <span class="by">{{ row.username }}</span></span></td>
<td>{{ row.note }}</td>
<td>{{ row.age_days }} d → {{ row.decay }}</td>
<td>{% if row.has_embedding && !row.neighbour_weight.is_empty() %}{{ row.neighbour_weight }}{% if row.beyond_lookback %} <span class="meta">(beyond lookback)</span>{% endif %}{% else if row.badge == "cleared" %}<span class="meta">—</span>{% else %}<span class="meta">no embedding</span>{% endif %}</td>
<td>{% for credit in row.feed_credits %}{{ credit.title }} {{ credit.credit }}{% if !loop.last %}<br>{% endif %}{% endfor %}</td>
<td>{% if row.in_prompt %}✓{% endif %}</td>
<td>{% if row.in_rebuild %}✓{% endif %}</td>
<td>{% if row.used_total > 0 %}{{ row.used_total }} ({{ row.used_selected }} selected){% endif %}</td>
<td class="min-w-[13rem]"><span class="badge {{ row.badge }}">{{ row.verdict }}</span>{% let widget = row.widget %}{% include "_rating_widget.html" %}</td>
<td class="cell-wrap"><a href="/dashboard/articles/{{ row.article_id }}">{{ row.title }}</a><br><span class="meta text-xs">{{ row.feed_title }}{% if !row.issue_date.is_empty() %} · <a href="/issues/{{ row.issue_date }}">{{ row.issue_date }}</a>{% endif %}</span></td>
<td class="cell-tight">{{ row.when }}<br><span class="meta text-xs">{{ row.source }} · <span class="by">{{ row.username }}</span></span></td>
<td class="cell-wrap text-muted">{{ row.note }}</td>
<td class="cell-tight tabular-nums">{{ row.age_days }} d → {{ row.decay }}</td>
<td class="num">{% if row.has_embedding && !row.neighbour_weight.is_empty() %}{{ row.neighbour_weight }}{% if row.beyond_lookback %} <span class="meta text-xs">(beyond lookback)</span>{% endif %}{% else if row.badge == "cleared" %}<span class="meta">—</span>{% else %}<span class="meta text-xs">no embedding</span>{% endif %}</td>
<td class="cell-tight text-muted">{% for credit in row.feed_credits %}{{ credit.title }} <span class="tabular-nums text-ink">{{ credit.credit }}</span>{% if !loop.last %}<br>{% endif %}{% endfor %}</td>
<td class="text-center">{% if row.in_prompt %}<span class="text-loved">✓</span>{% endif %}</td>
<td class="text-center">{% if row.in_rebuild %}<span class="text-loved">✓</span>{% endif %}</td>
<td class="cell-tight tabular-nums">{% if row.used_total > 0 %}{{ row.used_total }} ({{ row.used_selected }} selected){% endif %}</td>
</tr>
{% endfor %}
{% if current.is_empty() %}<tr><td colspan="10">No current verdicts match.</td></tr>{% endif %}
{% if current.is_empty() %}<tr><td colspan="10" class="text-muted">No current verdicts match.</td></tr>{% endif %}
</tbody></table></div>
{% endif %}
</section>{% endblock %}
+33 -24
View File
@@ -1,42 +1,51 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<h1>Run {{ run.id }} · {{ run.date }} <span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted">dry run</span>{% endif %}</h1>
<p class="muted">Started {{ run.started }} · finished {{ run.finished }} · {{ run.duration }} · {{ run.total_cost }}</p>
<nav class="run-nav">{% if let Some(prev) = run.prev_id %}<a href="/dashboard/runs/{{ prev }}">← Run {{ prev }}</a>{% endif %}{% if let Some(href) = run.issue_href %}<a href="{{ href }}">Issue {{ run.date }}</a>{% else %}<span class="muted">No issue for {{ run.date }}</span>{% endif %}{% if let Some(next) = run.next_id %}<a href="/dashboard/runs/{{ next }}">Run {{ next }} →</a>{% endif %}</nav>
<header class="page-head"><div>
<p class="page-eyebrow"><a class="text-muted no-underline hover:text-accent" href="/dashboard/runs">Runs</a> › {{ run.date }}</p>
<h1 class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">Run {{ run.id }} <span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="badge">dry run</span>{% endif %}</h1>
<p class="page-desc">Started {{ run.started }} · finished {{ run.finished }}.</p>
</div><nav class="page-actions" aria-label="Run navigation">{% if let Some(prev) = run.prev_id %}<a class="btn" href="/dashboard/runs/{{ prev }}">← Run {{ prev }}</a>{% endif %}{% if let Some(href) = run.issue_href %}<a class="btn" href="{{ href }}">Issue {{ run.date }}</a>{% else %}<span class="muted text-sm">No issue for {{ run.date }}</span>{% endif %}{% if let Some(next) = run.next_id %}<a class="btn" href="/dashboard/runs/{{ next }}">Run {{ next }} →</a>{% endif %}</nav></header>
<div class="tiles">
<div class="tile"><span class="tile-label">Issue date</span><span class="tile-num">{{ run.date }}</span><span class="tile-delta">run {{ run.id }}</span></div>
<div class="tile"><span class="tile-label">Duration</span><span class="tile-num">{{ run.duration }}</span><span class="tile-delta">wall clock</span></div>
<div class="tile"><span class="tile-label">Token cost</span><span class="tile-num">{{ run.total_cost }}</span><span class="tile-delta">every provider</span></div>
<div class="tile"><span class="tile-label">Warnings</span><span class="tile-num{% if !warnings.is_empty() %} text-warn{% endif %}">{{ warnings.len() }}</span><span class="tile-delta">{% if warnings.is_empty() %}clean{% else %}<a href="#warnings">see below</a>{% endif %}</span></div>
</div>
{% if let Some(error) = run.error %}<p class="error">{{ error }}</p>{% endif %}
<h2>Funnel</h2>
<div class="scroll-x"><table class="funnel-table">
<thead><tr><th>stage</th><th>reached</th><th class="num">reached</th><th class="num">stopped here</th><th>why</th></tr></thead>
<thead><tr><th>stage</th><th>share</th><th class="num">reached</th><th class="num">stopped here</th><th>why</th></tr></thead>
<tbody>{% for stage in funnel %}<tr>
<td><span class="badge {{ stage.stage }}">{{ stage.stage }}</span></td>
<td class="cell-tight align-middle"><span class="badge {{ stage.stage }}">{{ stage.stage }}</span></td>
<td class="funnel-cell"><div class="funnel"><svg width="{{ stage.percent }}%" height="12" role="img" aria-label="{{ stage.reached }} reached"></svg></div></td>
<td class="num">{{ stage.reached }}</td>
<td class="num">{{ stage.stopped }}</td>
<td class="reasons">{% for reason in stage.reasons %}{{ reason.reason }} {{ reason.count }}{% if !loop.last %} · {% endif %}{% endfor %}</td>
<td class="num align-middle">{{ stage.reached }}</td>
<td class="num align-middle text-muted">{{ stage.stopped }}</td>
<td class="reasons cell-wrap align-middle text-muted">{% for reason in stage.reasons %}{{ reason.reason }} <span class="tabular-nums text-ink">{{ reason.count }}</span>{% if !loop.last %} · {% endif %}{% endfor %}</td>
</tr>{% endfor %}</tbody></table></div>
{% if has_report %}<div class="cards">
<section class="card"><h2>Admission mix</h2>{% if admission.is_empty() %}<p class="muted">Nothing admitted.</p>{% else %}<dl class="kv">{% for line in admission %}<dt>{{ line.name }}</dt><dd>{{ line.count }}</dd>{% endfor %}</dl>{% endif %}</section>
<section class="card"><h2>Admission mix</h2>{% if admission.is_empty() %}<p class="muted">Nothing admitted.</p>{% else %}<dl class="kv">{% for line in admission %}<dt>{{ line.name }}</dt><dd class="tabular-nums">{{ line.count }}</dd>{% endfor %}</dl>{% endif %}</section>
<section class="card"><h2>Preference state</h2><dl class="kv">
<dt>rated with embeddings</dt><dd>{{ preference.rated_with_embeddings }}</dd>
<dt>knn gate</dt><dd>{{ preference.knn_gate }}</dd>
<dt>feed gate</dt><dd>{{ preference.feed_gate }}</dd>
<dt>verdicts in prompt</dt><dd>{{ preference.verdicts_in_prompt }}</dd>
<dt>rated with embeddings</dt><dd class="tabular-nums">{{ preference.rated_with_embeddings }}</dd>
<dt>knn gate</dt><dd class="tabular-nums">{{ preference.knn_gate }}</dd>
<dt>feed gate</dt><dd class="tabular-nums">{{ preference.feed_gate }}</dd>
<dt>verdicts in prompt</dt><dd class="tabular-nums">{{ preference.verdicts_in_prompt }}</dd>
</dl></section>
<section class="card"><h2>Timings</h2><div class="scroll-x"><table><thead><tr><th>stage</th><th class="num">seconds</th></tr></thead><tbody>{% for line in timings %}<tr><td>{{ line.stage }}</td><td class="num">{{ line.seconds }}</td></tr>{% endfor %}<tr><th>total</th><th class="num">{{ timings_total }}</th></tr></tbody></table></div></section>
<section class="card"><h2>Provider usage</h2><div class="scroll-x"><table><thead><tr><th>provider</th><th class="num">in</th><th class="num">cached</th><th class="num">cache write</th><th class="num">out</th><th class="num">cost</th></tr></thead><tbody>{% for line in providers %}<tr><td>{{ line.provider }}</td><td class="num">{{ line.input }}</td><td class="num">{{ line.cached }}</td><td class="num">{{ line.cache_write }}</td><td class="num">{{ line.output }}</td><td class="num">{{ line.cost }}</td></tr>{% endfor %}</tbody></table></div></section>
<section class="card" id="warnings"><h2>Warnings</h2>{% if warnings.is_empty() %}<p class="muted">None.</p>{% else %}<ul>{% for warning in warnings %}<li>{{ warning }}</li>{% endfor %}</ul>{% endif %}</section>
<section class="card" id="warnings"><h2>Warnings</h2>{% if warnings.is_empty() %}<p class="muted">None.</p>{% else %}<ul class="list-disc pl-5 text-warn">{% for warning in warnings %}<li>{{ warning }}</li>{% endfor %}</ul>{% endif %}</section>
<section class="card"><h2>Feeds (top {{ feeds.len() }})</h2><div class="scroll-x"><table><thead><tr><th>feed</th><th class="num">entries</th></tr></thead><tbody>{% for line in feeds %}<tr><td>{{ line.name }}</td><td class="num">{{ line.count }}</td></tr>{% endfor %}</tbody></table></div></section>
</div>{% else %}<p class="muted">This run has no stored report; only the funnel and candidates are available.</p>{% endif %}
</div>{% else %}<p class="muted text-sm">This run has no stored report; only the funnel and candidates are available.</p>{% endif %}
<h2>Config diff</h2>
{% if let Some(against) = diff_against %}{% if config_diff.is_empty() %}<p class="muted">No settings changed since <a href="/dashboard/runs/{{ against }}">run {{ against }}</a>.</p>{% else %}<p class="muted">Against the previous non-dry run, <a href="/dashboard/runs/{{ against }}">run {{ against }}</a>.</p>
<div class="scroll-x"><table class="diff"><thead><tr><th>key</th><th>before</th><th>after</th></tr></thead><tbody>{% for row in config_diff %}<tr><td><code>{{ row.key }}</code></td><td class="before">{{ row.before }}</td><td class="after">{{ row.after }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% else %}<p class="muted">No earlier non-dry run to compare against.</p>{% endif %}
{% if let Some(against) = diff_against %}{% if config_diff.is_empty() %}<p class="muted text-sm">No settings changed since <a href="/dashboard/runs/{{ against }}">run {{ against }}</a>.</p>{% else %}<p class="muted text-sm">Against the previous non-dry run, <a href="/dashboard/runs/{{ against }}">run {{ against }}</a>.</p>
<div class="scroll-x"><table class="diff"><thead><tr><th>key</th><th>before</th><th>after</th></tr></thead><tbody>{% for row in config_diff %}<tr><td><code>{{ row.key }}</code></td><td class="before cell-wrap text-muted">{{ row.before }}</td><td class="after cell-wrap text-ink">{{ row.after }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}{% else %}<p class="muted text-sm">No earlier non-dry run to compare against.</p>{% endif %}
<h2>Near misses</h2>
{% if near_misses.is_empty() %}<p class="muted">None recorded.</p>{% else %}<div class="scroll-x"><table>
<thead><tr><th>#</th><th class="num">score</th><th>title</th><th>feed</th><th class="num">quality</th><th class="num">fit</th><th>stage</th><th>reason</th></tr></thead>
<tbody>{% for miss in near_misses %}<tr><td>{{ loop.index }}</td><td class="num">{{ miss.score }}</td><td><a href="{{ miss.href }}">{{ miss.title }}</a></td><td>{{ miss.feed }}</td><td class="num">{{ miss.quality }}</td><td class="num">{{ miss.fit }}</td><td><span class="badge {{ miss.stage }}">{{ miss.stage }}</span></td><td>{% if let Some(reason) = miss.reason %}{{ reason }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
{% if near_misses.is_empty() %}<p class="muted text-sm">None recorded.</p>{% else %}<div class="scroll-x"><table>
<thead><tr><th class="num">#</th><th class="num">score</th><th>title</th><th>feed</th><th class="num">quality</th><th class="num">fit</th><th>stage</th><th>reason</th></tr></thead>
<tbody>{% for miss in near_misses %}<tr><td class="num text-muted">{{ loop.index }}</td><td class="num">{{ miss.score }}</td><td class="cell-wrap"><a href="{{ miss.href }}">{{ miss.title }}</a></td><td class="cell-tight text-muted">{{ miss.feed }}</td><td class="num">{{ miss.quality }}</td><td class="num">{{ miss.fit }}</td><td class="cell-tight"><span class="badge {{ miss.stage }}">{{ miss.stage }}</span></td><td class="cell-wrap text-muted">{% if let Some(reason) = miss.reason %}{{ reason }}{% endif %}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
<h2>Candidates</h2>
<form class="filters" method="get" action="/dashboard/runs/{{ run.id }}">
@@ -44,13 +53,13 @@
<label>Reason <select name="reason"><option value="">any</option>{% for name in reasons %}<option value="{{ name }}"{% if let Some(current) = filters.reason %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
<label>Admitted by <select name="admitted_by"><option value="">any</option>{% for name in retrievers %}<option value="{{ name }}"{% if let Some(current) = filters.admitted_by %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
<label>Flag <select name="flag"><option value="">any</option><option value="exploration"{% if let Some(current) = filters.flag %}{% if current == "exploration" %} selected{% endif %}{% endif %}>exploration</option><option value="auto"{% if let Some(current) = filters.flag %}{% if current == "auto" %} selected{% endif %}{% endif %}>auto-include</option></select></label>
<label>Title <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}"></label>
<label>Title <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}" placeholder="contains…"></label>
<label>Sort <select name="sort">{% for name in sorts %}<option value="{{ name }}"{% if filters.sort == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
<button type="submit">Apply</button> <a href="/dashboard/runs/{{ run.id }}">Reset</a>
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/runs/{{ run.id }}">Reset</a></div>
</form>
{% include "dashboard/_pager.html" %}
<div class="scroll-x"><table class="candidates" data-filter>
<div class="scroll-x tall"><table class="candidates" data-filter>
<thead><tr><th>title</th><th>feed</th><th class="num">words</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>flags</th><th>editor</th></tr></thead>
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}</tbody></table></div>
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}{% if candidates.is_empty() %}<tr><td colspan="14" class="text-muted">No candidates match this filter.</td></tr>{% endif %}</tbody></table></div>
{% include "dashboard/_pager.html" %}
</section>{% endblock %}
+15 -12
View File
@@ -1,21 +1,24 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<header class="page-head"><div>
<h1>Runs</h1>
<p class="page-desc">Every recorded pipeline run, newest first: how the funnel narrowed, what it cost and whether it finished clean.</p>
</div><div class="page-actions"><a class="btn" href="/dashboard/stats">Stats</a><a class="btn" href="/dashboard/jobs">Jobs</a></div></header>
<form class="filters" method="get" action="/dashboard/runs">
<label>Status <select name="status"><option value="">any</option>{% for name in statuses %}<option value="{{ name }}"{% if status == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
<button type="submit">Filter</button>
<div class="filter-actions"><button class="btn" type="submit">Filter</button>{% if !status.is_empty() %}<a class="btn-quiet" href="/dashboard/runs">Reset</a>{% endif %}</div>
</form>
<div class="scroll-x"><table data-filter>
<thead><tr><th>run</th><th>date</th><th>status</th><th>started</th><th>duration</th><th>considered → eligible → triaged → assessed → shortlisted → selected</th><th>cost</th><th class="num">total</th><th>warnings</th></tr></thead>
<div class="scroll-x tall"><table data-filter>
<thead><tr><th>run</th><th>date</th><th>status</th><th>started</th><th>duration</th><th>considered → eligible → triaged → assessed → shortlisted → selected</th><th>cost</th><th class="num">total</th><th class="num">warnings</th></tr></thead>
<tbody>{% for run in runs %}<tr>
<td><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
<td><a href="/issues/{{ run.date }}">{{ run.date }}</a></td>
<td><span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted">dry</span>{% endif %}</td>
<td>{{ run.started }}</td>
<td>{{ run.duration }}</td>
<td>{{ run.funnel }}</td>
<td>{{ run.costs }}</td>
<td class="num"><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
<td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td>
<td class="cell-tight"><span class="badge {{ run.status }}">{{ run.status }}</span>{% if run.dry_run %} <span class="muted text-xs">dry</span>{% endif %}</td>
<td class="cell-tight text-muted">{{ run.started }}</td>
<td class="num text-muted">{{ run.duration }}</td>
<td class="cell-tight tabular-nums">{{ run.funnel }}</td>
<td class="text-muted">{{ run.costs }}</td>
<td class="num">{{ run.total }}</td>
<td class="num">{% if run.warnings > 0 %}<a href="/dashboard/runs/{{ run.id }}#warnings">{{ run.warnings }}</a>{% else %}0{% endif %}</td>
</tr>{% endfor %}</tbody></table></div>
<td class="num">{% if run.warnings > 0 %}<a href="/dashboard/runs/{{ run.id }}#warnings">{{ run.warnings }}</a>{% else %}<span class="text-muted">0</span>{% endif %}</td>
</tr>{% endfor %}{% if runs.is_empty() %}<tr><td colspan="9" class="text-muted">No runs match this filter.</td></tr>{% endif %}</tbody></table></div>
{% include "dashboard/_pager.html" %}
</section>{% endblock %}
+19 -13
View File
@@ -1,15 +1,21 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard settings">
<header class="page-head"><div>
<h1>Settings</h1>
<p class="meta">{% match config_path %}{% when Some with (path) %}Editing <code>{{ path }}</code>. Saved settings apply to the next run; a hand edit on disk shows up on the next page view.{% when None %}No config file is configured; start the server with <code>--config</code> to edit settings here. The values below are the built-in defaults plus the environment.{% endmatch %} · <a href="/dashboard/settings/history">History</a></p>
{% match load_error %}{% when Some with (error) %}<p class="error banner">! config.toml on disk does not load: {{ error }} — the previous configuration stays live.</p>{% when None %}{% endmatch %}
<p class="page-desc">{% match config_path %}{% when Some with (path) %}Editing <code>{{ path }}</code>. Saved settings apply to the next run; a hand edit on disk shows up on the next page view.{% when None %}No config file is configured; start the server with <code>--config</code> to edit settings here. The values below are the built-in defaults plus the environment.{% endmatch %}</p>
</div><div class="page-actions"><a class="btn" href="/dashboard/settings/history">History</a><a class="btn" href="#add-provider">Add provider</a></div></header>
{% match load_error %}{% when Some with (error) %}<p class="error banner">config.toml on disk does not load: {{ error }} — the previous configuration stays live.</p>{% when None %}{% endmatch %}
{% if !errors.is_empty() %}<ul class="error banner">{% for error in errors %}<li>{{ error }}</li>{% endfor %}</ul>{% endif %}
<nav class="flex flex-wrap gap-x-3 gap-y-1 border-y border-rule py-3 font-mono text-xs" aria-label="Settings groups">{% for group in groups %}<a class="text-muted no-underline hover:text-accent" href="#{{ group.id }}">{{ group.id }}</a>{% endfor %}</nav>
<form method="post" action="/dashboard/settings" class="settings-form" id="settings-form">
{% for group in groups %}<section class="card" id="{{ group.id }}">
<h2>{{ group.title }}</h2>
{% match group.provider %}{% when Some with (provider) %}<p class="meta">Key: <code>{{ crate::config::ProviderConfig::api_key_env_var(provider.name) }}</code> in the env file. {% if provider.removable() %}Not named by any <code>[llm]</code> role. <button type="submit" form="remove-{{ provider.name }}" class="danger">Remove provider</button>{% else if provider.built_in %}Built in: declared by the defaults, so it can be edited and left unreferenced but not removed.{% else %}Named by <code>llm.{{ provider.roles() }}</code>; reassign the role before removing it.{% endif %}</p>{% when None %}{% endmatch %}
{% if group.is_weights() %}<p class="meta">Weights need not sum to 1; they are renormalized over the signals present for each article.</p>{% endif %}
{% for group in groups %}<section class="card scroll-mt-4" id="{{ group.id }}">
<div>
<p class="page-eyebrow font-mono normal-case tracking-normal">{{ group.id }}</p>
<h2 class="mt-0.5">{{ group.title }}</h2>
</div>
{% match group.provider %}{% when Some with (provider) %}<p class="meta text-xs">Key: <code>{{ crate::config::ProviderConfig::api_key_env_var(provider.name) }}</code> in the env file. {% if provider.removable() %}Not named by any <code>[llm]</code> role. <button type="submit" form="remove-{{ provider.name }}" class="danger">Remove provider</button>{% else if provider.built_in %}Built in: declared by the defaults, so it can be edited and left unreferenced but not removed.{% else %}Named by <code>llm.{{ provider.roles() }}</code>; reassign the role before removing it.{% endif %}</p>{% when None %}{% endmatch %}
{% if group.is_weights() %}<p class="meta text-xs">Weights need not sum to 1; they are renormalized over the signals present for each article.</p>{% endif %}
{% for field in group.fields %}<div class="setting kind-{{ field.kind_name() }}">
<div class="setting-label"><label for="{{ field.input_id() }}"><code>{{ field.key }}</code></label>{% if field.restart_required %} <span class="badge">restart</span>{% endif %}{% match field.help %}{% when Some with (help) %}<p class="help">{{ help }}</p>{% when None %}{% endmatch %}</div>
<div class="setting-label"><label for="{{ field.input_id() }}"><code>{{ field.key }}</code></label>{% if field.restart_required %} <span class="badge restart">restart</span>{% endif %}{% if field.is_env() %} <span class="badge">locked</span>{% endif %}{% match field.help %}{% when Some with (help) %}<p class="help">{{ help }}</p>{% when None %}{% endmatch %}</div>
<div class="setting-input">
{% if field.is_secret() %}<p class="secret">{% if field.is_set() %}set{% if field.is_env() %} (from <code>{{ field.env_var() }}</code>){% else if field.is_file() %} (in the config file — move it to the env file){% endif %}{% else %}not set — set <code>{{ field.env_var() }}</code> in the env file{% endif %}</p>
{% else if field.is_env() %}{% if field.is_text_list() %}<textarea id="{{ field.input_id() }}" disabled>{{ field.current }}</textarea>{% else %}<input id="{{ field.input_id() }}" type="text" value="{{ field.current }}" disabled>{% endif %}<p class="help">locked by <code>{{ field.env_var() }}</code> in the env file</p>
@@ -25,17 +31,17 @@
{% endfor %}
</section>
{% endfor %}
<div class="actions">{% match config_path %}{% when Some with (_path) %}<button type="submit">Save settings</button>{% when None %}<button type="submit" disabled>Save settings</button>{% endmatch %}</div>
<div class="actions save-bar">{% match config_path %}{% when Some with (path) %}<span class="meta">Writes <code>{{ path }}</code>.</span><button class="btn-primary" type="submit">Save settings</button>{% when None %}<span class="meta">No config file: settings are read-only.</span><button class="btn-primary" type="submit" disabled>Save settings</button>{% endmatch %}</div>
</form>
{% for group in groups %}{% match group.provider %}{% when Some with (provider) %}{% if provider.removable() %}<form method="post" action="/dashboard/settings/providers" id="remove-{{ provider.name }}" class="inline" data-confirm="Remove [providers.{{ provider.name }}] from config.toml?"><input type="hidden" name="action" value="remove"><input type="hidden" name="name" value="{{ provider.name }}"></form>{% endif %}{% when None %}{% endmatch %}{% endfor %}
<section class="card" id="add-provider">
{% for group in groups %}{% match group.provider %}{% when Some with (provider) %}{% if provider.removable() %}<form method="post" action="/dashboard/settings/providers" id="remove-{{ provider.name }}" class="form-inline" data-confirm="Remove [providers.{{ provider.name }}] from config.toml?"><input type="hidden" name="action" value="remove"><input type="hidden" name="name" value="{{ provider.name }}"></form>{% endif %}{% when None %}{% endmatch %}{% endfor %}
<section class="card scroll-mt-4" id="add-provider">
<h2>Add provider</h2>
<form method="post" action="/dashboard/settings/providers" class="add-provider">
<form method="post" action="/dashboard/settings/providers" class="add-provider filters border-y-0 py-0">
<input type="hidden" name="action" value="add">
<label>Name <input name="name" type="text" pattern="[a-z0-9_]+" placeholder="mistral" required{% if config_path.is_none() %} disabled{% endif %}></label>
<label>Kind <select name="kind">{% for kind in provider_kinds %}<option value="{{ kind }}">{{ kind }}</option>{% endfor %}</select></label>
<p class="help">Inserts <code>[providers.&lt;name&gt;]</code> with placeholder <code>base_url</code> and <code>model</code>; edit them above afterwards and put <code>DAILY_EPUB_PROVIDERS__&lt;NAME&gt;__API_KEY</code> in the env file.</p>
<button type="submit"{% if config_path.is_none() %} disabled{% endif %}>Add provider</button>
<div class="filter-actions"><button class="btn" type="submit"{% if config_path.is_none() %} disabled{% endif %}>Add provider</button></div>
</form>
<p class="help">Inserts <code>[providers.&lt;name&gt;]</code> with placeholder <code>base_url</code> and <code>model</code>; edit them above afterwards and put <code>DAILY_EPUB_PROVIDERS__&lt;NAME&gt;__API_KEY</code> in the env file.</p>
</section>
</section>{% endblock %}
@@ -1,18 +1,18 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<header class="page-head"><div>
<h1>Settings history</h1>
<p class="meta"><a href="/dashboard/settings">Settings</a> · every change made through the dashboard, newest first.</p>
{% if changes.is_empty() %}<p>No settings have been changed through the dashboard yet.</p>{% else %}<div class="scroll-x"><table class="history">
<p class="page-desc">Every settings change made through the dashboard, newest first. Hand edits to <code>config.toml</code> on disk are not recorded here.</p>
</div><div class="page-actions"><a class="btn" href="/dashboard/settings">Back to settings</a></div></header>
{% if changes.is_empty() %}<p class="muted text-sm">No settings have been changed through the dashboard yet.</p>{% else %}<div class="scroll-x tall"><table class="history" data-filter>
<thead><tr><th>When</th><th>Who</th><th>Key</th><th>Before</th><th>After</th></tr></thead>
<tbody>{% for change in changes %}<tr>
<td>{{ change.changed_at }}</td>
<td>{% match change.username %}{% when Some with (name) %}{{ name }}{% when None %}<em>removed user</em>{% endmatch %}</td>
<td class="cell-tight text-muted">{{ change.changed_at }}</td>
<td class="cell-tight">{% match change.username %}{% when Some with (name) %}{{ name }}{% when None %}<em class="text-muted">removed user</em>{% endmatch %}</td>
<td><code>{{ change.key }}</code></td>
<td>{% match change.old_value %}{% when Some with (value) %}<pre>{{ value }}</pre>{% when None %}<em>absent</em>{% endmatch %}</td>
<td>{% match change.new_value %}{% when Some with (value) %}<pre>{{ value }}</pre>{% when None %}<em>removed</em>{% endmatch %}</td>
<td class="cell-wrap">{% match change.old_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">absent</em>{% endmatch %}</td>
<td class="cell-wrap">{% match change.new_value %}{% when Some with (value) %}<pre class="preview">{{ value }}</pre>{% when None %}<em class="text-muted">removed</em>{% endmatch %}</td>
</tr>{% endfor %}</tbody>
</table></div>
{% include "_pagination.html" %}
{% if pagination.page < pagination.pages() %}<p><a href="/dashboard/settings/history?page={{ pagination.page + 1 }}">Older</a></p>{% endif %}
{% if pagination.page > 1 %}<p><a href="/dashboard/settings/history?page={{ pagination.page - 1 }}">Newer</a></p>{% endif %}
{% if pagination.pages() > 1 %}<nav class="pager" aria-label="Pagination">{% include "_pagination.html" %}<span class="flex items-center gap-2">{% if pagination.page > 1 %}<a class="btn" href="/dashboard/settings/history?page={{ pagination.page - 1 }}">← Newer</a>{% endif %}{% if pagination.page < pagination.pages() %}<a class="btn" href="/dashboard/settings/history?page={{ pagination.page + 1 }}">Older →</a>{% endif %}</span></nav>{% endif %}
{% endif %}
</section>{% endblock %}
+24 -15
View File
@@ -1,31 +1,40 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<header class="page-head"><div>
<h1>Stats</h1>
<p class="muted">Last {{ days }} days ({{ since_date }} → {{ today }}) · window: {% for window in windows %}{% if *window == days %}<strong>{{ window }}</strong>{% else %}<a href="/dashboard/stats?days={{ window }}">{{ window }}</a>{% endif %}{% if !loop.last %} · {% endif %}{% endfor %} days · the same figures as <code>daily-epub stats --days {{ days }}</code>.</p>
<p class="page-desc">The last {{ days }} days ({{ since_date }} → {{ today }}) — the same figures as <code>daily-epub stats --days {{ days }}</code>.</p>
</div><nav class="page-actions" aria-label="Window">{% for window in windows %}{% if *window == days %}<span class="btn-primary" aria-current="true">{{ window }} days</span>{% else %}<a class="btn" href="/dashboard/stats?days={{ window }}">{{ window }} days</a>{% endif %}{% endfor %}</nav></header>
<div class="tiles">{% for line in summary %}<div class="tile"><span class="tile-label">{{ line.key }}</span><span class="tile-num">{{ line.value }}</span></div>{% endfor %}</div>
<div class="sparklines">{% for item in sparklines %}{% let spark = item %}{% include "dashboard/_sparkline.html" %}{% endfor %}</div>
<div class="cards">
<section class="card"><h2>Issues and ratings</h2>
<dl class="kv">{% for line in summary %}<dt>{{ line.key }}</dt><dd>{{ line.value }}</dd>{% endfor %}</dl>
{% if ratings.is_empty() %}<p class="muted">No explicit ratings in the window.</p>{% else %}<p>{% for line in ratings %}<span class="badge {{ line.key }}">{{ line.key }}</span> {{ line.value }}{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %}
<section class="card"><h2>Verdicts in the window</h2>
{% if ratings.is_empty() %}<p class="muted">No explicit ratings in the window.</p>{% else %}<p class="flex flex-wrap items-center gap-x-3 gap-y-1.5">{% for line in ratings %}<span class="inline-flex items-center gap-1.5"><span class="badge {{ line.key }}">{{ line.key }}</span> <span class="tabular-nums">{{ line.value }}</span></span>{% endfor %}</p>{% endif %}
<p class="muted text-xs">Explicit verdicts only; cleared ones still count as events.</p>
</section>
<section class="card"><h2>Retriever yield</h2>
{% if retrievers.is_empty() %}<p class="muted">No rated picks by admitting retriever in the window.</p>{% else %}<div class="scroll-x"><table>
<thead><tr><th>admitted by</th><th class="num">rated</th><th class="num">up</th><th class="num">down</th><th class="num">ratio</th></tr></thead>
<tbody>{% for line in retrievers %}<tr><td>{{ line.retriever }}</td><td class="num">{{ line.rated }}</td><td class="num">{{ line.up }}</td><td class="num">{{ line.down }}</td><td class="num">{{ line.ratio }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
<h3>Exploration</h3>
<dl class="kv">{% for line in exploration %}<dt>{{ line.key }}</dt><dd>{{ line.value }}</dd>{% endfor %}</dl>
<tbody>{% for line in retrievers %}<tr><td>{{ line.retriever }}</td><td class="num">{{ line.rated }}</td><td class="num text-loved">{{ line.up }}</td><td class="num text-down">{{ line.down }}</td><td class="num">{{ line.ratio }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
<h3 class="text-sm">Exploration</h3>
<dl class="kv">{% for line in exploration %}<dt>{{ line.key }}</dt><dd class="tabular-nums">{{ line.value }}</dd>{% endfor %}</dl>
</section>
<section class="card"><h2>Cost per day</h2>
<dl class="kv">{% for line in cost_per_day %}<dt>{{ line.key }}</dt><dd>{{ line.value }}</dd>{% endfor %}</dl>
<p class="muted">Window spend divided by {{ days }} days, per provider.</p>
<dl class="kv">{% for line in cost_per_day %}<dt>{{ line.key }}</dt><dd class="tabular-nums">{{ line.value }}</dd>{% endfor %}</dl>
<p class="muted text-xs">Window spend divided by {{ days }} days, per provider.</p>
</section>
</div>
<h2>Spend by day</h2>
{% if cost_rows.is_empty() %}<p class="muted">No provider costs recorded in the window.</p>{% else %}<div class="scroll-x"><table data-filter>
{% if cost_rows.is_empty() %}<p class="muted text-sm">No provider costs recorded in the window.</p>{% else %}<div class="scroll-x tall"><table data-filter>
<thead><tr><th>day (UTC)</th>{% for provider in providers %}<th class="num">{{ provider }}</th>{% endfor %}<th class="num">total</th></tr></thead>
<tbody>{% for row in cost_rows %}<tr><td>{{ row.date }}</td>{% for cell in row.cells %}<td class="num">{{ cell }}</td>{% endfor %}<td class="num">{{ row.total }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
<tbody>{% for row in cost_rows %}<tr><td class="cell-tight">{{ row.date }}</td>{% for cell in row.cells %}<td class="num text-muted">{{ cell }}</td>{% endfor %}<td class="num">{{ row.total }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
<h2>Runs in the window</h2>
{% if runs.is_empty() %}<p class="muted">No finished runs in the window.</p>{% else %}<div class="scroll-x"><table data-filter>
<thead><tr><th>run</th><th>date</th><th>status</th><th class="num">cost</th><th class="num">selected</th><th class="num">duration</th></tr></thead>
<tbody>{% for run in runs %}<tr><td><a href="/dashboard/runs/{{ run.run_id }}">{{ run.run_id }}</a></td><td><a href="/issues/{{ run.date }}">{{ run.date }}</a></td><td><span class="badge {{ run.status }}">{{ run.status }}</span></td><td class="num">{{ run.cost }}</td><td class="num">{{ run.selected }}</td><td class="num">{{ run.duration }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
<details class="explain"><summary>As text (<code>daily-epub stats --days {{ days }}</code>)</summary><pre class="preview">{{ text }}</pre></details>
{% if runs.is_empty() %}<p class="muted text-sm">No finished runs in the window.</p>{% else %}<div class="scroll-x tall"><table data-filter>
<thead><tr><th class="num">run</th><th>date</th><th>status</th><th class="num">cost</th><th class="num">selected</th><th class="num">duration</th></tr></thead>
<tbody>{% for run in runs %}<tr><td class="num"><a href="/dashboard/runs/{{ run.run_id }}">{{ run.run_id }}</a></td><td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td><td class="cell-tight"><span class="badge {{ run.status }}">{{ run.status }}</span></td><td class="num">{{ run.cost }}</td><td class="num">{{ run.selected }}</td><td class="num text-muted">{{ run.duration }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
<details class="explain disclosure" id="stats-text"><summary>As text (<code>daily-epub stats --days {{ days }}</code>)</summary><div class="disclosure-body"><pre class="preview">{{ text }}</pre></div></details>
</section>{% endblock %}
+9 -6
View File
@@ -1,15 +1,18 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard">
<header class="page-head"><div>
<h1>Users</h1>
<p class="muted">Accounts are read-only here. Create, change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI on the server.</p>
<div class="scroll-x"><table data-filter>
<p class="page-desc">Every account on this server, with its role and open sessions.</p>
</div></header>
<p class="muted text-sm">Accounts are read-only here. Create, change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI on the server.</p>
<div class="scroll-x tall"><table data-filter>
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th class="num">Open sessions</th></tr></thead>
<tbody>{% for user in users %}<tr>
<td>{{ user.username }}</td>
<td class="font-medium text-ink">{{ user.username }}</td>
<td><span class="badge">{{ user.role }}</span></td>
<td>{% if user.disabled %}<span class="badge down">disabled</span>{% else %}<span class="badge loved">enabled</span>{% endif %}</td>
<td>{{ user.created }}</td>
<td>{{ user.last_login }}</td>
<td class="cell-tight text-muted">{{ user.created }}</td>
<td class="cell-tight text-muted">{{ user.last_login }}</td>
<td class="num">{{ user.open_sessions }}</td>
</tr>{% endfor %}{% if users.is_empty() %}<tr><td colspan="6">No users. Bootstrap an admin with <code>daily-epub users add &lt;username&gt; --admin</code>.</td></tr>{% endif %}</tbody>
</tr>{% endfor %}{% if users.is_empty() %}<tr><td colspan="6" class="text-muted">No users. Bootstrap an admin with <code>daily-epub users add &lt;username&gt;</code> <code>--admin</code>.</td></tr>{% endif %}</tbody>
</table></div>
</section>{% endblock %}
+1 -1
View File
@@ -1 +1 @@
{% extends "layout.html" %}{% block content %}<section class="reading"><h1>{{ heading }}</h1><p>{{ message }}</p></section>{% endblock %}
{% extends "layout.html" %}{% block ears %}<span>Something went wrong</span>{% endblock %}{% block content %}<section class="mx-auto max-w-[68ch] px-4 py-20 text-center sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-down">Error</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">{{ heading }}</h1><p class="mt-5 text-lg text-ink-2">{{ message }}</p><p class="mt-8 font-sans text-sm"><a href="/">Return to the latest issue</a></p></section>{% endblock %}
+1 -1
View File
@@ -1 +1 @@
{% for section in issue.sections %}<h2>{{ section.name }}</h2><ul>{% for entry in section.entries %}<li><a href="{{ entry.url }}">{{ entry.title }}</a> — {{ entry.source }} ({{ entry.domain }}){% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}<a href="{{ link.url }}">{{ link.label }}</a>{% endfor %}{% endif %}</li>{% endfor %}</ul>{% endfor %}
{% for section in issue.sections %}<h2>{{ section.name }}</h2><ul>{% for entry in section.entries %}<li><a href="{{ entry.url }}">{{ entry.title }}</a> — {{ entry.source }} ({{ entry.domain }}){% match entry.summary %}{% when Some with (summary) %}<p class="summary">{{ summary }}</p>{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}<p class="why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}<a href="{{ link.url }}">{{ link.label }}</a>{% endfor %}{% endif %}</li>{% endfor %}</ul>{% endfor %}
+13 -33
View File
@@ -1,33 +1,13 @@
{% extends "layout.html" %}{% block content %}<article class="reading issue full-issue">
<p class="dateline">{{ display_date }} · No. {{ issue_number }}</p>
<p class="stats">{{ stats_line }}</p>
<hr class="rule">
<h1 class="kicker">The Brief</h1>
<div class="editorial">{{ front_page_html|safe }}</div>
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a class="button" href="{{ download.href }}">Download {{ download.label }} <small>({{ download.size }})</small></a>{% endfor %}</p>{% endif %}
<hr class="rule">
<h1>In This Issue</h1>
{% for section in sections %}<section><h2>{{ section.name }}</h2><ul class="index-list">{% for entry in section.entries %}<li class="index-entry">
<h3><a href="{{ entry.href }}">{{ entry.title }}</a></h3>
<p class="index-meta">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>
{% if !entry.summary.is_empty() %}<p class="index-summary">{{ entry.summary }}</p>{% endif %}
{% match entry.why %}{% when Some with (why) %}<p class="index-why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}
{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}
</li>{% endfor %}</ul></section>{% endfor %}
{% if has_world || has_behind %}<nav class="issue-chapters">{% if has_world %}<a href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %} · {% endif %}{% if has_behind %}<a href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %}
<footer class="colophon"><h2>Colophon</h2>
<p><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p>
<dl class="kv">
<dt>Generated</dt><dd>{{ colophon.generated_at }}</dd>
<dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd>
<dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd>
<dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd>
<dt>Entries considered</dt><dd>{{ colophon.entries_fetched }} from {{ colophon.feeds_seen }} feeds</dd>
<dt>Candidates scored</dt><dd>{{ colophon.candidates }}</dd>
<dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd>
<dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>
{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}
<dt>Total token cost</dt><dd>{{ colophon.cost_usd }}</dd>
<dt>Generator</dt><dd>{{ colophon.generator_version }}</dd>
</dl></footer>
</article>{% endblock %}
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}
<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
<article class="mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0">
<header class="mb-8 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ display_date }} · No. {{ issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ stats_line }}</p></header>
<section aria-labelledby="brief-heading"><h1 id="brief-heading" class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
{% if !downloads.is_empty() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% for download in downloads %}<a class="btn" href="{{ download.href }}">Download {{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
<section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2>
{% for section in sections %}<section class="mt-10"><h3 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>{% if !entry.summary.is_empty() %}<p class="mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
</section>
{% if has_world || has_behind %}<nav class="my-12 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-y border-rule py-4 text-center font-sans text-sm uppercase tracking-[0.08em]" aria-label="Issue chapters">{% if has_world %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %}<span class="text-muted" aria-hidden="true">·</span>{% endif %}{% if has_behind %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %}
<footer id="colophon" class="mt-12 scroll-mt-6 border-t border-rule pt-2 font-sans text-sm leading-relaxed"><h2 class="text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>
</article>
</div>{% endblock %}
+1 -1
View File
@@ -1 +1 @@
{% extends "layout.html" %}{% block content %}<section class="reading"><h1>Issue archive</h1>{% for month in months %}<h2>{{ month.label }}</h2><ul>{% for issue in month.issues %}<li><a href="/issues/{{ issue.date }}">{{ issue.display_date }}</a> · No. {{ issue.issue_number }} · {{ issue.article_count }} articles</li>{% endfor %}</ul>{% endfor %}</section>{% endblock %}
{% extends "layout.html" %}{% block ears %}<span>Past editions</span>{% endblock %}{% block content %}<section class="mx-auto mt-10 max-w-[68ch] px-4 sm:mt-14 sm:px-6"><header class="mb-10"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Past editions</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">Issue archive</h1></header>{% for month in months %}<section class="mt-10"><h2 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ month.label }}</h2><ul class="m-0 list-none p-0">{% for issue in month.issues %}<li class="grid grid-cols-[1fr_auto] gap-x-4 border-b border-rule py-4"><a class="text-xl font-semibold leading-tight text-ink no-underline hover:text-accent" href="/issues/{{ issue.date }}">{{ issue.display_date }}</a><span class="font-sans text-sm tabular-nums text-muted">No. {{ issue.issue_number }}</span><span class="font-sans text-sm text-muted">{{ issue.article_count }} articles</span></li>{% endfor %}</ul></section>{% endfor %}</section>{% endblock %}
+8 -7
View File
@@ -1,7 +1,8 @@
{% extends "layout.html" %}{% block content %}<article class="reading issue">
{% if empty %}<h1>No issue yet</h1><p>The first issue has not been published.</p>{% else %}
<p class="dateline">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="stats">{{ issue.stats_line }}</p>
<p class="strap">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p>
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a href="{{ download.href }}">{{ download.label }} ({{ download.size }})</a>{% endfor %}</p>{% endif %}
{% for section in issue.sections %}<section><h2>{{ section.name }}</h2>{% for entry in section.entries %}<article{% if entry.is_lead %} class="lead"{% endif %}><h3><a href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="byline">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }} ({{ entry.domain }}) · {{ entry.reading_minutes }} min · {{ entry.word_count }} words</p>{% if !entry.comment_links.is_empty() %}<p class="comments">{% for link in entry.comment_links %}<a rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</section>{% endfor %}
<p><a href="/issues">Browse the archive</a></p>{% endif %}</article>{% endblock %}
{% extends "layout.html" %}{% block ears %}{% if empty %}<span>Morning edition</span>{% else %}<span class="sm:hidden">{{ issue.short_date }}</span><span class="hidden sm:inline">{{ issue.display_date }} · No. {{ issue.issue_number }}</span>{% endif %}{% endblock %}{% block content %}
<article class="mx-auto mt-8 max-w-[68ch] px-4 sm:mt-12 sm:px-6">
{% if empty %}<div class="py-16 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Morning edition</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">No issue yet</h1><p class="mt-4 text-ink-2">The first issue has not been published.</p></div>{% else %}
<header class="mb-10 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ issue.stats_line }}</p><p class="mx-auto mt-5 max-w-[58ch] text-lg italic leading-relaxed text-ink-2">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p></header>
{% if !downloads.is_empty() %}<div class="mb-8 flex flex-wrap justify-center gap-2">{% for download in downloads %}<a class="btn" href="{{ download.href }}">{{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
{% for section in issue.sections %}<section class="mt-10"><h2 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ section.name }}</h2><div>{% for entry in section.entries %}<article class="border-b border-rule py-6{% if entry.is_lead %} pt-5{% endif %}"><h3 class="font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="mt-2 font-sans text-sm leading-relaxed text-muted">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% if !entry.domain.is_empty() %} ({{ entry.domain }}){% endif %} · {{ entry.reading_minutes }} min</p>{% match entry.summary %}{% when Some with (summary) %}<p class="mt-4">{{ summary }}</p>{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}<p class="mt-3 flex flex-wrap gap-x-4 gap-y-1 font-sans text-sm text-muted">{% for link in entry.comment_links %}<a class="text-muted hover:text-accent" rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</div></section>{% endfor %}
<p class="mt-10 text-center font-sans text-sm"><a href="/issues">Browse the archive →</a></p>
{% endif %}</article>{% endblock %}
+40 -8
View File
@@ -4,17 +4,49 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page.title }} · The Daily EPUB</title>
<link rel="stylesheet" href="/static/app.css?v={{ page.version }}">
<script src="/static/theme.js?v={{ page.asset_version }}"></script>
<link rel="preload" href="/static/Newsreader.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/app.css?v={{ page.asset_version }}">
<link rel="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml">
<link rel="icon" href="/static/favicon.svg">
</head>
<body>
<header class="masthead"><a href="/">The Daily EPUB</a></header>
<nav class="primary" aria-label="Site"><a href="/">Latest</a><a href="/issues">Archive</a><a href="/feed.xml">Feed</a>{% match page.viewer %}{% when Some with (viewer) %}<a href="/account">{{ viewer.username }}</a>{% when None %}<a href="/login">Sign in</a>{% endmatch %}</nav>
{% if page.is_admin() %}<nav class="admin" aria-label="Dashboard"><a href="/dashboard">Overview</a><a href="/dashboard/runs">Runs</a><a href="/dashboard/articles">Articles</a><a href="/dashboard/ratings">Ratings</a><a href="/dashboard/profile">Profile</a><a href="/dashboard/stats">Stats</a><a href="/dashboard/jobs">Jobs</a><a href="/dashboard/settings">Settings</a><a href="/dashboard/users">Users</a></nav>{% endif %}
{% match page.flash %}{% when Some with (flash) %}<div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div>{% when None %}{% endmatch %}
<main>{% block content %}{% endblock %}</main>
<footer>daily-epub {{ page.version }}</footer>
<script src="/static/app.js?v={{ page.version }}"></script>
<a class="fixed left-3 top-3 z-50 -translate-y-20 bg-ink px-3 py-2 font-sans text-sm text-paper no-underline focus:translate-y-0" href="#content">Skip to content</a>
<header class="mx-auto max-w-7xl px-4 pt-4 sm:px-6 sm:pt-6">
<div class="mb-2 flex min-h-8 flex-nowrap items-center justify-between gap-2 border-b border-rule pb-2 font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted sm:gap-4">
<div class="min-w-0 truncate whitespace-nowrap">{% block ears %}<span>Morning edition</span>{% endblock %}</div>
<div class="flex shrink-0 items-center gap-2 whitespace-nowrap normal-case tracking-normal sm:gap-3">
<button class="flex min-h-10 shrink-0 items-center gap-1.5 whitespace-nowrap border-0 bg-transparent px-1.5 py-1 text-xs text-muted hover:text-ink" type="button" data-theme-toggle aria-label="Theme: system">
<svg data-theme-icon="system" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75"><path d="M4 5.5h16v11H4zM9 20h6M12 16.5V20"/></svg>
<svg data-theme-icon="light" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75" hidden><circle cx="12" cy="12" r="3.5"/><path d="M12 2v2.2M12 19.8V22M4.93 4.93l1.56 1.56M17.51 17.51l1.56 1.56M2 12h2.2M19.8 12H22M4.93 19.07l1.56-1.56M17.51 6.49l1.56-1.56"/></svg>
<svg data-theme-icon="dark" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75" hidden><path d="M20 15.1A8.5 8.5 0 0 1 8.9 4a8.5 8.5 0 1 0 11.1 11.1Z"/></svg>
<span data-theme-label>System</span>
</button>
{% match page.viewer %}{% when Some with (viewer) %}<a class="max-w-32 shrink-0 truncate whitespace-nowrap text-ink no-underline hover:text-accent" href="/account">{{ viewer.username }}</a>{% when None %}<a class="shrink-0 whitespace-nowrap text-ink no-underline hover:text-accent" href="/login">Sign in</a>{% endmatch %}
</div>
</div>
<div class="border-b-[3px] border-double border-ink py-3 text-center sm:py-4"><a class="font-serif text-3xl font-semibold leading-none tracking-[-0.02em] text-ink no-underline hover:text-ink sm:text-5xl" href="/">The Daily EPUB</a></div>
<nav class="flex min-h-11 flex-wrap items-center justify-center gap-x-6 gap-y-1 border-b border-rule font-sans text-[0.72rem] font-medium uppercase tracking-[0.12em]" aria-label="Site">
<a class="flex min-h-11 items-center border-b-2 {% if page.active_nav == "latest" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %} no-underline hover:text-ink" href="/"{% if page.active_nav == "latest" %} aria-current="page"{% endif %}>Latest</a>
<a class="flex min-h-11 items-center border-b-2 {% if page.active_nav == "archive" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %} no-underline hover:text-ink" href="/issues"{% if page.active_nav == "archive" %} aria-current="page"{% endif %}>Archive</a>
<a class="flex min-h-11 items-center border-b-2 border-transparent text-muted no-underline hover:text-ink" href="/feed.xml">Feed</a>
{% match page.viewer %}{% when Some with (_) %}<a class="flex min-h-11 items-center border-b-2 {% if page.active_nav == "account" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %} no-underline hover:text-ink" href="/account"{% if page.active_nav == "account" %} aria-current="page"{% endif %}>Account</a>{% when None %}<a class="flex min-h-11 items-center border-b-2 {% if page.active_nav == "login" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %} no-underline hover:text-ink" href="/login"{% if page.active_nav == "login" %} aria-current="page"{% endif %}>Sign in</a>{% endmatch %}
</nav>
{% if page.is_admin() %}<nav class="flex flex-wrap items-center justify-center gap-x-4 border-b border-rule py-1 font-sans text-[0.68rem] uppercase tracking-[0.1em]" aria-label="Dashboard">
<a class="py-2 no-underline {% if page.active_nav == "dashboard" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard"{% if page.active_nav == "dashboard" %} aria-current="page"{% endif %}>Overview</a>
<a class="py-2 no-underline {% if page.active_nav == "runs" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/runs"{% if page.active_nav == "runs" %} aria-current="page"{% endif %}>Runs</a>
<a class="py-2 no-underline {% if page.active_nav == "articles" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/articles"{% if page.active_nav == "articles" %} aria-current="page"{% endif %}>Articles</a>
<a class="py-2 no-underline {% if page.active_nav == "ratings" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/ratings"{% if page.active_nav == "ratings" %} aria-current="page"{% endif %}>Ratings</a>
<a class="py-2 no-underline {% if page.active_nav == "profile" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/profile"{% if page.active_nav == "profile" %} aria-current="page"{% endif %}>Profile</a>
<a class="py-2 no-underline {% if page.active_nav == "stats" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/stats"{% if page.active_nav == "stats" %} aria-current="page"{% endif %}>Stats</a>
<a class="py-2 no-underline {% if page.active_nav == "jobs" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/jobs"{% if page.active_nav == "jobs" %} aria-current="page"{% endif %}>Jobs</a>
<a class="py-2 no-underline {% if page.active_nav == "settings" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/settings"{% if page.active_nav == "settings" %} aria-current="page"{% endif %}>Settings</a>
<a class="py-2 no-underline {% if page.active_nav == "users" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/users"{% if page.active_nav == "users" %} aria-current="page"{% endif %}>Users</a>
</nav>{% endif %}
</header>
{% match page.flash %}{% when Some with (flash) %}<div class="mx-auto max-w-7xl px-4 sm:px-6"><div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div></div>{% when None %}{% endmatch %}
<main id="content" class="min-h-[68vh]">{% block content %}{% endblock %}</main>
<footer class="mx-auto mt-16 flex max-w-7xl flex-wrap justify-between gap-2 border-t border-rule px-4 py-6 font-sans text-[0.72rem] uppercase tracking-[0.1em] text-muted sm:px-6"><span>The Daily EPUB</span><span>daily-epub {{ page.version }}</span></footer>
<script src="/static/app.js?v={{ page.asset_version }}"></script>
</body>
</html>
+1 -1
View File
@@ -1 +1 @@
{% extends "layout.html" %}{% block content %}<section class="reading narrow"><h1>Sign in</h1>{% if !error.is_empty() %}<p class="error">{{ error }}</p>{% endif %}<form method="post" action="/login"><input type="hidden" name="next" value="{{ next }}"><label>Username <input name="username" autocomplete="username" required></label><label>Password <input type="password" name="password" autocomplete="current-password" required></label><button type="submit">Sign in</button></form></section>{% endblock %}
{% extends "layout.html" %}{% block ears %}<span>Reader access</span>{% endblock %}{% block content %}<section class="mx-auto mt-12 max-w-md px-4 sm:mt-16 sm:px-6"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Reader access</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">Sign in</h1><p class="mt-4 text-ink-2">Open the full paper, article chapters, and downloads.</p>{% if !error.is_empty() %}<p class="error mt-6">{{ error }}</p>{% endif %}<form class="mt-8 grid gap-5 font-sans text-sm" method="post" action="/login"><input type="hidden" name="next" value="{{ next }}"><label class="grid gap-1.5 font-medium">Username <input class="min-h-11 w-full" name="username" autocomplete="username" required></label><label class="grid gap-1.5 font-medium">Password <input class="min-h-11 w-full" type="password" name="password" autocomplete="current-password" required></label><button class="btn-primary mt-1 w-full" type="submit">Sign in</button></form></section>{% endblock %}
+3 -5
View File
@@ -1,5 +1,3 @@
{% extends "layout.html" %}{% block content %}<article class="reading world-briefing">
<h1>World Briefing</h1><p class="dateline">{{ display_date }}</p><hr class="rule">
<div class="world-body">{{ body_html|safe }}</div>
<p><a href="{{ issue_href }}">← Back to this issue</a></p>
</article>{% endblock %}
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
<article class="mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0"><header class="mb-9">{% match display_date %}{% when Some with (date) %}<p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ date }}</p>{% when None %}{% endmatch %}<h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">World Briefing</h1></header><div class="prose-body border-t border-rule pt-7">{{ body_html|safe }}</div><p class="mt-10 border-t border-rule pt-5 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></article>
</div>{% endblock %}