UI pass: table-of-contents sidebar fixes and scroll sync

- The open hamburger panel now fills the viewport below the sticky bar
  (100dvh, measured bar offset) and locks the page behind it. Locking only
  the root element: locking body too made it its own scroll container and
  un-stuck the bar.
- Front-page scroll-spy: the current chapter marker, mobile label/counter,
  desktop "Chapter N of M" line and both progress bars follow the reader
  through "In This Issue"; passed chapters read in ink. The sidebar
  auto-scrolls to keep the current chapter in view.
- The sidebar scrollbar sits in a reserved gutter instead of over the text.
- Hamburger cross-fades to a close icon; 44px tap targets on phones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MD4VWGq6mGcd8Bg67qyx9k
This commit is contained in:
2026-09-04 17:05:39 +00:00
co-authored by Claude Fable 5.1
parent 560bbc13af
commit ad2e99eeb9
7 changed files with 291 additions and 54 deletions
+89
View File
@@ -0,0 +1,89 @@
# Task 1 handoff — table-of-contents sidebar
## What changed
- `src/web/templates/_toc.html`
- Reworked the narrow-screen panel to use a measured viewport offset and a
`100dvh`-based height instead of the old `72vh` cap.
- Added live label, count, desktop-status, link-label, and per-link progress
hooks used by the shared TOC state updater.
- Moved current-link presentation onto `.toc-link[aria-current]` and kept the
server-rendered `aria-current="page"` state for chapter pages.
- Added both hamburger and close SVGs for the open-state cross-fade and raised
the mobile toggle/link hit areas to at least 44 px.
- Added explicit right padding around the scrollable list.
- `src/web/templates/issue_full.html`
- Marked The Brief, every article index item, and the colophon with matching
`data-toc-entry` values for the front-page scroll-spy.
- `src/web/static/app.js`
- Added mobile panel sizing from the sticky bar's current viewport position,
recalculated on open and layout/visual-viewport resize.
- Added mobile document scroll lock and retained toggle, Escape,
outside-click, link-click, and `lg` breakpoint close behavior.
- Added a passive, requestAnimationFrame-throttled front-page scroll-spy. A
single current-link update now drives `aria-current="location"`, the mobile
title/count, desktop chapter status, both progress bars, and TOC reveal.
- Updated current-link reveal to use `scrollTo`, smooth unless reduced motion
is requested, without scrolling the page.
- Preserved progress-within-chapter behavior on article pages.
- `src/web/tailwind.css`
- Added the dynamic mobile panel height and scroll-lock rules.
- Added stable scrollbar gutter, right-side spacing, thin/rule-colored
cross-browser scrollbar styling, and overscroll containment.
- Added the centralized current/hover/focus link styles and exact 150 ms
opacity/scale/blur hamburger-to-close transition.
- `src/web/static/app.css`
- Rebuilt from the Tailwind source.
- `src/web/issue.rs`
- Added each TOC item's semantic progress position (including back matter) and
coverage for those positions and the rendered scroll-spy hooks.
- Made the current-link router assertion insensitive to HTML attribute order.
## Design decisions
- The open panel remains positioned directly below the shared sticky bar. JS
measures only the bar's on-screen bottom edge into `--toc-panel-top`; CSS owns
the final `calc(100dvh - var(--toc-panel-top))` height. This handles both the
bar still below the masthead and the bar already stuck at the top.
- The Brief uses progress position 0. Article, World, and Behind entries use
their chapter position. The colophon uses the issue total, so reaching it
communicates completion even though World and Behind are separate pages.
- Front-page position changes only toggle `aria-current` for current styling;
the other text/progress surfaces are derived from that same link's label and
progress metadata.
- Scrollbar gutter and padding are both used: the gutter reserves space for
classic scrollbars, while padding protects labels on overlay-scrollbar
platforms.
## Deviations and open items
- No live screenshots could be captured. The sandbox rejected the assigned
`127.0.0.1:3601` bind with `Operation not permitted`, exactly as anticipated
by the shared brief. Consequently desktop/mobile, light/dark, open-panel, and
scripted-scroll visual states remain to be checked outside the sandbox.
- The full unit test run also found three OpenAI mock-server tests blocked by
loopback binding in addition to the bind-dependent tests listed in the shared
brief. They fail at the same listener creation point and are unrelated to this
change.
## Verification
- `node --check src/web/static/app.js` — passed.
- `cargo fmt --check` — passed.
- `npm run css` — passed with Tailwind 4.3.3; `src/web/static/app.css` rebuilt.
- `npm run css:check` — passed with no diff. The worktree lacked its own CLI
shim, so both npm commands used the already-installed adjacent checkout's
`node_modules/.bin` via `PATH`; all inputs and outputs stayed in this worktree.
- `cargo test --lib web::` — 83 passed, 0 failed.
- `cargo clippy --all-targets -- -D warnings` — passed.
- `cargo test` — library phase: 431 passed, 13 bind-dependent failures. The
failures were 7 LLM mock-server tests, the documented relative-URL extraction
test, and the documented 5 server tests; each failed with `Operation not
permitted` while creating a loopback listener.
- `cargo test --test config_check --test e2e_pipeline --test m2_pipeline --test m3_curation --test m4_epub`
— 25 passed, 0 failed. `m7_server` was excluded because it requires the same
forbidden loopback bind.
- Attempted the configured dev-server command on port 3601 — build passed; bind
failed with `Operation not permitted`, so screenshots were not possible.
- `git diff --check` — passed.
+54 -4
View File
@@ -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 &#38; Other Tales")); assert!(html.contains("A Niche Delight &#38; Other Tales"));
if path == issue_href(date) {
assert!(html.contains(&format!(
"data-toc-entry=\"{}\"",
article_href(date, source.lineup.picks[0].article.id)
)));
assert!(
html.contains(&format!("data-toc-entry=\"{}#colophon\"", issue_href(date)))
);
}
} }
let anonymous = app let anonymous = app
File diff suppressed because one or more lines are too long
+98 -14
View File
@@ -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,10 +171,69 @@ 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);
} }
let currentLink = tocPanel.querySelector(".toc-link[aria-current]");
// Chapters above the current one read as "already passed" in ink rather than ink-2.
const markPassed = (link) => {
let passed = true;
tocLinks.forEach((candidate) => {
if (candidate === link) passed = false;
candidate.toggleAttribute("data-passed", passed);
});
};
if (currentLink) markPassed(currentLink);
const setCurrent = (link) => {
if (!link || (link === currentLink && link.getAttribute("aria-current") === "location")) return;
tocLinks.forEach((candidate) => candidate.removeAttribute("aria-current"));
markPassed(link);
link.setAttribute("aria-current", "location");
currentLink = link;
const position = Math.max(0, Number(link.dataset.tocPosition));
const total = tocBars.length ? Number(tocBars[0].max) : 0;
const isEnd = link.hasAttribute("data-toc-end");
const label = link.querySelector("[data-toc-link-label]");
if (tocLabel && label) tocLabel.textContent = label.textContent;
if (tocCount) {
tocCount.hidden = position === 0 || isEnd;
tocCount.textContent = `${position} / ${total}`;
} }
const tocBars = document.querySelectorAll("[data-toc-progress]"); if (tocStatus) tocStatus.textContent = isEnd ? "End of issue" : position > 0 ? `Chapter ${position} of ${total}` : "Front page";
tocBars.forEach((bar) => {
delete bar.dataset.live;
bar.value = position;
});
revealCurrent();
};
const tocEntries = Array.from(document.querySelectorAll("[data-toc-entry]")).map((entry) => ({
entry,
link: tocLinks.find((candidate) => candidate.getAttribute("href") === entry.dataset.tocEntry),
})).filter(({ link }) => link);
if (tocEntries.length) {
let queued = false;
const paintCurrent = () => {
queued = false;
const threshold = (tocBar ? tocBar.offsetHeight : 0) + window.innerHeight / 3;
let next = tocEntries[0].link;
tocEntries.forEach(({ entry, link }) => {
if (entry.getBoundingClientRect().top <= threshold) next = link;
});
setCurrent(next);
};
const scheduleCurrent = () => {
if (queued) return;
queued = true;
window.requestAnimationFrame(paintCurrent);
};
window.addEventListener("scroll", scheduleCurrent, { passive: true });
window.addEventListener("resize", scheduleCurrent);
scheduleCurrent();
}
const tocChapter = document.querySelector("[data-toc-scroll]"); const tocChapter = document.querySelector("[data-toc-scroll]");
if (tocBars.length && tocChapter) { if (tocBars.length && tocChapter) {
// "Chapter N" is worth position N once finished; show N-1 plus how far down we are. // "Chapter N" is worth position N once finished; show N-1 plus how far down we are.
@@ -177,3 +260,4 @@ if (tocBars.length && tocChapter) {
window.addEventListener("resize", schedule); window.addEventListener("resize", schedule);
schedule(); schedule();
} }
}
+14 -1
View File
@@ -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) {
+9 -8
View File
@@ -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>
+3 -3
View File
@@ -2,12 +2,12 @@
<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %} <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 %}