UI pass: nav state, ears, feeds table, font flash, faster navigation

- Issue pages highlight Archive instead of Latest unless the issue is the
  newest one (IssueView::is_latest), with a router test.
- The ears row is empty by default and says "Dashboard" on dashboard pages;
  "Morning edition" only survives on the empty-state hero.
- Run page: the Feeds card table is fixed-layout and truncates long feed
  names with the full title on hover instead of pushing the entries column
  out of the card.
- Static assets are content-hashed, so serve them immutable for a year;
  preload both Newsreader faces and use font-display: block so a cache
  revalidation never paints the fallback serif first. Persisted details
  state is restored pre-paint from theme.js.
- Cross-document view transitions (140 ms fade, masthead held still) and
  Speculation Rules (prerender reader pages, prefetch dashboard pages on
  hover) served from /static/speculation.json via the Speculation-Rules
  header, which the CSP would otherwise block inline. No client router.
- Dashboard nav gets the same accent underline as the site nav; 40 px
  targets for the theme toggle, account link and dashboard nav.

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:06:48 +00:00
co-authored by Claude Fable 5.1
parent 560bbc13af
commit 5cc4ac4bd3
11 changed files with 442 additions and 30 deletions
+94 -4
View File
@@ -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,
@@ -955,7 +958,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 +1027,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 +1094,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 +1128,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 +1543,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;
+81 -4
View File
@@ -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
View File
@@ -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
+7 -1
View File
@@ -109,7 +109,13 @@ 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 */
const tocPanel = document.querySelector("[data-toc-panel]");
+26
View File
@@ -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"
}
]
}
+19
View File
@@ -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 });
})();
+38 -5
View File
@@ -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 ---------- */
@@ -91,7 +124,7 @@
.btn:disabled, .btn-primary:disabled, .btn-danger:disabled { @apply cursor-not-allowed border-rule bg-paper-2 text-muted hover:bg-paper-2 hover:text-muted; }
.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 transition-colors duration-150 hover:bg-down hover:text-paper; }
.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); }
@@ -182,14 +215,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; }
+1 -1
View File
@@ -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>
+14 -13
View File
@@ -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 %}