Merge branch 'ui-toc': table-of-contents fixes and scroll sync
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
# Task 1 handoff — table-of-contents sidebar
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
- `src/web/templates/_toc.html`
|
||||||
|
- Reworked the narrow-screen panel to use a measured viewport offset and a
|
||||||
|
`100dvh`-based height instead of the old `72vh` cap.
|
||||||
|
- Added live label, count, desktop-status, link-label, and per-link progress
|
||||||
|
hooks used by the shared TOC state updater.
|
||||||
|
- Moved current-link presentation onto `.toc-link[aria-current]` and kept the
|
||||||
|
server-rendered `aria-current="page"` state for chapter pages.
|
||||||
|
- Added both hamburger and close SVGs for the open-state cross-fade and raised
|
||||||
|
the mobile toggle/link hit areas to at least 44 px.
|
||||||
|
- Added explicit right padding around the scrollable list.
|
||||||
|
- `src/web/templates/issue_full.html`
|
||||||
|
- Marked The Brief, every article index item, and the colophon with matching
|
||||||
|
`data-toc-entry` values for the front-page scroll-spy.
|
||||||
|
- `src/web/static/app.js`
|
||||||
|
- Added mobile panel sizing from the sticky bar's current viewport position,
|
||||||
|
recalculated on open and layout/visual-viewport resize.
|
||||||
|
- Added mobile document scroll lock and retained toggle, Escape,
|
||||||
|
outside-click, link-click, and `lg` breakpoint close behavior.
|
||||||
|
- Added a passive, requestAnimationFrame-throttled front-page scroll-spy. A
|
||||||
|
single current-link update now drives `aria-current="location"`, the mobile
|
||||||
|
title/count, desktop chapter status, both progress bars, and TOC reveal.
|
||||||
|
- Updated current-link reveal to use `scrollTo`, smooth unless reduced motion
|
||||||
|
is requested, without scrolling the page.
|
||||||
|
- Preserved progress-within-chapter behavior on article pages.
|
||||||
|
- `src/web/tailwind.css`
|
||||||
|
- Added the dynamic mobile panel height and scroll-lock rules.
|
||||||
|
- Added stable scrollbar gutter, right-side spacing, thin/rule-colored
|
||||||
|
cross-browser scrollbar styling, and overscroll containment.
|
||||||
|
- Added the centralized current/hover/focus link styles and exact 150 ms
|
||||||
|
opacity/scale/blur hamburger-to-close transition.
|
||||||
|
- `src/web/static/app.css`
|
||||||
|
- Rebuilt from the Tailwind source.
|
||||||
|
- `src/web/issue.rs`
|
||||||
|
- Added each TOC item's semantic progress position (including back matter) and
|
||||||
|
coverage for those positions and the rendered scroll-spy hooks.
|
||||||
|
- Made the current-link router assertion insensitive to HTML attribute order.
|
||||||
|
|
||||||
|
## Design decisions
|
||||||
|
|
||||||
|
- The open panel remains positioned directly below the shared sticky bar. JS
|
||||||
|
measures only the bar's on-screen bottom edge into `--toc-panel-top`; CSS owns
|
||||||
|
the final `calc(100dvh - var(--toc-panel-top))` height. This handles both the
|
||||||
|
bar still below the masthead and the bar already stuck at the top.
|
||||||
|
- The Brief uses progress position 0. Article, World, and Behind entries use
|
||||||
|
their chapter position. The colophon uses the issue total, so reaching it
|
||||||
|
communicates completion even though World and Behind are separate pages.
|
||||||
|
- Front-page position changes only toggle `aria-current` for current styling;
|
||||||
|
the other text/progress surfaces are derived from that same link's label and
|
||||||
|
progress metadata.
|
||||||
|
- Scrollbar gutter and padding are both used: the gutter reserves space for
|
||||||
|
classic scrollbars, while padding protects labels on overlay-scrollbar
|
||||||
|
platforms.
|
||||||
|
|
||||||
|
## Deviations and open items
|
||||||
|
|
||||||
|
- No live screenshots could be captured. The sandbox rejected the assigned
|
||||||
|
`127.0.0.1:3601` bind with `Operation not permitted`, exactly as anticipated
|
||||||
|
by the shared brief. Consequently desktop/mobile, light/dark, open-panel, and
|
||||||
|
scripted-scroll visual states remain to be checked outside the sandbox.
|
||||||
|
- The full unit test run also found three OpenAI mock-server tests blocked by
|
||||||
|
loopback binding in addition to the bind-dependent tests listed in the shared
|
||||||
|
brief. They fail at the same listener creation point and are unrelated to this
|
||||||
|
change.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `node --check src/web/static/app.js` — passed.
|
||||||
|
- `cargo fmt --check` — passed.
|
||||||
|
- `npm run css` — passed with Tailwind 4.3.3; `src/web/static/app.css` rebuilt.
|
||||||
|
- `npm run css:check` — passed with no diff. The worktree lacked its own CLI
|
||||||
|
shim, so both npm commands used the already-installed adjacent checkout's
|
||||||
|
`node_modules/.bin` via `PATH`; all inputs and outputs stayed in this worktree.
|
||||||
|
- `cargo test --lib web::` — 83 passed, 0 failed.
|
||||||
|
- `cargo clippy --all-targets -- -D warnings` — passed.
|
||||||
|
- `cargo test` — library phase: 431 passed, 13 bind-dependent failures. The
|
||||||
|
failures were 7 LLM mock-server tests, the documented relative-URL extraction
|
||||||
|
test, and the documented 5 server tests; each failed with `Operation not
|
||||||
|
permitted` while creating a loopback listener.
|
||||||
|
- `cargo test --test config_check --test e2e_pipeline --test m2_pipeline --test m3_curation --test m4_epub`
|
||||||
|
— 25 passed, 0 failed. `m7_server` was excluded because it requires the same
|
||||||
|
forbidden loopback bind.
|
||||||
|
- Attempted the configured dev-server command on port 3601 — build passed; bind
|
||||||
|
failed with `Operation not permitted`, so screenshots were not possible.
|
||||||
|
- `git diff --check` — passed.
|
||||||
|
|
||||||
+54
-4
@@ -659,6 +659,8 @@ struct TocItem {
|
|||||||
href: String,
|
href: String,
|
||||||
number: Option<usize>,
|
number: Option<usize>,
|
||||||
minutes: Option<i64>,
|
minutes: Option<i64>,
|
||||||
|
/// Progress position used by the shared TOC UI (0 for The Brief).
|
||||||
|
progress: usize,
|
||||||
current: bool,
|
current: bool,
|
||||||
/// First entry of the back-matter group; the template draws a hairline above it.
|
/// First entry of the back-matter group; the template draws a hairline above it.
|
||||||
divider: bool,
|
divider: bool,
|
||||||
@@ -668,6 +670,10 @@ impl TocItem {
|
|||||||
fn is_section(&self) -> bool {
|
fn is_section(&self) -> bool {
|
||||||
matches!(self.kind, TocKind::Section)
|
matches!(self.kind, TocKind::Section)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_colophon(&self) -> bool {
|
||||||
|
matches!(self.kind, TocKind::Colophon)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The table of contents shared by the four signed-in issue pages.
|
/// The table of contents shared by the four signed-in issue pages.
|
||||||
@@ -703,12 +709,18 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
|||||||
let issue = &view.issue;
|
let issue = &view.issue;
|
||||||
let date = issue.meta.date;
|
let date = issue.meta.date;
|
||||||
let issue_href = issue_href(date);
|
let issue_href = issue_href(date);
|
||||||
let plain = |kind: TocKind, label: &str, href: String, current: bool, divider: bool| TocItem {
|
let plain = |kind: TocKind,
|
||||||
|
label: &str,
|
||||||
|
href: String,
|
||||||
|
progress: usize,
|
||||||
|
current: bool,
|
||||||
|
divider: bool| TocItem {
|
||||||
kind,
|
kind,
|
||||||
label: label.to_string(),
|
label: label.to_string(),
|
||||||
href,
|
href,
|
||||||
number: None,
|
number: None,
|
||||||
minutes: None,
|
minutes: None,
|
||||||
|
progress,
|
||||||
current,
|
current,
|
||||||
divider,
|
divider,
|
||||||
};
|
};
|
||||||
@@ -717,13 +729,21 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
|||||||
TocKind::Brief,
|
TocKind::Brief,
|
||||||
"The Brief",
|
"The Brief",
|
||||||
issue_href.clone(),
|
issue_href.clone(),
|
||||||
|
0,
|
||||||
current == TocPosition::FrontPage,
|
current == TocPosition::FrontPage,
|
||||||
false,
|
false,
|
||||||
)];
|
)];
|
||||||
let mut position = 0usize;
|
let mut position = 0usize;
|
||||||
let mut total = 0usize;
|
let mut total = 0usize;
|
||||||
for name in chapters::section_names(issue) {
|
for name in chapters::section_names(issue) {
|
||||||
items.push(plain(TocKind::Section, &name, String::new(), false, false));
|
items.push(plain(
|
||||||
|
TocKind::Section,
|
||||||
|
&name,
|
||||||
|
String::new(),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
));
|
||||||
for pick in issue.lineup.section_picks(&name) {
|
for pick in issue.lineup.section_picks(&name) {
|
||||||
total += 1;
|
total += 1;
|
||||||
let is_current = current == TocPosition::Article(pick.article.id);
|
let is_current = current == TocPosition::Article(pick.article.id);
|
||||||
@@ -736,6 +756,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
|||||||
href: article_href(date, pick.article.id),
|
href: article_href(date, pick.article.id),
|
||||||
number: Some(total),
|
number: Some(total),
|
||||||
minutes: Some(pick.article.reading_minutes()),
|
minutes: Some(pick.article.reading_minutes()),
|
||||||
|
progress: total,
|
||||||
current: is_current,
|
current: is_current,
|
||||||
divider: false,
|
divider: false,
|
||||||
});
|
});
|
||||||
@@ -753,6 +774,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
|||||||
TocKind::World,
|
TocKind::World,
|
||||||
"World Briefing",
|
"World Briefing",
|
||||||
format!("/issues/{date}/world"),
|
format!("/issues/{date}/world"),
|
||||||
|
total,
|
||||||
is_current,
|
is_current,
|
||||||
divider,
|
divider,
|
||||||
));
|
));
|
||||||
@@ -768,6 +790,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
|||||||
TocKind::Behind,
|
TocKind::Behind,
|
||||||
"Behind the paper",
|
"Behind the paper",
|
||||||
format!("/issues/{date}/behind"),
|
format!("/issues/{date}/behind"),
|
||||||
|
total,
|
||||||
is_current,
|
is_current,
|
||||||
divider,
|
divider,
|
||||||
));
|
));
|
||||||
@@ -777,6 +800,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
|||||||
TocKind::Colophon,
|
TocKind::Colophon,
|
||||||
"Colophon",
|
"Colophon",
|
||||||
format!("{issue_href}#colophon"),
|
format!("{issue_href}#colophon"),
|
||||||
|
total,
|
||||||
false,
|
false,
|
||||||
divider,
|
divider,
|
||||||
));
|
));
|
||||||
@@ -2160,6 +2184,15 @@ mod tests {
|
|||||||
assert_eq!(front.position, 0);
|
assert_eq!(front.position, 0);
|
||||||
assert_eq!(front.current_label(), "The Brief");
|
assert_eq!(front.current_label(), "The Brief");
|
||||||
assert_eq!(front.short_date, "Sat, Aug 15");
|
assert_eq!(front.short_date, "Sat, Aug 15");
|
||||||
|
assert_eq!(
|
||||||
|
front
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter(|item| !item.is_section())
|
||||||
|
.map(|item| item.progress)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![0, 1, 2, 3, 4, 4]
|
||||||
|
);
|
||||||
// Exactly one hairline, above the first back-matter entry.
|
// Exactly one hairline, above the first back-matter entry.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
front
|
front
|
||||||
@@ -2234,13 +2267,30 @@ mod tests {
|
|||||||
let html = response_text(response).await;
|
let html = response_text(response).await;
|
||||||
assert!(html.contains("data-toc-toggle"), "{path} has no toc bar");
|
assert!(html.contains("data-toc-toggle"), "{path} has no toc bar");
|
||||||
assert!(html.contains("id=\"toc\""), "{path} has no toc panel");
|
assert!(html.contains("id=\"toc\""), "{path} has no toc panel");
|
||||||
assert!(html.contains(progress), "{path} is missing {progress:?}");
|
|
||||||
assert!(
|
assert!(
|
||||||
html.contains(&format!("href=\"{current}\" aria-current=\"page\"")),
|
html.contains("data-toc-status"),
|
||||||
|
"{path} has no live status"
|
||||||
|
);
|
||||||
|
assert!(html.contains(progress), "{path} is missing {progress:?}");
|
||||||
|
let current_link = html
|
||||||
|
.split(&format!("class=\"toc-link\" href=\"{current}\""))
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|rest| rest.split("</a>").next());
|
||||||
|
assert!(
|
||||||
|
current_link.is_some_and(|link| link.contains("aria-current=\"page\"")),
|
||||||
"{path} does not mark {current} as current"
|
"{path} does not mark {current} as current"
|
||||||
);
|
);
|
||||||
assert!(html.contains(&format!("{}#colophon", issue_href(date))));
|
assert!(html.contains(&format!("{}#colophon", issue_href(date))));
|
||||||
assert!(html.contains("A Niche Delight & Other Tales"));
|
assert!(html.contains("A Niche Delight & Other Tales"));
|
||||||
|
if path == issue_href(date) {
|
||||||
|
assert!(html.contains(&format!(
|
||||||
|
"data-toc-entry=\"{}\"",
|
||||||
|
article_href(date, source.lineup.picks[0].article.id)
|
||||||
|
)));
|
||||||
|
assert!(
|
||||||
|
html.contains(&format!("data-toc-entry=\"{}#colophon\"", issue_href(date)))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let anonymous = app
|
let anonymous = app
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+121
-37
@@ -111,27 +111,51 @@ 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 */
|
/* step 4: table-of-contents panel, current chapter, and reading progress */
|
||||||
const tocPanel = document.querySelector("[data-toc-panel]");
|
const tocPanel = document.querySelector("[data-toc-panel]");
|
||||||
const tocToggle = document.querySelector("[data-toc-toggle]");
|
const tocToggle = document.querySelector("[data-toc-toggle]");
|
||||||
if (tocPanel) {
|
if (tocPanel) {
|
||||||
|
const tocBar = document.querySelector("[data-toc-bar]");
|
||||||
|
const tocLinks = Array.from(tocPanel.querySelectorAll(".toc-link"));
|
||||||
|
const tocBars = document.querySelectorAll("[data-toc-progress]");
|
||||||
|
const tocLabel = document.querySelector("[data-toc-label]");
|
||||||
|
const tocCount = document.querySelector("[data-toc-count]");
|
||||||
|
const tocStatus = document.querySelector("[data-toc-status]");
|
||||||
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||||
|
const largeScreen = window.matchMedia("(min-width: 64rem)");
|
||||||
|
|
||||||
// Long issues overflow the sidebar; scroll just enough to show where we are.
|
// Long issues overflow the sidebar; scroll just enough to show where we are.
|
||||||
const revealCurrent = () => {
|
const revealCurrent = () => {
|
||||||
const current = tocPanel.querySelector("a[aria-current=page]");
|
const current = tocPanel.querySelector(".toc-link[aria-current]");
|
||||||
if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return;
|
if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return;
|
||||||
const margin = 24;
|
const stickyHeader = tocPanel.querySelector(":scope > div");
|
||||||
const top = current.offsetTop - margin;
|
const topMargin = (stickyHeader && stickyHeader.offsetHeight > 0 ? stickyHeader.offsetHeight : 0) + 24;
|
||||||
const bottom = current.offsetTop + current.offsetHeight + margin;
|
const panelRect = tocPanel.getBoundingClientRect();
|
||||||
if (bottom > tocPanel.scrollTop + tocPanel.clientHeight) {
|
const currentRect = current.getBoundingClientRect();
|
||||||
tocPanel.scrollTop = bottom - tocPanel.clientHeight;
|
const top = tocPanel.scrollTop + currentRect.top - panelRect.top;
|
||||||
} else if (top < tocPanel.scrollTop) {
|
const visibleTop = tocPanel.scrollTop + topMargin;
|
||||||
tocPanel.scrollTop = Math.max(0, top);
|
const visibleBottom = tocPanel.scrollTop + tocPanel.clientHeight - 24;
|
||||||
}
|
if (top >= visibleTop && top + currentRect.height <= visibleBottom) return;
|
||||||
|
const target = Math.max(0, top - Math.max(topMargin, (tocPanel.clientHeight - currentRect.height) / 2));
|
||||||
|
tocPanel.scrollTo({ top: target, behavior: reducedMotion.matches ? "auto" : "smooth" });
|
||||||
|
};
|
||||||
|
const sizeOpenPanel = () => {
|
||||||
|
if (tocPanel.dataset.open !== "true" || largeScreen.matches || !tocBar) return;
|
||||||
|
tocPanel.style.setProperty("--toc-panel-top", `${Math.max(0, tocBar.getBoundingClientRect().bottom)}px`);
|
||||||
};
|
};
|
||||||
const setOpen = (open) => {
|
const setOpen = (open) => {
|
||||||
tocPanel.dataset.open = open ? "true" : "false";
|
tocPanel.dataset.open = open ? "true" : "false";
|
||||||
if (tocToggle) tocToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
document.documentElement.classList.toggle("toc-panel-open", open && !largeScreen.matches);
|
||||||
if (open) revealCurrent();
|
if (tocToggle) {
|
||||||
|
tocToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
tocToggle.setAttribute("aria-label", open ? "Close contents" : "Contents");
|
||||||
|
}
|
||||||
|
if (open) {
|
||||||
|
sizeOpenPanel();
|
||||||
|
revealCurrent();
|
||||||
|
} else {
|
||||||
|
tocPanel.style.removeProperty("--toc-panel-top");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
revealCurrent();
|
revealCurrent();
|
||||||
@@ -147,33 +171,93 @@ if (tocPanel) {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
tocToggle.focus();
|
tocToggle.focus();
|
||||||
});
|
});
|
||||||
window.matchMedia("(min-width: 64rem)").addEventListener("change", () => setOpen(false));
|
largeScreen.addEventListener("change", () => setOpen(false));
|
||||||
|
window.addEventListener("resize", sizeOpenPanel);
|
||||||
|
if (window.visualViewport) window.visualViewport.addEventListener("resize", sizeOpenPanel);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const tocBars = document.querySelectorAll("[data-toc-progress]");
|
let currentLink = tocPanel.querySelector(".toc-link[aria-current]");
|
||||||
const tocChapter = document.querySelector("[data-toc-scroll]");
|
// Chapters above the current one read as "already passed" in ink rather than ink-2.
|
||||||
if (tocBars.length && tocChapter) {
|
const markPassed = (link) => {
|
||||||
// "Chapter N" is worth position N once finished; show N-1 plus how far down we are.
|
let passed = true;
|
||||||
const base = Math.max(0, Number(tocBars[0].getAttribute("value")) - 1);
|
tocLinks.forEach((candidate) => {
|
||||||
let queued = false;
|
if (candidate === link) passed = false;
|
||||||
const paint = () => {
|
candidate.toggleAttribute("data-passed", passed);
|
||||||
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 (currentLink) markPassed(currentLink);
|
||||||
if (queued) return;
|
const setCurrent = (link) => {
|
||||||
queued = true;
|
if (!link || (link === currentLink && link.getAttribute("aria-current") === "location")) return;
|
||||||
window.requestAnimationFrame(paint);
|
tocLinks.forEach((candidate) => candidate.removeAttribute("aria-current"));
|
||||||
|
markPassed(link);
|
||||||
|
link.setAttribute("aria-current", "location");
|
||||||
|
currentLink = link;
|
||||||
|
const position = Math.max(0, Number(link.dataset.tocPosition));
|
||||||
|
const total = tocBars.length ? Number(tocBars[0].max) : 0;
|
||||||
|
const isEnd = link.hasAttribute("data-toc-end");
|
||||||
|
const label = link.querySelector("[data-toc-link-label]");
|
||||||
|
if (tocLabel && label) tocLabel.textContent = label.textContent;
|
||||||
|
if (tocCount) {
|
||||||
|
tocCount.hidden = position === 0 || isEnd;
|
||||||
|
tocCount.textContent = `${position} / ${total}`;
|
||||||
|
}
|
||||||
|
if (tocStatus) tocStatus.textContent = isEnd ? "End of issue" : position > 0 ? `Chapter ${position} of ${total}` : "Front page";
|
||||||
|
tocBars.forEach((bar) => {
|
||||||
|
delete bar.dataset.live;
|
||||||
|
bar.value = position;
|
||||||
|
});
|
||||||
|
revealCurrent();
|
||||||
};
|
};
|
||||||
window.addEventListener("scroll", schedule, { passive: true });
|
|
||||||
window.addEventListener("resize", schedule);
|
const tocEntries = Array.from(document.querySelectorAll("[data-toc-entry]")).map((entry) => ({
|
||||||
schedule();
|
entry,
|
||||||
|
link: tocLinks.find((candidate) => candidate.getAttribute("href") === entry.dataset.tocEntry),
|
||||||
|
})).filter(({ link }) => link);
|
||||||
|
if (tocEntries.length) {
|
||||||
|
let queued = false;
|
||||||
|
const paintCurrent = () => {
|
||||||
|
queued = false;
|
||||||
|
const threshold = (tocBar ? tocBar.offsetHeight : 0) + window.innerHeight / 3;
|
||||||
|
let next = tocEntries[0].link;
|
||||||
|
tocEntries.forEach(({ entry, link }) => {
|
||||||
|
if (entry.getBoundingClientRect().top <= threshold) next = link;
|
||||||
|
});
|
||||||
|
setCurrent(next);
|
||||||
|
};
|
||||||
|
const scheduleCurrent = () => {
|
||||||
|
if (queued) return;
|
||||||
|
queued = true;
|
||||||
|
window.requestAnimationFrame(paintCurrent);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", scheduleCurrent, { passive: true });
|
||||||
|
window.addEventListener("resize", scheduleCurrent);
|
||||||
|
scheduleCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tocChapter = document.querySelector("[data-toc-scroll]");
|
||||||
|
if (tocBars.length && tocChapter) {
|
||||||
|
// "Chapter N" is worth position N once finished; show N-1 plus how far down we are.
|
||||||
|
const base = Math.max(0, Number(tocBars[0].getAttribute("value")) - 1);
|
||||||
|
let queued = false;
|
||||||
|
const paint = () => {
|
||||||
|
queued = false;
|
||||||
|
const start = window.scrollY + tocChapter.getBoundingClientRect().top;
|
||||||
|
const end = start + tocChapter.offsetHeight - window.innerHeight;
|
||||||
|
const read = end > start ? (window.scrollY - start) / (end - start) : 1;
|
||||||
|
const value = base + Math.min(1, Math.max(0, read));
|
||||||
|
tocBars.forEach((bar) => {
|
||||||
|
// The CSS transition is for navigation, not for tracking a finger.
|
||||||
|
bar.dataset.live = "true";
|
||||||
|
bar.value = value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const schedule = () => {
|
||||||
|
if (queued) return;
|
||||||
|
queued = true;
|
||||||
|
window.requestAnimationFrame(paint);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", schedule, { passive: true });
|
||||||
|
window.addEventListener("resize", schedule);
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -259,9 +259,22 @@
|
|||||||
/* While JS drives the bar from scroll position it must track the finger exactly. */
|
/* While JS drives the bar from scroll position it must track the finger exactly. */
|
||||||
.toc-progress[data-live]::-webkit-progress-value { transition:none; }
|
.toc-progress[data-live]::-webkit-progress-value { transition:none; }
|
||||||
@media (prefers-reduced-motion: reduce) { .toc-progress::-webkit-progress-value { transition:none; } }
|
@media (prefers-reduced-motion: reduce) { .toc-progress::-webkit-progress-value { transition:none; } }
|
||||||
[data-toc-panel] { scrollbar-width:thin; scrollbar-color:var(--rule) transparent; }
|
[data-toc-panel] { scrollbar-width:thin; scrollbar-color:var(--rule) transparent; scrollbar-gutter:stable; }
|
||||||
|
[data-toc-panel]::-webkit-scrollbar { width:0.5rem; }
|
||||||
|
[data-toc-panel]::-webkit-scrollbar-thumb { border:2px solid transparent; background:var(--rule); background-clip:padding-box; }
|
||||||
|
.toc-link { @apply flex min-h-11 items-baseline gap-2 border-l-2 border-transparent py-2.5 pl-3 text-ink-2 no-underline transition-colors duration-150 hover:border-rule-strong hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper lg:min-h-10 lg:py-1.5; }
|
||||||
|
.toc-link[data-passed] { @apply text-ink; }
|
||||||
|
.toc-link[data-passed] > span:first-child { @apply text-ink; }
|
||||||
|
.toc-link[aria-current] { @apply border-accent font-semibold text-ink; }
|
||||||
|
.toc-toggle-icon { opacity:1; scale:1; filter:blur(0); transition-property:opacity, scale, filter; transition-duration:150ms; transition-timing-function:cubic-bezier(0.2, 0, 0, 1); }
|
||||||
|
.toc-toggle-icon-close { opacity:0; scale:0.25; filter:blur(4px); }
|
||||||
|
[data-toc-toggle][aria-expanded="true"] .toc-toggle-icon-menu { opacity:0; scale:0.25; filter:blur(4px); }
|
||||||
|
[data-toc-toggle][aria-expanded="true"] .toc-toggle-icon-close { opacity:1; scale:1; filter:blur(0); }
|
||||||
/* Without JS the panel is simply visible under the bar; `has-js` is set in the head. */
|
/* Without JS the panel is simply visible under the bar; `has-js` is set in the head. */
|
||||||
@media (width < 64rem) {
|
@media (width < 64rem) {
|
||||||
|
/* Lock only the root: locking body as well makes body its own scroll container and un-sticks the bar. */
|
||||||
|
html.toc-panel-open { overflow:hidden; scrollbar-gutter:stable; }
|
||||||
|
.has-js [data-toc-panel][data-open="true"] { height:calc(100dvh - var(--toc-panel-top, 3rem)); max-height:calc(100dvh - var(--toc-panel-top, 3rem)); }
|
||||||
.has-js [data-toc-panel]:not([data-open="true"]) { display:none; }
|
.has-js [data-toc-panel]:not([data-open="true"]) { display:none; }
|
||||||
}
|
}
|
||||||
@media (max-width:40rem) {
|
@media (max-width:40rem) {
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
<div class="sticky top-0 z-30 lg:static lg:z-auto">
|
<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">
|
<div class="relative flex min-h-12 items-center gap-3 border-b border-rule bg-paper px-4 sm:px-6 lg:hidden" data-toc-bar>
|
||||||
<button class="-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">
|
<button class="relative -ml-2 flex h-11 w-11 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-ink hover:text-accent" type="button" data-toc-toggle aria-controls="toc" aria-expanded="false" aria-label="Contents">
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" class="h-5 w-5 fill-none stroke-current" stroke-width="1.75"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
<svg aria-hidden="true" viewBox="0 0 24 24" class="toc-toggle-icon toc-toggle-icon-menu absolute h-5 w-5 fill-none stroke-current" stroke-width="1.75"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" class="toc-toggle-icon toc-toggle-icon-close absolute h-5 w-5 fill-none stroke-current" stroke-width="1.75"><path d="m5 5 14 14M19 5 5 19"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="min-w-0 flex-1 truncate font-sans text-sm leading-tight text-ink">{{ toc.current_label() }}</span>
|
<span class="min-w-0 flex-1 truncate font-sans text-sm leading-tight text-ink" data-toc-label>{{ toc.current_label() }}</span>
|
||||||
{% if toc.position > 0 %}<span class="shrink-0 font-sans text-xs tabular-nums text-muted">{{ toc.position }} / {{ toc.total }}</span>{% endif %}
|
<span class="shrink-0 font-sans text-xs tabular-nums text-muted" data-toc-count{% if toc.position == 0 %} hidden{% endif %}>{{ toc.position }} / {{ toc.total }}</span>
|
||||||
<progress class="toc-progress absolute inset-x-0 -bottom-px" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
|
<progress class="toc-progress absolute inset-x-0 -bottom-px" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
|
||||||
</div>
|
</div>
|
||||||
<nav id="toc" data-toc-panel class="absolute inset-x-0 top-full z-30 max-h-[72vh] overflow-y-auto border-b border-rule bg-paper px-4 pb-7 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:px-6 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100vh-3rem)] lg:border-0 lg:px-0 lg:pb-10 lg:pt-0 lg:shadow-none" aria-label="Contents">
|
<nav id="toc" data-toc-panel class="absolute inset-x-0 top-full z-30 overflow-y-auto overscroll-contain border-b border-rule bg-paper pb-7 pl-4 pr-6 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:pl-6 sm:pr-8 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100dvh-3rem)] lg:border-0 lg:pb-10 lg:pl-0 lg:pr-3 lg:pt-0 lg:shadow-none" aria-label="Contents">
|
||||||
<div class="hidden lg:sticky lg:top-0 lg:z-10 lg:block lg:bg-paper lg:pb-3">
|
<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>
|
<a class="block font-sans text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted no-underline hover:text-ink" href="{{ toc.issue_href }}">No. {{ toc.issue_number }} <span class="text-rule" aria-hidden="true">·</span> {{ toc.short_date }}</a>
|
||||||
<p class="mt-2 font-sans text-[0.72rem] text-ink-2">{% if toc.position > 0 %}Chapter {{ toc.position }} of {{ toc.total }}{% else %}Front page{% endif %}</p>
|
<p class="mt-2 font-sans text-[0.72rem] text-ink-2" data-toc-status>{% if toc.position > 0 %}Chapter {{ toc.position }} of {{ toc.total }}{% else %}Front page{% endif %}</p>
|
||||||
<progress class="toc-progress mt-2.5" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
|
<progress class="toc-progress mt-2.5" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
|
||||||
</div>
|
</div>
|
||||||
<ul class="m-0 mt-1 list-none p-0 lg:mt-4">
|
<ul class="m-0 mt-1 list-none p-0 lg:mt-4">
|
||||||
{% for item in toc.items %}{% if item.is_section() %}<li class="mt-5 border-t border-rule pt-2 font-sans text-[0.66rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ item.label }}</li>{% else %}<li{% if item.divider %} class="mt-4 border-t border-rule pt-3"{% endif %}><a class="flex items-baseline gap-2 border-l-2 py-2.5 pl-3 no-underline transition-colors duration-150 lg:py-1.5 {% if item.current %}border-accent font-semibold text-ink{% else %}border-transparent text-ink-2 hover:border-rule-strong hover:text-accent{% endif %}" href="{{ item.href }}"{% if item.current %} aria-current="page"{% endif %}>{% match item.number %}{% when Some with (number) %}<span class="w-4 shrink-0 font-sans text-[0.66rem] tabular-nums text-muted">{{ number }}</span>{% when None %}<span class="w-4 shrink-0" aria-hidden="true"></span>{% endmatch %}<span class="min-w-0 flex-1 font-serif text-[0.95rem] leading-snug">{{ item.label }}</span>{% match item.minutes %}{% when Some with (minutes) %}<span class="shrink-0 font-sans text-[0.66rem] tabular-nums text-muted">{{ minutes }} min</span>{% when None %}{% endmatch %}</a></li>{% endif %}{% endfor %}
|
{% for item in toc.items %}{% if item.is_section() %}<li class="mt-5 border-t border-rule pt-2 font-sans text-[0.66rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ item.label }}</li>{% else %}<li{% if item.divider %} class="mt-4 border-t border-rule pt-3"{% endif %}><a class="toc-link" href="{{ item.href }}" data-toc-position="{{ item.progress }}"{% if item.is_colophon() %} data-toc-end{% endif %}{% if item.current %} aria-current="page"{% endif %}>{% match item.number %}{% when Some with (number) %}<span class="w-4 shrink-0 font-sans text-[0.66rem] tabular-nums text-muted">{{ number }}</span>{% when None %}<span class="w-4 shrink-0" aria-hidden="true"></span>{% endmatch %}<span class="min-w-0 flex-1 font-serif text-[0.95rem] leading-snug" data-toc-link-label>{{ item.label }}</span>{% match item.minutes %}{% when Some with (minutes) %}<span class="shrink-0 font-sans text-[0.66rem] tabular-nums text-muted">{{ minutes }} min</span>{% when None %}{% endmatch %}</a></li>{% endif %}{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
|
<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
|
||||||
<article class="mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0">
|
<article class="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" data-toc-entry="/issues/{{ date }}"><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 %}
|
||||||
<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-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" 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="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-12 scroll-mt-6 border-t border-rule pt-2 font-sans text-sm leading-relaxed"><h2 class="text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>
|
<footer id="colophon" class="mt-12 scroll-mt-6 border-t border-rule pt-2 font-sans text-sm leading-relaxed" data-toc-entry="/issues/{{ date }}#colophon"><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>
|
</article>
|
||||||
</div>{% endblock %}
|
</div>{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user