Stop inlining the Newsreader faces into the render-blocking stylesheet

/static/app.css was 305 KB because APP_CSS base64-embedded both woff2
faces into it at runtime, and the browser cannot paint until it has the
whole sheet. Lighthouse mobile put FCP and LCP at 2.4 s for it.

The inlining was justified by a Firefox font-flash theory that the later
FOUC investigation (239125a) disproved: the flash came from theme.js
running ahead of the stylesheet, not from fonts arriving late. So point
the faces back at /static/Newsreader*.woff2, versioned with ASSET_VERSION
so the immutable one-year max-age stays safe. Gzipped, the sheet goes
from 304 KB to 12 KB.

That leaves first paint waiting on a font, so switch both faces from
font-display: block to swap and give them metric-matched local fallbacks
(Georgia, Times New Roman, Noto Serif, DejaVu Serif). The overrides are
computed from Newsreader's own metrics at the body optical size against
@capsizecss/metrics widths, so the fallback occupies the same box and the
swap changes glyph shapes and nothing else: every block on the page lands
at the same y-position either way, and CLS stays at 0.001.

Preload the regular face so it starts with the stylesheet rather than
after it parses; italic stays on demand. theme.js keeps its place after
the stylesheet link.

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:58:28 +00:00
co-authored by Claude Fable 5.1
parent 239125a8e5
commit 4c8eeda147
4 changed files with 114 additions and 26 deletions
+47 -22
View File
@@ -214,27 +214,32 @@ pub struct Page {
const NEWSREADER: &[u8] = include_bytes!("static/fonts/Newsreader.woff2");
const NEWSREADER_ITALIC: &[u8] = include_bytes!("static/fonts/Newsreader-italic.woff2");
/// The stylesheet with both Newsreader faces embedded as `data:` URIs.
/// The stylesheet with both Newsreader faces pointed at versioned URLs.
///
/// A font referenced by URL is applied *after* first paint whenever the browser
/// has to bring it back from the disk cache (Firefox drops fonts from memory as
/// soon as no page uses them), which shows as a flash of invisible or fallback
/// text on the first navigation after an idle spell. Fonts embedded in the
/// render-blocking stylesheet are decoded synchronously, so the first paint is
/// already set in Newsreader.
/// The faces used to be inlined here as `data:` URIs on the theory that a font
/// fetched by URL lands after first paint and flashes. That theory was wrong:
/// the flash of unstyled content came from script ordering (a parser-blocking
/// `theme.js` ahead of the stylesheet let Gecko paint before the sheet applied,
/// Bugzilla 1459305), and moving the script after the `<link>` fixed it. What
/// the inlining did cost was real: it tripled the render-blocking stylesheet to
/// ~305 KB and pushed first paint out by more than a second on mobile.
///
/// So the URLs stay URLs, carrying `?v=<ASSET_VERSION>` so the immutable
/// one-year `max-age` on `/static/*` is safe across deploys. The fonts are part
/// of the `ASSET_VERSION` hash, so a new face mints a new URL. `layout.html`
/// preloads the regular face, and the faces use `font-display: swap` behind
/// metric-matched local fallbacks, so first paint is immediate and the swap
/// shifts nothing.
pub static APP_CSS: LazyLock<String> = LazyLock::new(|| {
use base64::Engine;
let data_uri = |bytes: &[u8]| {
format!(
"url(data:font/woff2;base64,{})",
base64::engine::general_purpose::STANDARD.encode(bytes)
)
};
let version = ASSET_VERSION.as_str();
include_str!("static/app.css")
.replace("url(/static/Newsreader.woff2)", &data_uri(NEWSREADER))
.replace(
"url(/static/Newsreader.woff2)",
&format!("url(/static/Newsreader.woff2?v={version})"),
)
.replace(
"url(/static/Newsreader-italic.woff2)",
&data_uri(NEWSREADER_ITALIC),
&format!("url(/static/Newsreader-italic.woff2?v={version})"),
)
});
@@ -1100,16 +1105,36 @@ 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)));
// The fonts ride inside the stylesheet; a preload would fetch them twice.
assert!(!html.contains("rel=\"preload\""), "{html}");
// The regular face is preloaded so it starts downloading alongside the
// stylesheet that declares it; the italic face is left to load on demand.
let preload = format!(
"<link rel=\"preload\" href=\"/static/Newsreader.woff2?v={}\" as=\"font\" type=\"font/woff2\" crossorigin>",
ASSET_VERSION.as_str()
);
assert!(html.contains(&preload), "{html}");
assert!(!html.contains("Newsreader-italic.woff2"), "{html}");
}
#[test]
fn stylesheet_embeds_both_newsreader_faces() {
fn stylesheet_points_both_newsreader_faces_at_versioned_urls() {
let css = APP_CSS.as_str();
assert_eq!(css.matches("url(data:font/woff2;base64,").count(), 2);
assert!(!css.contains("/static/Newsreader"));
assert!(css.contains("font-display:block"), "{}", &css[..200]);
let version = ASSET_VERSION.as_str();
assert!(
css.contains(&format!("url(/static/Newsreader.woff2?v={version})")),
"regular face missing from stylesheet"
);
assert!(
css.contains(&format!("url(/static/Newsreader-italic.woff2?v={version})")),
"italic face missing from stylesheet"
);
// Unversioned references would be cached forever under a stale URL, and
// inlined faces would put ~280 KB back into the render-blocking sheet.
assert!(!css.contains("url(/static/Newsreader.woff2)"));
assert!(!css.contains("url(/static/Newsreader-italic.woff2)"));
assert!(!css.contains("data:font"));
// `swap` paints immediately in the metric-matched local fallback.
assert_eq!(css.matches("font-display:swap").count(), 2);
assert!(!css.contains("font-display:block"));
}
#[tokio::test]