Web dashboard v2 step 4: table-of-contents sidebar
Signed-in issue pages (issue, article, world, behind) get a chapter list that marks where the reader is. One `issue_toc` helper builds it for all four handlers: the picks in issue order numbered across sections, then World Briefing, Behind the paper and a colophon anchor, with position/total over the navigable chapters. At `lg` and up it is a sticky left column with its own sticky header (issue number, "Chapter N of M", a 2px progress bar); below `lg` the same `<nav>` collapses behind a sticky hamburger bar and drops down as a panel that closes on link tap, Escape and outside tap. Without JS the panel is simply visible; `theme.js` marks the document scripted before paint so it never flashes open. Article pages advance the progress bar with scroll position through `requestAnimationFrame`, and the bar is a `<progress>` element because a percentage width would need an inline style the CSP forbids. Also fixes the phone "ears" row from the step 2 review: the row no longer wraps at 390px, the dateline has a short form below `sm:`, and the toggle and account link stay on one line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
@@ -0,0 +1,151 @@
|
|||||||
|
# Step 4 handoff — table-of-contents sidebar
|
||||||
|
|
||||||
|
Branch `v2-toc`, worktree `/home/thallada/workspace/the-daily-epub-v2d`, one commit.
|
||||||
|
Builds on step 2's design system (`handoff-step2.md`) and step 1's legacy fallback
|
||||||
|
(`handoff-step1.md`). Also folds in one reader-side fix from the step-2 review: the
|
||||||
|
phone "ears" row.
|
||||||
|
|
||||||
|
## Landed
|
||||||
|
|
||||||
|
### Data (`src/web/issue.rs`)
|
||||||
|
|
||||||
|
- `issue_toc(&IssueView, TocPosition) -> Toc`, one helper feeding all four signed-in
|
||||||
|
handlers (`render_full`, `article`, `world`, `behind`) as the template field `toc`.
|
||||||
|
- `TocPosition` (`FrontPage`, `Article(ArticleId)`, `World`, `Behind`) and `TocKind`
|
||||||
|
(`Brief`, `Section`, `Chapter`, `World`, `Behind`, `Colophon`) as the brief specifies.
|
||||||
|
- Chapters are the picks in `chapters::section_names` × `Lineup::section_picks` order —
|
||||||
|
the same order the issue index and the EPUB use — numbered `1..n` across sections with
|
||||||
|
the section names interleaved as non-links. World Briefing and Behind the paper follow
|
||||||
|
when available (`world_briefing.is_some() || world_html.is_some()`, and `has_behind`,
|
||||||
|
both as computed after step 1), then a `#colophon` anchor on the issue page.
|
||||||
|
- `position` / `total` count navigable chapters only (articles + world + behind), so an
|
||||||
|
article page says "Chapter 4 of 11"; the issue page has `position == 0` and reads
|
||||||
|
"Front page".
|
||||||
|
- New small helpers: `issue_href(date)` (replaces four inline `format!`s) and
|
||||||
|
`crate::pipeline::short_display_date(date)` → `"Wed, Sep 2"`.
|
||||||
|
|
||||||
|
### Markup (`src/web/templates/_toc.html`, included by the four reader templates)
|
||||||
|
|
||||||
|
- One `<nav id="toc" data-toc-panel>` for both breakpoints, wrapped with the article in a
|
||||||
|
`lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[18rem_minmax(0,1fr)]` shell
|
||||||
|
inside `max-w-7xl`. The reading column keeps `max-w-[68ch]`; at `lg` (1024px viewport)
|
||||||
|
it still gets ~680px, comfortably above the ~60ch floor the brief asks for.
|
||||||
|
- Desktop: `lg:sticky lg:top-6 lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto`, thin
|
||||||
|
scrollbar. Header (issue number + short date linking to the issue, "Chapter N of M",
|
||||||
|
2px progress bar) is `lg:sticky lg:top-0` **inside** the scrollable nav, so it stays put
|
||||||
|
while the chapter list scrolls under it. Section labels are tracked uppercase sans;
|
||||||
|
chapters are numbered Newsreader `text-[0.95rem]` links with the read time in muted
|
||||||
|
sans; the current one is bold with a 2px accent bar and `aria-current="page"`. World
|
||||||
|
Briefing / Behind the paper / Colophon follow a hairline.
|
||||||
|
- Below `lg`: a sticky bar (`sticky top-0 z-30` on the wrapper, `lg:static`) with the
|
||||||
|
hamburger `<button data-toc-toggle aria-controls="toc" aria-expanded>`, the current
|
||||||
|
chapter title truncated, "4 / 11", and the progress bar as its bottom edge. The same
|
||||||
|
`<nav>` drops under it as an absolutely positioned panel.
|
||||||
|
- No inline `<style>`/`<script>` and no `style=""`; every hook step 2 listed is intact.
|
||||||
|
|
||||||
|
### Behaviour (`src/web/static/app.js`, `theme.js`)
|
||||||
|
|
||||||
|
- The panel toggles by `data-open` on the nav; `@media (width < 64rem) .has-js
|
||||||
|
[data-toc-panel]:not([data-open="true"]) { display:none }` in `tailwind.css`. `theme.js`
|
||||||
|
(synchronous, in `<head>`) adds `has-js` to `<html>`, so **without JS the panel is simply
|
||||||
|
visible** under the bar and **with JS there is no open-then-collapse flash**.
|
||||||
|
- Closes on link tap, on Escape (focus returns to the toggle), on a tap outside, and when
|
||||||
|
crossing to `lg`. Body scroll is never locked.
|
||||||
|
- `revealCurrent()` scrolls the panel — never the page — the minimum needed to bring the
|
||||||
|
current chapter into view, on load and on open. Long issues no longer hide the reader's
|
||||||
|
position behind the fold.
|
||||||
|
- Article pages (`<article data-toc-scroll>`) update every `[data-toc-progress]` with
|
||||||
|
`position - 1 + scrolled fraction`, throttled through `requestAnimationFrame` on a
|
||||||
|
passive scroll listener.
|
||||||
|
|
||||||
|
### Progress bar and CSP
|
||||||
|
|
||||||
|
The bar is a real `<progress class="toc-progress">`: a percentage width would need a
|
||||||
|
`style=""` attribute, which the CSP forbids, and Tailwind cannot generate a class for a
|
||||||
|
runtime number. It is styled in `@layer components` (2px, `--rule` track, `--accent`
|
||||||
|
value) and marked `aria-hidden="true"` because the adjacent text already says
|
||||||
|
"Chapter N of M". Reduced motion: the 150ms transition on the value is dropped both under
|
||||||
|
`prefers-reduced-motion: reduce` and while JS drives the bar (`[data-live]`), so scrolling
|
||||||
|
never animates.
|
||||||
|
|
||||||
|
### Ears row (step-2 review follow-up)
|
||||||
|
|
||||||
|
`layout.html`: the row is `flex-nowrap` with `gap-2 sm:gap-4`; the left cell is
|
||||||
|
`min-w-0 truncate whitespace-nowrap`; the right group and the theme toggle are
|
||||||
|
`shrink-0 whitespace-nowrap`; "Sign in" is `whitespace-nowrap` and a long username is
|
||||||
|
`max-w-32 truncate`. The four issue pages and `issue_public.html` now show
|
||||||
|
`short_display_date` below `sm:` and the full dateline + issue number above it, so at
|
||||||
|
390px the whole row is one line for anonymous, reader and admin.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `cargo test` — **478 passed, 0 failed** (442 lib + 9 bin + 2 + 3 + 4 + 7 + 9 + 2
|
||||||
|
integration, 0 doc-tests). Run outside the Codex sandbox, so the socket-binding tests
|
||||||
|
ran too.
|
||||||
|
- New: `web::issue::tests::toc_numbers_chapters_across_sections_and_tracks_the_reader`
|
||||||
|
(unit — full item shape across two sections, `total == 4`, `position` for front page /
|
||||||
|
article / world / behind, the single back-matter divider, `current_label`, short date)
|
||||||
|
and `web::issue::tests::the_sidebar_marks_the_current_chapter_on_every_signed_in_issue_page`
|
||||||
|
(router — all four pages carry the bar and panel, the right "Chapter N of M", the right
|
||||||
|
`aria-current="page"` href and the colophon anchor; the anonymous issue page has none).
|
||||||
|
- Extended: the public-page test now asserts no `data-toc-toggle` / `id="toc"` leaks; the
|
||||||
|
legacy tests assert the sidebar offers World Briefing and Behind the paper, and that it
|
||||||
|
omits World when there is no EPUB to recover it from.
|
||||||
|
- Browser checks (Playwright, `shoot/toc-behaviour.mjs`): 14/14 — collapsed on load,
|
||||||
|
`aria-expanded`/`aria-controls`, open on tap, close on Escape with focus return, close on
|
||||||
|
outside tap, close + navigate on link tap, `aria-current` after navigation, desktop panel
|
||||||
|
always visible with the bar hidden, progress 4 → 5 on scrolling a chapter, no toc on the
|
||||||
|
public page, no console errors.
|
||||||
|
- `cargo fmt`, `cargo clippy --all-targets -- -D warnings`, `npm run css`,
|
||||||
|
`npm run css:check` — all clean.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
- **`TocItem` has a seventh field, `divider: bool`.** The brief's hairline "after the
|
||||||
|
chapters" cannot be expressed in askama without knowing which entry starts the
|
||||||
|
back-matter group; the helper marks it instead. Everything else matches the brief's
|
||||||
|
shapes. `Toc` also carries `short_date` (for the header and the ears row) and gains one
|
||||||
|
method, `current_label()`, for the mobile bar.
|
||||||
|
- **The toggled element is the `<nav id="toc">` itself**, and the issue header lives inside
|
||||||
|
it (hidden below `lg`, where the sticky bar already shows the same information). There is
|
||||||
|
still exactly one copy of the list, as the brief requires.
|
||||||
|
- **The progress bar is a `<progress>` element**, not a div with a percentage width — see
|
||||||
|
above; a width would need an inline style.
|
||||||
|
- **Visibility is a CSS rule keyed on `.has-js` + `data-open`**, not the `hidden`
|
||||||
|
attribute: `[hidden]` would also hide the desktop sidebar, and a server-rendered `hidden`
|
||||||
|
would break the no-JS case.
|
||||||
|
- **`theme.js` gained one line** (`classList.add("has-js")`). It is the only script that
|
||||||
|
runs before paint, and without it the mobile panel flashes open on every load.
|
||||||
|
- Article top margins were normalised to `mt-10 lg:mt-12` across the four pages so the
|
||||||
|
sidebar and the reading column start on the same line.
|
||||||
|
- `web::public::PublicIssue` gained `short_date` (no private data; the public template
|
||||||
|
needed the phone dateline).
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
`/tmp/claude-1000/-home-thallada-workspace-the-daily-epub/e963db53-510f-4312-ac87-460291a92781/scratchpad/step4-shots/`
|
||||||
|
— 137 PNGs. Eight paths (`/issues/2026-09-02`, `…/articles/1`, `…/articles/5` — mid-issue,
|
||||||
|
`…/world`, `…/behind`, and the legacy `/issues/2026-09-01` with its `/world` and `/behind`)
|
||||||
|
× {reader, admin} × {light, dark} × {1280, 1024, 390 mobile closed, 390 mobile open}, named
|
||||||
|
`<path>-<user>-<theme>-<width|mobile>[-open].png`. Plus `-390-ears` crops of the phone ears
|
||||||
|
row for anonymous, reader and admin, `-1280-mid` (scrolled, progress bar advanced) and
|
||||||
|
`-1280-tall` (1500px, the whole sidebar including the back-matter group).
|
||||||
|
|
||||||
|
The Playwright helper is `shoot/shoot4.mjs` (adds `--width/--height`, `--click`, `--scroll`
|
||||||
|
and `--tag` to step 2's `shoot.mjs`, and aborts loudly if the sign-in is throttled).
|
||||||
|
|
||||||
|
## Notes for whoever runs the dev server next
|
||||||
|
|
||||||
|
- `src/web/static/app.css`, `app.js`, `theme.js` and the templates are all compiled into
|
||||||
|
the binary (`include_str!` / askama), so **the server must be rebuilt and restarted after
|
||||||
|
every CSS or template edit** — a browser reload alone shows the old assets.
|
||||||
|
- `server.login_attempts` defaults to 10 per 15 minutes per IP. A screenshot sweep signs in
|
||||||
|
once per invocation and trips the throttle after ten runs, which silently yields
|
||||||
|
screenshots of the sign-in page. The throwaway `./dev/config.toml` was given
|
||||||
|
`login_attempts = 2000`; `dev/` is git-ignored and `seed_dev_db` rewrites it.
|
||||||
|
|
||||||
|
## Left for later
|
||||||
|
|
||||||
|
- Nothing from step 4. On a very long issue the sidebar is taller than the viewport at
|
||||||
|
1280×900, so the back-matter group needs a scroll of the sidebar (by design — the brief's
|
||||||
|
`max-h`); `revealCurrent` handles the reader's own position.
|
||||||
@@ -139,6 +139,17 @@ pub fn display_date(date: Date) -> String {
|
|||||||
format!("{weekday}, {month} {}, {}", date.day(), date.year())
|
format!("{weekday}, {month} {}, {}", date.day(), date.year())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "Fri, Aug 15" — the same dateline abbreviated for narrow screens (web only).
|
||||||
|
pub fn short_display_date(date: Date) -> String {
|
||||||
|
const WEEKDAYS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
const MONTHS: [&str; 12] = [
|
||||||
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||||
|
];
|
||||||
|
let weekday = WEEKDAYS[(date.weekday().to_monday_zero_offset() as usize).min(6)];
|
||||||
|
let month = MONTHS[(date.month() as usize).clamp(1, 12) - 1];
|
||||||
|
format!("{weekday}, {month} {}", date.day())
|
||||||
|
}
|
||||||
|
|
||||||
/// Materialize the [`Issue`] the EPUB builder consumes (§3.10).
|
/// Materialize the [`Issue`] the EPUB builder consumes (§3.10).
|
||||||
///
|
///
|
||||||
/// Pure: every count is derived from the lineup, so the same inputs always give
|
/// Pure: every count is derived from the lineup, so the same inputs always give
|
||||||
|
|||||||
+320
-4
@@ -633,6 +633,165 @@ fn format_file_size(bytes: u64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the reader is inside the issue, for [`issue_toc`].
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TocPosition {
|
||||||
|
FrontPage,
|
||||||
|
Article(ArticleId),
|
||||||
|
World,
|
||||||
|
Behind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum TocKind {
|
||||||
|
Brief,
|
||||||
|
Section,
|
||||||
|
Chapter,
|
||||||
|
World,
|
||||||
|
Behind,
|
||||||
|
Colophon,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct TocItem {
|
||||||
|
kind: TocKind,
|
||||||
|
label: String,
|
||||||
|
href: String,
|
||||||
|
number: Option<usize>,
|
||||||
|
minutes: Option<i64>,
|
||||||
|
current: bool,
|
||||||
|
/// First entry of the back-matter group; the template draws a hairline above it.
|
||||||
|
divider: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TocItem {
|
||||||
|
fn is_section(&self) -> bool {
|
||||||
|
matches!(self.kind, TocKind::Section)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The table of contents shared by the four signed-in issue pages.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct Toc {
|
||||||
|
display_date: String,
|
||||||
|
short_date: String,
|
||||||
|
issue_number: i64,
|
||||||
|
items: Vec<TocItem>,
|
||||||
|
/// 1-based index of the current chapter among the navigable ones; 0 on the issue page.
|
||||||
|
position: usize,
|
||||||
|
/// Navigable chapters: articles plus World Briefing and Behind the paper.
|
||||||
|
total: usize,
|
||||||
|
issue_href: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Toc {
|
||||||
|
/// Title shown next to the hamburger on narrow screens.
|
||||||
|
fn current_label(&self) -> &str {
|
||||||
|
self.items
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.current)
|
||||||
|
.map_or("Front page", |item| item.label.as_str())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the chapter list for `view`, marking `current`.
|
||||||
|
///
|
||||||
|
/// Chapters are the picks in issue order, numbered `1..n` across sections, with
|
||||||
|
/// the section names interleaved as non-links; the World Briefing and Behind the
|
||||||
|
/// paper chapters follow when the issue has them, then the colophon anchor.
|
||||||
|
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 {
|
||||||
|
kind,
|
||||||
|
label: label.to_string(),
|
||||||
|
href,
|
||||||
|
number: None,
|
||||||
|
minutes: None,
|
||||||
|
current,
|
||||||
|
divider,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut items = vec![plain(
|
||||||
|
TocKind::Brief,
|
||||||
|
"The Brief",
|
||||||
|
issue_href.clone(),
|
||||||
|
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));
|
||||||
|
for pick in issue.lineup.section_picks(&name) {
|
||||||
|
total += 1;
|
||||||
|
let is_current = current == TocPosition::Article(pick.article.id);
|
||||||
|
if is_current {
|
||||||
|
position = total;
|
||||||
|
}
|
||||||
|
items.push(TocItem {
|
||||||
|
kind: TocKind::Chapter,
|
||||||
|
label: pick.article.title.clone(),
|
||||||
|
href: article_href(date, pick.article.id),
|
||||||
|
number: Some(total),
|
||||||
|
minutes: Some(pick.article.reading_minutes()),
|
||||||
|
current: is_current,
|
||||||
|
divider: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut divider = true;
|
||||||
|
if issue.world_briefing.is_some() || view.world_html.is_some() {
|
||||||
|
total += 1;
|
||||||
|
let is_current = current == TocPosition::World;
|
||||||
|
if is_current {
|
||||||
|
position = total;
|
||||||
|
}
|
||||||
|
items.push(plain(
|
||||||
|
TocKind::World,
|
||||||
|
"World Briefing",
|
||||||
|
format!("/issues/{date}/world"),
|
||||||
|
is_current,
|
||||||
|
divider,
|
||||||
|
));
|
||||||
|
divider = false;
|
||||||
|
}
|
||||||
|
if view.has_behind {
|
||||||
|
total += 1;
|
||||||
|
let is_current = current == TocPosition::Behind;
|
||||||
|
if is_current {
|
||||||
|
position = total;
|
||||||
|
}
|
||||||
|
items.push(plain(
|
||||||
|
TocKind::Behind,
|
||||||
|
"Behind the paper",
|
||||||
|
format!("/issues/{date}/behind"),
|
||||||
|
is_current,
|
||||||
|
divider,
|
||||||
|
));
|
||||||
|
divider = false;
|
||||||
|
}
|
||||||
|
items.push(plain(
|
||||||
|
TocKind::Colophon,
|
||||||
|
"Colophon",
|
||||||
|
format!("{issue_href}#colophon"),
|
||||||
|
false,
|
||||||
|
divider,
|
||||||
|
));
|
||||||
|
|
||||||
|
Toc {
|
||||||
|
display_date: issue.meta.display_date.clone(),
|
||||||
|
short_date: crate::pipeline::short_display_date(date),
|
||||||
|
issue_number: issue.meta.issue_number,
|
||||||
|
items,
|
||||||
|
position,
|
||||||
|
total,
|
||||||
|
issue_href,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct FullEntry {
|
struct FullEntry {
|
||||||
title: String,
|
title: String,
|
||||||
@@ -679,6 +838,7 @@ struct ColophonView {
|
|||||||
#[template(path = "issue_full.html")]
|
#[template(path = "issue_full.html")]
|
||||||
struct IssueFullTemplate {
|
struct IssueFullTemplate {
|
||||||
page: Page,
|
page: Page,
|
||||||
|
toc: Toc,
|
||||||
display_date: String,
|
display_date: String,
|
||||||
issue_number: i64,
|
issue_number: i64,
|
||||||
stats_line: String,
|
stats_line: String,
|
||||||
@@ -701,6 +861,7 @@ struct ArticleLink {
|
|||||||
#[template(path = "article.html")]
|
#[template(path = "article.html")]
|
||||||
struct ArticleTemplate {
|
struct ArticleTemplate {
|
||||||
page: Page,
|
page: Page,
|
||||||
|
toc: Toc,
|
||||||
title: String,
|
title: String,
|
||||||
source_url: String,
|
source_url: String,
|
||||||
byline: Option<String>,
|
byline: Option<String>,
|
||||||
@@ -722,6 +883,7 @@ struct ArticleTemplate {
|
|||||||
#[template(path = "world.html")]
|
#[template(path = "world.html")]
|
||||||
struct WorldTemplate {
|
struct WorldTemplate {
|
||||||
page: Page,
|
page: Page,
|
||||||
|
toc: Toc,
|
||||||
display_date: Option<String>,
|
display_date: Option<String>,
|
||||||
body_html: String,
|
body_html: String,
|
||||||
issue_href: String,
|
issue_href: String,
|
||||||
@@ -737,6 +899,7 @@ struct NearMissView {
|
|||||||
#[template(path = "behind.html")]
|
#[template(path = "behind.html")]
|
||||||
struct BehindTemplate {
|
struct BehindTemplate {
|
||||||
page: Page,
|
page: Page,
|
||||||
|
toc: Toc,
|
||||||
summary_line: String,
|
summary_line: String,
|
||||||
admitted_line: String,
|
admitted_line: String,
|
||||||
learned_line: String,
|
learned_line: String,
|
||||||
@@ -758,7 +921,8 @@ pub async fn render_full(
|
|||||||
} else {
|
} else {
|
||||||
HashMap::new()
|
HashMap::new()
|
||||||
};
|
};
|
||||||
let issue_href = format!("/issues/{date}");
|
let issue_href = issue_href(date);
|
||||||
|
let toc = issue_toc(&view, TocPosition::FrontPage);
|
||||||
let sections = chapters::section_names(&view.issue)
|
let sections = chapters::section_names(&view.issue)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|name| FullSection {
|
.map(|name| FullSection {
|
||||||
@@ -795,6 +959,7 @@ pub async fn render_full(
|
|||||||
page.flash = take_flash(session).await?;
|
page.flash = take_flash(session).await?;
|
||||||
Ok(Html(IssueFullTemplate {
|
Ok(Html(IssueFullTemplate {
|
||||||
page,
|
page,
|
||||||
|
toc,
|
||||||
display_date: view.issue.meta.display_date.clone(),
|
display_date: view.issue.meta.display_date.clone(),
|
||||||
issue_number: view.issue.meta.issue_number,
|
issue_number: view.issue.meta.issue_number,
|
||||||
stats_line: view.issue.meta.stats_line(),
|
stats_line: view.issue.meta.stats_line(),
|
||||||
@@ -834,6 +999,7 @@ pub async fn article(
|
|||||||
else {
|
else {
|
||||||
return Err(WebError::NotFound);
|
return Err(WebError::NotFound);
|
||||||
};
|
};
|
||||||
|
let toc = issue_toc(&view, TocPosition::Article(article_id));
|
||||||
let pick = &view.issue.lineup.picks[index];
|
let pick = &view.issue.lineup.picks[index];
|
||||||
let current = if viewer.role == crate::web::users::Role::Admin {
|
let current = if viewer.role == crate::web::users::Role::Admin {
|
||||||
rate::current_for_issue(&state, date).await?
|
rate::current_for_issue(&state, date).await?
|
||||||
@@ -861,6 +1027,7 @@ pub async fn article(
|
|||||||
page.flash = take_flash(&session).await?;
|
page.flash = take_flash(&session).await?;
|
||||||
Ok(Html(ArticleTemplate {
|
Ok(Html(ArticleTemplate {
|
||||||
page,
|
page,
|
||||||
|
toc,
|
||||||
title: article.title.clone(),
|
title: article.title.clone(),
|
||||||
source_url: article.canonical_url.clone(),
|
source_url: article.canonical_url.clone(),
|
||||||
byline: article.author.as_ref().map(|author| format!("By {author}")),
|
byline: article.author.as_ref().map(|author| format!("By {author}")),
|
||||||
@@ -890,7 +1057,7 @@ pub async fn article(
|
|||||||
}),
|
}),
|
||||||
previous,
|
previous,
|
||||||
next,
|
next,
|
||||||
issue_href: format!("/issues/{date}"),
|
issue_href: issue_href(date),
|
||||||
})
|
})
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
@@ -911,6 +1078,7 @@ pub async fn world(
|
|||||||
let Some(view) = load(&state.db, &state.config(), date).await? else {
|
let Some(view) = load(&state.db, &state.config(), date).await? else {
|
||||||
return Err(WebError::NotFound);
|
return Err(WebError::NotFound);
|
||||||
};
|
};
|
||||||
|
let toc = issue_toc(&view, TocPosition::World);
|
||||||
let (display_date, body_html) = if let Some(briefing) = view.issue.world_briefing {
|
let (display_date, body_html) = if let Some(briefing) = view.issue.world_briefing {
|
||||||
(
|
(
|
||||||
Some(display_date(briefing.date)),
|
Some(display_date(briefing.date)),
|
||||||
@@ -925,9 +1093,10 @@ pub async fn world(
|
|||||||
page.flash = take_flash(&session).await?;
|
page.flash = take_flash(&session).await?;
|
||||||
Ok(Html(WorldTemplate {
|
Ok(Html(WorldTemplate {
|
||||||
page,
|
page,
|
||||||
|
toc,
|
||||||
display_date,
|
display_date,
|
||||||
body_html,
|
body_html,
|
||||||
issue_href: format!("/issues/{date}"),
|
issue_href: issue_href(date),
|
||||||
})
|
})
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
@@ -951,11 +1120,13 @@ pub async fn behind(
|
|||||||
if !view.has_behind {
|
if !view.has_behind {
|
||||||
return Err(WebError::NotFound);
|
return Err(WebError::NotFound);
|
||||||
}
|
}
|
||||||
|
let toc = issue_toc(&view, TocPosition::Behind);
|
||||||
let behind = &view.issue.behind;
|
let behind = &view.issue.behind;
|
||||||
let mut page = Page::new("Behind the paper", Some(viewer), "latest");
|
let mut page = Page::new("Behind the paper", Some(viewer), "latest");
|
||||||
page.flash = take_flash(&session).await?;
|
page.flash = take_flash(&session).await?;
|
||||||
Ok(Html(BehindTemplate {
|
Ok(Html(BehindTemplate {
|
||||||
page,
|
page,
|
||||||
|
toc,
|
||||||
summary_line: chapters::behind_summary_line(behind),
|
summary_line: chapters::behind_summary_line(behind),
|
||||||
admitted_line: chapters::behind_admitted_line(behind),
|
admitted_line: chapters::behind_admitted_line(behind),
|
||||||
learned_line: chapters::behind_learned_line(behind),
|
learned_line: chapters::behind_learned_line(behind),
|
||||||
@@ -968,7 +1139,7 @@ pub async fn behind(
|
|||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
models_line: chapters::behind_models_line(behind),
|
models_line: chapters::behind_models_line(behind),
|
||||||
issue_href: format!("/issues/{date}"),
|
issue_href: issue_href(date),
|
||||||
})
|
})
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
@@ -977,6 +1148,10 @@ fn article_href(date: Date, article_id: ArticleId) -> String {
|
|||||||
format!("/issues/{date}/articles/{article_id}")
|
format!("/issues/{date}/articles/{article_id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn issue_href(date: Date) -> String {
|
||||||
|
format!("/issues/{date}")
|
||||||
|
}
|
||||||
|
|
||||||
fn summary_for<'a>(issue: &'a Issue, pick: &'a Pick) -> Option<&'a str> {
|
fn summary_for<'a>(issue: &'a Issue, pick: &'a Pick) -> Option<&'a str> {
|
||||||
pick.summary
|
pick.summary
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -1399,6 +1574,9 @@ mod tests {
|
|||||||
assert!(!html.contains("Something happened"));
|
assert!(!html.contains("Something happened"));
|
||||||
assert!(!html.contains("Body of"));
|
assert!(!html.contains("Body of"));
|
||||||
assert!(!html.contains("write path"));
|
assert!(!html.contains("write path"));
|
||||||
|
// The table of contents is a signed-in feature.
|
||||||
|
assert!(!html.contains("data-toc-toggle"));
|
||||||
|
assert!(!html.contains("id=\"toc\""));
|
||||||
|
|
||||||
let archive = app
|
let archive = app
|
||||||
.clone()
|
.clone()
|
||||||
@@ -1630,6 +1808,15 @@ mod tests {
|
|||||||
assert!(issue.contains(&format!("/issues/{}/world", source.meta.date)));
|
assert!(issue.contains(&format!("/issues/{}/world", source.meta.date)));
|
||||||
assert!(issue.contains(&format!("/issues/{}/behind", source.meta.date)));
|
assert!(issue.contains(&format!("/issues/{}/behind", source.meta.date)));
|
||||||
assert!(issue.contains("431 from 92 feeds"));
|
assert!(issue.contains("431 from 92 feeds"));
|
||||||
|
// The recovered chapters are offered by the sidebar as well as the page.
|
||||||
|
assert!(issue.contains("data-toc-toggle"));
|
||||||
|
assert_eq!(
|
||||||
|
issue
|
||||||
|
.matches(&format!("/issues/{}/world", source.meta.date))
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert!(issue.contains("Front page"));
|
||||||
assert!(issue.contains("deepseek-v4-flash"));
|
assert!(issue.contains("deepseek-v4-flash"));
|
||||||
assert!(issue.contains("claude-opus-5"));
|
assert!(issue.contains("claude-opus-5"));
|
||||||
assert!(!issue.contains("0 from 0 feeds"));
|
assert!(!issue.contains("0 from 0 feeds"));
|
||||||
@@ -1709,7 +1896,9 @@ mod tests {
|
|||||||
assert!(issue.contains("120"));
|
assert!(issue.contains("120"));
|
||||||
assert!(!issue.contains("0 from 0 feeds"));
|
assert!(!issue.contains("0 from 0 feeds"));
|
||||||
assert!(issue.contains(&format!("/issues/{}/behind", source.meta.date)));
|
assert!(issue.contains(&format!("/issues/{}/behind", source.meta.date)));
|
||||||
|
// No EPUB to recover the World Briefing from: the sidebar must not offer it.
|
||||||
assert!(!issue.contains(&format!("/issues/{}/world", source.meta.date)));
|
assert!(!issue.contains(&format!("/issues/{}/world", source.meta.date)));
|
||||||
|
assert!(issue.contains("Behind the paper"));
|
||||||
|
|
||||||
let behind = app
|
let behind = app
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -1940,6 +2129,133 @@ mod tests {
|
|||||||
assert_eq!(down, "not_for_me");
|
assert_eq!(down, "not_for_me");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn toc_numbers_chapters_across_sections_and_tracks_the_reader() {
|
||||||
|
let (_dir, db, source) = seeded_issue(true).await;
|
||||||
|
let view = load(&db, &crate::config::Config::default(), source.meta.date)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let front = issue_toc(&view, TocPosition::FrontPage);
|
||||||
|
let shape: Vec<(TocKind, Option<usize>, &str)> = front
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| (item.kind, item.number, item.label.as_str()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
shape,
|
||||||
|
vec![
|
||||||
|
(TocKind::Brief, None, "The Brief"),
|
||||||
|
(TocKind::Section, None, "Top Stories"),
|
||||||
|
(TocKind::Chapter, Some(1), "The Lead Story"),
|
||||||
|
(TocKind::Section, None, "Niche Corner"),
|
||||||
|
(TocKind::Chapter, Some(2), "A Niche Delight & Other Tales"),
|
||||||
|
(TocKind::World, None, "World Briefing"),
|
||||||
|
(TocKind::Behind, None, "Behind the paper"),
|
||||||
|
(TocKind::Colophon, None, "Colophon"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// Two articles plus the World Briefing and Behind the paper chapters.
|
||||||
|
assert_eq!(front.total, 4);
|
||||||
|
assert_eq!(front.position, 0);
|
||||||
|
assert_eq!(front.current_label(), "The Brief");
|
||||||
|
assert_eq!(front.short_date, "Sat, Aug 15");
|
||||||
|
// Exactly one hairline, above the first back-matter entry.
|
||||||
|
assert_eq!(
|
||||||
|
front
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| item.divider)
|
||||||
|
.map(|item| item.label.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["World Briefing"]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
front.items[2].minutes.is_some() && front.items[2].href.contains("/articles/"),
|
||||||
|
"chapters link to their article and carry a read time"
|
||||||
|
);
|
||||||
|
|
||||||
|
let second = issue_toc(
|
||||||
|
&view,
|
||||||
|
TocPosition::Article(view.issue.lineup.picks[1].article.id),
|
||||||
|
);
|
||||||
|
assert_eq!(second.position, 2);
|
||||||
|
assert_eq!(second.current_label(), "A Niche Delight & Other Tales");
|
||||||
|
assert_eq!(second.items.iter().filter(|item| item.current).count(), 1);
|
||||||
|
assert_eq!(issue_toc(&view, TocPosition::World).position, 3);
|
||||||
|
assert_eq!(issue_toc(&view, TocPosition::Behind).position, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_sidebar_marks_the_current_chapter_on_every_signed_in_issue_page() {
|
||||||
|
let (_dir, db, source) = seeded_issue(true).await;
|
||||||
|
crate::web::users::add(&db, "reader", "correct horse battery", false)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let app = crate::server::router(crate::server::AppState::new(
|
||||||
|
db,
|
||||||
|
crate::config::Config::default(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
let cookie = login_cookie(&app, "reader", "correct horse battery").await;
|
||||||
|
let date = source.meta.date;
|
||||||
|
let article_id = source.lineup.picks[1].article.id;
|
||||||
|
let cases = [
|
||||||
|
(format!("/issues/{date}"), "Front page", issue_href(date)),
|
||||||
|
(
|
||||||
|
article_href(date, article_id),
|
||||||
|
"Chapter 2 of 4",
|
||||||
|
article_href(date, article_id),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!("/issues/{date}/world"),
|
||||||
|
"Chapter 3 of 4",
|
||||||
|
format!("/issues/{date}/world"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
format!("/issues/{date}/behind"),
|
||||||
|
"Chapter 4 of 4",
|
||||||
|
format!("/issues/{date}/behind"),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (path, progress, current) in cases {
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(&path)
|
||||||
|
.header(header::COOKIE, &cookie)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK, "{path}");
|
||||||
|
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\"")),
|
||||||
|
"{path} does not mark {current} as current"
|
||||||
|
);
|
||||||
|
assert!(html.contains(&format!("{}#colophon", issue_href(date))));
|
||||||
|
assert!(html.contains("A Niche Delight & Other Tales"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let anonymous = app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.uri(format!("/issues/{date}"))
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(anonymous.status(), StatusCode::OK);
|
||||||
|
assert!(!response_text(anonymous).await.contains("data-toc-toggle"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn web_body_is_sanitized_and_images_get_browser_attributes() {
|
fn web_body_is_sanitized_and_images_get_browser_attributes() {
|
||||||
let body = prepare_body(
|
let body = prepare_body(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub struct PublicIssue {
|
|||||||
pub date: Date,
|
pub date: Date,
|
||||||
pub issue_number: i64,
|
pub issue_number: i64,
|
||||||
pub display_date: String,
|
pub display_date: String,
|
||||||
|
pub short_date: String,
|
||||||
pub article_count: i64,
|
pub article_count: i64,
|
||||||
pub reading_minutes: i64,
|
pub reading_minutes: i64,
|
||||||
pub stats_line: String,
|
pub stats_line: String,
|
||||||
@@ -129,6 +130,7 @@ impl From<&Issue> for PublicIssue {
|
|||||||
date: issue.meta.date,
|
date: issue.meta.date,
|
||||||
issue_number: issue.meta.issue_number,
|
issue_number: issue.meta.issue_number,
|
||||||
display_date: issue.meta.display_date.clone(),
|
display_date: issue.meta.display_date.clone(),
|
||||||
|
short_date: crate::pipeline::short_display_date(issue.meta.date),
|
||||||
article_count: issue.meta.article_count,
|
article_count: issue.meta.article_count,
|
||||||
reading_minutes: issue.meta.reading_minutes,
|
reading_minutes: issue.meta.reading_minutes,
|
||||||
stats_line: issue.meta.stats_line(),
|
stats_line: issue.meta.stats_line(),
|
||||||
@@ -359,6 +361,7 @@ fn empty_issue() -> PublicIssue {
|
|||||||
date: "1970-01-01".parse().expect("valid epoch date"),
|
date: "1970-01-01".parse().expect("valid epoch date"),
|
||||||
issue_number: 0,
|
issue_number: 0,
|
||||||
display_date: String::new(),
|
display_date: String::new(),
|
||||||
|
short_date: String::new(),
|
||||||
article_count: 0,
|
article_count: 0,
|
||||||
reading_minutes: 0,
|
reading_minutes: 0,
|
||||||
stats_line: String::new(),
|
stats_line: String::new(),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -111,3 +111,69 @@ document.querySelectorAll("[data-refresh]").forEach((element) => {
|
|||||||
const seconds = Number(element.dataset.refresh);
|
const seconds = Number(element.dataset.refresh);
|
||||||
if (seconds > 0) setTimeout(() => window.location.reload(), seconds * 1000);
|
if (seconds > 0) setTimeout(() => window.location.reload(), seconds * 1000);
|
||||||
});
|
});
|
||||||
|
/* step 4: table-of-contents panel (below `lg`) and reading-progress bar */
|
||||||
|
const tocPanel = document.querySelector("[data-toc-panel]");
|
||||||
|
const tocToggle = document.querySelector("[data-toc-toggle]");
|
||||||
|
if (tocPanel) {
|
||||||
|
// Long issues overflow the sidebar; scroll just enough to show where we are.
|
||||||
|
const revealCurrent = () => {
|
||||||
|
const current = tocPanel.querySelector("a[aria-current=page]");
|
||||||
|
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 setOpen = (open) => {
|
||||||
|
tocPanel.dataset.open = open ? "true" : "false";
|
||||||
|
if (tocToggle) tocToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
if (open) revealCurrent();
|
||||||
|
};
|
||||||
|
setOpen(false);
|
||||||
|
revealCurrent();
|
||||||
|
if (tocToggle) {
|
||||||
|
tocToggle.addEventListener("click", () => setOpen(tocPanel.dataset.open !== "true"));
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (tocPanel.dataset.open !== "true" || tocToggle.contains(event.target)) return;
|
||||||
|
// A tap on a chapter closes the panel; so does a tap anywhere outside it.
|
||||||
|
if (!tocPanel.contains(event.target) || event.target.closest("a")) setOpen(false);
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Escape" || tocPanel.dataset.open !== "true") return;
|
||||||
|
setOpen(false);
|
||||||
|
tocToggle.focus();
|
||||||
|
});
|
||||||
|
window.matchMedia("(min-width: 64rem)").addEventListener("change", () => setOpen(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const schedule = () => {
|
||||||
|
if (queued) return;
|
||||||
|
queued = true;
|
||||||
|
window.requestAnimationFrame(paint);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", schedule, { passive: true });
|
||||||
|
window.addEventListener("resize", schedule);
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
|
// Marks the document as scripted before first paint so progressively enhanced
|
||||||
|
// widgets (the contents panel) can start collapsed without a flash.
|
||||||
|
document.documentElement.classList.add("has-js");
|
||||||
try {
|
try {
|
||||||
const theme = localStorage.getItem("theme");
|
const theme = localStorage.getItem("theme");
|
||||||
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
|
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
|
||||||
|
|||||||
@@ -124,6 +124,19 @@
|
|||||||
.funnel > * { @apply my-0.5 min-w-px bg-good; }
|
.funnel > * { @apply my-0.5 min-w-px bg-good; }
|
||||||
.spark { @apply h-auto max-w-full; }
|
.spark { @apply h-auto max-w-full; }
|
||||||
.spark .s0 { fill:var(--good); } .spark .s1 { fill:var(--loved); } .spark .s2 { fill:var(--down); } .spark .s3 { fill:var(--accent); } .spark .s4 { fill:var(--muted); } .spark .s5 { fill:var(--ink); } .spark .line { stroke:var(--accent); }
|
.spark .s0 { fill:var(--good); } .spark .s1 { fill:var(--loved); } .spark .s2 { fill:var(--down); } .spark .s3 { fill:var(--accent); } .spark .s4 { fill:var(--muted); } .spark .s5 { fill:var(--ink); } .spark .line { stroke:var(--accent); }
|
||||||
|
/* step 4: table-of-contents sidebar */
|
||||||
|
.toc-progress { @apply block h-0.5 w-full appearance-none border-0 p-0 align-middle; background:var(--rule); }
|
||||||
|
.toc-progress::-webkit-progress-bar { background:var(--rule); }
|
||||||
|
.toc-progress::-webkit-progress-value { background:var(--accent); transition:width 150ms linear; }
|
||||||
|
.toc-progress::-moz-progress-bar { background:var(--accent); }
|
||||||
|
/* 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; }
|
||||||
|
/* Without JS the panel is simply visible under the bar; `has-js` is set in the head. */
|
||||||
|
@media (width < 64rem) {
|
||||||
|
.has-js [data-toc-panel]:not([data-open="true"]) { display:none; }
|
||||||
|
}
|
||||||
@media (max-width:40rem) {
|
@media (max-width:40rem) {
|
||||||
.kv { @apply block; } .kv dt { @apply mt-2; }
|
.kv { @apply block; } .kv dt { @apply mt-2; }
|
||||||
.rating { @apply items-stretch; } .rating-prompt, .rating-note { @apply basis-full; } .rating-prompt { @apply mb-2; }
|
.rating { @apply items-stretch; } .rating-prompt, .rating-note { @apply basis-full; } .rating-prompt { @apply mb-2; }
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<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>
|
||||||
|
</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 %}
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
<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 %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
{% extends "layout.html" %}{% block ears %}<span>From this issue</span>{% endblock %}{% block content %}<article class="mx-auto mt-10 max-w-[68ch] px-4 sm:mt-14 sm:px-6">
|
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
|
||||||
|
<article class="mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0" data-toc-scroll>
|
||||||
<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"><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>
|
||||||
</article>{% endblock %}
|
</article>
|
||||||
|
</div>{% endblock %}
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
{% extends "layout.html" %}{% block ears %}<span>Edition notes</span>{% endblock %}{% block content %}<article class="mx-auto mt-10 max-w-[68ch] px-4 sm:mt-14 sm:px-6"><header class="mb-9"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Edition notes</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">Behind the paper</h1></header><section class="border-y border-rule py-6"><p>{{ summary_line }}</p><p class="mt-3">{{ admitted_line }}</p><p class="mt-3">{{ learned_line }}</p></section><section class="mt-10"><h2 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">Near misses</h2><p class="mt-5 italic text-ink-2">Highest utility not selected.</p>{% if near_misses.is_empty() %}<p class="mt-4 text-muted">None recorded for this run.</p>{% else %}<ol class="mt-5 list-decimal space-y-3 pl-6">{% for near_miss in near_misses %}<li>{% if page.is_admin() %}<a class="text-ink decoration-rule hover:decoration-accent" href="/dashboard/articles/{{ near_miss.article_id }}">{{ near_miss.line }}</a>{% else %}{{ near_miss.line }}{% endif %}</li>{% endfor %}</ol>{% endif %}</section><p class="mt-10 border-l-2 border-rule pl-4 italic text-ink-2">{{ models_line }}</p><p class="mt-10 border-t border-rule pt-5 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></article>{% endblock %}
|
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
|
||||||
|
<article class="mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0"><header class="mb-9"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Edition notes</p><h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">Behind the paper</h1></header><section class="border-y border-rule py-6"><p>{{ summary_line }}</p><p class="mt-3">{{ admitted_line }}</p><p class="mt-3">{{ learned_line }}</p></section><section class="mt-10"><h2 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">Near misses</h2><p class="mt-5 italic text-ink-2">Highest utility not selected.</p>{% if near_misses.is_empty() %}<p class="mt-4 text-muted">None recorded for this run.</p>{% else %}<ol class="mt-5 list-decimal space-y-3 pl-6">{% for near_miss in near_misses %}<li>{% if page.is_admin() %}<a class="text-ink decoration-rule hover:decoration-accent" href="/dashboard/articles/{{ near_miss.article_id }}">{{ near_miss.line }}</a>{% else %}{{ near_miss.line }}{% endif %}</li>{% endfor %}</ol>{% endif %}</section><p class="mt-10 border-l-2 border-rule pl-4 italic text-ink-2">{{ models_line }}</p><p class="mt-10 border-t border-rule pt-5 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></article>
|
||||||
|
</div>{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "layout.html" %}{% block ears %}<span>{{ display_date }} <span class="hidden sm:inline">· No. {{ issue_number }}</span></span>{% endblock %}{% block content %}
|
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}
|
||||||
<article class="mx-auto mt-8 max-w-[68ch] px-4 sm:mt-12 sm:px-6">
|
<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
|
||||||
|
<article class="mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0">
|
||||||
<header class="mb-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"><h1 id="brief-heading" class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
|
<section aria-labelledby="brief-heading"><h1 id="brief-heading" class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
|
||||||
{% 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 %}
|
{% 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 %}
|
||||||
@@ -7,5 +8,6 @@
|
|||||||
{% for section in sections %}<section class="mt-10"><h3 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>{% if !entry.summary.is_empty() %}<p class="mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
|
{% for section in sections %}<section class="mt-10"><h3 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>{% if !entry.summary.is_empty() %}<p class="mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
|
||||||
</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 class="mt-12 border-t border-rule pt-2 font-sans text-sm leading-relaxed"><h2 class="text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>
|
<footer id="colophon" class="mt-12 scroll-mt-6 border-t border-rule pt-2 font-sans text-sm leading-relaxed"><h2 class="text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>
|
||||||
</article>{% endblock %}
|
</article>
|
||||||
|
</div>{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{% extends "layout.html" %}{% block ears %}{% if empty %}<span>Morning edition</span>{% else %}<span>{{ issue.display_date }} <span class="hidden sm:inline">· No. {{ issue.issue_number }}</span></span>{% endif %}{% endblock %}{% block content %}
|
{% extends "layout.html" %}{% block ears %}{% if empty %}<span>Morning edition</span>{% else %}<span class="sm:hidden">{{ issue.short_date }}</span><span class="hidden sm:inline">{{ issue.display_date }} · No. {{ issue.issue_number }}</span>{% endif %}{% endblock %}{% block content %}
|
||||||
<article class="mx-auto mt-8 max-w-[68ch] px-4 sm:mt-12 sm:px-6">
|
<article class="mx-auto mt-8 max-w-[68ch] px-4 sm:mt-12 sm:px-6">
|
||||||
{% if empty %}<div class="py-16 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Morning edition</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">No issue yet</h1><p class="mt-4 text-ink-2">The first issue has not been published.</p></div>{% else %}
|
{% if empty %}<div class="py-16 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Morning edition</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">No issue yet</h1><p class="mt-4 text-ink-2">The first issue has not been published.</p></div>{% else %}
|
||||||
<header class="mb-10 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ issue.stats_line }}</p><p class="mx-auto mt-5 max-w-[58ch] text-lg italic leading-relaxed text-ink-2">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p></header>
|
<header class="mb-10 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ issue.stats_line }}</p><p class="mx-auto mt-5 max-w-[58ch] text-lg italic leading-relaxed text-ink-2">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p></header>
|
||||||
|
|||||||
@@ -13,16 +13,16 @@
|
|||||||
<body>
|
<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>
|
<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="mx-auto max-w-7xl px-4 pt-4 sm:px-6 sm:pt-6">
|
||||||
<div class="mb-2 flex min-h-8 items-center justify-between gap-4 border-b border-rule pb-2 font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">
|
<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>{% block ears %}<span>Morning edition</span>{% endblock %}</div>
|
<div class="min-w-0 truncate whitespace-nowrap">{% block ears %}<span>Morning edition</span>{% endblock %}</div>
|
||||||
<div class="flex items-center gap-3 normal-case tracking-normal">
|
<div class="flex shrink-0 items-center gap-2 whitespace-nowrap normal-case tracking-normal sm:gap-3">
|
||||||
<button class="flex min-h-10 items-center gap-1.5 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">
|
<button class="flex min-h-10 shrink-0 items-center gap-1.5 whitespace-nowrap border-0 bg-transparent px-1.5 py-1 text-xs text-muted hover:text-ink" type="button" data-theme-toggle aria-label="Theme: system">
|
||||||
<svg data-theme-icon="system" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75"><path d="M4 5.5h16v11H4zM9 20h6M12 16.5V20"/></svg>
|
<svg data-theme-icon="system" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75"><path d="M4 5.5h16v11H4zM9 20h6M12 16.5V20"/></svg>
|
||||||
<svg data-theme-icon="light" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75" hidden><circle cx="12" cy="12" r="3.5"/><path d="M12 2v2.2M12 19.8V22M4.93 4.93l1.56 1.56M17.51 17.51l1.56 1.56M2 12h2.2M19.8 12H22M4.93 19.07l1.56-1.56M17.51 6.49l1.56-1.56"/></svg>
|
<svg data-theme-icon="light" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75" hidden><circle cx="12" cy="12" r="3.5"/><path d="M12 2v2.2M12 19.8V22M4.93 4.93l1.56 1.56M17.51 17.51l1.56 1.56M2 12h2.2M19.8 12H22M4.93 19.07l1.56-1.56M17.51 6.49l1.56-1.56"/></svg>
|
||||||
<svg data-theme-icon="dark" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75" hidden><path d="M20 15.1A8.5 8.5 0 0 1 8.9 4a8.5 8.5 0 1 0 11.1 11.1Z"/></svg>
|
<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>
|
<span data-theme-label>System</span>
|
||||||
</button>
|
</button>
|
||||||
{% match page.viewer %}{% when Some with (viewer) %}<a class="text-ink no-underline hover:text-accent" href="/account">{{ viewer.username }}</a>{% when None %}<a class="text-ink no-underline hover:text-accent" href="/login">Sign in</a>{% endmatch %}
|
{% 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 %}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<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>
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
{% extends "layout.html" %}{% block ears %}{% match display_date %}{% when Some with (date) %}<span>{{ date }}</span>{% when None %}<span>World Briefing</span>{% endmatch %}{% endblock %}{% block content %}<article class="mx-auto mt-10 max-w-[68ch] px-4 sm:mt-14 sm:px-6"><header class="mb-9">{% match display_date %}{% when Some with (date) %}<p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ date }}</p>{% when None %}{% endmatch %}<h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">World Briefing</h1></header><div class="prose-body border-t border-rule pt-7">{{ body_html|safe }}</div><p class="mt-10 border-t border-rule pt-5 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></article>{% endblock %}
|
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
|
||||||
|
<article class="mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0"><header class="mb-9">{% match display_date %}{% when Some with (date) %}<p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ date }}</p>{% when None %}{% endmatch %}<h1 class="mt-2 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl">World Briefing</h1></header><div class="prose-body border-t border-rule pt-7">{{ body_html|safe }}</div><p class="mt-10 border-t border-rule pt-5 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></article>
|
||||||
|
</div>{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user