Web dashboard v2: dev seed example and agent briefs
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
@@ -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`.
|
||||
Reference in New Issue
Block a user