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:
@@ -197,9 +197,19 @@ pub struct Flash {
|
|||||||
pub text: String,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Page {
|
pub struct Page {
|
||||||
pub title: String,
|
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 viewer: Option<Viewer>,
|
||||||
pub flash: Option<Flash>,
|
pub flash: Option<Flash>,
|
||||||
pub active_nav: String,
|
pub active_nav: String,
|
||||||
@@ -252,6 +262,7 @@ impl Page {
|
|||||||
pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self {
|
pub fn new(title: impl Into<String>, viewer: Option<Viewer>, active_nav: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
title: title.into(),
|
title: title.into(),
|
||||||
|
description: DEFAULT_DESCRIPTION.to_string(),
|
||||||
viewer,
|
viewer,
|
||||||
flash: None,
|
flash: None,
|
||||||
active_nav: active_nav.to_string(),
|
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 {
|
pub fn is_admin(&self) -> bool {
|
||||||
self.viewer
|
self.viewer
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -1104,6 +1122,46 @@ mod tests {
|
|||||||
assert!(!html.contains("rel=\"preload\""), "{html}");
|
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'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 "No. 3" & \
|
||||||
|
<b>4</b> for O'Donnell\">"
|
||||||
|
),
|
||||||
|
"{html}"
|
||||||
|
);
|
||||||
|
assert!(!html.contains("<b>4</b>"), "{html}");
|
||||||
|
assert!(!html.contains(DEFAULT_DESCRIPTION), "{html}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stylesheet_embeds_both_newsreader_faces() {
|
fn stylesheet_embeds_both_newsreader_faces() {
|
||||||
let css = APP_CSS.as_str();
|
let css = APP_CSS.as_str();
|
||||||
|
|||||||
+35
-4
@@ -11,6 +11,19 @@ use crate::web::issue::{self, Download};
|
|||||||
use crate::web::session::{AuthSession, Viewer};
|
use crate::web::session::{AuthSession, Viewer};
|
||||||
use crate::web::{Html, Page, WebError};
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PublicIssue {
|
pub struct PublicIssue {
|
||||||
pub date: Date,
|
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 {
|
fn domain(raw: &str) -> String {
|
||||||
url::Url::parse(raw)
|
url::Url::parse(raw)
|
||||||
.ok()
|
.ok()
|
||||||
@@ -193,7 +220,8 @@ pub async fn latest(
|
|||||||
let Some(date) = state.db.latest_issue_date().await? else {
|
let Some(date) = state.db.latest_issue_date().await? else {
|
||||||
let viewer = auth.user().await.map(Viewer::from);
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
let response = Html(IssuePublicTemplate {
|
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(),
|
issue: empty_issue(),
|
||||||
downloads: Vec::new(),
|
downloads: Vec::new(),
|
||||||
empty: true,
|
empty: true,
|
||||||
@@ -220,9 +248,11 @@ pub async fn show_issue(
|
|||||||
return Ok(public_cache(response, &headers));
|
return Ok(public_cache(response, &headers));
|
||||||
}
|
}
|
||||||
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
let active_nav = if view.is_latest { "latest" } else { "archive" };
|
||||||
|
let issue = PublicIssue::from(&view.issue);
|
||||||
let response = Html(IssuePublicTemplate {
|
let response = Html(IssuePublicTemplate {
|
||||||
page: Page::new(format!("Issue {date}"), None, active_nav),
|
page: Page::new(format!("Issue {date}"), None, active_nav)
|
||||||
issue: PublicIssue::from(&view.issue),
|
.with_description(issue.description()),
|
||||||
|
issue,
|
||||||
downloads: Vec::new(),
|
downloads: Vec::new(),
|
||||||
empty: false,
|
empty: false,
|
||||||
})
|
})
|
||||||
@@ -259,7 +289,8 @@ pub async fn archive(
|
|||||||
"Issue archive",
|
"Issue archive",
|
||||||
auth.user().await.map(Viewer::from),
|
auth.user().await.map(Viewer::from),
|
||||||
"archive",
|
"archive",
|
||||||
),
|
)
|
||||||
|
.with_description(ARCHIVE_DESCRIPTION),
|
||||||
months,
|
months,
|
||||||
})
|
})
|
||||||
.into_response();
|
.into_response();
|
||||||
|
|||||||
+8
-2
@@ -280,10 +280,16 @@ struct AccountTemplate {
|
|||||||
error: String,
|
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 {
|
pub async fn login_page(auth: AuthSession, Query(query): Query<LoginQuery>) -> Response {
|
||||||
let viewer = auth.user().await.map(Viewer::from);
|
let viewer = auth.user().await.map(Viewer::from);
|
||||||
Html(LoginTemplate {
|
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(),
|
next: valid_next(query.next.as_deref()).to_string(),
|
||||||
error: String::new(),
|
error: String::new(),
|
||||||
})
|
})
|
||||||
@@ -316,7 +322,7 @@ pub async fn login(
|
|||||||
None => Ok((
|
None => Ok((
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
Html(LoginTemplate {
|
Html(LoginTemplate {
|
||||||
page: Page::new("Sign in", None, "login"),
|
page: Page::new("Sign in", None, "login").with_description(LOGIN_DESCRIPTION),
|
||||||
next: destination,
|
next: destination,
|
||||||
error: "invalid username or password".into(),
|
error: "invalid username or password".into(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -25,9 +25,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
: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;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="description" content="{{ page.description }}">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<title>{{ page.title }} · The Daily EPUB</title>
|
<title>{{ page.title }} · The Daily EPUB</title>
|
||||||
<link rel="stylesheet" href="/static/app.css?v={{ page.asset_version }}">
|
<link rel="stylesheet" href="/static/app.css?v={{ page.asset_version }}">
|
||||||
|
|||||||
Reference in New Issue
Block a user