Compare commits

..
12 Commits
Author SHA1 Message Date
thalladaandClaude Fable 5.1 239125a8e5 Put theme.js after the stylesheet so Gecko cannot paint unstyled
Root cause of the Firefox-only flash of blue links on the first click after
an idle spell (Bugzilla 1459305, still open): Gecko does not block the
parser on a pending stylesheet, it only suppresses painting, and any layout
flush in that window (an extension content script, a ResizeObserver, a
getComputedStyle) initialises layout and paints the unstyled body. With
theme.js *before* the <link>, nothing stalled the parser while app.css was
coming back from the disk cache, so the whole body was parsed unstyled and
one flush painted it. Chrome blocks rendering on the sheet outright, which
is why it never showed this.

Gecko refuses to run a parser-blocking script while stylesheets are
pending, so a synchronous script placed after the <link> stalls the parser
inside <head>: there is nothing to paint. theme.js still runs before any
body content exists, so has-js and the saved theme are applied pre-paint
exactly as before.

Reproduced with Playwright Firefox, a proxy delaying app.css and an init
script forcing offsetHeight on every mutation: a 1.2 s unstyled paint with
the old order, none with the new order, in both Firefox and Chromium.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 19:08:52 +00:00
thalladaandClaude Fable 5.1 1047cf4633 Remove everything that can change after first paint
Firefox users saw a flash on the first navigation after an idle spell, and
Chrome occasionally flashed white. Nothing in the network path explained it
(assets are immutable, the stylesheet is render-blocking, a cold Firefox
load paints fully styled), so this removes every remaining way a page could
look different between its first paint and its final state:

- Both Newsreader faces are embedded in the served stylesheet as data: URIs
  (CSP gains `font-src 'self' data:`; the preloads go away). A font fetched
  by URL is applied after first paint whenever the browser has to bring it
  back from disk, which is exactly the "first click after a while" case.
- The cross-document view transition is gone; the operator wants snappy.
- A color-scheme meta, kept in step with the saved theme, so the canvas the
  browser paints before the stylesheet is the right shade.
- The theme toggle's icon and label are chosen by CSS from html[data-theme]
  (set pre-paint by theme.js) instead of being rewritten by app.js.
- Dashboard table filters are rendered by the templates (shown under
  `.has-js`) rather than inserted by app.js, so tables no longer jump.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 18:37:44 +00:00
thalladaandClaude Fable 5.1 9acd1b0800 Rebuild app.css after merging the UI pass branches
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 17:45:14 +00:00
thallada b299b4ba05 Merge branch 'ui-rating': rating widget, issue section headers, ratings verdict column 2026-09-04 17:43:23 +00:00
thalladaandClaude Fable 5.1 4248e48a8b UI pass: rating widget, issue section headers, ratings verdict column
Finishes the Codex partial (0cdc302) after seeing it rendered.

Rating widget
- Inactive segments go text-ink-2 (ink on hover) so the group recedes
  beside serif body copy; the per-verdict tint stays the only colour.
- Hit area: the ::after overlay is positioned against the padding box,
  so -inset-y-1/-1.5 only gave 37/41 px. Now -inset-y-1.5/-2 -> 41 px
  desktop, 45 px phone.
- focus-visible hugs the segment (z-20, ring-offset-0); the inherited
  ring-offset-2 painted a detached box over the neighbouring buttons.
- The note field is a quiet bottom-hairline input everywhere (rule at
  rest, ink on focus) instead of a box on reader pages and an invisible
  border in tables.

Issue section headers
- The Brief, every section name and Colophon now use one serif
  uppercase, letter-spaced mark on a double rule. A sentence-case
  text-2xl header was screenshotted first and rejected: it reads as
  another headline, which is the problem being fixed.

Ratings table verdict column
- New .rating-cell block: a one-column 15rem grid, segmented group with
  clear/cleared flush right, note field beneath on the same width, group
  top-aligned with the article title. 13rem cannot hold the labels plus
  the wider "cleared" state, so rows wrapped and overlapped the Article
  column.
- Drop the dead CurrentRow::verdict field left over from removing the
  badge (clippy -D warnings).

Also: reader-page on world/behind so the text-wrap: balance polish item
covers those chapters.

Verified on the dev server at 127.0.0.1:3602 in light and dark, desktop
and 390 px: rating clicks toggle .active/aria-pressed with no navigation
and no console output; note text round-trips; cargo fmt, clippy
--all-targets -D warnings, cargo test (445 lib + integration) and
npm run css:check are clean. See docs/plans/briefs/ui-pass/handoff-rating.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 17:42:21 +00:00
thallada bcbe1583af Merge branch 'main' into ui-rating
# Conflicts:
#	src/web/templates/issue_full.html
2026-09-04 17:09:06 +00:00
thallada 0cdc302f84 WIP: rating widget / section headers (Codex partial, quota cut-off) 2026-09-04 17:08:48 +00:00
thallada da24e7c7c5 Merge branch 'ui-shell': nav state, ears, feeds table, font flash, faster navigation
# Conflicts:
#	src/web/static/app.css
2026-09-04 17:08:10 +00:00
thalladaandClaude Fable 5.1 5cc4ac4bd3 UI pass: nav state, ears, feeds table, font flash, faster navigation
- Issue pages highlight Archive instead of Latest unless the issue is the
  newest one (IssueView::is_latest), with a router test.
- The ears row is empty by default and says "Dashboard" on dashboard pages;
  "Morning edition" only survives on the empty-state hero.
- Run page: the Feeds card table is fixed-layout and truncates long feed
  names with the full title on hover instead of pushing the entries column
  out of the card.
- Static assets are content-hashed, so serve them immutable for a year;
  preload both Newsreader faces and use font-display: block so a cache
  revalidation never paints the fallback serif first. Persisted details
  state is restored pre-paint from theme.js.
- Cross-document view transitions (140 ms fade, masthead held still) and
  Speculation Rules (prerender reader pages, prefetch dashboard pages on
  hover) served from /static/speculation.json via the Speculation-Rules
  header, which the CSP would otherwise block inline. No client router.
- Dashboard nav gets the same accent underline as the site nav; 40 px
  targets for the theme toggle, account link and dashboard nav.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 17:06:48 +00:00
thallada fafb737e53 Merge branch 'ui-toc': table-of-contents fixes and scroll sync 2026-09-04 17:06:26 +00:00
thalladaandClaude Fable 5.1 ad2e99eeb9 UI pass: table-of-contents sidebar fixes and scroll sync
- The open hamburger panel now fills the viewport below the sticky bar
  (100dvh, measured bar offset) and locks the page behind it. Locking only
  the root element: locking body too made it its own scroll container and
  un-stuck the bar.
- Front-page scroll-spy: the current chapter marker, mobile label/counter,
  desktop "Chapter N of M" line and both progress bars follow the reader
  through "In This Issue"; passed chapters read in ink. The sidebar
  auto-scrolls to keep the current chapter in view.
- The sidebar scrollbar sits in a reserved gutter instead of over the text.
- Hamburger cross-fades to a close icon; 44px tap targets on phones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 17:05:39 +00:00
thalladaandClaude Fable 5.1 560bbc13af Add briefs for the UI polish pass
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
2026-09-04 16:33:58 +00:00
32 changed files with 1375 additions and 133 deletions

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
# Shared brief — UI polish pass (2026-09-04)
The operator asked for a design/bug-fix pass over the web dashboard v2 (Tailwind
redesign, merged to `main` at `e719e03`+). The work is split into three tasks
(`01-toc.md`, `02-rating.md`, `03-shell.md`) that run in parallel in separate git
worktrees. Read `docs/plans/briefs/web-dashboard-v2/00-shared.md` for the design
language (quiet morning paper: warm paper, near-black ink, one red accent,
hairline rules, real typographic hierarchy; reader pages serif, dashboard sans)
and the Tailwind v4 conventions. This brief overrides it where they disagree.
## Ground rules
- Work only in the worktree named in your brief. **Never touch the main checkout**
at `/home/thallada/workspace/the-daily-epub`. Do not `git commit` (the sandbox
cannot write `.git/worktrees`); leave the work in the tree and the orchestrator
commits. If you can commit, one commit on the worktree branch is fine.
- Styling lives in **`src/web/tailwind.css`** (Tailwind v4, `@apply` in
`@layer components`, semantic colour tokens `paper/paper-2/ink/ink-2/muted/rule/
rule-strong/accent/loved/good/down/warn`) and in Tailwind utility classes in the
askama templates under `src/web/templates/`. **Rebuild the committed CSS** after
every change with `npm run css` (writes `src/web/static/app.css`; the binary
embeds it with `include_str!`, so rebuild Rust after the CSS). `npm run css:check`
must print no diff at the end.
- CSP is `default-src 'self'; style-src 'self'; script-src 'self'`: **no inline
`<style>`/`<script>`, no `style=""` attributes, no CDNs.** Setting CSS custom
properties or `element.style.*` from `app.js` (CSSOM) is allowed by CSP but
prefer class/attribute toggles styled from `tailwind.css`.
- JS is plain `src/web/static/app.js` (runs once at the end of `<body>`) and the
tiny pre-paint `src/web/static/theme.js`. Keep every hook app.js relies on:
`form.rating`, `button[data-label]`, `.active` + `aria-pressed` on rating
buttons, `button.clear`, `form[data-confirm]`, `details[id]`, `table[data-filter]`,
`.table-filter`, `button[data-reset]`/`data-default`, `[data-refresh]`,
`[data-toc-panel]`, `[data-toc-toggle]`, `[data-toc-progress]`, `[data-toc-scroll]`,
`[data-theme-toggle]`. Keep `id`/`name`/`action`/`aria-*` attributes used by
handlers or tests. Router tests assert on text and some class names; if a test
breaks only because a cosmetic class changed, update the test.
- Don't change routes, auth, migrations or `web::security_headers`.
- Prefer CSS transitions (interruptible) over keyframes; name exact transition
properties (never `transition: all`); respect the existing
`prefers-reduced-motion` rule. Touch targets ≥ 44 px on phones (extend with a
pseudo-element if the visible control is smaller); ≥ 40 px in the dense
dashboard. Tabular numerals for changing numbers. Hairline rules, not boxes,
for structure. No decorative motion on frequent interactions.
- Keep `cargo fmt`, `cargo clippy --all-targets -- -D warnings` and the tests
green. Iterate with narrow runs (`cargo test --lib web::`), one `cargo test` at
the end. **Sandbox note:** inside the Codex sandbox `bind()` on 127.0.0.1 is
forbidden, so these pre-existing tests fail there and must be ignored:
`curate::llm::tests::anthropic_*` (4), `extract::tests::relative_urls_resolve_against_the_url_we_landed_on`,
`server::tests::*` (5) and `tests/m7_server.rs`. Do not "fix" them.
- This host has 4 cores / 7 GB and other agents build Rust at the same time; be
patient with `cargo` and don't run several builds concurrently yourself.
- Don't gold-plate and don't widen scope beyond your brief's numbered items plus
the polish items it lists. Where the brief and the code disagree, follow the
code and note the deviation.
- Finish by writing `docs/plans/briefs/ui-pass/handoff-<task>.md`: what changed
(file by file, briefly), design decisions, deviations, what you verified and
how (commands, screenshots), anything left open.
## Previewing
A seeded dev database exists in the main checkout at
`/home/thallada/workspace/the-daily-epub/dev` (two issues: No. 18 on 2026-09-01,
legacy; No. 19 on 2026-09-02, full snapshot; accounts `admin`/`adminpassword123`
and `reader`/`readerpassword123`). Copy it if you need your own:
`cp -r /home/thallada/workspace/the-daily-epub/dev ./dev` (git-ignored). Run your
worktree's server on **your own port** (given in your brief; 3499 is production,
3599 is the orchestrator's):
```text
DAILY_EPUB_SERVER__HMAC_SECRET=$(head -c 48 /dev/urandom | base64) \
DAILY_EPUB_SERVER__BIND=127.0.0.1:<port> \
cargo run -- --config ./dev/config.toml serve
```
(the listen address is `server.bind` in `./dev/config.toml`, overridable by the
env var above; your worktree's `dev/config.toml` is already set to your port. If
the sandbox forbids binding, rely on router tests + `cargo test` and say so in
the handoff).
Screenshots: `node shoot.mjs <outdir> [--user admin|reader|none] [--theme light|dark] [--mobile] [--full] /path …`
with `BASE=http://127.0.0.1:<port>`, from
`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/553c7d77-d8f4-44f8-a379-73cedea23814/scratchpad/shoot/`
(Playwright with a headless Chromium already installed under `~/.cache/ms-playwright`).
If that directory is unreadable, the bare browser at
`~/.cache/ms-playwright/chromium_headless_shell-1208/chrome-headless-shell-linux64/chrome-headless-shell --headless --no-sandbox --screenshot=out.png --window-size=1280,1400 URL`
works for anonymous pages. **Look at your screenshots** (read the PNGs) before
calling a design change done, in light and dark, desktop and 390 px phone.
Useful dev URLs: `/` (latest issue, No. 19), `/issues/2026-09-01` (archived
issue), `/issues/2026-09-02/articles/<id>` (chapter; pick ids from the TOC),
`/issues/2026-09-02/world`, `/issues/2026-09-02/behind`, `/dashboard`,
`/dashboard/runs`, `/dashboard/runs/<id>`, `/dashboard/ratings`,
`/dashboard/settings`, `/dashboard/articles`.
+88
View File
@@ -0,0 +1,88 @@
# Task 1 — table-of-contents sidebar: mobile height, scroll sync, scrollbar
Worktree: `/home/thallada/workspace/the-daily-epub-ui1` (branch `ui-toc`). Dev port 3601.
Files you own: `src/web/templates/_toc.html`, the TOC parts of
`src/web/static/app.js` (the `[data-toc-*]` blocks), the "table-of-contents"
section of `src/web/tailwind.css`, and the index entries in
`src/web/templates/issue_full.html` (only to add data attributes / ids). Backend:
`src/web/issue.rs` `Toc`/`TocItem` if a field helps. Read the shared brief first.
The signed-in issue pages (`issue_full.html`, `article.html`, `world.html`,
`behind.html`) share `_toc.html`: a sticky bar with a hamburger below `lg`, a
sticky sidebar at `lg+`. Chapters are separate pages; the issue front page lists
every chapter in "In This Issue".
## 1. The open hamburger panel must fill the screen (operator issue 1)
Today the panel is `absolute top-full max-h-[72vh]`, so on a phone it stops
partway down with page content visible beneath it — it looks broken. Make the
open panel extend from the bottom of the sticky bar to the **bottom of the
viewport** (use `100dvh`, not `100vh`, so mobile browser chrome is accounted
for), with the chapter list scrolling inside it and the bar staying fixed above
it. Handle both states: the bar already stuck at the top of the viewport, and
the bar still in flow below the masthead on first open. Prefer a pure-CSS
solution; if the bar's on-screen position must be measured, set one CSS custom
property from app.js on open/resize and consume it in `tailwind.css`. Avoid
jumps of the underlying page when the panel opens or closes. Keep Escape, the
outside-click close, the tap-on-chapter close, and the `lg` breakpoint reset.
Content behind the panel must not scroll while it is open (e.g. toggle a class
on `<html>` that sets `overflow:hidden`, cleared on close).
## 2. Scroll-synced current chapter (operator issue 2)
On the issue front page the reader scrolls through "In This Issue"; each index
entry corresponds to a TOC chapter. As the reader scrolls, the TOC's current
marker must follow: when entry 5's card reaches the top region of the viewport,
chapters 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`.
+97
View File
@@ -0,0 +1,97 @@
# Task 2 — rating widget, issue section headers, ratings-table verdict column
Worktree: `/home/thallada/workspace/the-daily-epub-ui2` (branch `ui-rating`). Dev port 3602.
Files you own: `src/web/templates/_rating_widget.html`, the `.rating*`,
`td .rating`, `.prose-body` and reader-typography parts of `src/web/tailwind.css`,
`src/web/templates/issue_full.html` and `issue_public.html` (section headers),
`src/web/templates/article.html` (rating placement only),
`src/web/templates/dashboard/ratings.html`, and the rating `submit` handler in
`src/web/static/app.js` if the markup change needs it. Read the shared brief first.
## 1. Redesign the rating widget (operator issue 3)
`_rating_widget.html` renders "Was this a good pick? [Loved it][Good][Not for me]
clear" (+ a Note input on dashboard pages). The buttons are 40 px tall,
full-border segmented, solid-ink when active — too heavy next to the serif index
cards on the issue front page and the article footer; they read as a form, not as
part of the paper. Redesign it to be cohesive with the article display:
- Quieter and smaller: sans, `text-xs`/`text-[0.8rem]`, compact height (~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`.
+110
View File
@@ -0,0 +1,110 @@
# Task 3 — site shell: nav state, ears, runs feeds table, FOUC, faster navigation
Worktree: `/home/thallada/workspace/the-daily-epub-ui3` (branch `ui-shell`). Dev port 3603.
Files you own: `src/web/templates/layout.html`, `src/web/mod.rs` (static assets,
headers), `src/web/public.rs`, `src/web/issue.rs` (page/nav plumbing only, not
the `Toc`), `src/web/templates/dashboard/run.html`, the "dashboard shell" and
`@layer base` parts of `src/web/tailwind.css`, and `src/web/static/theme.js`.
Read the shared brief first.
## 1. "Latest" is highlighted on archived issues (operator issue 5)
Every issue page (`public::show_issue`, `issue::render_full`, `issue::article`,
`world`, `behind`) passes `"latest"` as `active_nav`, so viewing `/issues/2026-09-01`
from the archive highlights *Latest* in the site nav. Highlight *Archive*
instead whenever the issue is not the newest one. Cleanest: `issue::load` (which
has the db) records `is_latest` on `IssueView` via `db.latest_issue_date()`, and
each renderer chooses `"latest"`/`"archive"` from it; the public branch in
`show_issue` needs the same. Add a router test that fetches an older issue and
asserts `aria-current="page"` is on the Archive link and not on Latest (and the
reverse for the newest issue).
## 2. "Morning edition" ears on non-issue pages (operator issue 10)
`layout.html`'s default `{% block ears %}` prints "Morning edition" — it shows on
settings and every other dashboard/account page where it is meaningless. Make the
default empty and let issue pages keep overriding it with their dateline. On
dashboard pages show a short "Dashboard" label there instead (only when
`page.is_admin()` and the active nav is a dashboard section) so the row does not
look orphaned; anything else stays empty. Check the row keeps its height and the
theme toggle stays right-aligned when the left cell is empty. `issue_public.html`'s
empty-state hero may keep its own "Morning edition" eyebrow.
## 3. Runs page "Feeds (top 20)" overflow (operator issue 6)
`dashboard/run.html`: the Feeds card wraps its table in `.scroll-x`, whose
`> table { min-width: max-content }` lets long feed titles push the *entries*
column out of the card until the reader scrolls sideways. That table must fit the
card: `table-fixed w-full`, the feed cell `truncate` (nowrap + ellipsis) with the
full name in a `title` attribute, entries column a fixed narrow width, no
horizontal scroll container (or a `.scroll-x` variant that allows shrinking —
your call, but don't break the other tables that rely on `max-content`). Check
the Timings and Provider usage cards next to it still look right, and the
dashboard overview if it has a similar box.
## 4. Flash of unstyled content (operator issue 8)
The operator sees a brief flash of unstyled content when navigating between
pages, "or maybe just sometimes". Reproduce before fixing: with the dev server,
use Playwright to capture the first frames of a navigation with the cache
disabled and a throttled network (there are older experiments `flash.mjs` /
`flash2.mjs` in the screenshot directory named in the shared brief you may crib
from) and note what actually flashes: the fallback serif before Newsreader loads
(`font-display: swap`), a theme flip, the TOC panel, restored `details` state,
or something else. Then fix the causes you confirm. Expected fixes:
- Static assets are already versioned by content hash (`?v={{ page.asset_version }}`),
so serve `/static/*` with `Cache-Control: public, max-age=31536000, immutable`
(update `static_assets_use_content_hash_etags`); keep the ETag.
- Fonts: preload both Newsreader files, and change `font-display` from `swap` to
`block` (no visible fallback; with the preload and immutable cache the block
period is only ever paid once) — or `optional` if you can show it behaves
better here. Explain the choice in the handoff.
- Anything initialised by app.js at the end of `<body>` that changes layout
(e.g. `details[id]` open state restored from localStorage) should either be
decided pre-paint in `theme.js` or made not to shift layout.
## 5. Faster, SPA-like navigation without a client router (operator issue 9)
The operator wants link clicks to feel like an instant swap rather than a full
reload, but wants to decide on HTMX (boost mode) in a separate effort. **Do not**
write a fetch-and-swap router and do not add HTMX. Deliver the standards-based
progressive enhancements that get most of the way there:
- **Cross-document view transitions**: `@view-transition { navigation: auto; }`
in `tailwind.css` with a short (~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`.
+167
View File
@@ -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.
+159
View File
@@ -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.
+89
View File
@@ -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.
+1 -3
View File
@@ -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
View File
@@ -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 &#38; 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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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();
}
}
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
}
+4 -2
View File
@@ -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>
+9 -8
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 %}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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" %}
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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 %}
+1 -1
View File
@@ -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>
+4 -4
View File
@@ -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 %}
+2 -2
View File
@@ -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 %}
+21 -17
View File
@@ -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 -1
View File
@@ -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 %}