Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78e7e4aa2c | ||
|
|
6b0fa51e87 | ||
|
|
e719e031cf | ||
|
|
ca910d99e5 | ||
|
|
16244a6a70 | ||
|
|
a4c338a9f9 | ||
|
|
29c66da865 | ||
|
|
12134f3596 | ||
|
|
f410df47c0 | ||
|
|
61ed37fc20 |
No files matched your search
@@ -3,3 +3,5 @@ target/
|
||||
*.sqlite
|
||||
out/
|
||||
.env
|
||||
dev/
|
||||
node_modules/
|
||||
Generated
+1
-1
@@ -884,7 +884,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "daily-epub"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
|
||||
+2
-2
@@ -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"] }
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Generated
+313
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+56
-6
@@ -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
@@ -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
@@ -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
|
||||
@@ -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.
@@ -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.
|
||||
@@ -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");
|
||||
}
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
{% 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 @@
|
||||
{% 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 @@
|
||||
{% 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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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@<name>.service</code> through systemd; the unit runs <code>daily-epub job run <name></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@<name>.service</code> through systemd; the unit runs <code>daily-epub job run <name></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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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.<name>]</code> with placeholder <code>base_url</code> and <code>model</code>; edit them above afterwards and put <code>DAILY_EPUB_PROVIDERS__<NAME>__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.<name>]</code> with placeholder <code>base_url</code> and <code>model</code>; edit them above afterwards and put <code>DAILY_EPUB_PROVIDERS__<NAME>__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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 <username> --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 <username></code> <code>--admin</code>.</td></tr>{% endif %}</tbody>
|
||||
</table></div>
|
||||
</section>{% endblock %}
|
||||
@@ -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 @@
|
||||
{% 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 %}
|
||||
@@ -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 @@
|
||||
{% 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 @@
|
||||
{% 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 %}
|
||||
@@ -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 %}
|
||||
Reference in new issue
Block a user