Replaces the hand-written stylesheet with a Tailwind CSS v4 source (src/web/tailwind.css) whose minified build is committed as src/web/static/app.css and still embedded with include_str!, so the binary and the test suite never need Node. package.json pins tailwindcss and @tailwindcss/cli to 4.3.3 and adds css, css:watch and css:check. Colours are semantic CSS variables swapped per theme and exposed to Tailwind through @theme inline, so templates write bg-paper/text-ink/border-rule once and both themes work. theme.js runs synchronously in <head> to set data-theme before paint; app.js cycles the toggle system -> light -> dark and persists the choice. Newsreader is self-hosted and served from web::static_asset with the same ETag/304 logic as the other assets. Every reader template is restyled: ears row, centred masthead with its rules, tracked section labels, the drop cap on The Brief, "Why it's here" with its accent bar, the segmented rating control, prev/next cards, and the colophon as a two-column definition grid. Dashboard templates are untouched (step 3). Also fixes a .block collision with Tailwind's display utility, the SVG `hidden` property that never toggled the theme icon, and points the dev seed's profile_path at ./dev so previewing cannot rewrite data/profile.md. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
114 lines
4.4 KiB
JavaScript
114 lines
4.4 KiB
JavaScript
const themeToggle = document.querySelector("[data-theme-toggle]");
|
|
if (themeToggle) {
|
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
|
const readTheme = () => {
|
|
try {
|
|
const saved = localStorage.getItem("theme");
|
|
return saved === "light" || saved === "dark" ? saved : "system";
|
|
} catch (_) {
|
|
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);
|
|
});
|
|
};
|
|
const setTheme = (theme) => {
|
|
if (theme === "system") document.documentElement.removeAttribute("data-theme");
|
|
else document.documentElement.dataset.theme = theme;
|
|
try {
|
|
if (theme === "system") localStorage.removeItem("theme");
|
|
else localStorage.setItem("theme", theme);
|
|
} catch (_) {}
|
|
renderTheme(theme);
|
|
};
|
|
renderTheme(readTheme());
|
|
themeToggle.addEventListener("click", () => {
|
|
const current = readTheme();
|
|
setTheme(current === "system" ? "light" : current === "light" ? "dark" : "system");
|
|
});
|
|
media.addEventListener("change", () => {
|
|
if (readTheme() === "system") {
|
|
document.documentElement.removeAttribute("data-theme");
|
|
renderTheme("system");
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener("submit", (event) => {
|
|
const form = event.target;
|
|
if (form.matches("form.rating") && event.submitter) {
|
|
event.preventDefault();
|
|
const submitted = event.submitter;
|
|
const body = new URLSearchParams(new FormData(form));
|
|
body.set(submitted.name, submitted.value);
|
|
fetch(form.action, {
|
|
method: "POST",
|
|
body,
|
|
credentials: "same-origin",
|
|
headers: { Accept: "application/json" },
|
|
}).then((response) => {
|
|
if (!response.ok) throw new Error("rating request failed");
|
|
return response.json();
|
|
}).then((result) => {
|
|
form.querySelectorAll("button[data-label]").forEach((button) => {
|
|
const active = button.dataset.label === result.label;
|
|
button.classList.toggle("active", active);
|
|
button.setAttribute("aria-pressed", active ? "true" : "false");
|
|
});
|
|
}).catch(() => {
|
|
const label = document.createElement("input");
|
|
label.type = "hidden";
|
|
label.name = submitted.name;
|
|
label.value = submitted.value;
|
|
form.appendChild(label);
|
|
form.submit();
|
|
});
|
|
return;
|
|
}
|
|
const message = form.dataset.confirm;
|
|
if (message && !window.confirm(message)) event.preventDefault();
|
|
});
|
|
document.querySelectorAll("details[id]").forEach((details) => {
|
|
try {
|
|
const key = "details:" + details.id;
|
|
details.open = localStorage.getItem(key) === "open";
|
|
details.addEventListener("toggle", () => localStorage.setItem(key, details.open ? "open" : "closed"));
|
|
} catch (_) {}
|
|
});
|
|
/* 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);
|
|
input.addEventListener("input", () => {
|
|
const needle = input.value.trim().toLowerCase();
|
|
rows.forEach((row) => {
|
|
row.hidden = needle !== "" && !row.textContent.toLowerCase().includes(needle);
|
|
});
|
|
});
|
|
});
|
|
/* step 5: settings — "reset to default" fills the field with its default */
|
|
document.addEventListener("click", (event) => {
|
|
const button = event.target.closest("button[data-reset]");
|
|
if (!button) return;
|
|
const input = document.getElementById(button.dataset.reset);
|
|
if (!input) return;
|
|
input.value = button.dataset.default;
|
|
});
|
|
/* step 6: reload a job page every N seconds while its job is requested/running */
|
|
document.querySelectorAll("[data-refresh]").forEach((element) => {
|
|
const seconds = Number(element.dataset.refresh);
|
|
if (seconds > 0) setTimeout(() => window.location.reload(), seconds * 1000);
|
|
});
|