US09-04: Write the User Manual with Generated Screenshots (#110)
Some checks failed
Test / suites (push) Failing after 2m56s
Test / container (push) Failing after 5m27s

This commit was merged in pull request #110.
This commit is contained in:
2026-08-23 23:37:31 +02:00
parent 282e8b51e6
commit f1442527a2
34 changed files with 997 additions and 37 deletions

View File

@@ -1,6 +1,7 @@
import { api } from "./api.js";
import { renderArchive, setArchiveRender } from "./archive.js";
import { renderDocs } from "./docs.js";
import { show as showIn } from "./dom.js";
import { navigate, onRouteChange, parseHash } from "./router.js";
import { renderRenames, setRenamesRender } from "./renames.js";
import { renderUploads, setUploadsRender } from "./uploads.js";
@@ -35,7 +36,7 @@ function el(tag, attrs = {}, ...children) {
}
function show(...nodes) {
root.replaceChildren(...nodes);
showIn(root, ...nodes);
}
function setActiveNav(view) {

View File

@@ -9,7 +9,7 @@
// is not mounted produces an instruction naming it rather than a disabled mystery,
// and an operation whose evidence is ambiguous offers no button at all.
import { api } from "./api.js";
import { el, errorBanner, setActiveNav } from "./dom.js";
import { el, errorBanner, setActiveNav, show } from "./dom.js";
import { subscribeJob } from "./events.js";
import { navigate } from "./router.js";
@@ -46,7 +46,7 @@ export async function renderArchive(root, params = {}) {
try {
locations = (await api.archiveLocations()).locations;
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load archive locations: ${error.message}`));
show(root, errorBanner(`Failed to load archive locations: ${error.message}`));
return;
}
const location = locations.find((l) => l.id === params.location) || locations[0] || null;
@@ -61,7 +61,7 @@ export async function renderArchive(root, params = {}) {
),
outcomeBanner()
);
root.replaceChildren(...nodes.filter(Boolean));
show(root, ...nodes.filter(Boolean));
return;
}
@@ -95,7 +95,7 @@ export async function renderArchive(root, params = {}) {
archived ? archivedSection(archived.items, locations) : null,
restore ? restoreSection(restore, location) : null
);
root.replaceChildren(...nodes.filter(Boolean));
show(root, ...nodes.filter(Boolean));
}
function plans(listed) {

View File

@@ -21,6 +21,19 @@ export function errorBanner(message) {
return el("div", { class: "alert", role: "alert" }, message);
}
/**
* Replace a container's contents, dropping the absent ones.
*
* `el` already ignores null children, but `replaceChildren` does not: it stringifies
* them, so an optional node that is not there renders the word "null" on the page.
* Every view builds optional nodes — a banner only while a job runs, a conflict only
* after a 409 — so the filter belongs here rather than in each caller. It was missing
* from one of them, and a documentation screenshot is where that turned up (US09-04).
*/
export function show(root, ...nodes) {
root.replaceChildren(...nodes.flat().filter((node) => node != null && node !== false));
}
export function setActiveNav(view) {
document.querySelectorAll("nav a[data-nav]").forEach((a) => {
if (a.dataset.nav === view) a.setAttribute("aria-current", "page");

View File

@@ -5,7 +5,7 @@
// recovery classifications all come from the server; the view only shows them and
// refuses to offer an action the server would reject.
import { api } from "./api.js";
import { el, errorBanner, setActiveNav } from "./dom.js";
import { el, errorBanner, setActiveNav, show } from "./dom.js";
// What the last confirm did, for this tab only. Deliberately not persisted: after a
// reload the page must show what the journal says, not what this page remembers.
@@ -24,7 +24,7 @@ export async function renderRenames(root, params = {}) {
try {
[plans, recovery] = await Promise.all([api.listPlans(), api.renameRecovery()]);
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load renames: ${error.message}`));
show(root, errorBanner(`Failed to load renames: ${error.message}`));
return;
}
@@ -34,13 +34,13 @@ export async function renderRenames(root, params = {}) {
try {
plan = await api.getPlan(selectedId);
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load plan: ${error.message}`));
show(root, errorBanner(`Failed to load plan: ${error.message}`));
return;
}
}
const blocked = recovery.blocks_mutation;
root.replaceChildren(
show(root,
el("h1", {}, "Renames"),
recoveryPanel(recovery),
el(

View File

@@ -8,7 +8,7 @@
// preview arrives redacted — so nothing in this view may reconstruct, store, or
// route a secret.
import { api } from "./api.js";
import { el, errorBanner, setActiveNav } from "./dom.js";
import { el, errorBanner, setActiveNav, show } from "./dom.js";
import { subscribeJob } from "./events.js";
import { navigate } from "./router.js";
@@ -45,7 +45,7 @@ export async function renderUploads(root, params = {}) {
api.listUploadBatches(),
]);
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load uploads: ${error.message}`));
show(root, errorBanner(`Failed to load uploads: ${error.message}`));
return;
}
@@ -62,12 +62,12 @@ export async function renderUploads(root, params = {}) {
batch = detail;
history = verifications.verifications;
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load upload batch: ${error.message}`));
show(root, errorBanner(`Failed to load upload batch: ${error.message}`));
return;
}
}
root.replaceChildren(
show(root,
...[
el("h1", {}, "Upload"),
configurationCard(preflight),

View File

@@ -3,7 +3,7 @@
// HTML. Mutating actions are disabled while a job holds the library_write lock and
// say why (concept §shared interaction rules: read-only browsing stays available).
import { api } from "./api.js";
import { el, errorBanner, setActiveNav } from "./dom.js";
import { el, errorBanner, setActiveNav, show } from "./dom.js";
import { navigate } from "./router.js";
import { subscribeJob } from "./events.js";
@@ -26,7 +26,7 @@ export async function renderWorkflow(root) {
try {
data = await api.workflow();
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load workflow: ${error.message}`));
show(root, errorBanner(`Failed to load workflow: ${error.message}`));
return;
}
const active = data.active_job;
@@ -67,7 +67,7 @@ export async function renderWorkflow(root) {
)
);
});
root.replaceChildren(
show(root,
el("h1", {}, "Workflow"),
jobBanner(active),
el("div", { class: "stepper" }, ...cards)
@@ -105,7 +105,7 @@ export async function renderSafety(root, params) {
try {
data = await api.safetyQueue({ state });
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load safety queue: ${error.message}`));
show(root, errorBanner(`Failed to load safety queue: ${error.message}`));
return;
}
@@ -149,7 +149,7 @@ export async function renderSafety(root, params) {
)
);
root.replaceChildren(
show(root,
el("h1", {}, "Safety review"),
tabs,
el(
@@ -197,7 +197,7 @@ export async function renderLibrary(root, params) {
try {
data = await api.libraryAssets({ q, offset, limit, sort: params.sort || "path" });
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load library: ${error.message}`));
show(root, errorBanner(`Failed to load library: ${error.message}`));
return;
}
@@ -220,7 +220,7 @@ export async function renderLibrary(root, params) {
)
);
root.replaceChildren(
show(root,
el("h1", {}, "Library"),
el("div", { class: "toolbar" }, search, el("span", { class: "muted", "data-testid": "library-total" }, `${data.total} photos`)),
data.rows.length ? el("div", { class: "cluster-grid" }, ...cards) : el("p", { class: "muted" }, "No matching photos."),
@@ -235,7 +235,7 @@ export async function renderAnalyze(root) {
try {
[counts, workflow] = await Promise.all([api.analysisCounts(), api.workflow()]);
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load analysis: ${error.message}`));
show(root, errorBanner(`Failed to load analysis: ${error.message}`));
return;
}
const active = workflow.active_job;
@@ -269,7 +269,7 @@ export async function renderAnalyze(root) {
"Analyze eligible SFW assets"
);
root.replaceChildren(
show(root,
el("h1", {}, "Analyze"),
jobBanner(active),
el(
@@ -292,10 +292,10 @@ export async function renderStats(root) {
try {
data = await api.libraryStats();
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load stats: ${error.message}`));
show(root, errorBanner(`Failed to load stats: ${error.message}`));
return;
}
root.replaceChildren(
show(root,
el("h1", {}, "Stats"),
el("div", { class: "counts-grid", "data-testid": "stats-status" }, ...Object.entries(data.status).map(([k, v]) => stat(k, v))),
facetBlock("Top tags", data.top_tags),
@@ -338,7 +338,7 @@ export async function renderAlbums(root, params = {}) {
api.renameRecovery(),
]);
} catch (error) {
root.replaceChildren(errorBanner(`Failed to load albums: ${error.message}`));
show(root, errorBanner(`Failed to load albums: ${error.message}`));
return;
}
@@ -385,7 +385,7 @@ export async function renderAlbums(root, params = {}) {
)
: el("p", { class: "muted" }, "No albums with evidence yet.");
root.replaceChildren(
show(root,
el("h1", {}, "Albums"),
renameBlocked
? el(