Merge branch 'main' into ui-rating
# Conflicts: # src/web/templates/issue_full.html
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
# Task 3 handoff — site shell
|
||||
|
||||
## What changed
|
||||
|
||||
- `src/web/issue.rs`
|
||||
- `IssueView` now records `is_latest`, computed by `issue::load` from
|
||||
`Db::latest_issue_date()`.
|
||||
- The full issue, article, World Briefing, and Behind the paper renderers now
|
||||
select `latest` or `archive` navigation state from that flag.
|
||||
- Added a router test with two issue dates proving that only the newest issue
|
||||
marks Latest `aria-current="page"` and that an older issue marks Archive.
|
||||
- `src/web/public.rs`
|
||||
- Anonymous issue rendering uses the same `IssueView::is_latest` navigation
|
||||
state as signed-in issue rendering.
|
||||
- `src/web/templates/layout.html`
|
||||
- The default ears block is empty except on an admin dashboard route, where it
|
||||
says `Dashboard`. Issue/account/login/archive/error templates retain their
|
||||
explicit ears; the public empty issue retains its own `Morning edition`.
|
||||
- Added the italic Newsreader preload alongside the upright preload.
|
||||
- Named the site header for cross-document view transitions.
|
||||
- Made the ears-row theme and account/sign-in controls at least 40 px tall;
|
||||
the account/sign-in link also has a 40 px minimum width.
|
||||
- Dashboard navigation links now have a 40 × 40 px minimum target and use the same 2 px accent
|
||||
active underline, ink hover, and inherited focus-visible ring as site nav.
|
||||
- `src/web/templates/dashboard/run.html`
|
||||
- The Feeds table no longer uses `.scroll-x`. It is fixed-layout and full
|
||||
width, truncates feed names with the full value in `title`, and reserves a
|
||||
narrow fixed entries column. Timings and Provider usage retain their
|
||||
horizontal-scroll behavior.
|
||||
- `src/web/tailwind.css`
|
||||
- Both Newsreader faces use `font-display: block`.
|
||||
- Added automatic cross-document view transitions with a 140 ms root
|
||||
cross-fade. The named site header does not fade, and reduced-motion disables
|
||||
transition animation.
|
||||
- `.badge`, `.pager`, and `.pagination` use tabular numerals. (`.tile-num`
|
||||
already did; the brief said `.pager` did too, but the code did not, so it was
|
||||
corrected.)
|
||||
- `src/web/static/app.css`
|
||||
- Rebuilt from `tailwind.css` with Tailwind 4.3.3.
|
||||
- `src/web/static/speculation.json`
|
||||
- Added moderate same-origin prerender document rules for `/` and
|
||||
`/issues/*`, and moderate prefetch rules for `/dashboard` and
|
||||
`/dashboard/*`.
|
||||
- Logout, rating, static, and all query-string links are excluded. Excluding
|
||||
every query is deliberately conservative and therefore also excludes any
|
||||
query that could mutate state.
|
||||
- `src/web/mod.rs`
|
||||
- Serves the speculation rules with
|
||||
`application/speculationrules+json` and retains content-hash ETags.
|
||||
- All `/static/*` responses, including 304 responses, now send
|
||||
`Cache-Control: public, max-age=31536000, immutable`.
|
||||
- HTML responses send `Speculation-Rules: "/static/speculation.json"` while
|
||||
retaining their existing public/private/no-store cache behavior.
|
||||
- Added assertions for immutable cache headers, rules MIME type, both font
|
||||
preloads, the HTML speculation header, dashboard ears, and dashboard active
|
||||
underline.
|
||||
- `src/web/static/app.js`
|
||||
- A `[data-refresh]` job page now waits for `prerenderingchange` before
|
||||
scheduling reload, so a prerendered page cannot reload in the background.
|
||||
- `src/web/static/theme.js`
|
||||
- Continues to apply the saved theme and `has-js` in the head before first
|
||||
paint.
|
||||
- A short-lived mutation observer now restores persisted `details[id]` state
|
||||
as the parser creates disclosures, then disconnects at `DOMContentLoaded`.
|
||||
This prevents a cold/delayed `app.js` request from exposing the default
|
||||
disclosure state before restoration; `app.js` still owns toggle persistence.
|
||||
|
||||
## Design and implementation decisions
|
||||
|
||||
- Chose `font-display: block` rather than `optional`. This prevents a visible
|
||||
fallback-to-Newsreader swap and preserves the publication typeface. Preloading
|
||||
both faces plus immutable content-hashed assets limits the block cost to an
|
||||
uncached first visit.
|
||||
- Kept the 140 ms transition restrained and limited to document navigation.
|
||||
The masthead/nav swap immediately instead of fading, so navigation context
|
||||
stays visually anchored.
|
||||
- Added only platform progressive enhancements. There is no fetch/swap router,
|
||||
HTMX, or route/auth change.
|
||||
- Speculation Rules and cross-document view transitions are progressive:
|
||||
Chromium uses them; unsupported browsers ignore them and retain normal
|
||||
navigation.
|
||||
|
||||
## Flash reproduction and visual verification
|
||||
|
||||
The requested dev server could not start inside this sandbox:
|
||||
|
||||
```text
|
||||
$ DAILY_EPUB_SERVER__BIND=127.0.0.1:3603 cargo run -- --config ./dev/config.toml serve
|
||||
Error: could not bind 127.0.0.1:3603: Operation not permitted (os error 1)
|
||||
```
|
||||
|
||||
Therefore the throttled Playwright before/after capture and the requested light,
|
||||
dark, desktop, and 390 px screenshots could not be produced. The code audit
|
||||
found that theme and mobile TOC state were already applied by `theme.js` before
|
||||
paint. Persisted `details` state has now also moved into that pre-paint path.
|
||||
The other visible-swap candidate was Newsreader's `font-display: swap`; the
|
||||
preload/cache/block changes address that path.
|
||||
|
||||
The requested curl calls likewise could not connect:
|
||||
|
||||
```text
|
||||
$ curl -sS -D - -o /dev/null http://127.0.0.1:3603/
|
||||
curl: (7) Failed to connect to 127.0.0.1 port 3603 after 0 ms: Couldn't connect to server
|
||||
|
||||
$ curl -sS -D - -o /dev/null http://127.0.0.1:3603/static/app.css
|
||||
curl: (7) Failed to connect to 127.0.0.1 port 3603 after 0 ms: Couldn't connect to server
|
||||
```
|
||||
|
||||
Router tests verify the material headers that those calls should show:
|
||||
|
||||
```text
|
||||
GET /
|
||||
content-type: text/html; charset=utf-8
|
||||
cache-control: public, max-age=300
|
||||
speculation-rules: "/static/speculation.json"
|
||||
vary: Cookie
|
||||
|
||||
GET /static/app.css
|
||||
content-type: text/css; charset=utf-8
|
||||
cache-control: public, max-age=31536000, immutable
|
||||
etag: "0b00513092d50175d3615cfd6cdfc5fed6e416f2aea118c3b0f0110a66089036"
|
||||
```
|
||||
|
||||
Signed-in issue responses remain `private, no-store`; dashboard responses
|
||||
remain `no-store`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo fmt -- --check` — passed.
|
||||
- `cargo clippy --all-targets -- -D warnings` — passed.
|
||||
- `npm run css:check` — passed with no diff using the repository's pinned
|
||||
Tailwind 4.3.3 binary. (`npm ci` could not finish under the restricted npm
|
||||
environment, so the already-installed sibling-worktree binary was placed on
|
||||
`PATH`; that worktree was only read, never modified.)
|
||||
- `python3 -m json.tool src/web/static/speculation.json` — passed.
|
||||
- `node --check src/web/static/app.js` and `node --check
|
||||
src/web/static/theme.js` — passed.
|
||||
- `cargo test --lib web::` — 84 passed, 0 failed.
|
||||
- Dedicated newest/archive navigation test — passed.
|
||||
- Dedicated dashboard settings shell/ears/active-nav test — passed.
|
||||
- `cargo test` — 432 library tests passed; 13 tests failed solely because their
|
||||
mock HTTP servers could not bind loopback (`Operation not permitted`). Ten are
|
||||
the sandbox failures named in the shared brief; three newer OpenAI mock-server
|
||||
tests fail at the same listener helper for the same reason.
|
||||
- `cargo test --lib` with those exact 13 bind-dependent tests skipped — 432
|
||||
passed, 0 failed, 13 filtered out.
|
||||
- Non-server integration tests (`config_check`, `e2e_pipeline`, `m2_pipeline`,
|
||||
`m3_curation`, `m4_epub`) — 25 passed, 0 failed.
|
||||
- `cargo test --doc` — passed (0 doctests).
|
||||
- `git diff --check` — passed.
|
||||
|
||||
## Left open
|
||||
|
||||
- Run the four requested screenshot sets and throttled first-frame comparison
|
||||
outside the sandbox, where port 3603 can bind. This is also the remaining
|
||||
browser-level validation for view-transition behavior and Chrome's
|
||||
Speculation Rules panel.
|
||||
- `tests/m7_server.rs` was not run because it is explicitly excluded in this
|
||||
loopback-restricted sandbox.
|
||||
@@ -0,0 +1,89 @@
|
||||
# Task 1 handoff — table-of-contents sidebar
|
||||
|
||||
## What changed
|
||||
|
||||
- `src/web/templates/_toc.html`
|
||||
- Reworked the narrow-screen panel to use a measured viewport offset and a
|
||||
`100dvh`-based height instead of the old `72vh` cap.
|
||||
- Added live label, count, desktop-status, link-label, and per-link progress
|
||||
hooks used by the shared TOC state updater.
|
||||
- Moved current-link presentation onto `.toc-link[aria-current]` and kept the
|
||||
server-rendered `aria-current="page"` state for chapter pages.
|
||||
- Added both hamburger and close SVGs for the open-state cross-fade and raised
|
||||
the mobile toggle/link hit areas to at least 44 px.
|
||||
- Added explicit right padding around the scrollable list.
|
||||
- `src/web/templates/issue_full.html`
|
||||
- Marked The Brief, every article index item, and the colophon with matching
|
||||
`data-toc-entry` values for the front-page scroll-spy.
|
||||
- `src/web/static/app.js`
|
||||
- Added mobile panel sizing from the sticky bar's current viewport position,
|
||||
recalculated on open and layout/visual-viewport resize.
|
||||
- Added mobile document scroll lock and retained toggle, Escape,
|
||||
outside-click, link-click, and `lg` breakpoint close behavior.
|
||||
- Added a passive, requestAnimationFrame-throttled front-page scroll-spy. A
|
||||
single current-link update now drives `aria-current="location"`, the mobile
|
||||
title/count, desktop chapter status, both progress bars, and TOC reveal.
|
||||
- Updated current-link reveal to use `scrollTo`, smooth unless reduced motion
|
||||
is requested, without scrolling the page.
|
||||
- Preserved progress-within-chapter behavior on article pages.
|
||||
- `src/web/tailwind.css`
|
||||
- Added the dynamic mobile panel height and scroll-lock rules.
|
||||
- Added stable scrollbar gutter, right-side spacing, thin/rule-colored
|
||||
cross-browser scrollbar styling, and overscroll containment.
|
||||
- Added the centralized current/hover/focus link styles and exact 150 ms
|
||||
opacity/scale/blur hamburger-to-close transition.
|
||||
- `src/web/static/app.css`
|
||||
- Rebuilt from the Tailwind source.
|
||||
- `src/web/issue.rs`
|
||||
- Added each TOC item's semantic progress position (including back matter) and
|
||||
coverage for those positions and the rendered scroll-spy hooks.
|
||||
- Made the current-link router assertion insensitive to HTML attribute order.
|
||||
|
||||
## Design decisions
|
||||
|
||||
- The open panel remains positioned directly below the shared sticky bar. JS
|
||||
measures only the bar's on-screen bottom edge into `--toc-panel-top`; CSS owns
|
||||
the final `calc(100dvh - var(--toc-panel-top))` height. This handles both the
|
||||
bar still below the masthead and the bar already stuck at the top.
|
||||
- The Brief uses progress position 0. Article, World, and Behind entries use
|
||||
their chapter position. The colophon uses the issue total, so reaching it
|
||||
communicates completion even though World and Behind are separate pages.
|
||||
- Front-page position changes only toggle `aria-current` for current styling;
|
||||
the other text/progress surfaces are derived from that same link's label and
|
||||
progress metadata.
|
||||
- Scrollbar gutter and padding are both used: the gutter reserves space for
|
||||
classic scrollbars, while padding protects labels on overlay-scrollbar
|
||||
platforms.
|
||||
|
||||
## Deviations and open items
|
||||
|
||||
- No live screenshots could be captured. The sandbox rejected the assigned
|
||||
`127.0.0.1:3601` bind with `Operation not permitted`, exactly as anticipated
|
||||
by the shared brief. Consequently desktop/mobile, light/dark, open-panel, and
|
||||
scripted-scroll visual states remain to be checked outside the sandbox.
|
||||
- The full unit test run also found three OpenAI mock-server tests blocked by
|
||||
loopback binding in addition to the bind-dependent tests listed in the shared
|
||||
brief. They fail at the same listener creation point and are unrelated to this
|
||||
change.
|
||||
|
||||
## Verification
|
||||
|
||||
- `node --check src/web/static/app.js` — passed.
|
||||
- `cargo fmt --check` — passed.
|
||||
- `npm run css` — passed with Tailwind 4.3.3; `src/web/static/app.css` rebuilt.
|
||||
- `npm run css:check` — passed with no diff. The worktree lacked its own CLI
|
||||
shim, so both npm commands used the already-installed adjacent checkout's
|
||||
`node_modules/.bin` via `PATH`; all inputs and outputs stayed in this worktree.
|
||||
- `cargo test --lib web::` — 83 passed, 0 failed.
|
||||
- `cargo clippy --all-targets -- -D warnings` — passed.
|
||||
- `cargo test` — library phase: 431 passed, 13 bind-dependent failures. The
|
||||
failures were 7 LLM mock-server tests, the documented relative-URL extraction
|
||||
test, and the documented 5 server tests; each failed with `Operation not
|
||||
permitted` while creating a loopback listener.
|
||||
- `cargo test --test config_check --test e2e_pipeline --test m2_pipeline --test m3_curation --test m4_epub`
|
||||
— 25 passed, 0 failed. `m7_server` was excluded because it requires the same
|
||||
forbidden loopback bind.
|
||||
- Attempted the configured dev-server command on port 3601 — build passed; bind
|
||||
failed with `Operation not permitted`, so screenshots were not possible.
|
||||
- `git diff --check` — passed.
|
||||
|
||||
+148
-8
@@ -35,6 +35,7 @@ pub struct IssueView {
|
||||
pub downloads: Vec<Download>,
|
||||
pub from_json: bool,
|
||||
pub world_html: Option<String>,
|
||||
pub is_latest: bool,
|
||||
has_behind: bool,
|
||||
legacy_counts: Option<LegacyColophonCounts>,
|
||||
}
|
||||
@@ -80,6 +81,7 @@ pub async fn load(
|
||||
let Some(row) = db.issue_by_date(date).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let is_latest = db.latest_issue_date().await? == Some(date);
|
||||
let legacy = if row.issue_json.is_none() {
|
||||
Some(load_legacy_facts(db, date, row.report_json.as_deref()).await?)
|
||||
} else {
|
||||
@@ -273,6 +275,7 @@ pub async fn load(
|
||||
downloads,
|
||||
from_json,
|
||||
world_html,
|
||||
is_latest,
|
||||
has_behind: from_json || legacy.as_ref().is_some_and(|facts| facts.has_source),
|
||||
legacy_counts: legacy.map(|facts| LegacyColophonCounts {
|
||||
entries_fetched: facts.entries_fetched,
|
||||
@@ -659,6 +662,8 @@ struct TocItem {
|
||||
href: String,
|
||||
number: Option<usize>,
|
||||
minutes: Option<i64>,
|
||||
/// Progress position used by the shared TOC UI (0 for The Brief).
|
||||
progress: usize,
|
||||
current: bool,
|
||||
/// First entry of the back-matter group; the template draws a hairline above it.
|
||||
divider: bool,
|
||||
@@ -668,6 +673,10 @@ impl TocItem {
|
||||
fn is_section(&self) -> bool {
|
||||
matches!(self.kind, TocKind::Section)
|
||||
}
|
||||
|
||||
fn is_colophon(&self) -> bool {
|
||||
matches!(self.kind, TocKind::Colophon)
|
||||
}
|
||||
}
|
||||
|
||||
/// The table of contents shared by the four signed-in issue pages.
|
||||
@@ -703,12 +712,18 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
let issue = &view.issue;
|
||||
let date = issue.meta.date;
|
||||
let issue_href = issue_href(date);
|
||||
let plain = |kind: TocKind, label: &str, href: String, current: bool, divider: bool| TocItem {
|
||||
let plain = |kind: TocKind,
|
||||
label: &str,
|
||||
href: String,
|
||||
progress: usize,
|
||||
current: bool,
|
||||
divider: bool| TocItem {
|
||||
kind,
|
||||
label: label.to_string(),
|
||||
href,
|
||||
number: None,
|
||||
minutes: None,
|
||||
progress,
|
||||
current,
|
||||
divider,
|
||||
};
|
||||
@@ -717,13 +732,21 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
TocKind::Brief,
|
||||
"The Brief",
|
||||
issue_href.clone(),
|
||||
0,
|
||||
current == TocPosition::FrontPage,
|
||||
false,
|
||||
)];
|
||||
let mut position = 0usize;
|
||||
let mut total = 0usize;
|
||||
for name in chapters::section_names(issue) {
|
||||
items.push(plain(TocKind::Section, &name, String::new(), false, false));
|
||||
items.push(plain(
|
||||
TocKind::Section,
|
||||
&name,
|
||||
String::new(),
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
));
|
||||
for pick in issue.lineup.section_picks(&name) {
|
||||
total += 1;
|
||||
let is_current = current == TocPosition::Article(pick.article.id);
|
||||
@@ -736,6 +759,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
href: article_href(date, pick.article.id),
|
||||
number: Some(total),
|
||||
minutes: Some(pick.article.reading_minutes()),
|
||||
progress: total,
|
||||
current: is_current,
|
||||
divider: false,
|
||||
});
|
||||
@@ -753,6 +777,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
TocKind::World,
|
||||
"World Briefing",
|
||||
format!("/issues/{date}/world"),
|
||||
total,
|
||||
is_current,
|
||||
divider,
|
||||
));
|
||||
@@ -768,6 +793,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
TocKind::Behind,
|
||||
"Behind the paper",
|
||||
format!("/issues/{date}/behind"),
|
||||
total,
|
||||
is_current,
|
||||
divider,
|
||||
));
|
||||
@@ -777,6 +803,7 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
|
||||
TocKind::Colophon,
|
||||
"Colophon",
|
||||
format!("{issue_href}#colophon"),
|
||||
total,
|
||||
false,
|
||||
divider,
|
||||
));
|
||||
@@ -955,7 +982,8 @@ pub async fn render_full(
|
||||
})
|
||||
.collect();
|
||||
let colophon = colophon_view(&view.issue, view.legacy_counts.as_ref());
|
||||
let mut page = Page::new(format!("Issue {date}"), Some(viewer), "latest");
|
||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||
let mut page = Page::new(format!("Issue {date}"), Some(viewer), active_nav);
|
||||
page.flash = take_flash(session).await?;
|
||||
Ok(Html(IssueFullTemplate {
|
||||
page,
|
||||
@@ -1023,7 +1051,8 @@ pub async fn article(
|
||||
href: article_href(date, next.article.id),
|
||||
});
|
||||
let article = &pick.article;
|
||||
let mut page = Page::new(article.title.clone(), Some(viewer.clone()), "latest");
|
||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||
let mut page = Page::new(article.title.clone(), Some(viewer.clone()), active_nav);
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(ArticleTemplate {
|
||||
page,
|
||||
@@ -1089,7 +1118,8 @@ pub async fn world(
|
||||
} else {
|
||||
return Err(WebError::NotFound);
|
||||
};
|
||||
let mut page = Page::new("World Briefing", Some(viewer), "latest");
|
||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||
let mut page = Page::new("World Briefing", Some(viewer), active_nav);
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(WorldTemplate {
|
||||
page,
|
||||
@@ -1122,7 +1152,8 @@ pub async fn behind(
|
||||
}
|
||||
let toc = issue_toc(&view, TocPosition::Behind);
|
||||
let behind = &view.issue.behind;
|
||||
let mut page = Page::new("Behind the paper", Some(viewer), "latest");
|
||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||
let mut page = Page::new("Behind the paper", Some(viewer), active_nav);
|
||||
page.flash = take_flash(&session).await?;
|
||||
Ok(Html(BehindTemplate {
|
||||
page,
|
||||
@@ -1536,6 +1567,89 @@ mod tests {
|
||||
assert!(loaded.issue.editorial.front_page_html.contains("coffee"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn issue_navigation_marks_only_the_newest_issue_as_latest() {
|
||||
let (_dir, db, source) = seeded_issue(true).await;
|
||||
let old_date = source.meta.date;
|
||||
let latest_date: Date = "2026-08-16".parse().unwrap();
|
||||
let mut latest = source.clone();
|
||||
latest.meta.date = latest_date;
|
||||
latest.meta.issue_number += 1;
|
||||
latest.meta.display_date = display_date(latest_date);
|
||||
latest.lineup.date = latest_date;
|
||||
if let Some(world) = &mut latest.world_briefing {
|
||||
world.date = latest_date;
|
||||
}
|
||||
let latest_json = serde_json::to_string(&latest).unwrap();
|
||||
db.upsert_issue(
|
||||
latest_date,
|
||||
latest.meta.issue_number,
|
||||
latest.meta.generated_at,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(&latest.editorial.front_page_html),
|
||||
None,
|
||||
Some(&latest_json),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
db.replace_issue_articles(latest_date, &latest.lineup.picks)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let old_view = load(&db, &crate::config::Config::default(), old_date)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let latest_view = load(&db, &crate::config::Config::default(), latest_date)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!old_view.is_latest);
|
||||
assert!(latest_view.is_latest);
|
||||
|
||||
let app = crate::server::router(crate::server::AppState::new(
|
||||
db,
|
||||
crate::config::Config::default(),
|
||||
None,
|
||||
));
|
||||
let old = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{old_date}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(old.status(), StatusCode::OK);
|
||||
let old = response_text(old).await;
|
||||
assert!(
|
||||
old.contains("href=\"/issues\" aria-current=\"page\">Archive</a>"),
|
||||
"{old}"
|
||||
);
|
||||
assert!(!old.contains("href=\"/\" aria-current=\"page\">Latest</a>"));
|
||||
|
||||
let latest = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/issues/{latest_date}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(latest.status(), StatusCode::OK);
|
||||
let latest = response_text(latest).await;
|
||||
assert!(
|
||||
latest.contains("href=\"/\" aria-current=\"page\">Latest</a>"),
|
||||
"{latest}"
|
||||
);
|
||||
assert!(!latest.contains("href=\"/issues\" aria-current=\"page\">Archive</a>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_issue_archive_feed_robots_and_reports_are_served() {
|
||||
let (_dir, db, source) = seeded_issue(true).await;
|
||||
@@ -2160,6 +2274,15 @@ mod tests {
|
||||
assert_eq!(front.position, 0);
|
||||
assert_eq!(front.current_label(), "The Brief");
|
||||
assert_eq!(front.short_date, "Sat, Aug 15");
|
||||
assert_eq!(
|
||||
front
|
||||
.items
|
||||
.iter()
|
||||
.filter(|item| !item.is_section())
|
||||
.map(|item| item.progress)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![0, 1, 2, 3, 4, 4]
|
||||
);
|
||||
// Exactly one hairline, above the first back-matter entry.
|
||||
assert_eq!(
|
||||
front
|
||||
@@ -2234,13 +2357,30 @@ mod tests {
|
||||
let html = response_text(response).await;
|
||||
assert!(html.contains("data-toc-toggle"), "{path} has no toc bar");
|
||||
assert!(html.contains("id=\"toc\""), "{path} has no toc panel");
|
||||
assert!(html.contains(progress), "{path} is missing {progress:?}");
|
||||
assert!(
|
||||
html.contains(&format!("href=\"{current}\" aria-current=\"page\"")),
|
||||
html.contains("data-toc-status"),
|
||||
"{path} has no live status"
|
||||
);
|
||||
assert!(html.contains(progress), "{path} is missing {progress:?}");
|
||||
let current_link = html
|
||||
.split(&format!("class=\"toc-link\" href=\"{current}\""))
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.split("</a>").next());
|
||||
assert!(
|
||||
current_link.is_some_and(|link| link.contains("aria-current=\"page\"")),
|
||||
"{path} does not mark {current} as current"
|
||||
);
|
||||
assert!(html.contains(&format!("{}#colophon", issue_href(date))));
|
||||
assert!(html.contains("A Niche Delight & Other Tales"));
|
||||
if path == issue_href(date) {
|
||||
assert!(html.contains(&format!(
|
||||
"data-toc-entry=\"{}\"",
|
||||
article_href(date, source.lineup.picks[0].article.id)
|
||||
)));
|
||||
assert!(
|
||||
html.contains(&format!("data-toc-entry=\"{}#colophon\"", issue_href(date)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let anonymous = app
|
||||
|
||||
+81
-4
@@ -206,7 +206,7 @@ pub struct Page {
|
||||
pub version: &'static str,
|
||||
/// Cache-busting token for `/static/*.css|js` URLs: a content hash, so
|
||||
/// any stylesheet or script change reaches browsers that cached the
|
||||
/// previous build (they are served with a one-day `max-age`).
|
||||
/// previous build (they are served with an immutable one-year `max-age`).
|
||||
pub asset_version: &'static str,
|
||||
}
|
||||
|
||||
@@ -236,6 +236,22 @@ impl Page {
|
||||
.as_ref()
|
||||
.is_some_and(|viewer| viewer.role == users::Role::Admin)
|
||||
}
|
||||
|
||||
pub fn is_dashboard(&self) -> bool {
|
||||
self.is_admin()
|
||||
&& matches!(
|
||||
self.active_nav.as_str(),
|
||||
"dashboard"
|
||||
| "runs"
|
||||
| "articles"
|
||||
| "ratings"
|
||||
| "profile"
|
||||
| "stats"
|
||||
| "jobs"
|
||||
| "settings"
|
||||
| "users"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn take_flash(session: &Session) -> Result<Option<Flash>, WebError> {
|
||||
@@ -415,6 +431,10 @@ pub async fn security_headers(request: Request, next: Next) -> Response {
|
||||
.is_some_and(|value| value.starts_with("text/html"))
|
||||
{
|
||||
headers.append(header::VARY, HeaderValue::from_static("Cookie"));
|
||||
headers.insert(
|
||||
header::HeaderName::from_static("speculation-rules"),
|
||||
HeaderValue::from_static("\"/static/speculation.json\""),
|
||||
);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -529,6 +549,10 @@ async fn static_asset(
|
||||
"application/javascript; charset=utf-8",
|
||||
include_str!("static/theme.js").as_bytes(),
|
||||
),
|
||||
"speculation.json" => (
|
||||
"application/speculationrules+json",
|
||||
include_str!("static/speculation.json").as_bytes(),
|
||||
),
|
||||
"favicon.svg" => (
|
||||
"image/svg+xml",
|
||||
include_str!("static/favicon.svg").as_bytes(),
|
||||
@@ -553,7 +577,10 @@ async fn static_asset(
|
||||
StatusCode::NOT_MODIFIED,
|
||||
[
|
||||
(header::ETAG, etag),
|
||||
(header::CACHE_CONTROL, "public, max-age=86400".into()),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
"public, max-age=31536000, immutable".into(),
|
||||
),
|
||||
],
|
||||
)
|
||||
.into_response();
|
||||
@@ -562,7 +589,10 @@ async fn static_asset(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(header::CONTENT_TYPE, asset.0.to_string()),
|
||||
(header::CACHE_CONTROL, "public, max-age=86400".into()),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
"public, max-age=31536000, immutable".into(),
|
||||
),
|
||||
(header::ETAG, etag),
|
||||
],
|
||||
asset.1,
|
||||
@@ -653,6 +683,10 @@ mod tests {
|
||||
anonymous.headers().get(header::CACHE_CONTROL).unwrap(),
|
||||
"public, max-age=300"
|
||||
);
|
||||
assert_eq!(
|
||||
anonymous.headers().get("speculation-rules").unwrap(),
|
||||
"\"/static/speculation.json\""
|
||||
);
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
@@ -792,6 +826,27 @@ mod tests {
|
||||
allowed.headers().get(header::CACHE_CONTROL).unwrap(),
|
||||
"no-store"
|
||||
);
|
||||
let settings = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/dashboard/settings")
|
||||
.header(header::COOKIE, &admin)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(settings.status(), StatusCode::OK);
|
||||
let settings = response_text(settings).await;
|
||||
assert!(settings.contains("<span>Dashboard</span>"), "{settings}");
|
||||
assert!(!settings.contains("Morning edition"), "{settings}");
|
||||
assert!(
|
||||
settings.contains(
|
||||
"border-accent text-ink\" href=\"/dashboard/settings\" aria-current=\"page\""
|
||||
),
|
||||
"{settings}"
|
||||
);
|
||||
let allowed_rate = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
@@ -1025,6 +1080,8 @@ mod tests {
|
||||
let expected = format!("/static/app.css?v={}", ASSET_VERSION.as_str());
|
||||
assert!(html.contains(&expected), "{html}");
|
||||
assert!(!html.contains(&format!("/static/app.css?v={}", crate::VERSION)));
|
||||
assert!(html.contains("rel=\"preload\" href=\"/static/Newsreader.woff2\""));
|
||||
assert!(html.contains("rel=\"preload\" href=\"/static/Newsreader-italic.woff2\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1044,10 +1101,11 @@ mod tests {
|
||||
assert_eq!(first.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
first.headers().get(header::CACHE_CONTROL).unwrap(),
|
||||
"public, max-age=86400"
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
let etag = first.headers().get(header::ETAG).unwrap().clone();
|
||||
let cached = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/static/app.css")
|
||||
@@ -1058,6 +1116,25 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cached.status(), StatusCode::NOT_MODIFIED);
|
||||
assert_eq!(
|
||||
cached.headers().get(header::CACHE_CONTROL).unwrap(),
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
|
||||
let rules = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/static/speculation.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rules.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
rules.headers().get(header::CONTENT_TYPE).unwrap(),
|
||||
"application/speculationrules+json"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+2
-1
@@ -219,8 +219,9 @@ pub async fn show_issue(
|
||||
let response = issue::render_full(&state, view, viewer, &session).await?;
|
||||
return Ok(public_cache(response, &headers));
|
||||
}
|
||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||
let response = Html(IssuePublicTemplate {
|
||||
page: Page::new(format!("Issue {date}"), None, "latest"),
|
||||
page: Page::new(format!("Issue {date}"), None, active_nav),
|
||||
issue: PublicIssue::from(&view.issue),
|
||||
downloads: Vec::new(),
|
||||
empty: false,
|
||||
|
||||
File diff suppressed because one or more lines are too long
+105
-15
@@ -109,29 +109,59 @@ document.addEventListener("click", (event) => {
|
||||
/* step 6: reload a job page every N seconds while its job is requested/running */
|
||||
document.querySelectorAll("[data-refresh]").forEach((element) => {
|
||||
const seconds = Number(element.dataset.refresh);
|
||||
if (seconds > 0) setTimeout(() => window.location.reload(), seconds * 1000);
|
||||
if (seconds <= 0) return;
|
||||
const schedule = () => setTimeout(() => window.location.reload(), seconds * 1000);
|
||||
if (document.prerendering) {
|
||||
document.addEventListener("prerenderingchange", schedule, { once: true });
|
||||
} else {
|
||||
schedule();
|
||||
}
|
||||
});
|
||||
/* step 4: table-of-contents panel (below `lg`) and reading-progress bar */
|
||||
/* step 4: table-of-contents panel, current chapter, and reading progress */
|
||||
const tocPanel = document.querySelector("[data-toc-panel]");
|
||||
const tocToggle = document.querySelector("[data-toc-toggle]");
|
||||
if (tocPanel) {
|
||||
const tocBar = document.querySelector("[data-toc-bar]");
|
||||
const tocLinks = Array.from(tocPanel.querySelectorAll(".toc-link"));
|
||||
const tocBars = document.querySelectorAll("[data-toc-progress]");
|
||||
const tocLabel = document.querySelector("[data-toc-label]");
|
||||
const tocCount = document.querySelector("[data-toc-count]");
|
||||
const tocStatus = document.querySelector("[data-toc-status]");
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const largeScreen = window.matchMedia("(min-width: 64rem)");
|
||||
|
||||
// Long issues overflow the sidebar; scroll just enough to show where we are.
|
||||
const revealCurrent = () => {
|
||||
const current = tocPanel.querySelector("a[aria-current=page]");
|
||||
const current = tocPanel.querySelector(".toc-link[aria-current]");
|
||||
if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return;
|
||||
const margin = 24;
|
||||
const top = current.offsetTop - margin;
|
||||
const bottom = current.offsetTop + current.offsetHeight + margin;
|
||||
if (bottom > tocPanel.scrollTop + tocPanel.clientHeight) {
|
||||
tocPanel.scrollTop = bottom - tocPanel.clientHeight;
|
||||
} else if (top < tocPanel.scrollTop) {
|
||||
tocPanel.scrollTop = Math.max(0, top);
|
||||
}
|
||||
const stickyHeader = tocPanel.querySelector(":scope > div");
|
||||
const topMargin = (stickyHeader && stickyHeader.offsetHeight > 0 ? stickyHeader.offsetHeight : 0) + 24;
|
||||
const panelRect = tocPanel.getBoundingClientRect();
|
||||
const currentRect = current.getBoundingClientRect();
|
||||
const top = tocPanel.scrollTop + currentRect.top - panelRect.top;
|
||||
const visibleTop = tocPanel.scrollTop + topMargin;
|
||||
const visibleBottom = tocPanel.scrollTop + tocPanel.clientHeight - 24;
|
||||
if (top >= visibleTop && top + currentRect.height <= visibleBottom) return;
|
||||
const target = Math.max(0, top - Math.max(topMargin, (tocPanel.clientHeight - currentRect.height) / 2));
|
||||
tocPanel.scrollTo({ top: target, behavior: reducedMotion.matches ? "auto" : "smooth" });
|
||||
};
|
||||
const sizeOpenPanel = () => {
|
||||
if (tocPanel.dataset.open !== "true" || largeScreen.matches || !tocBar) return;
|
||||
tocPanel.style.setProperty("--toc-panel-top", `${Math.max(0, tocBar.getBoundingClientRect().bottom)}px`);
|
||||
};
|
||||
const setOpen = (open) => {
|
||||
tocPanel.dataset.open = open ? "true" : "false";
|
||||
if (tocToggle) tocToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
if (open) revealCurrent();
|
||||
document.documentElement.classList.toggle("toc-panel-open", open && !largeScreen.matches);
|
||||
if (tocToggle) {
|
||||
tocToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
tocToggle.setAttribute("aria-label", open ? "Close contents" : "Contents");
|
||||
}
|
||||
if (open) {
|
||||
sizeOpenPanel();
|
||||
revealCurrent();
|
||||
} else {
|
||||
tocPanel.style.removeProperty("--toc-panel-top");
|
||||
}
|
||||
};
|
||||
setOpen(false);
|
||||
revealCurrent();
|
||||
@@ -147,10 +177,69 @@ if (tocPanel) {
|
||||
setOpen(false);
|
||||
tocToggle.focus();
|
||||
});
|
||||
window.matchMedia("(min-width: 64rem)").addEventListener("change", () => setOpen(false));
|
||||
largeScreen.addEventListener("change", () => setOpen(false));
|
||||
window.addEventListener("resize", sizeOpenPanel);
|
||||
if (window.visualViewport) window.visualViewport.addEventListener("resize", sizeOpenPanel);
|
||||
}
|
||||
|
||||
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]");
|
||||
if (tocBars.length && tocChapter) {
|
||||
// "Chapter N" is worth position N once finished; show N-1 plus how far down we are.
|
||||
@@ -177,3 +266,4 @@ if (tocBars.length && tocChapter) {
|
||||
window.addEventListener("resize", schedule);
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"prerender": [
|
||||
{
|
||||
"source": "document",
|
||||
"where": {
|
||||
"and": [
|
||||
{ "href_matches": ["/", "/issues/*"] },
|
||||
{ "not": { "href_matches": ["/logout", "/rate", "/static/*", "/*\\?*"] } }
|
||||
]
|
||||
},
|
||||
"eagerness": "moderate"
|
||||
}
|
||||
],
|
||||
"prefetch": [
|
||||
{
|
||||
"source": "document",
|
||||
"where": {
|
||||
"and": [
|
||||
{ "href_matches": ["/dashboard", "/dashboard/*"] },
|
||||
{ "not": { "href_matches": ["/logout", "/rate", "/static/*", "/*\\?*"] } }
|
||||
]
|
||||
},
|
||||
"eagerness": "moderate"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,4 +9,23 @@
|
||||
} catch (_) {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
}
|
||||
|
||||
// Restore persisted disclosures as the parser creates them. This head script
|
||||
// runs before paint, so a cold app.js request cannot expose the server-default
|
||||
// open state and then shift the dashboard when the script finally arrives.
|
||||
const restoreDetails = (root) => {
|
||||
const details = [];
|
||||
if (root.nodeType === Node.ELEMENT_NODE && root.matches("details[id]")) details.push(root);
|
||||
root.querySelectorAll?.("details[id]").forEach((element) => details.push(element));
|
||||
details.forEach((element) => {
|
||||
try {
|
||||
element.open = localStorage.getItem("details:" + element.id) === "open";
|
||||
} catch (_) {}
|
||||
});
|
||||
};
|
||||
const detailsObserver = new MutationObserver((records) => {
|
||||
records.forEach((record) => record.addedNodes.forEach(restoreDetails));
|
||||
});
|
||||
detailsObserver.observe(document.documentElement, { childList: true, subtree: true });
|
||||
document.addEventListener("DOMContentLoaded", () => detailsObserver.disconnect(), { once: true });
|
||||
})();
|
||||
|
||||
+52
-6
@@ -9,7 +9,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 200 800;
|
||||
font-stretch: normal;
|
||||
font-display: swap;
|
||||
font-display: block;
|
||||
src: url("/static/Newsreader.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
font-style: italic;
|
||||
font-weight: 200 800;
|
||||
font-stretch: normal;
|
||||
font-display: swap;
|
||||
font-display: block;
|
||||
src: url("/static/Newsreader-italic.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -82,6 +82,39 @@
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior:auto !important; transition-duration:0.01ms !important; } }
|
||||
}
|
||||
|
||||
@view-transition {
|
||||
navigation: auto;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
view-transition-name: site-header;
|
||||
}
|
||||
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation-duration: 140ms;
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
::view-transition-group(site-header),
|
||||
::view-transition-new(site-header) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
::view-transition-old(site-header) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
::view-transition-group(site-header),
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root),
|
||||
::view-transition-old(site-header),
|
||||
::view-transition-new(site-header) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@layer components {
|
||||
/* ---------- shared controls ---------- */
|
||||
@@ -92,7 +125,7 @@
|
||||
.btn-danger, button.danger { @apply inline-flex min-h-9 items-center justify-center rounded-sm border border-down bg-transparent px-2.5 py-1 font-sans text-sm font-medium text-down duration-150 hover:bg-down hover:text-paper; transition-property:transform, background-color, color, border-color; }
|
||||
.btn:active, .button:active, .btn-primary:active, .btn-danger:active, button.danger:active { transform:scale(0.96); }
|
||||
|
||||
.badge { @apply inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 font-sans text-xs font-medium leading-5; color:var(--ink-2); background:color-mix(in oklab, var(--ink) 8%, transparent); }
|
||||
.badge { @apply inline-flex items-center whitespace-nowrap rounded-full px-2 py-0.5 font-sans text-xs font-medium leading-5 tabular-nums; color:var(--ink-2); background:color-mix(in oklab, var(--ink) 8%, transparent); }
|
||||
.badge.selected, .badge.loved, .badge.ok { color:var(--loved); background:color-mix(in oklab, var(--loved) 14%, transparent); }
|
||||
.badge.good, .badge.assessed, .badge.triaged, .badge.degraded { color:var(--good); background:color-mix(in oklab, var(--good) 14%, transparent); }
|
||||
.badge.down, .badge.excluded, .badge.failed, .badge.dry_run { color:var(--down); background:color-mix(in oklab, var(--down) 14%, transparent); }
|
||||
@@ -199,14 +232,14 @@
|
||||
.form-inline label { @apply flex items-center gap-2 text-sm; }
|
||||
.table-filter { @apply my-2 w-80 max-w-full font-sans text-sm; }
|
||||
|
||||
.pager { @apply flex flex-wrap items-center justify-between gap-x-6 gap-y-2 py-1 font-sans text-sm text-muted; }
|
||||
.pager { @apply flex flex-wrap items-center justify-between gap-x-6 gap-y-2 py-1 font-sans text-sm tabular-nums text-muted; }
|
||||
.pager a { @apply text-ink no-underline hover:text-accent; }
|
||||
.run-nav { @apply flex flex-wrap items-center justify-between gap-x-6 gap-y-2 font-sans text-sm; }
|
||||
.run-nav a { @apply text-ink no-underline hover:text-accent; }
|
||||
.tabs { @apply flex flex-wrap items-center gap-x-6 border-b border-rule font-sans text-[0.72rem] font-medium uppercase tracking-[0.12em]; }
|
||||
.tabs a { @apply -mb-px flex min-h-10 items-center border-b-2 border-transparent text-muted no-underline hover:text-ink; }
|
||||
.tabs a.active { @apply border-accent text-ink; }
|
||||
.pagination { @apply font-sans text-sm text-muted; }
|
||||
.pagination { @apply font-sans text-sm tabular-nums text-muted; }
|
||||
|
||||
tr.superseded > td { @apply text-muted; }
|
||||
tr.superseded a { @apply text-muted; }
|
||||
@@ -278,9 +311,22 @@
|
||||
/* While JS drives the bar from scroll position it must track the finger exactly. */
|
||||
.toc-progress[data-live]::-webkit-progress-value { transition:none; }
|
||||
@media (prefers-reduced-motion: reduce) { .toc-progress::-webkit-progress-value { transition:none; } }
|
||||
[data-toc-panel] { scrollbar-width:thin; scrollbar-color:var(--rule) transparent; }
|
||||
[data-toc-panel] { scrollbar-width:thin; scrollbar-color:var(--rule) transparent; scrollbar-gutter:stable; }
|
||||
[data-toc-panel]::-webkit-scrollbar { width:0.5rem; }
|
||||
[data-toc-panel]::-webkit-scrollbar-thumb { border:2px solid transparent; background:var(--rule); background-clip:padding-box; }
|
||||
.toc-link { @apply flex min-h-11 items-baseline gap-2 border-l-2 border-transparent py-2.5 pl-3 text-ink-2 no-underline transition-colors duration-150 hover:border-rule-strong hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper lg:min-h-10 lg:py-1.5; }
|
||||
.toc-link[data-passed] { @apply text-ink; }
|
||||
.toc-link[data-passed] > span:first-child { @apply text-ink; }
|
||||
.toc-link[aria-current] { @apply border-accent font-semibold text-ink; }
|
||||
.toc-toggle-icon { opacity:1; scale:1; filter:blur(0); transition-property:opacity, scale, filter; transition-duration:150ms; transition-timing-function:cubic-bezier(0.2, 0, 0, 1); }
|
||||
.toc-toggle-icon-close { opacity:0; scale:0.25; filter:blur(4px); }
|
||||
[data-toc-toggle][aria-expanded="true"] .toc-toggle-icon-menu { opacity:0; scale:0.25; filter:blur(4px); }
|
||||
[data-toc-toggle][aria-expanded="true"] .toc-toggle-icon-close { opacity:1; scale:1; filter:blur(0); }
|
||||
/* Without JS the panel is simply visible under the bar; `has-js` is set in the head. */
|
||||
@media (width < 64rem) {
|
||||
/* Lock only the root: locking body as well makes body its own scroll container and un-sticks the bar. */
|
||||
html.toc-panel-open { overflow:hidden; scrollbar-gutter:stable; }
|
||||
.has-js [data-toc-panel][data-open="true"] { height:calc(100dvh - var(--toc-panel-top, 3rem)); max-height:calc(100dvh - var(--toc-panel-top, 3rem)); }
|
||||
.has-js [data-toc-panel]:not([data-open="true"]) { display:none; }
|
||||
}
|
||||
@media (max-width:40rem) {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
<div class="sticky top-0 z-30 lg:static lg:z-auto">
|
||||
<div class="relative flex min-h-12 items-center gap-3 border-b border-rule bg-paper px-4 sm:px-6 lg:hidden">
|
||||
<button class="-ml-2 flex h-10 w-10 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-ink hover:text-accent" type="button" data-toc-toggle aria-controls="toc" aria-expanded="false" aria-label="Contents">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" class="h-5 w-5 fill-none stroke-current" stroke-width="1.75"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
<div class="relative flex min-h-12 items-center gap-3 border-b border-rule bg-paper px-4 sm:px-6 lg:hidden" data-toc-bar>
|
||||
<button class="relative -ml-2 flex h-11 w-11 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-ink hover:text-accent" type="button" data-toc-toggle aria-controls="toc" aria-expanded="false" aria-label="Contents">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" class="toc-toggle-icon toc-toggle-icon-menu absolute h-5 w-5 fill-none stroke-current" stroke-width="1.75"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" class="toc-toggle-icon toc-toggle-icon-close absolute h-5 w-5 fill-none stroke-current" stroke-width="1.75"><path d="m5 5 14 14M19 5 5 19"/></svg>
|
||||
</button>
|
||||
<span class="min-w-0 flex-1 truncate font-sans text-sm leading-tight text-ink">{{ toc.current_label() }}</span>
|
||||
{% if toc.position > 0 %}<span class="shrink-0 font-sans text-xs tabular-nums text-muted">{{ toc.position }} / {{ toc.total }}</span>{% endif %}
|
||||
<span class="min-w-0 flex-1 truncate font-sans text-sm leading-tight text-ink" data-toc-label>{{ toc.current_label() }}</span>
|
||||
<span class="shrink-0 font-sans text-xs tabular-nums text-muted" data-toc-count{% if toc.position == 0 %} hidden{% endif %}>{{ toc.position }} / {{ toc.total }}</span>
|
||||
<progress class="toc-progress absolute inset-x-0 -bottom-px" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
|
||||
</div>
|
||||
<nav id="toc" data-toc-panel class="absolute inset-x-0 top-full z-30 max-h-[72vh] overflow-y-auto border-b border-rule bg-paper px-4 pb-7 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:px-6 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100vh-3rem)] lg:border-0 lg:px-0 lg:pb-10 lg:pt-0 lg:shadow-none" aria-label="Contents">
|
||||
<nav id="toc" data-toc-panel class="absolute inset-x-0 top-full z-30 overflow-y-auto overscroll-contain border-b border-rule bg-paper pb-7 pl-4 pr-6 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:pl-6 sm:pr-8 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100dvh-3rem)] lg:border-0 lg:pb-10 lg:pl-0 lg:pr-3 lg:pt-0 lg:shadow-none" aria-label="Contents">
|
||||
<div class="hidden lg:sticky lg:top-0 lg:z-10 lg:block lg:bg-paper lg:pb-3">
|
||||
<a class="block font-sans text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted no-underline hover:text-ink" href="{{ toc.issue_href }}">No. {{ toc.issue_number }} <span class="text-rule" aria-hidden="true">·</span> {{ toc.short_date }}</a>
|
||||
<p class="mt-2 font-sans text-[0.72rem] text-ink-2">{% if toc.position > 0 %}Chapter {{ toc.position }} of {{ toc.total }}{% else %}Front page{% endif %}</p>
|
||||
<p class="mt-2 font-sans text-[0.72rem] text-ink-2" data-toc-status>{% if toc.position > 0 %}Chapter {{ toc.position }} of {{ toc.total }}{% else %}Front page{% endif %}</p>
|
||||
<progress class="toc-progress mt-2.5" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
|
||||
</div>
|
||||
<ul class="m-0 mt-1 list-none p-0 lg:mt-4">
|
||||
{% for item in toc.items %}{% if item.is_section() %}<li class="mt-5 border-t border-rule pt-2 font-sans text-[0.66rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ item.label }}</li>{% else %}<li{% if item.divider %} class="mt-4 border-t border-rule pt-3"{% endif %}><a class="flex items-baseline gap-2 border-l-2 py-2.5 pl-3 no-underline transition-colors duration-150 lg:py-1.5 {% if item.current %}border-accent font-semibold text-ink{% else %}border-transparent text-ink-2 hover:border-rule-strong hover:text-accent{% endif %}" href="{{ item.href }}"{% if item.current %} aria-current="page"{% endif %}>{% match item.number %}{% when Some with (number) %}<span class="w-4 shrink-0 font-sans text-[0.66rem] tabular-nums text-muted">{{ number }}</span>{% when None %}<span class="w-4 shrink-0" aria-hidden="true"></span>{% endmatch %}<span class="min-w-0 flex-1 font-serif text-[0.95rem] leading-snug">{{ item.label }}</span>{% match item.minutes %}{% when Some with (minutes) %}<span class="shrink-0 font-sans text-[0.66rem] tabular-nums text-muted">{{ minutes }} min</span>{% when None %}{% endmatch %}</a></li>{% endif %}{% endfor %}
|
||||
{% for item in toc.items %}{% if item.is_section() %}<li class="mt-5 border-t border-rule pt-2 font-sans text-[0.66rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ item.label }}</li>{% else %}<li{% if item.divider %} class="mt-4 border-t border-rule pt-3"{% endif %}><a class="toc-link" href="{{ item.href }}" data-toc-position="{{ item.progress }}"{% if item.is_colophon() %} data-toc-end{% endif %}{% if item.current %} aria-current="page"{% endif %}>{% match item.number %}{% when Some with (number) %}<span class="w-4 shrink-0 font-sans text-[0.66rem] tabular-nums text-muted">{{ number }}</span>{% when None %}<span class="w-4 shrink-0" aria-hidden="true"></span>{% endmatch %}<span class="min-w-0 flex-1 font-serif text-[0.95rem] leading-snug" data-toc-link-label>{{ item.label }}</span>{% match item.minutes %}{% when Some with (minutes) %}<span class="shrink-0 font-sans text-[0.66rem] tabular-nums text-muted">{{ minutes }} min</span>{% when None %}{% endmatch %}</a></li>{% endif %}{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<section class="card"><h2>Timings</h2><div class="scroll-x"><table><thead><tr><th>stage</th><th class="num">seconds</th></tr></thead><tbody>{% for line in timings %}<tr><td>{{ line.stage }}</td><td class="num">{{ line.seconds }}</td></tr>{% endfor %}<tr><th>total</th><th class="num">{{ timings_total }}</th></tr></tbody></table></div></section>
|
||||
<section class="card"><h2>Provider usage</h2><div class="scroll-x"><table><thead><tr><th>provider</th><th class="num">in</th><th class="num">cached</th><th class="num">cache write</th><th class="num">out</th><th class="num">cost</th></tr></thead><tbody>{% for line in providers %}<tr><td>{{ line.provider }}</td><td class="num">{{ line.input }}</td><td class="num">{{ line.cached }}</td><td class="num">{{ line.cache_write }}</td><td class="num">{{ line.output }}</td><td class="num">{{ line.cost }}</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
<section class="card" id="warnings"><h2>Warnings</h2>{% if warnings.is_empty() %}<p class="muted">None.</p>{% else %}<ul class="list-disc pl-5 text-warn">{% for warning in warnings %}<li>{{ warning }}</li>{% endfor %}</ul>{% endif %}</section>
|
||||
<section class="card"><h2>Feeds (top {{ feeds.len() }})</h2><div class="scroll-x"><table><thead><tr><th>feed</th><th class="num">entries</th></tr></thead><tbody>{% for line in feeds %}<tr><td>{{ line.name }}</td><td class="num">{{ line.count }}</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
<section class="card"><h2>Feeds (top {{ feeds.len() }})</h2><div><table class="table-fixed"><thead><tr><th>feed</th><th class="num w-20">entries</th></tr></thead><tbody>{% for line in feeds %}<tr><td class="truncate" title="{{ line.name }}">{{ line.name }}</td><td class="num w-20">{{ line.count }}</td></tr>{% endfor %}</tbody></table></div></section>
|
||||
</div>{% else %}<p class="muted text-sm">This run has no stored report; only the funnel and candidates are available.</p>{% endif %}
|
||||
|
||||
<h2>Config diff</h2>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[16rem_minmax(0,1fr)] lg:gap-x-10 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
|
||||
<article class="reader-page mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0">
|
||||
<header class="mb-8 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ display_date }} · No. {{ issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ stats_line }}</p></header>
|
||||
<section aria-labelledby="brief-heading"><h1 id="brief-heading" class="reader-section-heading">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
|
||||
<section aria-labelledby="brief-heading" data-toc-entry="/issues/{{ date }}"><h1 id="brief-heading" class="reader-section-heading">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
|
||||
{% if !downloads.is_empty() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% for download in downloads %}<a class="btn" href="{{ download.href }}">Download {{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
|
||||
<section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2>
|
||||
{% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
|
||||
{% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
|
||||
</section>
|
||||
{% if has_world || has_behind %}<nav class="my-12 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-y border-rule py-4 text-center font-sans text-sm uppercase tracking-[0.08em]" aria-label="Issue chapters">{% if has_world %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %}<span class="text-muted" aria-hidden="true">·</span>{% endif %}{% if has_behind %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %}
|
||||
<footer id="colophon" class="mt-14 scroll-mt-6 font-sans text-sm leading-relaxed"><h2 class="reader-section-heading">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>
|
||||
<footer id="colophon" class="mt-14 scroll-mt-6 font-sans text-sm leading-relaxed" data-toc-entry="/issues/{{ date }}#colophon"><h2 class="reader-section-heading">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>
|
||||
</article>
|
||||
</div>{% endblock %}
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
<title>{{ page.title }} · The Daily EPUB</title>
|
||||
<script src="/static/theme.js?v={{ page.asset_version }}"></script>
|
||||
<link rel="preload" href="/static/Newsreader.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/static/Newsreader-italic.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/static/app.css?v={{ page.asset_version }}">
|
||||
<link rel="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml">
|
||||
<link rel="icon" href="/static/favicon.svg">
|
||||
</head>
|
||||
<body>
|
||||
<a class="fixed left-3 top-3 z-50 -translate-y-20 bg-ink px-3 py-2 font-sans text-sm text-paper no-underline focus:translate-y-0" href="#content">Skip to content</a>
|
||||
<header class="mx-auto max-w-7xl px-4 pt-4 sm:px-6 sm:pt-6">
|
||||
<header class="site-header mx-auto max-w-7xl px-4 pt-4 sm:px-6 sm:pt-6">
|
||||
<div class="mb-2 flex min-h-8 flex-nowrap items-center justify-between gap-2 border-b border-rule pb-2 font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted sm:gap-4">
|
||||
<div class="min-w-0 truncate whitespace-nowrap">{% block ears %}<span>Morning edition</span>{% endblock %}</div>
|
||||
<div class="min-w-0 truncate whitespace-nowrap">{% block ears %}{% if page.is_dashboard() %}<span>Dashboard</span>{% endif %}{% endblock %}</div>
|
||||
<div class="flex shrink-0 items-center gap-2 whitespace-nowrap normal-case tracking-normal sm:gap-3">
|
||||
<button class="flex min-h-10 shrink-0 items-center gap-1.5 whitespace-nowrap border-0 bg-transparent px-1.5 py-1 text-xs text-muted hover:text-ink" type="button" data-theme-toggle aria-label="Theme: system">
|
||||
<svg data-theme-icon="system" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75"><path d="M4 5.5h16v11H4zM9 20h6M12 16.5V20"/></svg>
|
||||
@@ -22,7 +23,7 @@
|
||||
<svg data-theme-icon="dark" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75" hidden><path d="M20 15.1A8.5 8.5 0 0 1 8.9 4a8.5 8.5 0 1 0 11.1 11.1Z"/></svg>
|
||||
<span data-theme-label>System</span>
|
||||
</button>
|
||||
{% match page.viewer %}{% when Some with (viewer) %}<a class="max-w-32 shrink-0 truncate whitespace-nowrap text-ink no-underline hover:text-accent" href="/account">{{ viewer.username }}</a>{% when None %}<a class="shrink-0 whitespace-nowrap text-ink no-underline hover:text-accent" href="/login">Sign in</a>{% endmatch %}
|
||||
{% match page.viewer %}{% when Some with (viewer) %}<a class="flex min-h-10 min-w-10 max-w-32 shrink-0 items-center justify-center truncate whitespace-nowrap px-1.5 text-ink no-underline hover:text-accent" href="/account">{{ viewer.username }}</a>{% when None %}<a class="flex min-h-10 min-w-10 shrink-0 items-center justify-center whitespace-nowrap px-1.5 text-ink no-underline hover:text-accent" href="/login">Sign in</a>{% endmatch %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-b-[3px] border-double border-ink py-3 text-center sm:py-4"><a class="font-serif text-3xl font-semibold leading-none tracking-[-0.02em] text-ink no-underline hover:text-ink sm:text-5xl" href="/">The Daily EPUB</a></div>
|
||||
@@ -32,16 +33,16 @@
|
||||
<a class="flex min-h-11 items-center border-b-2 border-transparent text-muted no-underline hover:text-ink" href="/feed.xml">Feed</a>
|
||||
{% match page.viewer %}{% when Some with (_) %}<a class="flex min-h-11 items-center border-b-2 {% if page.active_nav == "account" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %} no-underline hover:text-ink" href="/account"{% if page.active_nav == "account" %} aria-current="page"{% endif %}>Account</a>{% when None %}<a class="flex min-h-11 items-center border-b-2 {% if page.active_nav == "login" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %} no-underline hover:text-ink" href="/login"{% if page.active_nav == "login" %} aria-current="page"{% endif %}>Sign in</a>{% endmatch %}
|
||||
</nav>
|
||||
{% if page.is_admin() %}<nav class="flex flex-wrap items-center justify-center gap-x-4 border-b border-rule py-1 font-sans text-[0.68rem] uppercase tracking-[0.1em]" aria-label="Dashboard">
|
||||
<a class="py-2 no-underline {% if page.active_nav == "dashboard" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard"{% if page.active_nav == "dashboard" %} aria-current="page"{% endif %}>Overview</a>
|
||||
<a class="py-2 no-underline {% if page.active_nav == "runs" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/runs"{% if page.active_nav == "runs" %} aria-current="page"{% endif %}>Runs</a>
|
||||
<a class="py-2 no-underline {% if page.active_nav == "articles" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/articles"{% if page.active_nav == "articles" %} aria-current="page"{% endif %}>Articles</a>
|
||||
<a class="py-2 no-underline {% if page.active_nav == "ratings" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/ratings"{% if page.active_nav == "ratings" %} aria-current="page"{% endif %}>Ratings</a>
|
||||
<a class="py-2 no-underline {% if page.active_nav == "profile" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/profile"{% if page.active_nav == "profile" %} aria-current="page"{% endif %}>Profile</a>
|
||||
<a class="py-2 no-underline {% if page.active_nav == "stats" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/stats"{% if page.active_nav == "stats" %} aria-current="page"{% endif %}>Stats</a>
|
||||
<a class="py-2 no-underline {% if page.active_nav == "jobs" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/jobs"{% if page.active_nav == "jobs" %} aria-current="page"{% endif %}>Jobs</a>
|
||||
<a class="py-2 no-underline {% if page.active_nav == "settings" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/settings"{% if page.active_nav == "settings" %} aria-current="page"{% endif %}>Settings</a>
|
||||
<a class="py-2 no-underline {% if page.active_nav == "users" %}text-accent{% else %}text-muted{% endif %}" href="/dashboard/users"{% if page.active_nav == "users" %} aria-current="page"{% endif %}>Users</a>
|
||||
{% if page.is_admin() %}<nav class="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 border-b border-rule font-sans text-[0.68rem] uppercase tracking-[0.1em]" aria-label="Dashboard">
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "dashboard" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard"{% if page.active_nav == "dashboard" %} aria-current="page"{% endif %}>Overview</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "runs" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/runs"{% if page.active_nav == "runs" %} aria-current="page"{% endif %}>Runs</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "articles" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/articles"{% if page.active_nav == "articles" %} aria-current="page"{% endif %}>Articles</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "ratings" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/ratings"{% if page.active_nav == "ratings" %} aria-current="page"{% endif %}>Ratings</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "profile" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/profile"{% if page.active_nav == "profile" %} aria-current="page"{% endif %}>Profile</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "stats" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/stats"{% if page.active_nav == "stats" %} aria-current="page"{% endif %}>Stats</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "jobs" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/jobs"{% if page.active_nav == "jobs" %} aria-current="page"{% endif %}>Jobs</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "settings" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/settings"{% if page.active_nav == "settings" %} aria-current="page"{% endif %}>Settings</a>
|
||||
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "users" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/users"{% if page.active_nav == "users" %} aria-current="page"{% endif %}>Users</a>
|
||||
</nav>{% endif %}
|
||||
</header>
|
||||
{% match page.flash %}{% when Some with (flash) %}<div class="mx-auto max-w-7xl px-4 sm:px-6"><div class="flash {{ flash.kind }}" role="status">{{ flash.text }}</div></div>{% when None %}{% endmatch %}
|
||||
|
||||
Reference in New Issue
Block a user