Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
111 lines
6.6 KiB
Markdown
111 lines
6.6 KiB
Markdown
# 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 (~120–150 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`.
|