Merge branch 'main' into ui-rating

# Conflicts:
#	src/web/templates/issue_full.html
This commit is contained in:
2026-09-04 17:09:06 +00:00
14 changed files with 732 additions and 83 deletions
+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.
+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
+81 -4
View File
@@ -206,7 +206,7 @@ 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,
}
@@ -236,6 +236,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> {
@@ -415,6 +431,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
}
@@ -529,6 +549,10 @@ 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(),
@@ -553,7 +577,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 +589,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 +683,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 +826,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 +1080,8 @@ 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)));
assert!(html.contains("rel=\"preload\" href=\"/static/Newsreader.woff2\""));
assert!(html.contains("rel=\"preload\" href=\"/static/Newsreader-italic.woff2\""));
}
#[tokio::test]
@@ -1044,10 +1101,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 +1116,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,
File diff suppressed because one or more lines are too long
+128 -38
View File
@@ -109,29 +109,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,33 +177,93 @@ 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);
}
}
const tocBars = document.querySelectorAll("[data-toc-progress]");
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.
const base = Math.max(0, Number(tocBars[0].getAttribute("value")) - 1);
let queued = false;
const paint = () => {
queued = false;
const start = window.scrollY + tocChapter.getBoundingClientRect().top;
const end = start + tocChapter.offsetHeight - window.innerHeight;
const read = end > start ? (window.scrollY - start) / (end - start) : 1;
const value = base + Math.min(1, Math.max(0, read));
tocBars.forEach((bar) => {
// The CSS transition is for navigation, not for tracking a finger.
bar.dataset.live = "true";
bar.value = value;
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);
});
};
const schedule = () => {
if (queued) return;
queued = true;
window.requestAnimationFrame(paint);
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}`;
}
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();
};
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
schedule();
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.
const base = Math.max(0, Number(tocBars[0].getAttribute("value")) - 1);
let queued = false;
const paint = () => {
queued = false;
const start = window.scrollY + tocChapter.getBoundingClientRect().top;
const end = start + tocChapter.offsetHeight - window.innerHeight;
const read = end > start ? (window.scrollY - start) / (end - start) : 1;
const value = base + Math.min(1, Math.max(0, read));
tocBars.forEach((bar) => {
// The CSS transition is for navigation, not for tracking a finger.
bar.dataset.live = "true";
bar.value = value;
});
};
const schedule = () => {
if (queued) return;
queued = true;
window.requestAnimationFrame(paint);
};
window.addEventListener("scroll", schedule, { passive: true });
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"
}
]
}
+19
View File
@@ -9,4 +9,23 @@
} 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 });
})();
+52 -6
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,6 +82,39 @@
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior:auto !important; transition-duration:0.01ms !important; } }
}
@view-transition {
navigation: auto;
}
.site-header {
view-transition-name: site-header;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 140ms;
animation-timing-function: ease-out;
}
::view-transition-group(site-header),
::view-transition-new(site-header) {
animation: none;
}
::view-transition-old(site-header) {
display: none;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(site-header),
::view-transition-old(root),
::view-transition-new(root),
::view-transition-old(site-header),
::view-transition-new(site-header) {
animation: none;
}
}
@layer components {
/* ---------- shared controls ---------- */
@@ -92,7 +125,7 @@
.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); }
@@ -199,14 +232,14 @@
.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; }
.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; }
@@ -278,9 +311,22 @@
/* 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) {
+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
@@ -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>
+3 -3
View File
@@ -2,12 +2,12 @@
<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="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="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>
<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-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"><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 %}
{% 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-14 scroll-mt-6 font-sans text-sm leading-relaxed"><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>
<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 %}
+14 -13
View File
@@ -6,15 +6,16 @@
<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="preload" href="/static/Newsreader-italic.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="/static/app.css?v={{ page.asset_version }}">
<link rel="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml">
<link rel="icon" href="/static/favicon.svg">
</head>
<body>
<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">
<header class="site-header 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>
@@ -22,7 +23,7 @@
<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>
</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 +33,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 %}