Add briefs for the UI polish pass

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
This commit is contained in:
2026-09-04 16:33:58 +00:00
co-authored by Claude Fable 5.1
parent 78e7e4aa2c
commit 560bbc13af
4 changed files with 388 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
# Shared brief — UI polish pass (2026-09-04)
The operator asked for a design/bug-fix pass over the web dashboard v2 (Tailwind
redesign, merged to `main` at `e719e03`+). The work is split into three tasks
(`01-toc.md`, `02-rating.md`, `03-shell.md`) that run in parallel in separate git
worktrees. Read `docs/plans/briefs/web-dashboard-v2/00-shared.md` for the design
language (quiet morning paper: warm paper, near-black ink, one red accent,
hairline rules, real typographic hierarchy; reader pages serif, dashboard sans)
and the Tailwind v4 conventions. This brief overrides it where they disagree.
## Ground rules
- Work only in the worktree named in your brief. **Never touch the main checkout**
at `/home/thallada/workspace/the-daily-epub`. Do not `git commit` (the sandbox
cannot write `.git/worktrees`); leave the work in the tree and the orchestrator
commits. If you can commit, one commit on the worktree branch is fine.
- Styling lives in **`src/web/tailwind.css`** (Tailwind v4, `@apply` in
`@layer components`, semantic colour tokens `paper/paper-2/ink/ink-2/muted/rule/
rule-strong/accent/loved/good/down/warn`) and in Tailwind utility classes in the
askama templates under `src/web/templates/`. **Rebuild the committed CSS** after
every change with `npm run css` (writes `src/web/static/app.css`; the binary
embeds it with `include_str!`, so rebuild Rust after the CSS). `npm run css:check`
must print no diff at the end.
- CSP is `default-src 'self'; style-src 'self'; script-src 'self'`: **no inline
`<style>`/`<script>`, no `style=""` attributes, no CDNs.** Setting CSS custom
properties or `element.style.*` from `app.js` (CSSOM) is allowed by CSP but
prefer class/attribute toggles styled from `tailwind.css`.
- JS is plain `src/web/static/app.js` (runs once at the end of `<body>`) and the
tiny pre-paint `src/web/static/theme.js`. Keep every hook app.js relies on:
`form.rating`, `button[data-label]`, `.active` + `aria-pressed` on rating
buttons, `button.clear`, `form[data-confirm]`, `details[id]`, `table[data-filter]`,
`.table-filter`, `button[data-reset]`/`data-default`, `[data-refresh]`,
`[data-toc-panel]`, `[data-toc-toggle]`, `[data-toc-progress]`, `[data-toc-scroll]`,
`[data-theme-toggle]`. Keep `id`/`name`/`action`/`aria-*` attributes used by
handlers or tests. Router tests assert on text and some class names; if a test
breaks only because a cosmetic class changed, update the test.
- Don't change routes, auth, migrations or `web::security_headers`.
- Prefer CSS transitions (interruptible) over keyframes; name exact transition
properties (never `transition: all`); respect the existing
`prefers-reduced-motion` rule. Touch targets ≥ 44 px on phones (extend with a
pseudo-element if the visible control is smaller); ≥ 40 px in the dense
dashboard. Tabular numerals for changing numbers. Hairline rules, not boxes,
for structure. No decorative motion on frequent interactions.
- Keep `cargo fmt`, `cargo clippy --all-targets -- -D warnings` and the tests
green. Iterate with narrow runs (`cargo test --lib web::`), one `cargo test` at
the end. **Sandbox note:** inside the Codex sandbox `bind()` on 127.0.0.1 is
forbidden, so 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) and `tests/m7_server.rs`. Do not "fix" them.
- This host has 4 cores / 7 GB and other agents build Rust at the same time; be
patient with `cargo` and don't run several builds concurrently yourself.
- Don't gold-plate and don't widen scope beyond your brief's numbered items plus
the polish items it lists. Where the brief and the code disagree, follow the
code and note the deviation.
- Finish by writing `docs/plans/briefs/ui-pass/handoff-<task>.md`: what changed
(file by file, briefly), design decisions, deviations, what you verified and
how (commands, screenshots), anything left open.
## Previewing
A seeded dev database exists in the main checkout at
`/home/thallada/workspace/the-daily-epub/dev` (two issues: No. 18 on 2026-09-01,
legacy; No. 19 on 2026-09-02, full snapshot; accounts `admin`/`adminpassword123`
and `reader`/`readerpassword123`). Copy it if you need your own:
`cp -r /home/thallada/workspace/the-daily-epub/dev ./dev` (git-ignored). Run your
worktree's server on **your own port** (given in your brief; 3499 is production,
3599 is the orchestrator's):
```text
DAILY_EPUB_SERVER__HMAC_SECRET=$(head -c 48 /dev/urandom | base64) \
DAILY_EPUB_SERVER__BIND=127.0.0.1:<port> \
cargo run -- --config ./dev/config.toml serve
```
(the listen address is `server.bind` in `./dev/config.toml`, overridable by the
env var above; your worktree's `dev/config.toml` is already set to your port. If
the sandbox forbids binding, rely on router tests + `cargo test` and say so in
the handoff).
Screenshots: `node shoot.mjs <outdir> [--user admin|reader|none] [--theme light|dark] [--mobile] [--full] /path …`
with `BASE=http://127.0.0.1:<port>`, from
`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/553c7d77-d8f4-44f8-a379-73cedea23814/scratchpad/shoot/`
(Playwright with a headless Chromium already installed under `~/.cache/ms-playwright`).
If that directory is unreadable, the bare browser 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** (read the PNGs) before
calling a design change done, in light and dark, desktop and 390 px phone.
Useful dev URLs: `/` (latest issue, No. 19), `/issues/2026-09-01` (archived
issue), `/issues/2026-09-02/articles/<id>` (chapter; pick ids from the TOC),
`/issues/2026-09-02/world`, `/issues/2026-09-02/behind`, `/dashboard`,
`/dashboard/runs`, `/dashboard/runs/<id>`, `/dashboard/ratings`,
`/dashboard/settings`, `/dashboard/articles`.
+88
View File
@@ -0,0 +1,88 @@
# Task 1 — table-of-contents sidebar: mobile height, scroll sync, scrollbar
Worktree: `/home/thallada/workspace/the-daily-epub-ui1` (branch `ui-toc`). Dev port 3601.
Files you own: `src/web/templates/_toc.html`, the TOC parts of
`src/web/static/app.js` (the `[data-toc-*]` blocks), the "table-of-contents"
section of `src/web/tailwind.css`, and the index entries in
`src/web/templates/issue_full.html` (only to add data attributes / ids). Backend:
`src/web/issue.rs` `Toc`/`TocItem` if a field helps. Read the shared brief first.
The signed-in issue pages (`issue_full.html`, `article.html`, `world.html`,
`behind.html`) share `_toc.html`: a sticky bar with a hamburger below `lg`, a
sticky sidebar at `lg+`. Chapters are separate pages; the issue front page lists
every chapter in "In This Issue".
## 1. The open hamburger panel must fill the screen (operator issue 1)
Today the panel is `absolute top-full max-h-[72vh]`, so on a phone it stops
partway down with page content visible beneath it — it looks broken. Make the
open panel extend from the bottom of the sticky bar to the **bottom of the
viewport** (use `100dvh`, not `100vh`, so mobile browser chrome is accounted
for), with the chapter list scrolling inside it and the bar staying fixed above
it. Handle both states: the bar already stuck at the top of the viewport, and
the bar still in flow below the masthead on first open. Prefer a pure-CSS
solution; if the bar's on-screen position must be measured, set one CSS custom
property from app.js on open/resize and consume it in `tailwind.css`. Avoid
jumps of the underlying page when the panel opens or closes. Keep Escape, the
outside-click close, the tap-on-chapter close, and the `lg` breakpoint reset.
Content behind the panel must not scroll while it is open (e.g. toggle a class
on `<html>` that sets `overflow:hidden`, cleared on close).
## 2. Scroll-synced current chapter (operator issue 2)
On the issue front page the reader scrolls through "In This Issue"; each index
entry corresponds to a TOC chapter. As the reader scrolls, the TOC's current
marker must follow: when entry 5's card reaches the top region of the viewport,
chapters 14 have visibly "passed" and 5 is current. Implement a scroll-spy in
app.js (IntersectionObserver or a rAF-throttled scroll listener; either is fine,
but it must be cheap and passive):
- Mark each index entry `<li>` in `issue_full.html` with the chapter it
represents (e.g. `data-toc-entry="{{ entry.href }}"`, matching the TOC link's
`href`); "The Brief" section maps to the Brief item, the colophon to the
Colophon item.
- The current entry is the last one whose top edge is above a threshold
(roughly the sticky bar height + ~1/3 of the viewport). Before the first entry
the Brief is current.
- Move the current styling to a single hook so JS only toggles one attribute:
give TOC links a class (say `toc-link`) and style `.toc-link[aria-current]`
in `tailwind.css` (keep `aria-current="page"` for the server-rendered chapter
on chapter pages; the scroll-spy may use `aria-current="location"`). Passed
chapters may stay ink-coloured while unread ones are `ink-2`; keep it subtle.
- Update the sticky mobile bar's label and "N / total" counter and both
`[data-toc-progress]` bars from the same source of truth (the front page
currently leaves the progress bar at 0; it should reflect the current chapter
as the reader passes entries). The desktop "Chapter N of M / Front page" line
should update too. Chapter pages keep today's progress-within-chapter logic.
- When the current item changes, **auto-scroll the TOC panel** so the current
link is comfortably in view (there is already a `revealCurrent()`; reuse it,
make it use `scrollTo({behavior:"smooth"})` unless `prefers-reduced-motion`,
and make sure it works for the desktop sticky sidebar as well as the open
mobile panel). Never scroll the window itself from this code.
- Chapter pages already mark the chapter current; make sure the sidebar reveals
it on load (desktop and mobile) — verify on a long TOC.
## 3. Scrollbar must not overlap the TOC text (operator issue 11)
In the sidebar the scrollbar is painted over the right edge of the chapter
labels. Reserve the gutter (`scrollbar-gutter: stable`, plus right padding on
the panel so the list ends before the gutter) so the scrollbar sits to the
right of the content on every platform, including overlay-scrollbar macOS.
Keep `scrollbar-width: thin` and the rule-coloured thumb.
## Polish while you are there
- Chapter links: ≥ 44 px tall tap targets on phones (they are `py-2.5` today,
check), hover/focus-visible states consistent with the nav.
- The hamburger icon should animate to a close icon while open using the
cross-fade recipe (both SVGs in the DOM, one absolutely positioned; opacity
`0→1`, scale `0.25→1`, blur `4px→0`, ~150 ms, `cubic-bezier(0.2,0,0,1)`), and
the `aria-label` should flip to "Close contents".
## Verification
Screenshot the front page and one chapter, desktop + phone, light + dark, with
the panel open on the phone; script a scroll on the front page (Playwright
`page.mouse.wheel` or `window.scrollTo`) and screenshot the sidebar at two
scroll positions to prove the marker moves and the panel auto-scrolls. Run the
web router tests. Write `handoff-toc.md`.
+97
View File
@@ -0,0 +1,97 @@
# Task 2 — rating widget, issue section headers, ratings-table verdict column
Worktree: `/home/thallada/workspace/the-daily-epub-ui2` (branch `ui-rating`). Dev port 3602.
Files you own: `src/web/templates/_rating_widget.html`, the `.rating*`,
`td .rating`, `.prose-body` and reader-typography parts of `src/web/tailwind.css`,
`src/web/templates/issue_full.html` and `issue_public.html` (section headers),
`src/web/templates/article.html` (rating placement only),
`src/web/templates/dashboard/ratings.html`, and the rating `submit` handler in
`src/web/static/app.js` if the markup change needs it. Read the shared brief first.
## 1. Redesign the rating widget (operator issue 3)
`_rating_widget.html` renders "Was this a good pick? [Loved it][Good][Not for me]
clear" (+ a Note input on dashboard pages). The buttons are 40 px tall,
full-border segmented, solid-ink when active — too heavy next to the serif index
cards on the issue front page and the article footer; they read as a form, not as
part of the paper. Redesign it to be cohesive with the article display:
- Quieter and smaller: sans, `text-xs`/`text-[0.8rem]`, compact height (~3032 px
visible; extend the hit area to 44 px on phones with a pseudo-element), hairline
`rule` borders or a single underlined-text style, `rounded-sm`.
- The active state should use the semantic colour of the verdict (`loved`, `good`,
`down`) as a tint, like `.badge.loved` etc. (`color-mix(in oklab, var(--loved)
14%, transparent)` background + coloured text/border), not solid ink — so the
chosen verdict is legible at a glance and matches the badges used elsewhere.
- The prompt "Was this a good pick?" becomes a small serif-italic or muted-sans
lead-in that sits on the same line as the buttons at desktop widths and above
them on phones; "clear" stays a quiet text link, visible only when a verdict is
active (still in the DOM for the handler; use `[hidden]` or an opacity class).
- Keep the markup contract: `form.rating`, `button[type=submit][name=label][value][data-label]`,
`.active`, `aria-pressed`, `button.clear`, `label.rating-note > input[name=note]`,
the three hidden inputs, `widget.show_note`, and the JSON fetch in app.js (it
toggles `.active`/`aria-pressed`; if you add a per-verdict tint class, derive it
from `data-label` in CSS — `.rating button.active[data-label="loved"]` — so JS
needs no change).
- Press feedback: `active:scale-[0.96]` with `transition-property: transform,
background-color, color, border-color` on the buttons.
Check both placements: the index card on `/` (after "Why it's here") and the
article footer on a chapter page; also `/dashboard/articles/<id>` which shows the
widget with the note field.
## 2. Section headers in the issue (operator issue 4)
On the issue front page ("In This Issue") and the public issue page, each section
name (e.g. *Technology*) is a 0.72 rem uppercase muted label above a hairline —
the same style as tiny eyebrows elsewhere — so readers scroll past it without
noticing the section changed. Make section headers read as headers: serif,
`text-2xl` or so, weight 600, `leading-[1.1]`, `tracking-[-0.01em]`,
`text-wrap:balance`, sitting on a **stronger rule** (`border-t-2 border-rule-strong`
or a double rule like the masthead) with generous space above, and a small
uppercase eyebrow ("Section" or the article count, optional) if it helps
hierarchy. Headlines inside the section stay `text-2xl`/lead `text-3xl`, so the
section header must be distinguishable from them by the rule + spacing + smaller
size rather than by being bigger; consider `text-xl` uppercase-tracked serif
small caps if that reads better — screenshot both and pick one. Apply the same
treatment to "The Brief" and "Colophon" headers on the front page so the page has
one heading system, and to the section names on `issue_public.html`. Leave the
TOC sidebar's section labels alone (task 1 owns that file).
Also check article bodies (`.prose-body h2/h3` on a chapter page): they should be
clearly headings against `1.0625rem` body text; adjust only if they are weak.
## 3. Ratings table verdict column (operator issue 7)
`dashboard/ratings.html`, tab "Current": the first column stacks a verdict badge,
the three rating buttons, a Note input and "clear" — busy and janky. Make that
cell one calm unit:
- Drop the separate badge: the tinted active button (from item 1) *is* the verdict.
If a row is `cleared` show a muted "cleared" state in the group instead.
- A compact segmented group of the three verdicts on one line (fixed width, e.g.
`w-[13rem]`), the "clear" link tucked at the end of that line, and the note
field beneath as a quiet single-line input (`placeholder="Add a note…"`,
bottom-hairline only, no box) that only shows a border on focus. Everything
left-aligned on one grid so rows line up; no wrapping inside the cell at the
table's minimum width.
- Vertical rhythm: the cell content top-aligned with the row's article title.
- Keep the note column of the table as is (it shows the stored note).
## Polish while you are there (reader pages)
- `text-wrap: balance` on headlines (`h1``h4` in reader pages, TOC excluded);
`text-wrap: pretty` on `.prose-body p` and index summaries.
- `.prose-body img`: add the depth outline `outline: 1px solid oklch(0 0 0 / 0.1)`
in light and `oklch(1 0 0 / 0.1)` in dark (pure black/white only, use the
existing `dark` variant), `outline-offset: -1px`.
- Buttons `.btn`, `.btn-primary`, `.btn-danger`: `active:scale-[0.96]` with named
transition properties (transform + colours), keeping the reduced-motion rule.
## Verification
Screenshot `/` (signed in as reader), a chapter page footer, `/dashboard/ratings`
(admin, tab Current) and `/dashboard/articles/<id>`, light + dark, desktop + phone.
Exercise a rating click on the dev server (the JSON handler) to confirm the tint
switches. Run `cargo test --lib web::` and the ratings/issue router tests. Write
`handoff-rating.md`.
+110
View File
@@ -0,0 +1,110 @@
# Task 3 — site shell: nav state, ears, runs feeds table, FOUC, faster navigation
Worktree: `/home/thallada/workspace/the-daily-epub-ui3` (branch `ui-shell`). Dev port 3603.
Files you own: `src/web/templates/layout.html`, `src/web/mod.rs` (static assets,
headers), `src/web/public.rs`, `src/web/issue.rs` (page/nav plumbing only, not
the `Toc`), `src/web/templates/dashboard/run.html`, the "dashboard shell" and
`@layer base` parts of `src/web/tailwind.css`, and `src/web/static/theme.js`.
Read the shared brief first.
## 1. "Latest" is highlighted on archived issues (operator issue 5)
Every issue page (`public::show_issue`, `issue::render_full`, `issue::article`,
`world`, `behind`) passes `"latest"` as `active_nav`, so viewing `/issues/2026-09-01`
from the archive highlights *Latest* in the site nav. Highlight *Archive*
instead whenever the issue is not the newest one. Cleanest: `issue::load` (which
has the db) records `is_latest` on `IssueView` via `db.latest_issue_date()`, and
each renderer chooses `"latest"`/`"archive"` from it; the public branch in
`show_issue` needs the same. Add a router test that fetches an older issue and
asserts `aria-current="page"` is on the Archive link and not on Latest (and the
reverse for the newest issue).
## 2. "Morning edition" ears on non-issue pages (operator issue 10)
`layout.html`'s default `{% block ears %}` prints "Morning edition" — it shows on
settings and every other dashboard/account page where it is meaningless. Make the
default empty and let issue pages keep overriding it with their dateline. On
dashboard pages show a short "Dashboard" label there instead (only when
`page.is_admin()` and the active nav is a dashboard section) so the row does not
look orphaned; anything else stays empty. Check the row keeps its height and the
theme toggle stays right-aligned when the left cell is empty. `issue_public.html`'s
empty-state hero may keep its own "Morning edition" eyebrow.
## 3. Runs page "Feeds (top 20)" overflow (operator issue 6)
`dashboard/run.html`: the Feeds card wraps its table in `.scroll-x`, whose
`> table { min-width: max-content }` lets long feed titles push the *entries*
column out of the card until the reader scrolls sideways. That table must fit the
card: `table-fixed w-full`, the feed cell `truncate` (nowrap + ellipsis) with the
full name in a `title` attribute, entries column a fixed narrow width, no
horizontal scroll container (or a `.scroll-x` variant that allows shrinking —
your call, but don't break the other tables that rely on `max-content`). Check
the Timings and Provider usage cards next to it still look right, and the
dashboard overview if it has a similar box.
## 4. Flash of unstyled content (operator issue 8)
The operator sees a brief flash of unstyled content when navigating between
pages, "or maybe just sometimes". Reproduce before fixing: with the dev server,
use Playwright to capture the first frames of a navigation with the cache
disabled and a throttled network (there are older experiments `flash.mjs` /
`flash2.mjs` in the screenshot directory named in the shared brief you may crib
from) and note what actually flashes: the fallback serif before Newsreader loads
(`font-display: swap`), a theme flip, the TOC panel, restored `details` state,
or something else. Then fix the causes you confirm. Expected fixes:
- Static assets are already versioned by content hash (`?v={{ page.asset_version }}`),
so serve `/static/*` with `Cache-Control: public, max-age=31536000, immutable`
(update `static_assets_use_content_hash_etags`); keep the ETag.
- Fonts: preload both Newsreader files, and change `font-display` from `swap` to
`block` (no visible fallback; with the preload and immutable cache the block
period is only ever paid once) — or `optional` if you can show it behaves
better here. Explain the choice in the handoff.
- Anything initialised by app.js at the end of `<body>` that changes layout
(e.g. `details[id]` open state restored from localStorage) should either be
decided pre-paint in `theme.js` or made not to shift layout.
## 5. Faster, SPA-like navigation without a client router (operator issue 9)
The operator wants link clicks to feel like an instant swap rather than a full
reload, but wants to decide on HTMX (boost mode) in a separate effort. **Do not**
write a fetch-and-swap router and do not add HTMX. Deliver the standards-based
progressive enhancements that get most of the way there:
- **Cross-document view transitions**: `@view-transition { navigation: auto; }`
in `tailwind.css` with a short (~120150 ms) cross-fade of the page and the
header kept stable (`view-transition-name` on the site header so the masthead
and nav do not fade; the reader's sticky TOC bar may also stay put). Respect
reduced motion (`@media (prefers-reduced-motion: reduce)` → no animation).
- **Speculation rules** for prerender/prefetch on hover: inline
`<script type="speculationrules">` is blocked by the CSP, so serve a JSON rule
set as a static asset (`/static/speculation.json`, embedded like the others)
and send the `Speculation-Rules: "/static/speculation.json"` response header
on HTML responses (add it in `web::security_headers` or the same layer).
Rules: `prerender` with `eagerness: "moderate"` for same-origin reader pages
(`/`, `/issues/*`); `prefetch` with `eagerness: "moderate"` for `/dashboard/*`
(heavy queries — don't prerender them); exclude `/logout`, `/rate`, `/static/*`
and anything with a query string that mutates. Both are Chromium-only today and
no-ops elsewhere; say so in the handoff.
- Make sure `Cache-Control` on HTML stays as is (`public_cache` / no-store for
signed-in) so prerendered pages are not stale for signed-in readers, and that
the `[data-refresh]` job-page reload cannot run in a prerendered page
(`document.prerendering` guard).
## Polish while you are there (shell)
- Site nav and dashboard nav links: consistent focus-visible ring (they inherit
the base `a` rule — check it is not clipped by `overflow`), and the dashboard
nav's active link should get the same 2 px accent underline treatment as the
site nav rather than just a colour change.
- Theme toggle and the Sign-in/username link: ≥ 40 px tap targets.
- `.tile-num`, `.pager`, and anything that shows changing counts already use
tabular numerals — confirm; add to `.badge` when it contains a number.
## Verification
`cargo test` (full, minus the sandbox-only failures), `npm run css:check`, and
screenshots of `/issues/2026-09-01` (archive highlighting), `/dashboard/settings`
(ears), `/dashboard/runs/<id>` (feeds card at desktop and phone width), plus your
flash reproduction before/after. Curl the headers of `/` and `/static/app.css`
and paste them in `handoff-shell.md`.