Fix the two Lighthouse findings: muted contrast and meta description

Accessibility (95 -> 100). Light `--muted` #7a7568 was 4.14:1 on `--paper`
and 3.72:1 on `--paper-2`, under the 4.5:1 AA floor for normal text — and
muted is the ears line, the nav, bylines, the footer, table headers and
placeholders, so it was most of the page's small type. An audit of every
text token against both surfaces turned up one more: light `--warn`
#8a6d1f at 4.42 / 3.97.

Both are darkened just enough to clear 4.5 on the darker of the two
surfaces, at constant hue and saturation, so the warm grey and the ochre
read the same:

  --muted  #7a7568 -> #6b665a   4.14/3.72 -> 5.16/4.63
  --warn   #8a6d1f -> #7d631c   4.42/3.97 -> 5.16/4.63

Everything else already passed on both surfaces (light ink 15.54/13.96,
ink-2 7.83/7.03, accent 6.73/6.04, loved 5.44/4.89, good 5.29/4.76, down
5.96/5.36), as did the whole dark set against #151513 / #1e1d1a (muted
5.31/4.90 is its floor), so the dark blocks are untouched and stay
identical to each other. `.badge` tints sit on near-paper, so the new
token values carry it.

SEO (91 -> 100). The layout had no `<meta name="description">`. `Page`
now carries one — `DEFAULT_DESCRIPTION` for the site, so all ~26
`Page::new` callers keep their signature — with `with_description` for
the pages worth writing one for: the issue page (issue number, date and
the counts the masthead already prints), the archive, the empty-latest
landing page and sign-in. Dashboard pages keep the default.

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 19:43:56 +00:00
co-authored by Claude Fable 5.1
parent 239125a8e5
commit 04bf14f710
6 changed files with 105 additions and 9 deletions
+58
View File
@@ -197,9 +197,19 @@ pub struct Flash {
pub text: String,
}
/// The site-wide `<meta name="description">`, used by every page that does not
/// set one of its own. Search engines truncate around 160 characters.
pub const DEFAULT_DESCRIPTION: &str = concat!(
"A daily newspaper of the web: articles hand-picked from one reader's feeds, ",
"published every morning as an EPUB and readable here."
);
#[derive(Debug, Clone)]
pub struct Page {
pub title: String,
/// The `<meta name="description">` for this page; `DEFAULT_DESCRIPTION`
/// unless a handler overrides it with [`Page::with_description`].
pub description: String,
pub viewer: Option<Viewer>,
pub flash: Option<Flash>,
pub active_nav: String,
@@ -252,6 +262,7 @@ impl Page {
pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self {
Self {
title: title.into(),
description: DEFAULT_DESCRIPTION.to_string(),
viewer,
flash: None,
active_nav: active_nav.to_string(),
@@ -260,6 +271,13 @@ impl Page {
}
}
/// Replace the site-wide description with one written for this page.
#[must_use]
pub fn with_description(mut self, text: impl Into<String>) -> Self {
self.description = text.into();
self
}
pub fn is_admin(&self) -> bool {
self.viewer
.as_ref()
@@ -1104,6 +1122,46 @@ mod tests {
assert!(!html.contains("rel=\"preload\""), "{html}");
}
#[test]
fn pages_render_a_meta_description_and_escape_it() {
let render = |page: Page| {
ErrorTemplate {
page,
heading: "h".into(),
message: "m".into(),
}
.render()
.unwrap()
};
// Every page carries a description; the default one when none is set.
assert!(DEFAULT_DESCRIPTION.len() <= 160, "{DEFAULT_DESCRIPTION}");
let html = render(Page::new("t", None, "latest"));
assert!(
html.contains(
"<meta name=\"description\" content=\"A daily newspaper of the web: articles \
hand-picked from one reader&#39;s feeds, published every morning as an EPUB and \
readable here.\">"
),
"{html}"
);
// A page-specific one replaces it, HTML-escaped into the attribute.
let page = Page::new("t", None, "latest")
.with_description("Issue \"No. 3\" & <b>4</b> for O'Donnell");
assert_eq!(page.description, "Issue \"No. 3\" & <b>4</b> for O'Donnell");
let html = render(page);
assert!(
html.contains(
"<meta name=\"description\" content=\"Issue &#34;No. 3&#34; &#38; \
&#60;b&#62;4&#60;/b&#62; for O&#39;Donnell\">"
),
"{html}"
);
assert!(!html.contains("<b>4</b>"), "{html}");
assert!(!html.contains(DEFAULT_DESCRIPTION), "{html}");
}
#[test]
fn stylesheet_embeds_both_newsreader_faces() {
let css = APP_CSS.as_str();
+35 -4
View File
@@ -11,6 +11,19 @@ use crate::web::issue::{self, Download};
use crate::web::session::{AuthSession, Viewer};
use crate::web::{Html, Page, WebError};
/// The `<meta name="description">` for the landing page before the first issue
/// of the day exists.
const NO_ISSUE_DESCRIPTION: &str = concat!(
"The latest issue of The Daily EPUB is not out yet; ",
"the next one lands tomorrow morning."
);
/// The `<meta name="description">` for the archive index.
const ARCHIVE_DESCRIPTION: &str = concat!(
"Every issue of The Daily EPUB, newest first: browse the archive by month ",
"and read or download any past morning's paper."
);
#[derive(Debug, Clone)]
pub struct PublicIssue {
pub date: Date,
@@ -140,6 +153,20 @@ impl From<&Issue> for PublicIssue {
}
}
impl PublicIssue {
/// The `<meta name="description">` for an issue page: the same counts the
/// masthead prints, in a sentence a search result can show.
fn description(&self) -> String {
format!(
"Issue No. {} for {}: {} articles across {} sections.",
self.issue_number,
self.display_date,
self.article_count,
self.sections.len(),
)
}
}
fn domain(raw: &str) -> String {
url::Url::parse(raw)
.ok()
@@ -193,7 +220,8 @@ pub async fn latest(
let Some(date) = state.db.latest_issue_date().await? else {
let viewer = auth.user().await.map(Viewer::from);
let response = Html(IssuePublicTemplate {
page: Page::new("Latest issue", viewer, "latest"),
page: Page::new("Latest issue", viewer, "latest")
.with_description(NO_ISSUE_DESCRIPTION),
issue: empty_issue(),
downloads: Vec::new(),
empty: true,
@@ -220,9 +248,11 @@ pub async fn show_issue(
return Ok(public_cache(response, &headers));
}
let active_nav = if view.is_latest { "latest" } else { "archive" };
let issue = PublicIssue::from(&view.issue);
let response = Html(IssuePublicTemplate {
page: Page::new(format!("Issue {date}"), None, active_nav),
issue: PublicIssue::from(&view.issue),
page: Page::new(format!("Issue {date}"), None, active_nav)
.with_description(issue.description()),
issue,
downloads: Vec::new(),
empty: false,
})
@@ -259,7 +289,8 @@ pub async fn archive(
"Issue archive",
auth.user().await.map(Viewer::from),
"archive",
),
)
.with_description(ARCHIVE_DESCRIPTION),
months,
})
.into_response();
+8 -2
View File
@@ -280,10 +280,16 @@ struct AccountTemplate {
error: String,
}
/// The `<meta name="description">` for both renders of the sign-in page.
const LOGIN_DESCRIPTION: &str = concat!(
"Sign in to The Daily EPUB to read the full issue, ",
"rate what you read, and download the morning's editions."
);
pub async fn login_page(auth: AuthSession, Query(query): Query<LoginQuery>) -> Response {
let viewer = auth.user().await.map(Viewer::from);
Html(LoginTemplate {
page: Page::new("Sign in", viewer, "login"),
page: Page::new("Sign in", viewer, "login").with_description(LOGIN_DESCRIPTION),
next: valid_next(query.next.as_deref()).to_string(),
error: String::new(),
})
@@ -316,7 +322,7 @@ pub async fn login(
None => Ok((
StatusCode::UNAUTHORIZED,
Html(LoginTemplate {
page: Page::new("Sign in", None, "login"),
page: Page::new("Sign in", None, "login").with_description(LOGIN_DESCRIPTION),
next: destination,
error: "invalid username or password".into(),
}),
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -25,9 +25,9 @@
}
:root {
--paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#7a7568;
--paper:#f6f3ec; --paper-2:#ece7db; --ink:#1c1b18; --ink-2:#4f4b43; --muted:#6b665a;
--rule:#d8d2c4; --rule-strong:#1c1b18; --accent:#a3231f; --accent-hover:#7c1a16;
--loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#8a6d1f; color-scheme:light;
--loved:#2f6f46; --good:#2f6a8f; --down:#9c3f36; --warn:#7d631c; color-scheme:light;
}
@media (prefers-color-scheme: dark) {
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="{{ page.description }}">
<meta name="color-scheme" content="light dark">
<title>{{ page.title }} · The Daily EPUB</title>
<link rel="stylesheet" href="/static/app.css?v={{ page.asset_version }}">