Remove everything that can change after first paint

Firefox users saw a flash on the first navigation after an idle spell, and
Chrome occasionally flashed white. Nothing in the network path explained it
(assets are immutable, the stylesheet is render-blocking, a cold Firefox
load paints fully styled), so this removes every remaining way a page could
look different between its first paint and its final state:

- Both Newsreader faces are embedded in the served stylesheet as data: URIs
  (CSP gains `font-src 'self' data:`; the preloads go away). A font fetched
  by URL is applied after first paint whenever the browser has to bring it
  back from disk, which is exactly the "first click after a while" case.
- The cross-document view transition is gone; the operator wants snappy.
- A color-scheme meta, kept in step with the saved theme, so the canvas the
  browser paints before the stylesheet is the right shade.
- The theme toggle's icon and label are chosen by CSS from html[data-theme]
  (set pre-paint by theme.js) instead of being rewritten by app.js.
- Dashboard table filters are rendered by the templates (shown under
  `.has-js`) rather than inserted by app.js, so tables no longer jump.

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 18:37:44 +00:00
co-authored by Claude Fable 5.1
parent 9acd1b0800
commit 1047cf4633
13 changed files with 91 additions and 82 deletions
+43 -15
View File
@@ -211,9 +211,38 @@ pub struct Page {
}
/// First 12 hex digits of the SHA-256 over the embedded CSS and JS assets.
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.
///
/// 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.
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)
)
};
include_str!("static/app.css")
.replace("url(/static/Newsreader.woff2)", &data_uri(NEWSREADER))
.replace(
"url(/static/Newsreader-italic.woff2)",
&data_uri(NEWSREADER_ITALIC),
)
});
pub static ASSET_VERSION: LazyLock<String> = LazyLock::new(|| {
let mut hasher = Sha256::new();
hasher.update(include_str!("static/app.css"));
hasher.update(NEWSREADER);
hasher.update(NEWSREADER_ITALIC);
hasher.update(include_str!("static/app.js"));
hasher.update(include_str!("static/theme.js"));
hex::encode(hasher.finalize())[..12].to_string()
@@ -411,7 +440,7 @@ pub async fn security_headers(request: Request, next: Next) -> Response {
headers.insert(
header::HeaderName::from_static("content-security-policy"),
HeaderValue::from_static(
"default-src 'self'; img-src * data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; form-action 'self'",
"default-src 'self'; img-src * data:; font-src 'self' data:; style-src 'self'; script-src 'self'; frame-ancestors 'none'; form-action 'self'",
),
);
headers.insert(
@@ -537,10 +566,7 @@ async fn static_asset(
headers: axum::http::HeaderMap,
) -> Response {
let asset: (&str, &'static [u8]) = match file.as_str() {
"app.css" => (
"text/css; charset=utf-8",
include_str!("static/app.css").as_bytes(),
),
"app.css" => ("text/css; charset=utf-8", APP_CSS.as_bytes()),
"app.js" => (
"application/javascript; charset=utf-8",
include_str!("static/app.js").as_bytes(),
@@ -557,14 +583,8 @@ async fn static_asset(
"image/svg+xml",
include_str!("static/favicon.svg").as_bytes(),
),
"Newsreader.woff2" => (
"font/woff2",
include_bytes!("static/fonts/Newsreader.woff2"),
),
"Newsreader-italic.woff2" => (
"font/woff2",
include_bytes!("static/fonts/Newsreader-italic.woff2"),
),
"Newsreader.woff2" => ("font/woff2", NEWSREADER),
"Newsreader-italic.woff2" => ("font/woff2", NEWSREADER_ITALIC),
_ => return WebError::NotFound.into_response(),
};
let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1)));
@@ -1080,8 +1100,16 @@ 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\""));
// The fonts ride inside the stylesheet; a preload would fetch them twice.
assert!(!html.contains("rel=\"preload\""), "{html}");
}
#[test]
fn stylesheet_embeds_both_newsreader_faces() {
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]);
}
#[tokio::test]
File diff suppressed because one or more lines are too long
+17 -15
View File
@@ -9,17 +9,14 @@ if (themeToggle) {
return "system";
}
};
const renderTheme = (theme) => {
themeToggle.setAttribute("aria-label", `Theme: ${theme}`);
themeToggle.querySelector("[data-theme-label]").textContent = theme[0].toUpperCase() + theme.slice(1);
themeToggle.querySelectorAll("[data-theme-icon]").forEach((icon) => {
// SVGElement has no `hidden` IDL attribute; toggle the content attribute.
icon.toggleAttribute("hidden", icon.getAttribute("data-theme-icon") !== theme);
});
};
// Icons and label are picked by CSS from `html[data-theme]`, which theme.js sets
// before first paint, so nothing here repaints the toggle after load.
const renderTheme = (theme) => themeToggle.setAttribute("aria-label", `Theme: ${theme}`);
const setTheme = (theme) => {
if (theme === "system") document.documentElement.removeAttribute("data-theme");
else document.documentElement.dataset.theme = theme;
const scheme = document.querySelector('meta[name="color-scheme"]');
if (scheme) scheme.content = theme === "system" ? "light dark" : theme;
try {
if (theme === "system") localStorage.removeItem("theme");
else localStorage.setItem("theme", theme);
@@ -83,14 +80,19 @@ document.querySelectorAll("details[id]").forEach((details) => {
/* step 3: filter-as-you-type on tables with data-filter (this page's rows only) */
document.querySelectorAll("table[data-filter]").forEach((table) => {
const rows = table.querySelectorAll("tbody tr");
if (rows.length < 2) return;
const input = document.createElement("input");
input.type = "search";
input.className = "table-filter";
input.placeholder = "Filter rows on this page";
input.setAttribute("aria-label", "Filter rows on this page");
const host = table.closest(".scroll-x") || table;
host.parentNode.insertBefore(input, host);
// The template renders the input (shown only under `.has-js`) so the table does
// not jump when this script runs after first paint; create one if it is missing.
let input = host.previousElementSibling;
if (!(input && input.matches("input[data-table-filter]"))) {
if (rows.length < 2) return;
input = document.createElement("input");
input.type = "search";
input.className = "table-filter";
input.placeholder = "Filter rows on this page";
input.setAttribute("aria-label", "Filter rows on this page");
host.parentNode.insertBefore(input, host);
}
input.addEventListener("input", () => {
const needle = input.value.trim().toLowerCase();
rows.forEach((row) => {
+10 -2
View File
@@ -2,10 +2,18 @@
// Marks the document as scripted before first paint so progressively enhanced
// widgets (the contents panel) can start collapsed without a flash.
document.documentElement.classList.add("has-js");
// The color-scheme meta decides what the browser paints before the stylesheet
// arrives; keep it in step with an explicit theme so that first paint is not
// the wrong shade.
const scheme = document.querySelector('meta[name="color-scheme"]');
try {
const theme = localStorage.getItem("theme");
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
else document.documentElement.removeAttribute("data-theme");
if (theme === "light" || theme === "dark") {
document.documentElement.dataset.theme = theme;
if (scheme) scheme.content = theme;
} else {
document.documentElement.removeAttribute("data-theme");
}
} catch (_) {
document.documentElement.removeAttribute("data-theme");
}
+7 -35
View File
@@ -82,40 +82,6 @@
@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 ---------- */
.btn, .button { @apply inline-flex min-h-10 items-center justify-center rounded-sm border border-rule-strong bg-transparent px-3 py-1.5 font-sans text-sm font-medium text-ink no-underline duration-150 hover:bg-ink hover:text-paper focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-paper; transition-property:transform, background-color, color, border-color; }
@@ -231,7 +197,13 @@
.filters .filter-actions { @apply flex items-center gap-3; }
.form-inline { @apply flex flex-wrap items-center gap-3 font-sans text-sm; }
.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; }
/* Rendered by the templates but only useful with the script; `has-js` is set pre-paint. */
.table-filter { @apply my-2 hidden w-80 max-w-full font-sans text-sm; }
.has-js .table-filter { @apply block; }
/* Theme toggle: the current theme's icon and label, chosen without script. */
[data-theme-icon], [data-theme-label] { display:none; }
html:not([data-theme]) [data-theme-icon="system"], html[data-theme="light"] [data-theme-icon="light"], html[data-theme="dark"] [data-theme-icon="dark"] { display:block; }
html:not([data-theme]) [data-theme-label="system"], html[data-theme="light"] [data-theme-label="light"], html[data-theme="dark"] [data-theme-label="dark"] { display:inline; }
.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; }
+1 -1
View File
@@ -17,7 +17,7 @@
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/articles">Reset</a></div>
</form>
{% include "dashboard/_pager.html" %}
<div class="scroll-x tall"><table class="articles" data-filter>
{% if articles.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="articles" data-filter>
<thead><tr><th>first seen</th><th>title</th><th>feed</th><th class="num">words</th><th>last stage</th><th>reason</th><th class="num">utility</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>rating</th><th>published</th></tr></thead>
<tbody>{% for article in articles %}<tr>
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
+1 -1
View File
@@ -17,7 +17,7 @@
</form>
</section>{% endfor %}</div>
<h2>History</h2>
{% if jobs.is_empty() %}<p class="muted text-sm">No jobs recorded yet.</p>{% else %}<div class="scroll-x tall"><table data-filter>
{% if jobs.is_empty() %}<p class="muted text-sm">No jobs recorded yet.</p>{% else %}{% if jobs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
<thead><tr><th class="num">job</th><th>name</th><th>requested by</th><th>requested</th><th>started</th><th>finished</th><th class="num">duration</th><th>status</th><th>message</th><th class="num">run</th></tr></thead>
<tbody>{% for job in jobs %}<tr>
<td class="num"><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
+1 -1
View File
@@ -58,7 +58,7 @@
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/runs/{{ run.id }}">Reset</a></div>
</form>
{% include "dashboard/_pager.html" %}
<div class="scroll-x tall"><table class="candidates" data-filter>
{% if candidates.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="candidates" data-filter>
<thead><tr><th>title</th><th>feed</th><th class="num">words</th><th>stage</th><th>reason</th><th>admitted by</th><th class="num">utility</th><th class="num">rank</th><th class="num">cluster</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>flags</th><th>editor</th></tr></thead>
<tbody>{% for candidate in candidates %}{% include "_candidate_row.html" %}{% endfor %}{% if candidates.is_empty() %}<tr><td colspan="14" class="text-muted">No candidates match this filter.</td></tr>{% endif %}</tbody></table></div>
{% include "dashboard/_pager.html" %}
+1 -1
View File
@@ -7,7 +7,7 @@
<label>Status <select name="status"><option value="">any</option>{% for name in statuses %}<option value="{{ name }}"{% if status == *name %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></label>
<div class="filter-actions"><button class="btn" type="submit">Filter</button>{% if !status.is_empty() %}<a class="btn-quiet" href="/dashboard/runs">Reset</a>{% endif %}</div>
</form>
<div class="scroll-x tall"><table data-filter>
{% if runs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
<thead><tr><th>run</th><th>date</th><th>status</th><th>started</th><th>duration</th><th>considered → eligible → triaged → assessed → shortlisted → selected</th><th>cost</th><th class="num">total</th><th class="num">warnings</th></tr></thead>
<tbody>{% for run in runs %}<tr>
<td class="num"><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
@@ -3,7 +3,7 @@
<h1>Settings history</h1>
<p class="page-desc">Every settings change made through the dashboard, newest first. Hand edits to <code>config.toml</code> on disk are not recorded here.</p>
</div><div class="page-actions"><a class="btn" href="/dashboard/settings">Back to settings</a></div></header>
{% if changes.is_empty() %}<p class="muted text-sm">No settings have been changed through the dashboard yet.</p>{% else %}<div class="scroll-x tall"><table class="history" data-filter>
{% if changes.is_empty() %}<p class="muted text-sm">No settings have been changed through the dashboard yet.</p>{% else %}{% if changes.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="history" data-filter>
<thead><tr><th>When</th><th>Who</th><th>Key</th><th>Before</th><th>After</th></tr></thead>
<tbody>{% for change in changes %}<tr>
<td class="cell-tight text-muted">{{ change.changed_at }}</td>
+2 -2
View File
@@ -27,12 +27,12 @@
</div>
<h2>Spend by day</h2>
{% if cost_rows.is_empty() %}<p class="muted text-sm">No provider costs recorded in the window.</p>{% else %}<div class="scroll-x tall"><table data-filter>
{% if cost_rows.is_empty() %}<p class="muted text-sm">No provider costs recorded in the window.</p>{% else %}{% if cost_rows.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
<thead><tr><th>day (UTC)</th>{% for provider in providers %}<th class="num">{{ provider }}</th>{% endfor %}<th class="num">total</th></tr></thead>
<tbody>{% for row in cost_rows %}<tr><td class="cell-tight">{{ row.date }}</td>{% for cell in row.cells %}<td class="num text-muted">{{ cell }}</td>{% endfor %}<td class="num">{{ row.total }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
<h2>Runs in the window</h2>
{% if runs.is_empty() %}<p class="muted text-sm">No finished runs in the window.</p>{% else %}<div class="scroll-x tall"><table data-filter>
{% if runs.is_empty() %}<p class="muted text-sm">No finished runs in the window.</p>{% else %}{% if runs.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
<thead><tr><th class="num">run</th><th>date</th><th>status</th><th class="num">cost</th><th class="num">selected</th><th class="num">duration</th></tr></thead>
<tbody>{% for run in runs %}<tr><td class="num"><a href="/dashboard/runs/{{ run.run_id }}">{{ run.run_id }}</a></td><td class="cell-tight"><a href="/issues/{{ run.date }}">{{ run.date }}</a></td><td class="cell-tight"><span class="badge {{ run.status }}">{{ run.status }}</span></td><td class="num">{{ run.cost }}</td><td class="num">{{ run.selected }}</td><td class="num text-muted">{{ run.duration }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}
+1 -1
View File
@@ -4,7 +4,7 @@
<p class="page-desc">Every account on this server, with its role and open sessions.</p>
</div></header>
<p class="muted text-sm">Accounts are read-only here. Create, change, disable, enable, or sign out users with the <code>daily-epub users</code> CLI on the server.</p>
<div class="scroll-x tall"><table data-filter>
{% if users.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table data-filter>
<thead><tr><th>Username</th><th>Role</th><th>Status</th><th>Created</th><th>Last login</th><th class="num">Open sessions</th></tr></thead>
<tbody>{% for user in users %}<tr>
<td class="font-medium text-ink">{{ user.username }}</td>
+5 -6
View File
@@ -3,25 +3,24 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<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="site-header mx-auto max-w-7xl px-4 pt-4 sm:px-6 sm:pt-6">
<header class="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 %}{% 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>
<svg data-theme-icon="light" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75" hidden><circle cx="12" cy="12" r="3.5"/><path d="M12 2v2.2M12 19.8V22M4.93 4.93l1.56 1.56M17.51 17.51l1.56 1.56M2 12h2.2M19.8 12H22M4.93 19.07l1.56-1.56M17.51 6.49l1.56-1.56"/></svg>
<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>
<svg data-theme-icon="light" aria-hidden="true" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current" stroke-width="1.75"><circle cx="12" cy="12" r="3.5"/><path d="M12 2v2.2M12 19.8V22M4.93 4.93l1.56 1.56M17.51 17.51l1.56 1.56M2 12h2.2M19.8 12H22M4.93 19.07l1.56-1.56M17.51 6.49l1.56-1.56"/></svg>
<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"><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">System</span><span data-theme-label="light">Light</span><span data-theme-label="dark">Dark</span>
</button>
{% 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>