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:
+43
-15
@@ -211,9 +211,38 @@ pub struct Page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// First 12 hex digits of the SHA-256 over the embedded CSS and JS assets.
|
/// 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(|| {
|
pub static ASSET_VERSION: LazyLock<String> = LazyLock::new(|| {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(include_str!("static/app.css"));
|
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/app.js"));
|
||||||
hasher.update(include_str!("static/theme.js"));
|
hasher.update(include_str!("static/theme.js"));
|
||||||
hex::encode(hasher.finalize())[..12].to_string()
|
hex::encode(hasher.finalize())[..12].to_string()
|
||||||
@@ -411,7 +440,7 @@ pub async fn security_headers(request: Request, next: Next) -> Response {
|
|||||||
headers.insert(
|
headers.insert(
|
||||||
header::HeaderName::from_static("content-security-policy"),
|
header::HeaderName::from_static("content-security-policy"),
|
||||||
HeaderValue::from_static(
|
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(
|
headers.insert(
|
||||||
@@ -537,10 +566,7 @@ async fn static_asset(
|
|||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let asset: (&str, &'static [u8]) = match file.as_str() {
|
let asset: (&str, &'static [u8]) = match file.as_str() {
|
||||||
"app.css" => (
|
"app.css" => ("text/css; charset=utf-8", APP_CSS.as_bytes()),
|
||||||
"text/css; charset=utf-8",
|
|
||||||
include_str!("static/app.css").as_bytes(),
|
|
||||||
),
|
|
||||||
"app.js" => (
|
"app.js" => (
|
||||||
"application/javascript; charset=utf-8",
|
"application/javascript; charset=utf-8",
|
||||||
include_str!("static/app.js").as_bytes(),
|
include_str!("static/app.js").as_bytes(),
|
||||||
@@ -557,14 +583,8 @@ async fn static_asset(
|
|||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
include_str!("static/favicon.svg").as_bytes(),
|
include_str!("static/favicon.svg").as_bytes(),
|
||||||
),
|
),
|
||||||
"Newsreader.woff2" => (
|
"Newsreader.woff2" => ("font/woff2", NEWSREADER),
|
||||||
"font/woff2",
|
"Newsreader-italic.woff2" => ("font/woff2", NEWSREADER_ITALIC),
|
||||||
include_bytes!("static/fonts/Newsreader.woff2"),
|
|
||||||
),
|
|
||||||
"Newsreader-italic.woff2" => (
|
|
||||||
"font/woff2",
|
|
||||||
include_bytes!("static/fonts/Newsreader-italic.woff2"),
|
|
||||||
),
|
|
||||||
_ => return WebError::NotFound.into_response(),
|
_ => return WebError::NotFound.into_response(),
|
||||||
};
|
};
|
||||||
let etag = format!("\"{}\"", hex::encode(Sha256::digest(asset.1)));
|
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());
|
let expected = format!("/static/app.css?v={}", ASSET_VERSION.as_str());
|
||||||
assert!(html.contains(&expected), "{html}");
|
assert!(html.contains(&expected), "{html}");
|
||||||
assert!(!html.contains(&format!("/static/app.css?v={}", crate::VERSION)));
|
assert!(!html.contains(&format!("/static/app.css?v={}", crate::VERSION)));
|
||||||
assert!(html.contains("rel=\"preload\" href=\"/static/Newsreader.woff2\""));
|
// The fonts ride inside the stylesheet; a preload would fetch them twice.
|
||||||
assert!(html.contains("rel=\"preload\" href=\"/static/Newsreader-italic.woff2\""));
|
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]
|
#[tokio::test]
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+17
-15
@@ -9,17 +9,14 @@ if (themeToggle) {
|
|||||||
return "system";
|
return "system";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const renderTheme = (theme) => {
|
// Icons and label are picked by CSS from `html[data-theme]`, which theme.js sets
|
||||||
themeToggle.setAttribute("aria-label", `Theme: ${theme}`);
|
// before first paint, so nothing here repaints the toggle after load.
|
||||||
themeToggle.querySelector("[data-theme-label]").textContent = theme[0].toUpperCase() + theme.slice(1);
|
const renderTheme = (theme) => themeToggle.setAttribute("aria-label", `Theme: ${theme}`);
|
||||||
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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const setTheme = (theme) => {
|
const setTheme = (theme) => {
|
||||||
if (theme === "system") document.documentElement.removeAttribute("data-theme");
|
if (theme === "system") document.documentElement.removeAttribute("data-theme");
|
||||||
else document.documentElement.dataset.theme = 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 {
|
try {
|
||||||
if (theme === "system") localStorage.removeItem("theme");
|
if (theme === "system") localStorage.removeItem("theme");
|
||||||
else localStorage.setItem("theme", 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) */
|
/* step 3: filter-as-you-type on tables with data-filter (this page's rows only) */
|
||||||
document.querySelectorAll("table[data-filter]").forEach((table) => {
|
document.querySelectorAll("table[data-filter]").forEach((table) => {
|
||||||
const rows = table.querySelectorAll("tbody tr");
|
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;
|
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", () => {
|
input.addEventListener("input", () => {
|
||||||
const needle = input.value.trim().toLowerCase();
|
const needle = input.value.trim().toLowerCase();
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
|
|||||||
+10
-2
@@ -2,10 +2,18 @@
|
|||||||
// Marks the document as scripted before first paint so progressively enhanced
|
// Marks the document as scripted before first paint so progressively enhanced
|
||||||
// widgets (the contents panel) can start collapsed without a flash.
|
// widgets (the contents panel) can start collapsed without a flash.
|
||||||
document.documentElement.classList.add("has-js");
|
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 {
|
try {
|
||||||
const theme = localStorage.getItem("theme");
|
const theme = localStorage.getItem("theme");
|
||||||
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
|
if (theme === "light" || theme === "dark") {
|
||||||
else document.documentElement.removeAttribute("data-theme");
|
document.documentElement.dataset.theme = theme;
|
||||||
|
if (scheme) scheme.content = theme;
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
document.documentElement.removeAttribute("data-theme");
|
document.documentElement.removeAttribute("data-theme");
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-35
@@ -82,40 +82,6 @@
|
|||||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior:auto !important; transition-duration:0.01ms !important; } }
|
@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 {
|
@layer components {
|
||||||
/* ---------- shared controls ---------- */
|
/* ---------- 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; }
|
.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; }
|
.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 { @apply flex flex-wrap items-center gap-3 font-sans text-sm; }
|
||||||
.form-inline label { @apply flex items-center gap-2 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 { @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; }
|
.pager a { @apply text-ink no-underline hover:text-accent; }
|
||||||
|
|||||||
@@ -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>
|
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/articles">Reset</a></div>
|
||||||
</form>
|
</form>
|
||||||
{% include "dashboard/_pager.html" %}
|
{% 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>
|
<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>
|
<tbody>{% for article in articles %}<tr>
|
||||||
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
|
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>{% endfor %}</div>
|
</section>{% endfor %}</div>
|
||||||
<h2>History</h2>
|
<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>
|
<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>
|
<tbody>{% for job in jobs %}<tr>
|
||||||
<td class="num"><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
|
<td class="num"><a href="/dashboard/jobs/{{ job.id }}">{{ job.id }}</a></td>
|
||||||
|
|||||||
@@ -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>
|
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/runs/{{ run.id }}">Reset</a></div>
|
||||||
</form>
|
</form>
|
||||||
{% include "dashboard/_pager.html" %}
|
{% 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>
|
<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>
|
<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" %}
|
{% include "dashboard/_pager.html" %}
|
||||||
|
|||||||
@@ -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>
|
<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>
|
<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>
|
</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>
|
<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>
|
<tbody>{% for run in runs %}<tr>
|
||||||
<td class="num"><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
|
<td class="num"><a href="/dashboard/runs/{{ run.id }}">{{ run.id }}</a></td>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<h1>Settings history</h1>
|
<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>
|
<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>
|
</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>
|
<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>
|
<tbody>{% for change in changes %}<tr>
|
||||||
<td class="cell-tight text-muted">{{ change.changed_at }}</td>
|
<td class="cell-tight text-muted">{{ change.changed_at }}</td>
|
||||||
|
|||||||
@@ -27,12 +27,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Spend by day</h2>
|
<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>
|
<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 %}
|
<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>
|
<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>
|
<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 %}
|
<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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<p class="page-desc">Every account on this server, with its role and open sessions.</p>
|
<p class="page-desc">Every account on this server, with its role and open sessions.</p>
|
||||||
</div></header>
|
</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>
|
<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>
|
<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>
|
<tbody>{% for user in users %}<tr>
|
||||||
<td class="font-medium text-ink">{{ user.username }}</td>
|
<td class="font-medium text-ink">{{ user.username }}</td>
|
||||||
|
|||||||
@@ -3,25 +3,24 @@
|
|||||||
<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="color-scheme" content="light dark">
|
||||||
<title>{{ page.title }} · The Daily EPUB</title>
|
<title>{{ page.title }} · The Daily EPUB</title>
|
||||||
<script src="/static/theme.js?v={{ page.asset_version }}"></script>
|
<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="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="alternate" type="application/atom+xml" title="The Daily EPUB" href="/feed.xml">
|
||||||
<link rel="icon" href="/static/favicon.svg">
|
<link rel="icon" href="/static/favicon.svg">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<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="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="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">
|
<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">
|
<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="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="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" 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>
|
<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</span>
|
<span data-theme-label="system">System</span><span data-theme-label="light">Light</span><span data-theme-label="dark">Dark</span>
|
||||||
</button>
|
</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 %}
|
{% 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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user