Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
239125a8e5 | ||
|
|
1047cf4633 | ||
|
|
9acd1b0800 | ||
|
|
b299b4ba05 | ||
|
|
4248e48a8b | ||
|
|
bcbe1583af | ||
|
|
0cdc302f84 | ||
|
|
da24e7c7c5 | ||
|
|
5cc4ac4bd3 | ||
|
|
fafb737e53 | ||
|
|
ad2e99eeb9 | ||
|
|
560bbc13af |
No files matched your search
@@ -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`.
|
||||
@@ -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 1–4 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`.
|
||||
@@ -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 (~30–32 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`.
|
||||
@@ -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 (~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`.
|
||||
@@ -0,0 +1,167 @@
|
||||
# Task 2 handoff — rating widget, issue section headers, ratings-table verdict column
|
||||
|
||||
Branch `ui-rating` in `/home/thallada/workspace/the-daily-epub-ui2`, dev port 3602.
|
||||
A previous agent (Codex) landed a partial pass as `0cdc302` before hitting a quota
|
||||
limit and never saw the rendered result; `main` was merged in afterwards
|
||||
(`bcbe158`). This pass kept the parts of that work that held up on screen, fixed
|
||||
the parts that did not, and finished the remaining items.
|
||||
|
||||
## What changed
|
||||
|
||||
### `src/web/tailwind.css`
|
||||
|
||||
- **`.reader-section-heading`** — replaced Codex's sentence-case `text-xl` serif
|
||||
heading with a serif **uppercase, letter-spaced section mark** on a double rule:
|
||||
`border-t-[3px] border-double border-rule-strong pt-3 font-serif text-[1.05rem]
|
||||
font-semibold uppercase leading-[1.2] tracking-[0.15em]`. `text-wrap: balance`
|
||||
stays. (Rationale under *Design decisions*.)
|
||||
- **Rating buttons** — inactive labels are now `text-ink-2` (were `text-ink`) and
|
||||
go to `text-ink` on hover, so the group recedes next to serif body copy.
|
||||
- **Rating button hit area** — the `::after` overlay went from `-inset-y-1` /
|
||||
`-inset-y-1.5` (phone) to `-inset-y-1.5` / `-inset-y-2`. The pseudo is
|
||||
positioned against the *padding* box, so the old values gave 37 px desktop /
|
||||
41 px phone; the new values measure 41 px and 45 px (verified with
|
||||
`elementFromPoint`), clearing the ≥ 40 px / ≥ 44 px rules in the shared brief.
|
||||
- **Rating focus ring** — `.rating button[data-label]:focus-visible { z-20
|
||||
ring-offset-0 }`. In a segmented group the inherited `ring-offset-2` painted a
|
||||
detached red box over the neighbouring buttons; hugging the segment reads as
|
||||
one control.
|
||||
- **`.rating-note input`** — now a quiet bottom-hairline field everywhere
|
||||
(`rounded-none border-x-0 border-t-0 border-b border-rule bg-transparent px-0
|
||||
py-1 shadow-none focus:border-ink`) instead of a boxed input on reader/dashboard
|
||||
pages and a *transparent* bottom border in tables.
|
||||
- **Table/list overrides** — `td .rating` / `.picks .rating` lost the
|
||||
`max-w-[15rem]` cap (the cell now sets its own width) and gained `gap-y-1`;
|
||||
table buttons use `px-1.5` so the group fits; `.picks .rating` gets `mt-2` so
|
||||
the widget is not welded to the pick title.
|
||||
- **New `.rating-cell` block** — the ratings-table verdict cell is a one-column
|
||||
grid, `w-[15rem]`, `gap-y-1.5`: row 1 is the segmented group with `clear`
|
||||
pushed to the right edge (`ml-auto`), row 2 is the note field spanning the same
|
||||
width. `min-h-0` on `.rating-actions` inside the cell top-aligns the group with
|
||||
the row's article title (measured: 1 px apart).
|
||||
|
||||
### `src/web/dashboard/ratings.rs`
|
||||
|
||||
- Removed the now-dead `CurrentRow::verdict` field and its assignment (Codex
|
||||
dropped `{{ row.verdict }}` from the template but left the field, which fails
|
||||
`clippy -D warnings`). `badge` stays — it is still read by the "no embedding" /
|
||||
"—" branch in the template.
|
||||
|
||||
### `src/web/templates/world.html`, `src/web/templates/behind.html`
|
||||
|
||||
- Added `reader-page` to the `<article>` so the polish item's
|
||||
`text-wrap: balance` on reader headlines covers the World Briefing and Behind
|
||||
the paper chapters too. (Deviation — see below.)
|
||||
|
||||
### Kept from Codex's partial commit (verified on screen, unchanged)
|
||||
|
||||
`_rating_widget.html` (`.rating-actions` wrapper, `clear`/`cleared` label spans,
|
||||
`placeholder="Add a note…"`), `article.html` / `issue_full.html` /
|
||||
`issue_public.html` (`reader-page`, `reader-section-heading`, `index-summary`,
|
||||
`mt-14` section spacing, colophon and The Brief on the same heading system),
|
||||
`dashboard/ratings.html` (badge dropped from the verdict cell, `rating-cell`
|
||||
class), the per-verdict tints, `.prose-body img` outline, `.prose-body p` /
|
||||
`.index-summary` `text-wrap: pretty`, and `active:scale-[0.96]` with named
|
||||
transition properties on `.btn` / `.btn-primary` / `.btn-danger`.
|
||||
|
||||
### `src/web/static/app.css`
|
||||
|
||||
Rebuilt from `tailwind.css` (Tailwind 4.3.3).
|
||||
|
||||
## Design decisions
|
||||
|
||||
- **Section headers: uppercase mark, not a bigger headline.** The brief asked for
|
||||
both options to be screenshotted. A sentence-case serif `text-2xl` header
|
||||
(`after-rating/sec/C.png`) is the same size as the article headlines directly
|
||||
beneath it and reads as another story, which is the exact failure being fixed.
|
||||
The uppercase, letter-spaced serif mark on a double rule
|
||||
(`after-rating/sec/B.png`, `B2-*.png`) is unmistakably a section marker at a
|
||||
glance and never competes with a headline — the newspaper convention the rest
|
||||
of the design language is borrowing. It is also visibly *not* the old 0.72 rem
|
||||
sans eyebrow: serif, ink-coloured, ~50 % larger, and sitting on the same double
|
||||
rule as the masthead. No extra eyebrow was added; the header is already
|
||||
uppercase, so a second uppercase line above it would be noise.
|
||||
- **`clear` stays `invisible`, not `[hidden]`.** It keeps its layout box, so
|
||||
choosing a verdict does not shift the group sideways. `.rating:has(button
|
||||
.active) button.clear` reveals it; the cleared state swaps the label to a muted
|
||||
"cleared" with no underline, which doubles as the brief's "muted cleared state
|
||||
in the group" for the ratings table.
|
||||
- **Verdict cell is `w-[15rem]`, not `w-[13rem]`.** 13 rem is 208 px; the three
|
||||
labels plus `clear` measure ~219 px at `text-xs`/`px-1.5`, and the wider
|
||||
"cleared" label needs ~237 px. 15 rem (240 px) fits both states with the
|
||||
`clear`/`cleared` link flush right in each, so rows stay aligned whichever state
|
||||
they are in. Shrinking the type to ~0.7 rem would have hit 13 rem but is too
|
||||
small for a dense table that is otherwise 14 px.
|
||||
- **Note field shows a faint rule at rest.** The brief said "bottom-hairline only,
|
||||
no box" *and* "only shows a border on focus"; Codex read that as a transparent
|
||||
border, which left "Add a note…" floating with no affordance and no shared
|
||||
baseline across rows. A `--rule` hairline that goes to `--ink` on focus keeps
|
||||
the cell calm, gives the unit a base line, and still reads as quiet.
|
||||
- **`.prose-body h2`/`h3` left alone.** Checked against 1.0625 rem body copy
|
||||
(`prose-heading-light.png`): `text-2xl` semibold over a hairline is already a
|
||||
clear heading. The brief said to adjust only if weak.
|
||||
|
||||
## Deviations
|
||||
|
||||
- `world.html` and `behind.html` are not in this task's file list, but the polish
|
||||
item asks for `text-wrap: balance` on reader-page headlines and those two are
|
||||
reader pages. The change is one class per file and touches nothing else.
|
||||
- Verdict cell width 15 rem rather than the brief's example 13 rem (reason above).
|
||||
- Section header is `text-[1.05rem]` uppercase rather than `text-2xl`/`text-xl`
|
||||
sentence case (reason above).
|
||||
|
||||
## Verification
|
||||
|
||||
Dev server on `127.0.0.1:3602` against this worktree's own copy of the dev DB
|
||||
(`dev/config.toml` was repointed from the main checkout's `dev/` to
|
||||
`the-daily-epub-ui2/dev/` so rating clicks could not touch the shared database;
|
||||
`dev/` is git-ignored).
|
||||
|
||||
- `cargo fmt -- --check` — clean.
|
||||
- `cargo clippy --all-targets -- -D warnings` — clean.
|
||||
- `cargo test --lib web::` — 84 passed, 0 failed.
|
||||
- `cargo test` — 445 lib tests + all integration tests passed, 0 failed. (One
|
||||
earlier run had a single flaky failure in
|
||||
`config::tests::registry_validation_rejects_bad_roles_kinds_and_efforts`; it
|
||||
passes in isolation and on re-run, and is unrelated to this change — the host
|
||||
was building for three agents at once.)
|
||||
- `npm run css:check` — no diff (Tailwind 4.3.3 from the adjacent checkout's
|
||||
`node_modules/.bin` via `PATH`; only read, never modified).
|
||||
- **Live JS handler** (`interact.mjs`): clicked loved → good → not-for-me → clear
|
||||
→ loved on `/issues/2026-09-02/articles/4`. `.active` and `aria-pressed` follow
|
||||
every click, the tint switches, **0 page navigations**, console clean (no errors
|
||||
or warnings). Also typed into the note field and confirmed it round-trips into
|
||||
the ratings table's Note column.
|
||||
- **Hit areas** measured with `elementFromPoint`: 41 px desktop, 45 px phone.
|
||||
- **Top alignment** measured: verdict group top is 1 px from the article title top.
|
||||
|
||||
### Screenshots
|
||||
|
||||
Under `/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/553c7d77-d8f4-44f8-a379-73cedea23814/scratchpad/after-rating/`:
|
||||
|
||||
- `sec/B.png`, `sec/C.png`, `sec/B2-brief.png`, `sec/B2-colophon.png` — the
|
||||
section-header variant comparison behind the decision above.
|
||||
- `wip/*` — Codex's partial state before this pass (the overflowing verdict cell
|
||||
is `wip/ratings-cell-light.png`).
|
||||
- `final/` — the shipped result:
|
||||
- `/` index card + section: `index-card-light`, `index-card-mobile`,
|
||||
`index-card-mobile-dark`, `index-section-light`, `index-section-dark`,
|
||||
`index-section-mobile`
|
||||
- front-page heading system: `brief-light`, `brief-dark`, `colophon-light`,
|
||||
`colophon-dark`
|
||||
- anonymous issue page: `public-issue-light`, `public-issue-dark`
|
||||
- chapter footer: `article-footer-light`, `article-footer-mobile`
|
||||
- ratings table: `ratings-light`, `ratings-dark`, `ratings-mobile`,
|
||||
`ratings-mobile-dark`, `ratings-cleared-light`, `ratings-cleared-dark`
|
||||
- dashboard article widget: `dash-article-light`, `dash-article-dark`,
|
||||
`dash-article-mobile`
|
||||
- states: `rating-focus`, `rating-hover`, `overview-picks`
|
||||
- polish checks: `prose-heading-light`, `prose-img-light`, `prose-img-dark`
|
||||
|
||||
## Left open
|
||||
|
||||
- Nothing from this brief. The dev server is left running on 3602 against
|
||||
`the-daily-epub-ui2/dev/daily-epub.db`, which now holds 5 current verdicts
|
||||
(loved / good / not-for-me / good / loved, one with a note) plus the extra
|
||||
rating events those clicks appended — `rating_events` is append-only, so the
|
||||
history is longer than the seeded baseline.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Task 3 handoff — site shell
|
||||
|
||||
## What changed
|
||||
|
||||
- `src/web/issue.rs`
|
||||
- `IssueView` now records `is_latest`, computed by `issue::load` from
|
||||
`Db::latest_issue_date()`.
|
||||
- The full issue, article, World Briefing, and Behind the paper renderers now
|
||||
select `latest` or `archive` navigation state from that flag.
|
||||
- Added a router test with two issue dates proving that only the newest issue
|
||||
marks Latest `aria-current="page"` and that an older issue marks Archive.
|
||||
- `src/web/public.rs`
|
||||
- Anonymous issue rendering uses the same `IssueView::is_latest` navigation
|
||||
state as signed-in issue rendering.
|
||||
- `src/web/templates/layout.html`
|
||||
- The default ears block is empty except on an admin dashboard route, where it
|
||||
says `Dashboard`. Issue/account/login/archive/error templates retain their
|
||||
explicit ears; the public empty issue retains its own `Morning edition`.
|
||||
- Added the italic Newsreader preload alongside the upright preload.
|
||||
- Named the site header for cross-document view transitions.
|
||||
- Made the ears-row theme and account/sign-in controls at least 40 px tall;
|
||||
the account/sign-in link also has a 40 px minimum width.
|
||||
- Dashboard navigation links now have a 40 × 40 px minimum target and use the same 2 px accent
|
||||
active underline, ink hover, and inherited focus-visible ring as site nav.
|
||||
- `src/web/templates/dashboard/run.html`
|
||||
- The Feeds table no longer uses `.scroll-x`. It is fixed-layout and full
|
||||
width, truncates feed names with the full value in `title`, and reserves a
|
||||
narrow fixed entries column. Timings and Provider usage retain their
|
||||
horizontal-scroll behavior.
|
||||
- `src/web/tailwind.css`
|
||||
- Both Newsreader faces use `font-display: block`.
|
||||
- Added automatic cross-document view transitions with a 140 ms root
|
||||
cross-fade. The named site header does not fade, and reduced-motion disables
|
||||
transition animation.
|
||||
- `.badge`, `.pager`, and `.pagination` use tabular numerals. (`.tile-num`
|
||||
already did; the brief said `.pager` did too, but the code did not, so it was
|
||||
corrected.)
|
||||
- `src/web/static/app.css`
|
||||
- Rebuilt from `tailwind.css` with Tailwind 4.3.3.
|
||||
- `src/web/static/speculation.json`
|
||||
- Added moderate same-origin prerender document rules for `/` and
|
||||
`/issues/*`, and moderate prefetch rules for `/dashboard` and
|
||||
`/dashboard/*`.
|
||||
- Logout, rating, static, and all query-string links are excluded. Excluding
|
||||
every query is deliberately conservative and therefore also excludes any
|
||||
query that could mutate state.
|
||||
- `src/web/mod.rs`
|
||||
- Serves the speculation rules with
|
||||
`application/speculationrules+json` and retains content-hash ETags.
|
||||
- All `/static/*` responses, including 304 responses, now send
|
||||
`Cache-Control: public, max-age=31536000, immutable`.
|
||||
- HTML responses send `Speculation-Rules: "/static/speculation.json"` while
|
||||
retaining their existing public/private/no-store cache behavior.
|
||||
- Added assertions for immutable cache headers, rules MIME type, both font
|
||||
preloads, the HTML speculation header, dashboard ears, and dashboard active
|
||||
underline.
|
||||
- `src/web/static/app.js`
|
||||
- A `[data-refresh]` job page now waits for `prerenderingchange` before
|
||||
scheduling reload, so a prerendered page cannot reload in the background.
|
||||
- `src/web/static/theme.js`
|
||||
- Continues to apply the saved theme and `has-js` in the head before first
|
||||
paint.
|
||||
- A short-lived mutation observer now restores persisted `details[id]` state
|
||||
as the parser creates disclosures, then disconnects at `DOMContentLoaded`.
|
||||
This prevents a cold/delayed `app.js` request from exposing the default
|
||||
disclosure state before restoration; `app.js` still owns toggle persistence.
|
||||
|
||||
## Design and implementation decisions
|
||||
|
||||
- Chose `font-display: block` rather than `optional`. This prevents a visible
|
||||
fallback-to-Newsreader swap and preserves the publication typeface. Preloading
|
||||
both faces plus immutable content-hashed assets limits the block cost to an
|
||||
uncached first visit.
|
||||
- Kept the 140 ms transition restrained and limited to document navigation.
|
||||
The masthead/nav swap immediately instead of fading, so navigation context
|
||||
stays visually anchored.
|
||||
- Added only platform progressive enhancements. There is no fetch/swap router,
|
||||
HTMX, or route/auth change.
|
||||
- Speculation Rules and cross-document view transitions are progressive:
|
||||
Chromium uses them; unsupported browsers ignore them and retain normal
|
||||
navigation.
|
||||
|
||||
## Flash reproduction and visual verification
|
||||
|
||||
The requested dev server could not start inside this sandbox:
|
||||
|
||||
```text
|
||||
$ DAILY_EPUB_SERVER__BIND=127.0.0.1:3603 cargo run -- --config ./dev/config.toml serve
|
||||
Error: could not bind 127.0.0.1:3603: Operation not permitted (os error 1)
|
||||
```
|
||||
|
||||
Therefore the throttled Playwright before/after capture and the requested light,
|
||||
dark, desktop, and 390 px screenshots could not be produced. The code audit
|
||||
found that theme and mobile TOC state were already applied by `theme.js` before
|
||||
paint. Persisted `details` state has now also moved into that pre-paint path.
|
||||
The other visible-swap candidate was Newsreader's `font-display: swap`; the
|
||||
preload/cache/block changes address that path.
|
||||
|
||||
The requested curl calls likewise could not connect:
|
||||
|
||||
```text
|
||||
$ curl -sS -D - -o /dev/null http://127.0.0.1:3603/
|
||||
curl: (7) Failed to connect to 127.0.0.1 port 3603 after 0 ms: Couldn't connect to server
|
||||
|
||||
$ curl -sS -D - -o /dev/null http://127.0.0.1:3603/static/app.css
|
||||
curl: (7) Failed to connect to 127.0.0.1 port 3603 after 0 ms: Couldn't connect to server
|
||||
```
|
||||
|
||||
Router tests verify the material headers that those calls should show:
|
||||
|
||||
```text
|
||||
GET /
|
||||
content-type: text/html; charset=utf-8
|
||||
cache-control: public, max-age=300
|
||||
speculation-rules: "/static/speculation.json"
|
||||
vary: Cookie
|
||||
|
||||
GET /static/app.css
|
||||
content-type: text/css; charset=utf-8
|
||||
cache-control: public, max-age=31536000, immutable
|
||||
etag: "0b00513092d50175d3615cfd6cdfc5fed6e416f2aea118c3b0f0110a66089036"
|
||||
```
|
||||
|
||||
Signed-in issue responses remain `private, no-store`; dashboard responses
|
||||
remain `no-store`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo fmt -- --check` — passed.
|
||||
- `cargo clippy --all-targets -- -D warnings` — passed.
|
||||
- `npm run css:check` — passed with no diff using the repository's pinned
|
||||
Tailwind 4.3.3 binary. (`npm ci` could not finish under the restricted npm
|
||||
environment, so the already-installed sibling-worktree binary was placed on
|
||||
`PATH`; that worktree was only read, never modified.)
|
||||
- `python3 -m json.tool src/web/static/speculation.json` — passed.
|
||||
- `node --check src/web/static/app.js` and `node --check
|
||||
src/web/static/theme.js` — passed.
|
||||
- `cargo test --lib web::` — 84 passed, 0 failed.
|
||||
- Dedicated newest/archive navigation test — passed.
|
||||
- Dedicated dashboard settings shell/ears/active-nav test — passed.
|
||||
- `cargo test` — 432 library tests passed; 13 tests failed solely because their
|
||||
mock HTTP servers could not bind loopback (`Operation not permitted`). Ten are
|
||||
the sandbox failures named in the shared brief; three newer OpenAI mock-server
|
||||
tests fail at the same listener helper for the same reason.
|
||||
- `cargo test --lib` with those exact 13 bind-dependent tests skipped — 432
|
||||
passed, 0 failed, 13 filtered out.
|
||||
- Non-server integration tests (`config_check`, `e2e_pipeline`, `m2_pipeline`,
|
||||
`m3_curation`, `m4_epub`) — 25 passed, 0 failed.
|
||||
- `cargo test --doc` — passed (0 doctests).
|
||||
- `git diff --check` — passed.
|
||||
|
||||
## Left open
|
||||
|
||||
- Run the four requested screenshot sets and throttled first-frame comparison
|
||||
outside the sandbox, where port 3603 can bind. This is also the remaining
|
||||
browser-level validation for view-transition behavior and Chrome's
|
||||
Speculation Rules panel.
|
||||
- `tests/m7_server.rs` was not run because it is explicitly excluded in this
|
||||
loopback-restricted sandbox.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Task 1 handoff — table-of-contents sidebar
|
||||
|
||||
## What changed
|
||||
|
||||
- `src/web/templates/_toc.html`
|
||||
- Reworked the narrow-screen panel to use a measured viewport offset and a
|
||||
`100dvh`-based height instead of the old `72vh` cap.
|
||||
- Added live label, count, desktop-status, link-label, and per-link progress
|
||||
hooks used by the shared TOC state updater.
|
||||
- Moved current-link presentation onto `.toc-link[aria-current]` and kept the
|
||||
server-rendered `aria-current="page"` state for chapter pages.
|
||||
- Added both hamburger and close SVGs for the open-state cross-fade and raised
|
||||
the mobile toggle/link hit areas to at least 44 px.
|
||||
- Added explicit right padding around the scrollable list.
|
||||
- `src/web/templates/issue_full.html`
|
||||
- Marked The Brief, every article index item, and the colophon with matching
|
||||
`data-toc-entry` values for the front-page scroll-spy.
|
||||
- `src/web/static/app.js`
|
||||
- Added mobile panel sizing from the sticky bar's current viewport position,
|
||||
recalculated on open and layout/visual-viewport resize.
|
||||
- Added mobile document scroll lock and retained toggle, Escape,
|
||||
outside-click, link-click, and `lg` breakpoint close behavior.
|
||||
- Added a passive, requestAnimationFrame-throttled front-page scroll-spy. A
|
||||
single current-link update now drives `aria-current="location"`, the mobile
|
||||
title/count, desktop chapter status, both progress bars, and TOC reveal.
|
||||
- Updated current-link reveal to use `scrollTo`, smooth unless reduced motion
|
||||
is requested, without scrolling the page.
|
||||
- Preserved progress-within-chapter behavior on article pages.
|
||||
- `src/web/tailwind.css`
|
||||
- Added the dynamic mobile panel height and scroll-lock rules.
|
||||
- Added stable scrollbar gutter, right-side spacing, thin/rule-colored
|
||||
cross-browser scrollbar styling, and overscroll containment.
|
||||
- Added the centralized current/hover/focus link styles and exact 150 ms
|
||||
opacity/scale/blur hamburger-to-close transition.
|
||||
- `src/web/static/app.css`
|
||||
- Rebuilt from the Tailwind source.
|
||||
- `src/web/issue.rs`
|
||||
- Added each TOC item's semantic progress position (including back matter) and
|
||||
coverage for those positions and the rendered scroll-spy hooks.
|
||||
- Made the current-link router assertion insensitive to HTML attribute order.
|
||||
|
||||
## Design decisions
|
||||
|
||||
- The open panel remains positioned directly below the shared sticky bar. JS
|
||||
measures only the bar's on-screen bottom edge into `--toc-panel-top`; CSS owns
|
||||
the final `calc(100dvh - var(--toc-panel-top))` height. This handles both the
|
||||
bar still below the masthead and the bar already stuck at the top.
|
||||
- The Brief uses progress position 0. Article, World, and Behind entries use
|
||||
their chapter position. The colophon uses the issue total, so reaching it
|
||||
communicates completion even though World and Behind are separate pages.
|
||||
- Front-page position changes only toggle `aria-current` for current styling;
|
||||
the other text/progress surfaces are derived from that same link's label and
|
||||
progress metadata.
|
||||
- Scrollbar gutter and padding are both used: the gutter reserves space for
|
||||
classic scrollbars, while padding protects labels on overlay-scrollbar
|
||||
platforms.
|
||||
|
||||
## Deviations and open items
|
||||
|
||||
- No live screenshots could be captured. The sandbox rejected the assigned
|
||||
`127.0.0.1:3601` bind with `Operation not permitted`, exactly as anticipated
|
||||
by the shared brief. Consequently desktop/mobile, light/dark, open-panel, and
|
||||
scripted-scroll visual states remain to be checked outside the sandbox.
|
||||
- The full unit test run also found three OpenAI mock-server tests blocked by
|
||||
loopback binding in addition to the bind-dependent tests listed in the shared
|
||||
brief. They fail at the same listener creation point and are unrelated to this
|
||||
change.
|
||||
|
||||
## Verification
|
||||
|
||||
- `node --check src/web/static/app.js` — passed.
|
||||
- `cargo fmt --check` — passed.
|
||||
- `npm run css` — passed with Tailwind 4.3.3; `src/web/static/app.css` rebuilt.
|
||||
- `npm run css:check` — passed with no diff. The worktree lacked its own CLI
|
||||
shim, so both npm commands used the already-installed adjacent checkout's
|
||||
`node_modules/.bin` via `PATH`; all inputs and outputs stayed in this worktree.
|
||||
- `cargo test --lib web::` — 83 passed, 0 failed.
|
||||
- `cargo clippy --all-targets -- -D warnings` — passed.
|
||||
- `cargo test` — library phase: 431 passed, 13 bind-dependent failures. The
|
||||
failures were 7 LLM mock-server tests, the documented relative-URL extraction
|
||||
test, and the documented 5 server tests; each failed with `Operation not
|
||||
permitted` while creating a loopback listener.
|
||||
- `cargo test --test config_check --test e2e_pipeline --test m2_pipeline --test m3_curation --test m4_epub`
|
||||
— 25 passed, 0 failed. `m7_server` was excluded because it requires the same
|
||||
forbidden loopback bind.
|
||||
- Attempted the configured dev-server command on port 3601 — build passed; bind
|
||||
failed with `Operation not permitted`, so screenshots were not possible.
|
||||
- `git diff --check` — passed.
|
||||
|
||||
@@ -344,7 +344,6 @@ struct CurrentRow {
|
||||
feed_title: String,
|
||||
issue_date: String,
|
||||
badge: String,
|
||||
verdict: String,
|
||||
widget: RatingWidget,
|
||||
when: String,
|
||||
source: String,
|
||||
@@ -678,7 +677,7 @@ async fn build_current_rows(
|
||||
.or_insert_with(|| feed_title_for(article.as_ref(), *feed_id));
|
||||
}
|
||||
|
||||
let (badge, verdict) = widget_label(&rating.label);
|
||||
let (badge, _) = widget_label(&rating.label);
|
||||
if filters.label.as_deref().is_some_and(|label| label != badge) {
|
||||
continue;
|
||||
}
|
||||
@@ -715,7 +714,6 @@ async fn build_current_rows(
|
||||
feed_title: rating.feed_title.clone(),
|
||||
issue_date: issue_date.clone(),
|
||||
badge: badge.to_string(),
|
||||
verdict: verdict.to_string(),
|
||||
widget: RatingWidget {
|
||||
article_id: rating.article_id,
|
||||
issue_date,
|
||||
|
||||
+148
-8
@@ -35,6 +35,7 @@ pub struct IssueView {
|
||||
pub downloads: Vec<Download>,
|
||||
pub from_json: bool,
|
||||
pub world_html: Option<String>,
|
||||
pub is_latest: bool,
|
||||
has_behind: bool,
|
||||
legacy_counts: Option<LegacyColophonCounts>,
|
||||
}
|
||||
@@ -80,6 +81,7 @@ pub async fn load(
|
||||
let Some(row) = db.issue_by_date(date).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let is_latest = db.latest_issue_date().await? == Some(date);
|
||||
let legacy = if row.issue_json.is_none() {
|
||||
Some(load_legacy_facts(db, date, row.report_json.as_deref()).await?)
|
||||
} else {
|
||||
@@ -273,6 +275,7 @@ pub async fn load(
|
||||
downloads,
|
||||
from_json,
|
||||
world_html,
|
||||
is_latest,
|
||||
has_behind: from_json || legacy.as_ref().is_some_and(|facts| facts.has_source),
|
||||
legacy_counts: legacy.map(|facts| LegacyColophonCounts {
|
||||
entries_fetched: facts.entries_fetched,
|
||||
@@ -659,6 +662,8 @@ struct TocItem {
|
||||
href: String,
|
||||
number: Option<usize>,
|
||||
minutes: Option<i64>,
|
||||
/// Progress position used by the shared TOC UI (0 for The Brief).
|
||||
progress: usize,
|
||||
current: bool,
|
||||
/// First entry of the back-matter group; the template draws a hairline above it.
|
||||
divider: bool,
|
||||
@@ -668,6 +673,10 @@ impl TocItem {
|
||||
fn is_section(&self) -> bool {
|
||||
matches!(self.kind, TocKind::Section)
|
||||
}
|
||||
|
||||
fn is_colophon(&self) -> bool {
|
||||
matches!(self.kind, TocKind::Colophon)
|
||||
}
|
||||
}
|
||||
|
||||
/// The table of contents shared by the four signed-in issue pages.
|
||||
@@ -703,12 +712,18 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
let issue = &view.issue;
|
||||
let date = issue.meta.date;
|
||||
let issue_href = issue_href(date);
|
||||
let plain = |kind: TocKind, label: &str, href: String, current: bool, divider: bool| TocItem {
|
||||
let plain = |kind: TocKind,
|
||||
label: &str,
|
||||
href: String,
|
||||
progress: usize,
|
||||
current: bool,
|
||||
divider: bool| TocItem {
|
||||
kind,
|
||||
label: label.to_string(),
|
||||
href,
|
||||
number: None,
|
||||
minutes: None,
|
||||
progress,
|
||||
current,
|
||||
divider,
|
||||
};
|
||||
@@ -717,13 +732,21 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
TocKind::Brief,
|
||||
"The Brief",
|
||||
issue_href.clone(),
|
||||
0,
|
||||
current == TocPosition::FrontPage,
|
||||
false,
|
||||
)];
|
||||
let mut position = 0usize;
|
||||
let mut total = 0usize;
|
||||
for name in chapters::section_names(issue) {
|
||||
items.push(plain(TocKind::Section, &name, String::new(), false, false));
|
||||
items.push(plain(
|
||||
TocKind::Section,
|
||||
&name,
|
||||
String::new(),
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
));
|
||||
for pick in issue.lineup.section_picks(&name) {
|
||||
total += 1;
|
||||
let is_current = current == TocPosition::Article(pick.article.id);
|
||||
@@ -736,6 +759,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
href: article_href(date, pick.article.id),
|
||||
number: Some(total),
|
||||
minutes: Some(pick.article.reading_minutes()),
|
||||
progress: total,
|
||||
current: is_current,
|
||||
divider: false,
|
||||
});
|
||||
@@ -753,6 +777,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
TocKind::World,
|
||||
"World Briefing",
|
||||
format!("/issues/{date}/world"),
|
||||
total,
|
||||
is_current,
|
||||
divider,
|
||||
));
|
||||
@@ -768,6 +793,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
TocKind::Behind,
|
||||
"Behind the paper",
|
||||
format!("/issues/{date}/behind"),
|
||||
total,
|
||||
is_current,
|
||||
divider,
|
||||
));
|
||||
@@ -777,6 +803,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
TocKind::Colophon,
|
||||
"Colophon",
|
||||
format!("{issue_href}#colophon"),
|
||||
total,
|
||||
false,
|
||||
divider,
|
||||
));
|
||||
@@ -955,7 +982,8 @@ pub async fn render_full(
|
||||
})
|
||||
.collect();
|
||||
let colophon = colophon_view(&view.issue, view.legacy_counts.as_ref());
|
||||
let mut page = Page::new(format!("Issue {date}"), Some(viewer), "latest");
|
||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||
let mut page = Page::new(format!("Issue {date}"), Some(viewer), active_nav);
|
||||
page.flash = take_flash(session).await?;
|
||||
Ok(Html(IssueFullTemplate {
|
||||
page,
|
||||
@@ -1023,7 +1051,8 @@ pub async fn article(
|
||||
href: article_href(date, next.article.id),
|
||||
});
|
||||
let article = &pick.article;
|
||||
let mut page = Page::new(article.title.clone(), Some(viewer.clone()), "latest");
|
||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||
let mut page = Page::new(article.title.clone(), Some(viewer.clone()), active_nav);
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(ArticleTemplate {
|
||||
page,
|
||||
@@ -1089,7 +1118,8 @@ pub async fn world(
|
||||
} else {
|
||||
return Err(WebError::NotFound);
|
||||
};
|
||||
let mut page = Page::new("World Briefing", Some(viewer), "latest");
|
||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||
let mut page = Page::new("World Briefing", Some(viewer), active_nav);
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(WorldTemplate {
|
||||
page,
|
||||
@@ -1122,7 +1152,8 @@ pub async fn behind(
|
||||
}
|
||||
let toc = issue_toc(&view, TocPosition::Behind);
|
||||
let behind = &view.issue.behind;
|
||||
let mut page = Page::new("Behind the paper", Some(viewer), "latest");
|
||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||
let mut page = Page::new("Behind the paper", Some(viewer), active_nav);
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(BehindTemplate {
|
||||
page,
|
||||
@@ -1536,6 +1567,89 @@ mod tests {
|
||||
assert!(loaded.issue.editorial.front_page_html.contains("coffee"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_navigation_marks_only_the_newest_issue_as_latest() {
|
||||
let (_dir, db, source) = seeded_issue(true).await;
|
||||
let old_date = source.meta.date;
|
||||
let latest_date: Date = "2026-08-16".parse().unwrap();
|
||||
let mut latest = source.clone();
|
||||
latest.meta.date = latest_date;
|
||||
latest.meta.issue_number += 1;
|
||||
latest.meta.display_date = display_date(latest_date);
|
||||
latest.lineup.date = latest_date;
|
||||
if let Some(world) = &mut latest.world_briefing {
|
||||
world.date = latest_date;
|
||||
}
|
||||
let latest_json = serde_json::to_string(&latest).unwrap();
|
||||
db.upsert_issue(
|
||||
latest_date,
|
||||
latest.meta.issue_number,
|
||||
latest.meta.generated_at,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&latest.editorial.front_page_html),
|
||||
None,
|
||||
Some(&latest_json),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.replace_issue_articles(latest_date, &latest.lineup.picks)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let old_view = load(&db, &crate::config::Config::default(), old_date)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let latest_view = load(&db, &crate::config::Config::default(), latest_date)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!old_view.is_latest);
|
||||
assert!(latest_view.is_latest);
|
||||
|
||||
let app = crate::server::router(crate::server::AppState::new(
|
||||
db,
|
||||
crate::config::Config::default(),
|
||||
None,
|
||||
));
|
||||
let old = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{old_date}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(old.status(), StatusCode::OK);
|
||||
let old = response_text(old).await;
|
||||
assert!(
|
||||
old.contains("href=\"/issues\" aria-current=\"page\">Archive</a>"),
|
||||
"{old}"
|
||||
);
|
||||
assert!(!old.contains("href=\"/\" aria-current=\"page\">Latest</a>"));
|
||||
|
||||
let latest = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{latest_date}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(latest.status(), StatusCode::OK);
|
||||
let latest = response_text(latest).await;
|
||||
assert!(
|
||||
latest.contains("href=\"/\" aria-current=\"page\">Latest</a>"),
|
||||
"{latest}"
|
||||
);
|
||||
assert!(!latest.contains("href=\"/issues\" aria-current=\"page\">Archive</a>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_issue_archive_feed_robots_and_reports_are_served() {
|
||||
let (_dir, db, source) = seeded_issue(true).await;
|
||||
@@ -2160,6 +2274,15 @@ mod tests {
|
||||
assert_eq!(front.position, 0);
|
||||
assert_eq!(front.current_label(), "The Brief");
|
||||
assert_eq!(front.short_date, "Sat, Aug 15");
|
||||
assert_eq!(
|
||||
front
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| !item.is_section())
|
||||
.map(|item| item.progress)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![0, 1, 2, 3, 4, 4]
|
||||
);
|
||||
// Exactly one hairline, above the first back-matter entry.
|
||||
assert_eq!(
|
||||
front
|
||||
@@ -2234,13 +2357,30 @@ mod tests {
|
||||
let html = response_text(response).await;
|
||||
assert!(html.contains("data-toc-toggle"), "{path} has no toc bar");
|
||||
assert!(html.contains("id=\"toc\""), "{path} has no toc panel");
|
||||
assert!(html.contains(progress), "{path} is missing {progress:?}");
|
||||
assert!(
|
||||
html.contains(&format!("href=\"{current}\" aria-current=\"page\"")),
|
||||
html.contains("data-toc-status"),
|
||||
"{path} has no live status"
|
||||
);
|
||||
assert!(html.contains(progress), "{path} is missing {progress:?}");
|
||||
let current_link = html
|
||||
.split(&format!("class=\"toc-link\" href=\"{current}\""))
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("</a>").next());
|
||||
assert!(
|
||||
current_link.is_some_and(|link| link.contains("aria-current=\"page\"")),
|
||||
"{path} does not mark {current} as current"
|
||||
);
|
||||
assert!(html.contains(&format!("{}#colophon", issue_href(date))));
|
||||
assert!(html.contains("A Niche Delight & Other Tales"));
|
||||
if path == issue_href(date) {
|
||||
assert!(html.contains(&format!(
|
||||
"data-toc-entry=\"{}\"",
|
||||
article_href(date, source.lineup.picks[0].article.id)
|
||||
)));
|
||||
assert!(
|
||||
html.contains(&format!("data-toc-entry=\"{}#colophon\"", issue_href(date)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let anonymous = app
|
||||
|
||||
+122
-17
@@ -206,14 +206,43 @@ pub struct Page {
|
||||
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`).
|
||||
/// previous build (they are served with an immutable one-year `max-age`).
|
||||
pub asset_version: &'static str,
|
||||
}
|
||||
|
||||
/// First 12 hex digits of the SHA-256 over the embedded CSS and JS assets.
|
||||
const NEWSREADER: &[u8] = include_bytes!("static/fonts/Newsreader.woff2");
|
||||
const NEWSREADER_ITALIC: &[u8] = include_bytes!("static/fonts/Newsreader-italic.woff2");
|
||||
|
||||
/// The stylesheet with both Newsreader faces embedded as `data:` URIs.
|
||||
///
|
||||
/// A font referenced by URL is applied *after* first paint whenever the browser
|
||||
/// has to bring it back from the disk cache (Firefox drops fonts from memory as
|
||||
/// soon as no page uses them), which shows as a flash of invisible or fallback
|
||||
/// text on the first navigation after an idle spell. Fonts embedded in the
|
||||
/// render-blocking stylesheet are decoded synchronously, so the first paint is
|
||||
/// already set in Newsreader.
|
||||
pub static APP_CSS: LazyLock<String> = LazyLock::new(|| {
|
||||
use base64::Engine;
|
||||
let data_uri = |bytes: &[u8]| {
|
||||
format!(
|
||||
"url(data:font/woff2;base64,{})",
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
)
|
||||
};
|
||||
include_str!("static/app.css")
|
||||
.replace("url(/static/Newsreader.woff2)", &data_uri(NEWSREADER))
|
||||
.replace(
|
||||
"url(/static/Newsreader-italic.woff2)",
|
||||
&data_uri(NEWSREADER_ITALIC),
|
||||
)
|
||||
});
|
||||
|
||||
pub static ASSET_VERSION: LazyLock<String> = LazyLock::new(|| {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(include_str!("static/app.css"));
|
||||
hasher.update(NEWSREADER);
|
||||
hasher.update(NEWSREADER_ITALIC);
|
||||
hasher.update(include_str!("static/app.js"));
|
||||
hasher.update(include_str!("static/theme.js"));
|
||||
hex::encode(hasher.finalize())[..12].to_string()
|
||||
@@ -236,6 +265,22 @@ impl Page {
|
||||
.as_ref()
|
||||
.is_some_and(|viewer| viewer.role == users::Role::Admin)
|
||||
}
|
||||
|
||||
pub fn is_dashboard(&self) -> bool {
|
||||
self.is_admin()
|
||||
&& matches!(
|
||||
self.active_nav.as_str(),
|
||||
"dashboard"
|
||||
| "runs"
|
||||
| "articles"
|
||||
| "ratings"
|
||||
| "profile"
|
||||
| "stats"
|
||||
| "jobs"
|
||||
| "settings"
|
||||
| "users"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn take_flash(session: &Session) -> Result<Option<Flash>, WebError> {
|
||||
@@ -395,7 +440,7 @@ pub async fn security_headers(request: Request, next: Next) -> Response {
|
||||
headers.insert(
|
||||
header::HeaderName::from_static("content-security-policy"),
|
||||
HeaderValue::from_static(
|
||||
"default-src 'self'; img-src * data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; form-action 'self'",
|
||||
"default-src 'self'; img-src * data:; font-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; form-action 'self'",
|
||||
),
|
||||
);
|
||||
headers.insert(
|
||||
@@ -415,6 +460,10 @@ pub async fn security_headers(request: Request, next: Next) -> Response {
|
||||
.is_some_and(|value| value.starts_with("text/html"))
|
||||
{
|
||||
headers.append(header::VARY, HeaderValue::from_static("Cookie"));
|
||||
headers.insert(
|
||||
header::HeaderName::from_static("speculation-rules"),
|
||||
HeaderValue::from_static("\"/static/speculation.json\""),
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -517,10 +566,7 @@ async fn static_asset(
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let asset: (&str, &'static [u8]) = match file.as_str() {
|
||||
"app.css" => (
|
||||
"text/css; charset=utf-8",
|
||||
include_str!("static/app.css").as_bytes(),
|
||||
),
|
||||
"app.css" => ("text/css; charset=utf-8", APP_CSS.as_bytes()),
|
||||
"app.js" => (
|
||||
"application/javascript; charset=utf-8",
|
||||
include_str!("static/app.js").as_bytes(),
|
||||
@@ -529,18 +575,16 @@ async fn static_asset(
|
||||
"application/javascript; charset=utf-8",
|
||||
include_str!("static/theme.js").as_bytes(),
|
||||
),
|
||||
"speculation.json" => (
|
||||
"application/speculationrules+json",
|
||||
include_str!("static/speculation.json").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"),
|
||||
),
|
||||
"Newsreader.woff2" => ("font/woff2", NEWSREADER),
|
||||
"Newsreader-italic.woff2" => ("font/woff2", NEWSREADER_ITALIC),
|
||||
_ => return WebError::NotFound.into_response(),
|
||||
};
|
||||
let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1)));
|
||||
@@ -553,7 +597,10 @@ async fn static_asset(
|
||||
StatusCode::NOT_MODIFIED,
|
||||
[
|
||||
(header::ETAG, etag),
|
||||
(header::CACHE_CONTROL, "public, max-age=86400".into()),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
"public, max-age=31536000, immutable".into(),
|
||||
),
|
||||
],
|
||||
)
|
||||
.into_response();
|
||||
@@ -562,7 +609,10 @@ async fn static_asset(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, asset.0.to_string()),
|
||||
(header::CACHE_CONTROL, "public, max-age=86400".into()),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
"public, max-age=31536000, immutable".into(),
|
||||
),
|
||||
(header::ETAG, etag),
|
||||
],
|
||||
asset.1,
|
||||
@@ -653,6 +703,10 @@ mod tests {
|
||||
anonymous.headers().get(header::CACHE_CONTROL).unwrap(),
|
||||
"public, max-age=300"
|
||||
);
|
||||
assert_eq!(
|
||||
anonymous.headers().get("speculation-rules").unwrap(),
|
||||
"\"/static/speculation.json\""
|
||||
);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
@@ -792,6 +846,27 @@ mod tests {
|
||||
allowed.headers().get(header::CACHE_CONTROL).unwrap(),
|
||||
"no-store"
|
||||
);
|
||||
let settings = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/dashboard/settings")
|
||||
.header(header::COOKIE, &admin)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(settings.status(), StatusCode::OK);
|
||||
let settings = response_text(settings).await;
|
||||
assert!(settings.contains("<span>Dashboard</span>"), "{settings}");
|
||||
assert!(!settings.contains("Morning edition"), "{settings}");
|
||||
assert!(
|
||||
settings.contains(
|
||||
"border-accent text-ink\" href=\"/dashboard/settings\" aria-current=\"page\""
|
||||
),
|
||||
"{settings}"
|
||||
);
|
||||
let allowed_rate = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -1025,6 +1100,16 @@ mod tests {
|
||||
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)));
|
||||
// The fonts ride inside the stylesheet; a preload would fetch them twice.
|
||||
assert!(!html.contains("rel=\"preload\""), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylesheet_embeds_both_newsreader_faces() {
|
||||
let css = APP_CSS.as_str();
|
||||
assert_eq!(css.matches("url(data:font/woff2;base64,").count(), 2);
|
||||
assert!(!css.contains("/static/Newsreader"));
|
||||
assert!(css.contains("font-display:block"), "{}", &css[..200]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1044,10 +1129,11 @@ mod tests {
|
||||
assert_eq!(first.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
first.headers().get(header::CACHE_CONTROL).unwrap(),
|
||||
"public, max-age=86400"
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
let etag = first.headers().get(header::ETAG).unwrap().clone();
|
||||
let cached = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/static/app.css")
|
||||
@@ -1058,6 +1144,25 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cached.status(), StatusCode::NOT_MODIFIED);
|
||||
assert_eq!(
|
||||
cached.headers().get(header::CACHE_CONTROL).unwrap(),
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
|
||||
let rules = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/static/speculation.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rules.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
rules.headers().get(header::CONTENT_TYPE).unwrap(),
|
||||
"application/speculationrules+json"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+2
-1
@@ -219,8 +219,9 @@ pub async fn show_issue(
|
||||
let response = issue::render_full(&state, view, viewer, &session).await?;
|
||||
return Ok(public_cache(response, &headers));
|
||||
}
|
||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||
let response = Html(IssuePublicTemplate {
|
||||
page: Page::new(format!("Issue {date}"), None, "latest"),
|
||||
page: Page::new(format!("Issue {date}"), None, active_nav),
|
||||
issue: PublicIssue::from(&view.issue),
|
||||
downloads: Vec::new(),
|
||||
empty: false,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/*! 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
|
||||
@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-outline-style:solid;--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([mLine truncated
|
||||
+117
-25
@@ -9,17 +9,14 @@ if (themeToggle) {
|
||||
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);
|
||||
});
|
||||
};
|
||||
// Icons and label are picked by CSS from `html[data-theme]`, which theme.js sets
|
||||
// before first paint, so nothing here repaints the toggle after load.
|
||||
const renderTheme = (theme) => themeToggle.setAttribute("aria-label", `Theme: ${theme}`);
|
||||
const setTheme = (theme) => {
|
||||
if (theme === "system") document.documentElement.removeAttribute("data-theme");
|
||||
else document.documentElement.dataset.theme = theme;
|
||||
const scheme = document.querySelector('meta[name="color-scheme"]');
|
||||
if (scheme) scheme.content = theme === "system" ? "light dark" : theme;
|
||||
try {
|
||||
if (theme === "system") localStorage.removeItem("theme");
|
||||
else localStorage.setItem("theme", theme);
|
||||
@@ -83,14 +80,19 @@ document.querySelectorAll("details[id]").forEach((details) => {
|
||||
/* step 3: filter-as-you-type on tables with data-filter (this page's rows only) */
|
||||
document.querySelectorAll("table[data-filter]").forEach((table) => {
|
||||
const rows = table.querySelectorAll("tbody tr");
|
||||
const host = table.closest(".scroll-x") || table;
|
||||
// The template renders the input (shown only under `.has-js`) so the table does
|
||||
// not jump when this script runs after first paint; create one if it is missing.
|
||||
let input = host.previousElementSibling;
|
||||
if (!(input && input.matches("input[data-table-filter]"))) {
|
||||
if (rows.length < 2) return;
|
||||
const input = document.createElement("input");
|
||||
input = document.createElement("input");
|
||||
input.type = "search";
|
||||
input.className = "table-filter";
|
||||
input.placeholder = "Filter rows on this page";
|
||||
input.setAttribute("aria-label", "Filter rows on this page");
|
||||
const host = table.closest(".scroll-x") || table;
|
||||
host.parentNode.insertBefore(input, host);
|
||||
}
|
||||
input.addEventListener("input", () => {
|
||||
const needle = input.value.trim().toLowerCase();
|
||||
rows.forEach((row) => {
|
||||
@@ -109,29 +111,59 @@ document.addEventListener("click", (event) => {
|
||||
/* step 6: reload a job page every N seconds while its job is requested/running */
|
||||
document.querySelectorAll("[data-refresh]").forEach((element) => {
|
||||
const seconds = Number(element.dataset.refresh);
|
||||
if (seconds > 0) setTimeout(() => window.location.reload(), seconds * 1000);
|
||||
if (seconds <= 0) return;
|
||||
const schedule = () => setTimeout(() => window.location.reload(), seconds * 1000);
|
||||
if (document.prerendering) {
|
||||
document.addEventListener("prerenderingchange", schedule, { once: true });
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
});
|
||||
/* step 4: table-of-contents panel (below `lg`) and reading-progress bar */
|
||||
/* step 4: table-of-contents panel, current chapter, and reading progress */
|
||||
const tocPanel = document.querySelector("[data-toc-panel]");
|
||||
const tocToggle = document.querySelector("[data-toc-toggle]");
|
||||
if (tocPanel) {
|
||||
const tocBar = document.querySelector("[data-toc-bar]");
|
||||
const tocLinks = Array.from(tocPanel.querySelectorAll(".toc-link"));
|
||||
const tocBars = document.querySelectorAll("[data-toc-progress]");
|
||||
const tocLabel = document.querySelector("[data-toc-label]");
|
||||
const tocCount = document.querySelector("[data-toc-count]");
|
||||
const tocStatus = document.querySelector("[data-toc-status]");
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const largeScreen = window.matchMedia("(min-width: 64rem)");
|
||||
|
||||
// Long issues overflow the sidebar; scroll just enough to show where we are.
|
||||
const revealCurrent = () => {
|
||||
const current = tocPanel.querySelector("a[aria-current=page]");
|
||||
const current = tocPanel.querySelector(".toc-link[aria-current]");
|
||||
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 stickyHeader = tocPanel.querySelector(":scope > div");
|
||||
const topMargin = (stickyHeader && stickyHeader.offsetHeight > 0 ? stickyHeader.offsetHeight : 0) + 24;
|
||||
const panelRect = tocPanel.getBoundingClientRect();
|
||||
const currentRect = current.getBoundingClientRect();
|
||||
const top = tocPanel.scrollTop + currentRect.top - panelRect.top;
|
||||
const visibleTop = tocPanel.scrollTop + topMargin;
|
||||
const visibleBottom = tocPanel.scrollTop + tocPanel.clientHeight - 24;
|
||||
if (top >= visibleTop && top + currentRect.height <= visibleBottom) return;
|
||||
const target = Math.max(0, top - Math.max(topMargin, (tocPanel.clientHeight - currentRect.height) / 2));
|
||||
tocPanel.scrollTo({ top: target, behavior: reducedMotion.matches ? "auto" : "smooth" });
|
||||
};
|
||||
const sizeOpenPanel = () => {
|
||||
if (tocPanel.dataset.open !== "true" || largeScreen.matches || !tocBar) return;
|
||||
tocPanel.style.setProperty("--toc-panel-top", `${Math.max(0, tocBar.getBoundingClientRect().bottom)}px`);
|
||||
};
|
||||
const setOpen = (open) => {
|
||||
tocPanel.dataset.open = open ? "true" : "false";
|
||||
if (tocToggle) tocToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
if (open) revealCurrent();
|
||||
document.documentElement.classList.toggle("toc-panel-open", open && !largeScreen.matches);
|
||||
if (tocToggle) {
|
||||
tocToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
tocToggle.setAttribute("aria-label", open ? "Close contents" : "Contents");
|
||||
}
|
||||
if (open) {
|
||||
sizeOpenPanel();
|
||||
revealCurrent();
|
||||
} else {
|
||||
tocPanel.style.removeProperty("--toc-panel-top");
|
||||
}
|
||||
};
|
||||
setOpen(false);
|
||||
revealCurrent();
|
||||
@@ -147,10 +179,69 @@ if (tocPanel) {
|
||||
setOpen(false);
|
||||
tocToggle.focus();
|
||||
});
|
||||
window.matchMedia("(min-width: 64rem)").addEventListener("change", () => setOpen(false));
|
||||
largeScreen.addEventListener("change", () => setOpen(false));
|
||||
window.addEventListener("resize", sizeOpenPanel);
|
||||
if (window.visualViewport) window.visualViewport.addEventListener("resize", sizeOpenPanel);
|
||||
}
|
||||
|
||||
let currentLink = tocPanel.querySelector(".toc-link[aria-current]");
|
||||
// Chapters above the current one read as "already passed" in ink rather than ink-2.
|
||||
const markPassed = (link) => {
|
||||
let passed = true;
|
||||
tocLinks.forEach((candidate) => {
|
||||
if (candidate === link) passed = false;
|
||||
candidate.toggleAttribute("data-passed", passed);
|
||||
});
|
||||
};
|
||||
if (currentLink) markPassed(currentLink);
|
||||
const setCurrent = (link) => {
|
||||
if (!link || (link === currentLink && link.getAttribute("aria-current") === "location")) return;
|
||||
tocLinks.forEach((candidate) => candidate.removeAttribute("aria-current"));
|
||||
markPassed(link);
|
||||
link.setAttribute("aria-current", "location");
|
||||
currentLink = link;
|
||||
const position = Math.max(0, Number(link.dataset.tocPosition));
|
||||
const total = tocBars.length ? Number(tocBars[0].max) : 0;
|
||||
const isEnd = link.hasAttribute("data-toc-end");
|
||||
const label = link.querySelector("[data-toc-link-label]");
|
||||
if (tocLabel && label) tocLabel.textContent = label.textContent;
|
||||
if (tocCount) {
|
||||
tocCount.hidden = position === 0 || isEnd;
|
||||
tocCount.textContent = `${position} / ${total}`;
|
||||
}
|
||||
const tocBars = document.querySelectorAll("[data-toc-progress]");
|
||||
if (tocStatus) tocStatus.textContent = isEnd ? "End of issue" : position > 0 ? `Chapter ${position} of ${total}` : "Front page";
|
||||
tocBars.forEach((bar) => {
|
||||
delete bar.dataset.live;
|
||||
bar.value = position;
|
||||
});
|
||||
revealCurrent();
|
||||
};
|
||||
|
||||
const tocEntries = Array.from(document.querySelectorAll("[data-toc-entry]")).map((entry) => ({
|
||||
entry,
|
||||
link: tocLinks.find((candidate) => candidate.getAttribute("href") === entry.dataset.tocEntry),
|
||||
})).filter(({ link }) => link);
|
||||
if (tocEntries.length) {
|
||||
let queued = false;
|
||||
const paintCurrent = () => {
|
||||
queued = false;
|
||||
const threshold = (tocBar ? tocBar.offsetHeight : 0) + window.innerHeight / 3;
|
||||
let next = tocEntries[0].link;
|
||||
tocEntries.forEach(({ entry, link }) => {
|
||||
if (entry.getBoundingClientRect().top <= threshold) next = link;
|
||||
});
|
||||
setCurrent(next);
|
||||
};
|
||||
const scheduleCurrent = () => {
|
||||
if (queued) return;
|
||||
queued = true;
|
||||
window.requestAnimationFrame(paintCurrent);
|
||||
};
|
||||
window.addEventListener("scroll", scheduleCurrent, { passive: true });
|
||||
window.addEventListener("resize", scheduleCurrent);
|
||||
scheduleCurrent();
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -177,3 +268,4 @@ if (tocBars.length && tocChapter) {
|
||||
window.addEventListener("resize", schedule);
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"prerender": [
|
||||
{
|
||||
"source": "document",
|
||||
"where": {
|
||||
"and": [
|
||||
{ "href_matches": ["/", "/issues/*"] },
|
||||
{ "not": { "href_matches": ["/logout", "/rate", "/static/*", "/*\\?*"] } }
|
||||
]
|
||||
},
|
||||
"eagerness": "moderate"
|
||||
}
|
||||
],
|
||||
"prefetch": [
|
||||
{
|
||||
"source": "document",
|
||||
"where": {
|
||||
"and": [
|
||||
{ "href_matches": ["/dashboard", "/dashboard/*"] },
|
||||
{ "not": { "href_matches": ["/logout", "/rate", "/static/*", "/*\\?*"] } }
|
||||
]
|
||||
},
|
||||
"eagerness": "moderate"
|
||||
}
|
||||
]
|
||||
}
|
||||
+29
-2
@@ -2,11 +2,38 @@
|
||||
// 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");
|
||||
// The color-scheme meta decides what the browser paints before the stylesheet
|
||||
// arrives; keep it in step with an explicit theme so that first paint is not
|
||||
// the wrong shade.
|
||||
const scheme = document.querySelector('meta[name="color-scheme"]');
|
||||
try {
|
||||
const theme = localStorage.getItem("theme");
|
||||
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
|
||||
else document.documentElement.removeAttribute("data-theme");
|
||||
if (theme === "light" || theme === "dark") {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
if (scheme) scheme.content = theme;
|
||||
} else {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
}
|
||||
} catch (_) {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
}
|
||||
|
||||
// Restore persisted disclosures as the parser creates them. This head script
|
||||
// runs before paint, so a cold app.js request cannot expose the server-default
|
||||
// open state and then shift the dashboard when the script finally arrives.
|
||||
const restoreDetails = (root) => {
|
||||
const details = [];
|
||||
if (root.nodeType === Node.ELEMENT_NODE && root.matches("details[id]")) details.push(root);
|
||||
root.querySelectorAll?.("details[id]").forEach((element) => details.push(element));
|
||||
details.forEach((element) => {
|
||||
try {
|
||||
element.open = localStorage.getItem("details:" + element.id) === "open";
|
||||
} catch (_) {}
|
||||
});
|
||||
};
|
||||
const detailsObserver = new MutationObserver((records) => {
|
||||
records.forEach((record) => record.addedNodes.forEach(restoreDetails));
|
||||
});
|
||||
detailsObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||
document.addEventListener("DOMContentLoaded", () => detailsObserver.disconnect(), { once: true });
|
||||
})();
|
||||
+73
-30
@@ -9,7 +9,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 200 800;
|
||||
font-stretch: normal;
|
||||
font-display: swap;
|
||||
font-display: block;
|
||||
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;
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
font-style: italic;
|
||||
font-weight: 200 800;
|
||||
font-stretch: normal;
|
||||
font-display: swap;
|
||||
font-display: block;
|
||||
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;
|
||||
}
|
||||
@@ -82,16 +82,16 @@
|
||||
@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, .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 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; transition-property:transform, background-color, color, border-color; }
|
||||
.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 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; transition-property:transform, background-color, color, border-color; }
|
||||
.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; }
|
||||
.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 duration-150 hover:bg-down hover:text-paper; transition-property:transform, background-color, color, border-color; }
|
||||
.btn:active, .button:active, .btn-primary:active, .btn-danger:active, button.danger:active { transform:scale(0.96); }
|
||||
|
||||
.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 { @apply inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 font-sans text-xs font-medium leading-5 tabular-nums; 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); }
|
||||
@@ -107,10 +107,13 @@
|
||||
|
||||
.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 p, .index-summary { text-wrap:pretty; }
|
||||
.prose-body h2 { @apply mt-10 border-t border-rule pt-4 text-2xl font-semibold leading-[1.1] tracking-[-0.01em]; }
|
||||
.prose-body h3 { @apply mt-8 text-xl font-semibold leading-[1.15] tracking-[-0.01em]; }
|
||||
.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 img { @apply mx-auto my-7 block h-auto max-w-full rounded-sm text-center font-sans text-xs italic text-muted; outline:1px solid oklch(0 0 0 / 0.1); outline-offset:-1px; }
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .prose-body img { outline-color:oklch(1 0 0 / 0.1); } }
|
||||
:root[data-theme="dark"] .prose-body img { outline-color:oklch(1 0 0 / 0.1); }
|
||||
.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; }
|
||||
@@ -119,14 +122,28 @@
|
||||
.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; }
|
||||
.reader-page :where(h1, h2, h3, h4), .reader-section-heading { text-wrap:balance; }
|
||||
.reader-section-heading { @apply border-t-[3px] border-double border-rule-strong pt-3 font-serif text-[1.05rem] font-semibold uppercase leading-[1.2] tracking-[0.15em] text-ink; }
|
||||
|
||||
.rating { @apply my-5 flex flex-wrap items-center gap-x-3 font-sans text-xs; }
|
||||
.rating-prompt { @apply font-serif text-[0.8rem] italic text-muted; }
|
||||
.rating-actions { @apply flex min-h-10 items-center whitespace-nowrap; }
|
||||
.rating button[data-label] { @apply relative h-8 text-xs duration-150; transition-property:transform, background-color, color, border-color; }
|
||||
.rating button[data-label]::after { content:""; @apply absolute -inset-y-1.5 inset-x-0; }
|
||||
.rating button[data-label]:active { transform:scale(0.96); }
|
||||
.rating button[data-label]:focus-visible { @apply z-20 ring-offset-0; }
|
||||
.rating button[data-label]:not(.clear) { @apply -ml-px rounded-none border-rule bg-transparent px-2 py-1 text-ink-2 first:ml-0 first:rounded-l-sm last:rounded-r-sm hover:z-10 hover:border-ink hover:bg-paper-2 hover:text-ink; }
|
||||
.rating button.active[data-label="loved"] { @apply z-10 border-loved text-loved; background:color-mix(in oklab, var(--loved) 14%, transparent); }
|
||||
.rating button.active[data-label="good"] { @apply z-10 border-good text-good; background:color-mix(in oklab, var(--good) 14%, transparent); }
|
||||
.rating button.active[data-label="down"] { @apply z-10 border-down text-down; background:color-mix(in oklab, var(--down) 14%, transparent); }
|
||||
.rating button.clear { @apply invisible ml-1.5 border-0 bg-transparent px-1 text-muted underline decoration-rule underline-offset-4 hover:text-ink; }
|
||||
.rating:has(button[data-label]:not(.clear).active) button.clear, .rating button.clear.active { @apply visible; }
|
||||
.rating button.clear.active { @apply no-underline; }
|
||||
.rating-cleared-label { @apply hidden; }
|
||||
.rating button.clear.active .rating-clear-label { @apply hidden; }
|
||||
.rating button.clear.active .rating-cleared-label { @apply inline; }
|
||||
.rating-note { @apply mt-2 flex w-full max-w-md basis-full items-center gap-2 text-[0.72rem] text-muted; }
|
||||
.rating-note input { @apply min-w-0 flex-1 rounded-none border-x-0 border-t-0 border-b border-rule bg-transparent px-0 py-1 shadow-none focus:border-ink; }
|
||||
|
||||
.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; }
|
||||
@@ -180,28 +197,39 @@
|
||||
.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; }
|
||||
/* Rendered by the templates but only useful with the script; `has-js` is set pre-paint. */
|
||||
.table-filter { @apply my-2 hidden w-80 max-w-full font-sans text-sm; }
|
||||
.has-js .table-filter { @apply block; }
|
||||
/* Theme toggle: the current theme's icon and label, chosen without script. */
|
||||
[data-theme-icon], [data-theme-label] { display:none; }
|
||||
html:not([data-theme]) [data-theme-icon="system"], html[data-theme="light"] [data-theme-icon="light"], html[data-theme="dark"] [data-theme-icon="dark"] { display:block; }
|
||||
html:not([data-theme]) [data-theme-label="system"], html[data-theme="light"] [data-theme-label="light"], html[data-theme="dark"] [data-theme-label="dark"] { display:inline; }
|
||||
|
||||
.pager { @apply flex flex-wrap items-center justify-between gap-x-6 gap-y-2 py-1 font-sans text-sm text-muted; }
|
||||
.pager { @apply flex flex-wrap items-center justify-between gap-x-6 gap-y-2 py-1 font-sans text-sm tabular-nums 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; }
|
||||
.pagination { @apply font-sans text-sm tabular-nums 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; }
|
||||
/* the rating widget has to fit inside a table cell or a dense list */
|
||||
td .rating, .picks .rating { @apply my-0 max-w-none gap-y-1 text-xs; }
|
||||
.picks .rating { @apply mt-2; }
|
||||
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; }
|
||||
td .rating button[data-label]:not(.clear), .picks .rating button[data-label]:not(.clear) { @apply px-1.5 py-1 text-xs; }
|
||||
td .rating button.clear, .picks .rating button.clear { @apply ml-1.5 px-0 text-xs; }
|
||||
td .rating-note { @apply mt-0 max-w-none gap-1.5 text-[0.7rem]; }
|
||||
td .rating-note-label { @apply sr-only; }
|
||||
td .rating-note input { @apply text-xs; }
|
||||
/* the ratings table's verdict cell: one column, buttons then note, nothing wraps */
|
||||
.rating-cell .rating { @apply grid w-[15rem] grid-cols-1 gap-y-1.5; }
|
||||
.rating-cell .rating-actions { @apply flex w-full min-h-0 items-center; }
|
||||
.rating-cell .rating button.clear { @apply ml-auto; }
|
||||
|
||||
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; }
|
||||
@@ -259,14 +287,29 @@
|
||||
/* 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; }
|
||||
[data-toc-panel] { scrollbar-width:thin; scrollbar-color:var(--rule) transparent; scrollbar-gutter:stable; }
|
||||
[data-toc-panel]::-webkit-scrollbar { width:0.5rem; }
|
||||
[data-toc-panel]::-webkit-scrollbar-thumb { border:2px solid transparent; background:var(--rule); background-clip:padding-box; }
|
||||
.toc-link { @apply flex min-h-11 items-baseline gap-2 border-l-2 border-transparent py-2.5 pl-3 text-ink-2 no-underline transition-colors duration-150 hover:border-rule-strong hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper lg:min-h-10 lg:py-1.5; }
|
||||
.toc-link[data-passed] { @apply text-ink; }
|
||||
.toc-link[data-passed] > span:first-child { @apply text-ink; }
|
||||
.toc-link[aria-current] { @apply border-accent font-semibold text-ink; }
|
||||
.toc-toggle-icon { opacity:1; scale:1; filter:blur(0); transition-property:opacity, scale, filter; transition-duration:150ms; transition-timing-function:cubic-bezier(0.2, 0, 0, 1); }
|
||||
.toc-toggle-icon-close { opacity:0; scale:0.25; filter:blur(4px); }
|
||||
[data-toc-toggle][aria-expanded="true"] .toc-toggle-icon-menu { opacity:0; scale:0.25; filter:blur(4px); }
|
||||
[data-toc-toggle][aria-expanded="true"] .toc-toggle-icon-close { opacity:1; scale:1; filter:blur(0); }
|
||||
/* Without JS the panel is simply visible under the bar; `has-js` is set in the head. */
|
||||
@media (width < 64rem) {
|
||||
/* Lock only the root: locking body as well makes body its own scroll container and un-sticks the bar. */
|
||||
html.toc-panel-open { overflow:hidden; scrollbar-gutter:stable; }
|
||||
.has-js [data-toc-panel][data-open="true"] { height:calc(100dvh - var(--toc-panel-top, 3rem)); max-height:calc(100dvh - var(--toc-panel-top, 3rem)); }
|
||||
.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; }
|
||||
.rating { @apply items-stretch; } .rating-prompt, .rating-note { @apply basis-full; } .rating-prompt { @apply mb-1; }
|
||||
.rating-actions { @apply min-h-11; }
|
||||
.rating button[data-label]::after { @apply -inset-y-2; }
|
||||
.filters label { @apply w-full; }
|
||||
.filters input, .filters select { @apply w-full; }
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
<input type="hidden" name="issue_date" value="{{ widget.issue_date }}">
|
||||
<input type="hidden" name="next" value="{{ widget.next }}">
|
||||
<span class="rating-prompt">Was this a good pick?</span>
|
||||
<span class="rating-actions">
|
||||
<button type="submit" name="label" value="loved" data-label="loved"{% if widget.current == "loved" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>Loved it</button>
|
||||
<button type="submit" name="label" value="good" data-label="good"{% if widget.current == "good" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>Good</button>
|
||||
<button type="submit" name="label" value="down" data-label="down"{% if widget.current == "down" %} class="active" aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>Not for me</button>
|
||||
{% if widget.show_note %}<label class="rating-note">Note <input name="note"></label>{% endif %}
|
||||
<button class="clear{% if widget.current == "cleared" %} active{% endif %}" type="submit" name="label" value="cleared" data-label="cleared"{% if widget.current == "cleared" %} aria-pressed="true"{% else %} aria-pressed="false"{% endif %}>clear</button>
|
||||
<button class="clear{% if widget.current == "cleared" %} active{% endif %}" type="submit" name="label" value="cleared" data-label="cleared"{% if widget.current == "cleared" %} aria-pressed="true"{% else %} aria-pressed="false"{% endif %}><span class="rating-clear-label">clear</span><span class="rating-cleared-label">cleared</span></button>
|
||||
</span>
|
||||
{% if widget.show_note %}<label class="rating-note"><span class="rating-note-label">Note</span><input name="note" placeholder="Add a note…"></label>{% endif %}
|
||||
</form>
|
||||
@@ -1,20 +1,21 @@
|
||||
<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>
|
||||
<div class="relative flex min-h-12 items-center gap-3 border-b border-rule bg-paper px-4 sm:px-6 lg:hidden" data-toc-bar>
|
||||
<button class="relative -ml-2 flex h-11 w-11 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="toc-toggle-icon toc-toggle-icon-menu absolute h-5 w-5 fill-none stroke-current" stroke-width="1.75"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" class="toc-toggle-icon toc-toggle-icon-close absolute h-5 w-5 fill-none stroke-current" stroke-width="1.75"><path d="m5 5 14 14M19 5 5 19"/></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 %}
|
||||
<span class="min-w-0 flex-1 truncate font-sans text-sm leading-tight text-ink" data-toc-label>{{ toc.current_label() }}</span>
|
||||
<span class="shrink-0 font-sans text-xs tabular-nums text-muted" data-toc-count{% if toc.position == 0 %} hidden{% endif %}>{{ toc.position }} / {{ toc.total }}</span>
|
||||
<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">
|
||||
<nav id="toc" data-toc-panel class="absolute inset-x-0 top-full z-30 overflow-y-auto overscroll-contain border-b border-rule bg-paper pb-7 pl-4 pr-6 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:pl-6 sm:pr-8 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100dvh-3rem)] lg:border-0 lg:pb-10 lg:pl-0 lg:pr-3 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>
|
||||
<p class="mt-2 font-sans text-[0.72rem] text-ink-2" data-toc-status>{% 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 %}
|
||||
{% 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="toc-link" href="{{ item.href }}" data-toc-position="{{ item.progress }}"{% if item.is_colophon() %} data-toc-end{% endif %}{% 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" data-toc-link-label>{{ 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,5 +1,5 @@
|
||||
{% 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>
|
||||
<article class="reader-page 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 %}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{% 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>
|
||||
<article class="reader-page 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 %}
|
||||
@@ -17,7 +17,7 @@
|
||||
<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 tall"><table class="articles" data-filter>
|
||||
{% if articles.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<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 class="cell-tight text-muted">{{ article.first_seen }}</td>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</form>
|
||||
</section>{% endfor %}</div>
|
||||
<h2>History</h2>
|
||||
{% if jobs.is_empty() %}<p class="muted text-sm">No jobs recorded yet.</p>{% else %}<div class="scroll-x tall"><table data-filter>
|
||||
{% if jobs.is_empty() %}<p class="muted text-sm">No jobs recorded yet.</p>{% else %}{% if jobs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<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 class="num"><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<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 class="min-w-[13rem]"><span class="badge {{ row.badge }}">{{ row.verdict }}</span>{% let widget = row.widget %}{% include "_rating_widget.html" %}</td>
|
||||
<td class="rating-cell min-w-[13rem]">{% 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>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<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 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>
|
||||
<section class="card"><h2>Feeds (top {{ feeds.len() }})</h2><div><table class="table-fixed"><thead><tr><th>feed</th><th class="num w-20">entries</th></tr></thead><tbody>{% for line in feeds %}<tr><td class="truncate" title="{{ line.name }}">{{ line.name }}</td><td class="num w-20">{{ line.count }}</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
</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>
|
||||
@@ -58,7 +58,7 @@
|
||||
<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 tall"><table class="candidates" data-filter>
|
||||
{% if candidates.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<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 %}{% 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" %}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<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>
|
||||
<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 tall"><table data-filter>
|
||||
{% if runs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<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 class="num"><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<h1>Settings history</h1>
|
||||
<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>
|
||||
{% if changes.is_empty() %}<p class="muted text-sm">No settings have been changed through the dashboard yet.</p>{% else %}{% if changes.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<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 class="cell-tight text-muted">{{ change.changed_at }}</td>
|
||||
|
||||
@@ -27,12 +27,12 @@
|
||||
</div>
|
||||
|
||||
<h2>Spend by day</h2>
|
||||
{% 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>
|
||||
{% if cost_rows.is_empty() %}<p class="muted text-sm">No provider costs recorded in the window.</p>{% else %}{% if cost_rows.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<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 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 text-sm">No finished runs in the window.</p>{% else %}<div class="scroll-x tall"><table data-filter>
|
||||
{% if runs.is_empty() %}<p class="muted text-sm">No finished runs in the window.</p>{% else %}{% if runs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<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 %}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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>
|
||||
{% if users.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<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 class="font-medium text-ink">{{ user.username }}</td>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{% 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">
|
||||
<article class="reader-page 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>
|
||||
<section aria-labelledby="brief-heading" data-toc-entry="/issues/{{ date }}"><h1 id="brief-heading" class="reader-section-heading">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 %}
|
||||
{% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><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="index-summary 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>
|
||||
<footer id="colophon" class="mt-14 scroll-mt-6 font-sans text-sm leading-relaxed" data-toc-entry="/issues/{{ date }}#colophon"><h2 class="reader-section-heading">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,8 +1,8 @@
|
||||
{% 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">
|
||||
<article class="reader-page 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 %}
|
||||
{% for section in issue.sections %}<section class="mt-14"><h2 class="reader-section-heading">{{ 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="index-summary 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 %}
|
||||
@@ -3,10 +3,14 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>{{ page.title }} · The Daily EPUB</title>
|
||||
<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 }}">
|
||||
{# Keep this synchronous script *after* the stylesheet: Gecko will not run a
|
||||
parser-blocking script while a stylesheet is pending, so the parser stalls
|
||||
here and nothing unstyled exists to paint if something forces a layout
|
||||
flush (Bugzilla 1459305). Chrome blocks rendering on the sheet regardless. #}
|
||||
<script src="/static/theme.js?v={{ page.asset_version }}"></script>
|
||||
<link rel="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml">
|
||||
<link rel="icon" href="/static/favicon.svg">
|
||||
</head>
|
||||
@@ -14,15 +18,15 @@
|
||||
<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="min-w-0 truncate whitespace-nowrap">{% block ears %}{% if page.is_dashboard() %}<span>Dashboard</span>{% endif %}{% 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>
|
||||
<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"><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"><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">System</span><span data-theme-label="light">Light</span><span data-theme-label="dark">Dark</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 %}
|
||||
{% match page.viewer %}{% when Some with (viewer) %}<a class="flex min-h-10 min-w-10 max-w-32 shrink-0 items-center justify-center truncate whitespace-nowrap px-1.5 text-ink no-underline hover:text-accent" href="/account">{{ viewer.username }}</a>{% when None %}<a class="flex min-h-10 min-w-10 shrink-0 items-center justify-center whitespace-nowrap px-1.5 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>
|
||||
@@ -32,16 +36,16 @@
|
||||
<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>
|
||||
{% if page.is_admin() %}<nav class="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 border-b border-rule font-sans text-[0.68rem] uppercase tracking-[0.1em]" aria-label="Dashboard">
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "dashboard" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard"{% if page.active_nav == "dashboard" %} aria-current="page"{% endif %}>Overview</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "runs" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/runs"{% if page.active_nav == "runs" %} aria-current="page"{% endif %}>Runs</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "articles" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/articles"{% if page.active_nav == "articles" %} aria-current="page"{% endif %}>Articles</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "ratings" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/ratings"{% if page.active_nav == "ratings" %} aria-current="page"{% endif %}>Ratings</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "profile" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/profile"{% if page.active_nav == "profile" %} aria-current="page"{% endif %}>Profile</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "stats" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/stats"{% if page.active_nav == "stats" %} aria-current="page"{% endif %}>Stats</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "jobs" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/jobs"{% if page.active_nav == "jobs" %} aria-current="page"{% endif %}>Jobs</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "settings" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/settings"{% if page.active_nav == "settings" %} aria-current="page"{% endif %}>Settings</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "users" %}border-accent text-ink{% else %}border-transparent 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 %}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{% 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>
|
||||
<article class="reader-page 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