9af784d30135

give the operator a focused workspace with separate profile editing (v3.0.119)

time
2026-09-29 20:38 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
gpt-6-sol
session
operator workspace
parent
52bd51d09124
12 files package.json+1 −1public/css/kepr.css+110 −0public/js/kepr.js+13 −0src/admin/console.ts+68 −13src/admin/repos.ts+27 −3src/admin/site.ts+42 −10src/server/app.ts+2 −0tasks/plan.md+14 −0tasks/todo.md+7 −0tests/admin.test.ts+1 −0tests/console.test.ts+44 −2tests/navbar.test.ts+1 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.118",3+	"version": "3.0.119",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+110 −0
@@ -2053,6 +2053,116 @@20532053 20542054 /* ----- docs and support --------------------------------------------------- */20552055 2056+.admin-layout {2057+	display: grid;2058+	grid-template-columns: 160px minmax(0, 1fr);2059+	gap: 48px;2060+	min-height: 70vh;2061+}2062+.admin-sidebar {2063+	position: sticky;2064+	top: 24px;2065+	align-self: start;2066+	padding: 24px 0;2067+	font-size: 0.75rem;2068+}2069+.admin-sidebar nav,2070+.admin-sidebar-foot {2071+	display: flex;2072+	flex-direction: column;2073+	gap: 16px;2074+	margin-top: 20px;2075+}2076+.admin-sidebar a {2077+	color: var(--text-secondary);2078+}2079+.admin-sidebar a[aria-current] {2080+	color: var(--accent);2081+	border-left: 2px solid var(--accent);2082+	padding-left: 12px;2083+}2084+.admin-sidebar-foot {2085+	border-top: 1px solid var(--border);2086+	padding-top: 24px;2087+	margin-top: 40px;2088+}2089+.admin-content {2090+	min-width: 0;2091+}2092+.admin-heading {2093+	border-bottom: 1px solid var(--border);2094+	padding: 24px 0;2095+	margin-bottom: 32px;2096+}2097+.admin-heading h1 {2098+	font-size: 2rem;2099+	font-weight: 400;2100+	margin: 8px 0 0;2101+	color: var(--text-heading);2102+}2103+.admin-intro {2104+	color: var(--text-secondary);2105+	margin-bottom: 24px;2106+}2107+.admin-notice {2108+	color: var(--sage);2109+	border-left: 2px solid var(--sage);2110+	padding-left: 16px;2111+}2112+.admin-content .profile-signature {2113+	max-width: 240px;2114+	max-height: 120px;2115+	object-fit: contain;2116+}2117+.admin-vitals {2118+	display: flex;2119+	flex-wrap: wrap;2120+	gap: 24px 40px;2121+	padding: 0 0 24px;2122+	border-bottom: 1px solid var(--border);2123+}2124+.admin-vitals strong {2125+	display: block;2126+	font-size: 1.75rem;2127+	font-weight: 400;2128+	color: var(--accent);2129+}2130+.admin-repos {2131+	display: grid;2132+	grid-template-columns: minmax(120px, 0.7fr) minmax(0, 1.3fr);2133+	gap: 24px;2134+	margin-top: 24px;2135+}2136+.admin-repos .issue-row {2137+	flex-wrap: wrap;2138+}2139+.admin-repos [aria-current] {2140+	border-left: 2px solid var(--accent);2141+	padding-left: 12px;2142+}2143+@media (max-width: 680px) {2144+	.admin-repos {2145+		grid-template-columns: minmax(0, 1fr);2146+	}2147+	.admin-layout {2148+		grid-template-columns: minmax(0, 1fr);2149+		gap: 0;2150+	}2151+	.admin-sidebar {2152+		position: static;2153+		padding: 8px 0;2154+	}2155+	.admin-sidebar nav,2156+	.admin-sidebar-foot {2157+		flex-flow: row wrap;2158+		gap: 12px 20px;2159+	}2160+	.admin-sidebar-foot {2161+		margin-top: 16px;2162+		padding-top: 16px;2163+	}2164+}2165+20562166 .site-doc {20572167 	max-width: 72ch;20582168 	margin-top: 16px;

view file

public/js/kepr.js+13 −0
@@ -623,6 +623,19 @@623623 		const button = element("button", "", "log out");624624 		button.type = "submit";625625 		form.append(token, button);626+		form.addEventListener("submit", async (event) => {627+			event.preventDefault();628+			button.disabled = true;629+			try {630+				const result = await fetch(form.action, { method: "POST", body: new FormData(form) });631+				if (!result.ok) throw new Error("Log out failed. Reload and try again.");632+				location.href = "/";633+			} catch (error) {634+				button.disabled = false;635+				button.textContent = "try log out again";636+				console.error(error);637+			}638+		});626639 		const admin = element("a", "", "admin");627640 		admin.href = "/admin";628641 		link.replaceWith(admin, form);

view file

src/admin/console.ts+68 −13
@@ -1,19 +1,23 @@11 import type { Context, Hono } from "hono";22 import { checkFormat, listRepos } from "../bucket/repo.ts";33 import type { SyncTarget } from "../bucket/sync.ts";4+import { operatorIssues } from "../issues/store.ts";45 import { type Html, html } from "../pages/html.ts";5-import { type PageContext, page, plural } from "../pages/layout.ts";6+import { type PageContext, page, plural, relativeTime } from "../pages/layout.ts";67 import { backups, newestBackupTarball } from "../server/backup.ts";78 import { MACHINES_LISTED } from "../server/connect.ts";9+import { setting } from "../server/db.ts";810 import { formToken } from "../server/login.ts";11+import { allRepos } from "./store.ts";912 1013 export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string; canConnect: boolean };1114 1215 const SECTIONS = [13-	["/admin", "status"],16+	["/admin", "overview"],1417 	["/admin/repos", "repos"],1518 	["/admin/issues", "issues"],1619 	["/admin/releases", "releases"],20+	["/admin/site?section=profile", "profile"],1721 	["/admin/site", "site"],1822 ] as const;1923 @@ -38,17 +42,35 @@3842 /** An admin page: the site's own look, with the console's sections and sign-out on top. */3943 export function adminPage(c: Context, ctx: AdminContext, title: string, body: Html): string {4044 	const tab = ([href, name]: (typeof SECTIONS)[number]) =>41-		html`<a class="issues-tab${c.req.path === href ? " active" : ""}" href="${href}">${name}</a>`;45+		html`<a href="${href}"${46+			(47+				c.req.path === "/admin/site"48+					? (c.req.query("section") === "profile" ? "profile" : "site") === name49+					: c.req.path === href || (href !== "/admin" && c.req.path.startsWith(`${href}/`))50+			)51+				? html` aria-current="page"`52+				: ""53+		}>${name}</a>`;54+	const handle = setting(ctx.kepr, "handle");4255 	return page(ctx, {4356 		title: `${title} · admin`,4457 		description: "",4558 		path: c.req.path,46-		body: html`<form class="issues-bar mono" method="post" action="/admin/sign-out">47-${SECTIONS.map(tab)}${formField(c)}48-<button class="issues-tab issues-new" type="submit">sign out</button>49-</form>50-<h1 class="file-page-title">${title}</h1>51-${body}`,59+		body: html`<div class="admin-layout">60+<aside class="admin-sidebar mono">61+<span class="section-label">workspace</span>62+<nav aria-label="operator workspace">${SECTIONS.map(tab)}</nav>63+<div class="admin-sidebar-foot">64+${handle && html`<a href="/@${handle}">view profile ↗</a>`}65+<a href="/">view site ↗</a>66+<form class="nav-logout" method="post" action="/admin/sign-out">67+${formField(c)}<button type="submit">log out</button>68+</form></div></aside>69+<section class="admin-content">70+<header class="admin-heading"><span class="section-label mono">operator</span>71+<h1>${title}</h1></header>72+${c.req.query("saved") === "1" && html`<p class="admin-notice" role="status">Changes saved.</p>`}73+${body}</section></div>`,5274 	});5375 }5476 @@ -113,6 +135,37 @@113135 		};114136 		const lines = (await sourceLines()).toLocaleString("en-US");115137 		const newest = backups(ctx.dataDir).at(-1);138+		const repos = allRepos(ctx.kepr);139+		const open = operatorIssues(ctx.kepr, "open").length;140+		const attention = repos.filter((repo) => repo.last_sync_error || repo.last_sync_at === null);141+		const vital = (label: string, value: number, href: string) =>142+			html`<a href="${href}"><strong>${value}</strong><span class="mono">${label}</span></a>`;143+		const overview = html`<div class="admin-vitals">144+${vital("repositories", repos.length, "/admin/repos")}145+${vital("public", repos.filter((repo) => repo.public).length, "/browse")}146+${vital("open issues", open, "/admin/issues")}147+</div>148+<section class="repo-section"><h2 class="section-label mono">needs attention</h2>149+${attention.map(150+	(repo) => html`<a class="issue-row" href="/admin/repos?repo=${repo.name}">151+<span>${repo.name}</span><span class="issues-error">${repo.last_sync_error ?? "Not synced yet"}</span></a>`,152+)}153+${!newest && html`<p class="issues-error">No backup yet. Check the server log for backup errors.</p>`}154+${!setting(ctx.kepr, "handle") && html`<p><a href="/admin/site?section=profile">Set your handle to publish your profile →</a></p>`}155+${attention.length === 0 && newest && html`<p class="muted">All repositories are synced. A local backup is available.</p>`}156+</section>157+<section class="repo-section"><h2 class="section-label mono">repositories</h2>158+${repos159+	.slice()160+	.sort((a, b) => (b.last_sync_at ?? 0) - (a.last_sync_at ?? 0))161+	.slice(0, 8)162+	.map(163+		(repo) => html`164+<a class="issue-row" href="/admin/repos?repo=${repo.name}"><span>${repo.name}</span>165+<span class="issue-meta mono">${repo.public ? "public" : "private"} · ${repo.last_sync_at === null ? "never synced" : `synced ${relativeTime(repo.last_sync_at)}`}</span></a>`,166+	)}167+${repos.length === 0 && html`<p class="muted">No repositories yet. Offer one with Koh, then sync it here.</p>`}168+</section>`;116169 		const backup = newest117170 			? html`<p>Last backup: ${newest.replace(118171 					/^(\d{4})(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)Z$/,@@ -123,11 +176,13 @@123176 			adminPage(124177 				c,125178 				ctx,126-				"status",127-				html`<section class="repo-section markdown">${bucket}179+				"overview",180+				html`${overview}<section class="repo-section markdown">181+<h2 class="section-label mono">backup</h2>${backup}182+<p class="muted">Backups stay on this server. Keep a copy elsewhere, along with kepr.toml.</p>183+<details><summary class="mono">bucket and runtime</summary>${bucket}128184 ${ctx.canConnect ? connectedMachines(ctx) : ""}129-${backup}130-<p>Running kepr ${version}: ${lines} lines of TypeScript.</p></section>`,185+<p>Running kepr ${version}: ${lines} lines of TypeScript.</p></details></section>`,131186 			),132187 		);133188 	});

view file

src/admin/repos.ts+27 −3
@@ -45,17 +45,40 @@4545 			return refuse("an install line is one line.");4646 		}4747 		updateRepo(ctx.kepr, repo.name, edit);48-		return c.redirect(`/admin/repos#repo-${repo.name}`, 303);48+		return c.redirect(`/admin/repos?repo=${repo.name}&saved=1#repo-editor`, 303);4949 	});5050 }5151 5252 function reposPage(c: Context, ctx: AdminContext, error: string): string {5353 	const repos = allRepos(ctx.kepr);54+	const query = (c.req.query("q") ?? "").slice(0, 100);55+	const filtered = repos.filter((repo) =>56+		`${repo.name} ${repo.category}`.toLowerCase().includes(query.toLowerCase()),57+	);58+	const selected = filtered.find((repo) => repo.name === c.req.query("repo")) ?? filtered[0];5459 	const body = html`${notice(error)}60+<p class="admin-intro">Choose a repository to manage its visibility and public page.</p>61+<form class="issues-form" method="get" action="/admin/repos">62+<label class="issues-label" for="repo-search">find a repository</label>63+<input class="issues-input" id="repo-search" name="q" value="${query}" maxlength="100" type="search">64+<button class="issues-submit" type="submit">search</button></form>5565 <form method="post" action="/admin/sync">5666 ${formField(c)}<button class="issues-submit" type="submit">sync now</button></form>5767 ${repos.length === 0 && html`<p class="muted mono save-empty">no repos found in the bucket yet</p>`}58-${repos.map((repo) => repoForm(c, repo, quarantinedPaths(openMirror(ctx.mirrorDir, repo.name))))}`;68+<div class="admin-repos">69+<nav aria-label="repositories">70+${filtered.map(71+	(72+		repo,73+	) => html`<a class="issue-row" id="repo-${repo.name}" href="/admin/repos?repo=${repo.name}&q=${encodeURIComponent(query)}#repo-editor"${selected?.name === repo.name ? html` aria-current="page"` : ""}>74+<span>${repo.name}</span><span>${repo.public ? "public" : "private"}</span>75+<span class="issue-meta mono">${repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`}</span>76+${repo.last_sync_error && html`<span class="issues-error">${repo.last_sync_error}</span>`}</a>`,77+)}78+${repos.length > 0 && filtered.length === 0 && html`<p class="muted">No repositories match “${query}”. <a href="/admin/repos">clear search</a></p>`}79+</nav>80+${selected && repoForm(c, selected, quarantinedPaths(openMirror(ctx.mirrorDir, selected.name)))}81+</div>`;5982 	return adminPage(c, ctx, "repos", body);6083 }6184 @@ -65,7 +88,7 @@6588 <input class="issues-input" id="${repo.name}-${name}" name="${name}" maxlength="${MAX_FIELD}" value="${String(repo[name])}">`;6689 	const synced =6790 		repo.last_sync_at === null ? "never synced" : `last sync ${formatDate(repo.last_sync_at)} UTC`;68-	return html`<article class="issue-post" id="repo-${repo.name}">91+	return html`<article class="issue-post" id="repo-editor">6992 <p class="issue-post-head mono">7093 <span class="issue-author">7194 ${repo.public ? html`<a href="/${repo.name}">${repo.name}</a>` : repo.name}</span>@@ -84,6 +107,7 @@84107 ${input("category", "category")}85108 ${input("homepage", "homepage")}86109 ${input("sort_order", "card order")}110+<p class="issue-meta mono">Lower numbers appear first. Repositories with the same number sort by name.</p>87111 ${input("install1", "install line 1")}88112 ${input("install2", "install line 2")}89113 ${input("install3", "install line 3")}

view file

src/admin/site.ts+42 −10
@@ -21,6 +21,7 @@2121 	["support_page", "support page (Markdown)", 4000],2222 ];2323 const TEXTAREAS = new Set(["bio", "support_page"]);24+const PROFILE_FIELDS = new Set(["handle", "bio", "location", "link", "building_since"]);2425 const HANDLE = /^[A-Za-z0-9._-]+$/;2526 const HTTP_URL = /^https?:\/\/[^\s]+$/i;2627 // Under the 64 KiB body cap with room for the form's other fields; the docs promise 60 KB.@@ -46,9 +47,13 @@4647 4748 	app.post("/admin/site", async (c) => {4849 		const fields = await textFields(c);49-		const refuse = (error: string) => c.html(sitePage(c, ctx, error), 400);50+		const section = fields.section;51+		if (section && section !== "profile" && section !== "site")52+			return c.text("Unknown section.", 400);53+		const refuse = (error: string) => c.html(sitePage(c, ctx, error, fields), 400);5054 		const values: Record<string, string> = {};5155 		for (const [key, label, max] of FIELDS) {56+			if (section && PROFILE_FIELDS.has(key) !== (section === "profile")) continue;5257 			values[key] = (fields[key] ?? "").trim();5358 			if (values[key].length > max) return refuse(`The ${label} is over ${max} characters.`);5459 		}@@ -56,12 +61,17 @@5661 			return refuse("A handle is letters, digits, '.', '_' and '-'.");5762 		}5863 		// Shown by default: the setting records only that it was switched off.59-		values.support_off = fields.support === "1" ? "" : "1";64+		if (section !== "profile") values.support_off = fields.support === "1" ? "" : "1";6065 		if (values.link && !HTTP_URL.test(values.link)) {6166 			return refuse("The link must be an http or https address.");6267 		}6368 		const upload = (await c.req.parseBody()).signature;6469 		const previous = setting(ctx.kepr, "signature");70+		if (71+			section === "site" &&72+			((upload instanceof File && upload.size > 0) || fields.remove_signature === "1")73+		)74+			return refuse("Edit your signature in profile settings.");6575 		if (upload instanceof File && upload.size > MAX_SIGNATURE) {6676 			return refuse("The signature must be under 60 KB.");6777 		}@@ -87,15 +97,25 @@8797 		) {8898 			rmSync(join(assets, previous), { force: true });8999 		}90-		return c.redirect("/admin/site", 303);100+		return c.redirect(`/admin/site?${section === "profile" ? "section=profile&" : ""}saved=1`, 303);91101 	});92102 }93103 94-function sitePage(c: Context, ctx: AdminContext, error: string): string {104+function sitePage(105+	c: Context,106+	ctx: AdminContext,107+	error: string,108+	fields?: Record<string, string>,109+): string {110+	const profile = (fields?.section ?? c.req.query("section")) === "profile";95111 	const signature = setting(ctx.kepr, "signature");96112 	// The support page's box starts from the words it shows today.97113 	const value = (key: string) =>98-		key === "support_page" ? supportText(ctx.kepr) : (setting(ctx.kepr, key) ?? "");114+		fields115+			? (fields[key] ?? "")116+			: key === "support_page"117+				? supportText(ctx.kepr)118+				: (setting(ctx.kepr, key) ?? "");99119 	const input = ([key, label, max]: (typeof FIELDS)[number]) =>100120 		html`<label class="issues-label" for="${key}">${label}</label>101121 ${@@ -106,13 +126,23 @@106126 value="${value(key)}">`107127 }`;108128 	const body = html`${notice(error)}129+<p class="admin-intro">${profile ? "Your public profile brings your work together. A handle gives it an address beginning with @." : "The name and words visitors see across your site."}</p>130+${profile && setting(ctx.kepr, "handle") && html`<p><a href="/@${setting(ctx.kepr, "handle")}">view profile ↗</a></p>`}109131 <form class="issues-form" method="post" action="/admin/site" enctype="multipart/form-data">110132 ${formField(c)}111-${FIELDS.map(input)}133+<input type="hidden" name="section" value="${profile ? "profile" : "site"}">134+${FIELDS.filter(([key]) => PROFILE_FIELDS.has(key) === profile).map(input)}135+${136+	!profile &&137+	html`112138 <label class="issues-label"><input type="checkbox" name="support" value="1"${113-		!setting(ctx.kepr, "support_off") && " checked"139+		(fields ? fields.support === "1" : !setting(ctx.kepr, "support_off")) && " checked"114140 	}>115-show the support page</label>141+show the support page</label>`142+}143+${144+	profile &&145+	html`116146 <label class="issues-label" for="signature">signature image117147 <span class="muted">(PNG, JPEG or WebP, under 60 KB)</span></label>118148 ${signature && html`<img class="profile-signature" src="/static/assets/${encodeURIComponent(signature)}" alt="current signature">`}@@ -122,7 +152,9 @@122152 	html`<label class="issues-label"><input type="checkbox" name="remove_signature" value="1">123153 remove the signature</label>`124154 }125-<button class="issues-submit" type="submit">save</button>155+`156+}157+<button class="issues-submit" type="submit">save ${profile ? "profile" : "site"}</button>126158 </form>`;127-	return adminPage(c, ctx, "site and profile", body);159+	return adminPage(c, ctx, profile ? "profile" : "site", body);128160 }

view file

src/server/app.ts+2 −0
@@ -47,6 +47,8 @@4747 	app.use("*", async (c, next) => {4848 		await next();4949 		const path = c.req.path;50+		if (/^\/(admin(?:\/|$)|sign-in$|connect$)/.test(path))51+			c.header("Referrer-Policy", "same-origin");5052 		if (/^\/[^/]+\/raw\/.+/.test(path) || path.startsWith("/static/assets/")) {5153 			const policy = c.res.headers.get("Content-Security-Policy");5254 			c.header("Content-Security-Policy", policy ? `${policy}; sandbox` : "sandbox");

view file

tasks/plan.md+14 −0
@@ -0,0 +1,14 @@1+# Operator workspace2+3+Use Kepr 2's hierarchy and focused workflows with Kepr 3's existing data.4+Approved by June, 2026-09-29. Rollback: 0d03d747c5a2.5+6+1. Persistent sidebar; separate profile and site views on the existing site route.7+   Preserve settings across partial forms, show validation input and save confirmation.8+2. Overview with repo visibility, issues, sync attention and backup status;9+   searchable repo selection with one settings form. Preserve all existing actions.10+3. Notifications: inspect Kepr 2; confirm delivery scope before adding storage or routes.11+12+Verify each slice with relevant tests and the four repository gates.13+Use an isolated browser for desktop and phone layouts, sign-in and operator form submissions.14+No schema changes, new dependencies, or Koh bucket writes for the workspace redesign.

view file

tasks/todo.md+7 −0
@@ -0,0 +1,7 @@1+# Operator workspace2+3+- [x] Sidebar, profile separation, validation input, saved feedback and browser-safe forms.4+- [x] Useful overview and focused, searchable repo management.5+- [ ] Resolve notification delivery scope and implement the approved inbox.6+- [ ] Gate, browser verification, continuity and atomic Koh saves.7+- [ ] Match the issues, builds and archives islands to Kepr 2's relevant designs.

view file

tests/admin.test.ts+1 −0
@@ -40,6 +40,7 @@4040 		const response = await new Browser(inst.app).request("/sign-in");4141 		expect(response.status).toBe(200);4242 		expect(response.headers.get("cache-control")).toBe("no-store");43+		expect(response.headers.get("referrer-policy")).toBe("same-origin");4344 		const cookie = response.headers.getSetCookie()[0] ?? "";4445 		expect(cookie).toMatch(4546 			/^kepr_sign_in=[0-9a-f]{64}; Path=\/; HttpOnly; Secure; SameSite=Strict$/,

view file

tests/console.test.ts+44 −2
@@ -76,11 +76,22 @@7676 		const page = await (await browser.get("/admin/repos")).text();7777 		expect(page).toContain('id="repo-app"');7878 		expect(page).toContain('id="repo-other"');79-		expect(page.match(/<span>private<\/span>/g)?.length).toBe(2);79+		expect(page.match(/action="\/admin\/repos\//g)?.length).toBe(1);8080 		expect(page).toContain("last sync 2026-09-26 12:00 UTC");8181 		expect((await statuses()).every((line) => line.endsWith(" 404"))).toBe(true);8282 	});8383 84+	test("repo search focuses one settings form and links back to the selected repo after saving", async () => {85+		const page = await (await browser.get("/admin/repos?q=other")).text();86+		expect(page).toContain('action="/admin/repos/other"');87+		expect(page).not.toContain('id="repo-app"');88+		const saved = await publish("other", {});89+		expect(saved.headers.get("location")).toBe("/admin/repos?repo=other&saved=1#repo-editor");90+		expect(await (await browser.get("/admin/repos?repo=other&saved=1")).text()).toContain(91+			"Changes saved.",92+		);93+	});94+8495 	test("bad settings are refused and change nothing", async () => {8596 		for (const fields of <Record<string, string>[]>[8697 			{ homepage: "javascript:alert(1)" },@@ -362,10 +373,41 @@362373 		]) {363374 			expect(profile).toContain(value);364375 		}365-		expect(await (await operator.get("/admin/site")).text()).toContain('value="Lisbon"');376+		expect(await (await operator.get("/admin/site?section=profile")).text()).toContain(377+			'value="Lisbon"',378+		);366379 		// Emptying a field removes the setting, so its default comes back.367380 		expect((await operator.post("/admin/site", { ...settings, site_name: "" })).status).toBe(303);368381 		expect(await text("/")).toContain("<title>kepr</title>");382+	});383+384+	test("profile and site forms preserve each other's settings and retain invalid input", async () => {385+		await operator.post("/admin/site", settings);386+		const profile = await (await operator.get("/admin/site?section=profile")).text();387+		expect(profile).toContain('aria-label="operator workspace"');388+		expect(profile).toContain('href="/@ada"');389+		expect(profile).not.toContain('name="site_name"');390+		expect(391+			(392+				await operator.post("/admin/site", {393+					section: "profile",394+					handle: "ada",395+					bio: "New bio",396+					link: "https://ada.example",397+				})398+			).status,399+		).toBe(303);400+		expect(await text("/")).toContain("Small &lt;tools&gt;.");401+		await operator.post("/admin/site", { section: "site", site_name: "New site", support: "1" });402+		expect(await text("/@ada")).toContain("New bio");403+		const bad = await operator.post("/admin/site", {404+			section: "profile",405+			handle: "a/b",406+			bio: "Keep my draft",407+		});408+		expect(bad.status).toBe(400);409+		expect(await bad.text()).toContain("Keep my draft");410+		expect(await text("/@ada")).toContain("New bio");369411 	});370412 371413 	test("the support page is the operator's to write, and to switch off", async () => {

view file

tests/navbar.test.ts+1 −0
@@ -26,6 +26,7 @@2626 			createElement: (tag: string) => {2727 				const node: Record<string, unknown> = {2828 					tag,29+					addEventListener: () => {},2930 					append: (...children: unknown[]) => {3031 						node.children = children;3132 					},

view file