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
↓ kepr-9af784d3.tar.gzatlas at this save
files changed12 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 −0package.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": {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;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);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 });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")}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 }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");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.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.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$/,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 <tools>.");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 () => {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 },