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
+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]