Add admin dashboard links on article pages and a single download menu

Signed-in issue and article pages now carry an admin-only link to the
article's dashboard page. The separate Download EPUB / X4 EPUB / XTC
buttons become one "Download EPUB" button (Standard edition first) with a
no-JS <details> menu listing every available format and its size.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVPagF6jfDv78CC5Jv2wp4
This commit is contained in:
2026-09-06 17:18:23 +00:00
co-authored by Claude Fable 5.1
parent 142a8d9905
commit 71665c293c
5 changed files with 125 additions and 19 deletions
+5 -3
View File
@@ -199,9 +199,11 @@ The server is both the public newspaper index and the private operator UI. An
anonymous visitor sees titles, authors, sources, metadata, AI summaries, why anonymous visitor sees titles, authors, sources, metadata, AI summaries, why
lines and outbound comment links; article bodies, the Brief, the World Briefing lines and outbound comment links; article bodies, the Brief, the World Briefing
and comments stay private. A signed-in `user` and comments stay private. A signed-in `user`
sees complete issues and article chapters and can download artifacts. An sees complete issues and article chapters and can download available formats
`admin` can additionally rate articles and use every `/dashboard/*` page, from a single download menu. An
including settings and jobs. Personalization is shared across accounts for now. `admin` can additionally follow direct dashboard links from issue entries and
article chapters, rate articles, and use every `/dashboard/*` page, including
settings and jobs. Personalization is shared across accounts for now.
Accounts are deliberately managed on the host, not in the browser. Usernames Accounts are deliberately managed on the host, not in the browser. Usernames
are case-insensitive and passwords must be 12–1024 characters. Bootstrap with are case-insensitive and passwords must be 12–1024 characters. Bootstrap with
+116 -12
View File
@@ -31,12 +31,19 @@ pub struct Download {
pub size: String, pub size: String,
} }
/// Available issue downloads, with the Standard EPUB first when it exists.
#[derive(Debug, Clone)]
pub struct Downloads {
primary: Download,
others: Vec<Download>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct IssueView { pub struct IssueView {
pub issue: Issue, pub issue: Issue,
/// Signed-in BookOrbit redirect route when the Standard EPUB can be downloaded. /// Signed-in BookOrbit redirect route when the Standard EPUB can be downloaded.
pub read_href: Option<String>, pub read_href: Option<String>,
pub downloads: Vec<Download>, pub downloads: Option<Downloads>,
pub from_json: bool, pub from_json: bool,
pub world_html: Option<String>, pub world_html: Option<String>,
pub is_latest: bool, pub is_latest: bool,
@@ -271,7 +278,7 @@ pub async fn load(
); );
let read_href = (config.bookorbit.is_active() && standard_download.is_some()) let read_href = (config.bookorbit.is_active() && standard_download.is_some())
.then(|| format!("/issues/{date}/read")); .then(|| format!("/issues/{date}/read"));
let downloads = [ let mut available_downloads = [
standard_download, standard_download,
download( download(
"X4 EPUB", "X4 EPUB",
@@ -286,8 +293,11 @@ pub async fn load(
download("XTC", row.xtc_path.as_deref(), None, "xtc"), download("XTC", row.xtc_path.as_deref(), None, "xtc"),
] ]
.into_iter() .into_iter()
.flatten() .flatten();
.collect(); let downloads = available_downloads.next().map(|primary| Downloads {
primary,
others: available_downloads.collect(),
});
Ok(Some(IssueView { Ok(Some(IssueView {
issue, issue,
read_href, read_href,
@@ -842,6 +852,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
struct FullEntry { struct FullEntry {
title: String, title: String,
href: String, href: String,
dashboard_href: String,
source: String, source: String,
reading_minutes: i64, reading_minutes: i64,
is_lead: bool, is_lead: bool,
@@ -890,7 +901,7 @@ struct IssueFullTemplate {
stats_line: String, stats_line: String,
front_page_html: String, front_page_html: String,
read_href: Option<String>, read_href: Option<String>,
downloads: Vec<Download>, downloads: Option<Downloads>,
sections: Vec<FullSection>, sections: Vec<FullSection>,
has_world: bool, has_world: bool,
has_behind: bool, has_behind: bool,
@@ -920,6 +931,7 @@ struct ArticleTemplate {
body_html: String, body_html: String,
discussion_html: Option<String>, discussion_html: Option<String>,
read_online_url: String, read_online_url: String,
dashboard_href: String,
rating: Option<RatingWidget>, rating: Option<RatingWidget>,
previous: Option<ArticleLink>, previous: Option<ArticleLink>,
next: Option<ArticleLink>, next: Option<ArticleLink>,
@@ -981,6 +993,7 @@ pub async fn render_full(
.map(|pick| FullEntry { .map(|pick| FullEntry {
title: pick.article.title.clone(), title: pick.article.title.clone(),
href: article_href(date, pick.article.id), href: article_href(date, pick.article.id),
dashboard_href: format!("/dashboard/articles/{}", pick.article.id),
source: pick.article.feed_title.clone(), source: pick.article.feed_title.clone(),
reading_minutes: pick.article.reading_minutes(), reading_minutes: pick.article.reading_minutes(),
is_lead: pick.is_lead, is_lead: pick.is_lead,
@@ -1097,6 +1110,7 @@ pub async fn article(
.as_ref() .as_ref()
.map(|discussion| crate::comments::render_xhtml(discussion, &article.title)), .map(|discussion| crate::comments::render_xhtml(discussion, &article.title)),
read_online_url: article.url.clone(), read_online_url: article.url.clone(),
dashboard_href: format!("/dashboard/articles/{}", article.id),
rating: (viewer.role == crate::web::users::Role::Admin).then(|| { rating: (viewer.role == crate::web::users::Role::Admin).then(|| {
RatingWidget::for_issue( RatingWidget::for_issue(
article.id, article.id,
@@ -1949,6 +1963,8 @@ mod tests {
assert!(!issue.contains("Was this a good pick?")); assert!(!issue.contains("Was this a good pick?"));
let article_id = source.lineup.picks[0].article.id; let article_id = source.lineup.picks[0].article.id;
let dashboard_href = format!("/dashboard/articles/{article_id}");
assert!(!issue.contains(&dashboard_href));
let article = app let article = app
.clone() .clone()
.oneshot( .oneshot(
@@ -1971,6 +1987,7 @@ mod tests {
assert!(article.contains("referrerpolicy=\"no-referrer\"")); assert!(article.contains("referrerpolicy=\"no-referrer\""));
assert!(article.contains("A Niche Delight")); assert!(article.contains("A Niche Delight"));
assert!(article.contains("rel=\"next\"")); assert!(article.contains("rel=\"next\""));
assert!(!article.contains(&dashboard_href));
let world = app let world = app
.clone() .clone()
@@ -2156,19 +2173,19 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn downloads_are_listed_only_while_the_files_exist() { async fn single_nonstandard_download_renders_as_the_primary_without_a_menu() {
let (dir, db, source) = seeded_issue(true).await; let (dir, db, source) = seeded_issue(true).await;
crate::web::users::add(&db, "reader", "correct horse battery", false) crate::web::users::add(&db, "reader", "correct horse battery", false)
.await .await
.unwrap(); .unwrap();
let epub_dir = dir.path().join("epubs"); let epub_dir = dir.path().join("epubs");
std::fs::create_dir(&epub_dir).unwrap(); std::fs::create_dir(&epub_dir).unwrap();
let standard = epub_dir.join(crate::publish::issue_filename( let x4 = epub_dir.join(crate::publish::issue_filename(
source.meta.date, source.meta.date,
Edition::Standard, Edition::X4,
"epub", "epub",
)); ));
std::fs::write(&standard, vec![0; 2 * 1024]).unwrap(); std::fs::write(&x4, vec![0; 2 * 1024]).unwrap();
let mut config = crate::config::Config::default(); let mut config = crate::config::Config::default();
config.publish.epub_dir = epub_dir; config.publish.epub_dir = epub_dir;
let app = crate::server::router(crate::server::AppState::new(db, config, None)); let app = crate::server::router(crate::server::AppState::new(db, config, None));
@@ -2184,11 +2201,79 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let issue = response_text(issue).await; let issue = response_text(issue).await;
assert!(issue.contains("Download EPUB")); assert!(issue.contains(">Download X4 EPUB</a>"));
assert!(issue.contains("2.0 KB"));
assert!(!issue.contains("2048 bytes")); assert!(!issue.contains("2048 bytes"));
assert!(!issue.contains("Download X4 EPUB")); assert!(!issue.contains(">Download EPUB</a>"));
assert!(!issue.contains("Download XTC")); assert!(!issue.contains("Download XTC"));
assert!(!issue.contains("Choose download format"));
}
#[tokio::test]
async fn download_menu_lists_all_formats_with_standard_first() {
let (dir, db, source) = seeded_issue(true).await;
crate::web::users::add(&db, "reader", "correct horse battery", false)
.await
.unwrap();
let epub_dir = dir.path().join("epubs");
let xtc_dir = dir.path().join("xtc");
std::fs::create_dir(&epub_dir).unwrap();
std::fs::create_dir(&xtc_dir).unwrap();
let standard = epub_dir.join(crate::publish::issue_filename(
source.meta.date,
Edition::Standard,
"epub",
));
let x4 = epub_dir.join(crate::publish::issue_filename(
source.meta.date,
Edition::X4,
"epub",
));
let xtc = xtc_dir.join("issue.xtc");
std::fs::write(&standard, vec![0; 2 * 1024]).unwrap();
std::fs::write(&x4, vec![0; 3 * 1024]).unwrap();
std::fs::write(&xtc, vec![0; 4 * 1024]).unwrap();
sqlx::query("UPDATE issues SET xtc_path = ? WHERE date = ?")
.bind(xtc.to_string_lossy().as_ref())
.bind(source.meta.date.to_string())
.execute(db.pool())
.await
.unwrap();
let mut config = crate::config::Config::default();
config.publish.epub_dir = epub_dir;
let loaded = load(&db, &config, source.meta.date).await.unwrap().unwrap();
let downloads = loaded.downloads.as_ref().unwrap();
assert_eq!(downloads.primary.label, "EPUB");
assert_eq!(
downloads
.others
.iter()
.map(|download| download.label.as_str())
.collect::<Vec<_>>(),
["X4 EPUB", "XTC"]
);
let app = crate::server::router(crate::server::AppState::new(db, config, None));
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
let issue = app
.oneshot(
Request::builder()
.uri(format!("/issues/{}", source.meta.date))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let issue = response_text(issue).await;
assert!(issue.contains(">Download EPUB</a>"));
assert!(issue.contains("Choose download format"));
assert!(issue.contains("Standard EPUB"));
assert!(issue.contains("X4 EPUB"));
assert!(issue.contains("XTC"));
assert!(issue.contains("2.0 KB"));
assert!(issue.contains("3.0 KB"));
assert!(issue.contains("4.0 KB"));
} }
#[tokio::test] #[tokio::test]
@@ -2423,6 +2508,7 @@ mod tests {
let admin_cookie = login_cookie(&app, "admin", "correct horse battery").await; let admin_cookie = login_cookie(&app, "admin", "correct horse battery").await;
let reader_cookie = login_cookie(&app, "reader", "correct horse battery").await; let reader_cookie = login_cookie(&app, "reader", "correct horse battery").await;
let article_id = source.lineup.picks[0].article.id; let article_id = source.lineup.picks[0].article.id;
let dashboard_href = format!("/dashboard/articles/{article_id}");
let json_response = app let json_response = app
.clone() .clone()
@@ -2474,6 +2560,24 @@ mod tests {
let admin_issue = response_text(admin_issue).await; let admin_issue = response_text(admin_issue).await;
assert!(admin_issue.contains("Was this a good pick?")); assert!(admin_issue.contains("Was this a good pick?"));
assert!(admin_issue.contains("value=\"loved\" data-label=\"loved\" class=\"active\"")); assert!(admin_issue.contains("value=\"loved\" data-label=\"loved\" class=\"active\""));
assert!(admin_issue.contains(&dashboard_href));
let admin_article = app
.clone()
.oneshot(
Request::builder()
.uri(format!(
"/issues/{}/articles/{article_id}",
source.meta.date
))
.header(header::COOKIE, &admin_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(admin_article.status(), StatusCode::OK);
assert!(response_text(admin_article).await.contains(&dashboard_href));
let admin_behind = app let admin_behind = app
.clone() .clone()
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -3,6 +3,6 @@
<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> <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> <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 %} {% 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 %}
<footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6"><a class="btn" href="{{ read_online_url }}">Read online ↗</a></p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer> <footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6 flex flex-wrap gap-2"><a class="btn" href="{{ read_online_url }}">Read online ↗</a>{% if page.is_admin() %}<a class="btn" href="{{ dashboard_href }}">Dashboard</a>{% endif %}</p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer>
</article> </article>
</div>{% endblock %} </div>{% endblock %}
+2 -2
View File
@@ -3,9 +3,9 @@
<article class="reader-page 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> <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" 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> <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 read_href.is_some() || !downloads.is_empty() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% 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 %} {% if read_href.is_some() || downloads.is_some() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% match downloads %}{% when Some with (downloads) %}{% if downloads.others.is_empty() %}<a class="btn" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a>{% else %}<div class="relative inline-flex"><a class="btn rounded-r-none" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a><details class="-ml-px"><summary class="btn list-none rounded-l-none px-2 [&::-webkit-details-marker]:hidden" aria-label="Choose download format"><span aria-hidden="true">▾</span></summary><div class="absolute left-0 top-full z-20 mt-1 min-w-52 border border-rule bg-paper p-1 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)]"><a class="btn group w-full justify-between border-0" href="{{ downloads.primary.href }}"><span>{% if downloads.primary.label == "EPUB" %}Standard {% endif %}{{ downloads.primary.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ downloads.primary.size }}</span></a>{% for download in downloads.others %}<a class="btn group w-full justify-between border-0" href="{{ download.href }}"><span>{{ download.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ download.size }}</span></a>{% endfor %}</div></details></div>{% endif %}{% when None %}{% endmatch %}</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> <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" 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 %} {% 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{% if page.is_admin() %} · <a class="text-muted hover:text-accent" href="{{ entry.dashboard_href }}">dashboard</a>{% endif %}</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> </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 %} {% 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" 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> <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>