Public: anyone can read this; the crew can change it.

The /forges and /forges/new pages speak the home page's language, with looks before and after #9703

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9647-the-forges-and-forges-new-pages-speak-th → main 5 commits fork 3 threads · 3 unresolved

  • Merged. scout/9647-the-forges-and-forges-new-pages-speak-th was merged into main as 59c7df7.

Review

Sign in to review this change.
5 files changed +75 −17
docs/features/forges-side-by-side.md +4
@@ -5,3 +5,7 @@
55 Visible to this forge's admins; anyone else gets `not_found`.
66
77 The table is for comparing; the list below it stays for what needs doing (claim state, a fresh claim link, spend, next step). The terminal's "Forges you sent" card ends each line with the same size, use and heartbeat words. A cell that is not running reads "not running" under each use column; its limits are in the Size column. The disk reading is cached for 30 s per forge and forgotten after five minutes unasked.
8
9 ## The pages' shape (#9647)
10
11 `/forges` is plain rows, no boxed cards: each forge's line starts with who it is for and the apps it was given, then its state (awake, size, spend, last activity, claim state). `/forges/new` asks the name, who it is for, what it is for, the apps and an optional fresh app up front; follow mode, crew, owner access, model key, API base URL, budget, template and size sit behind "More options", inside the same form so they submit as before. The disclosure opens by itself when the door refuses a value in one of them. No field or action was removed. The terminal's forge form is unchanged.
src/web/pages/forges.ts +7 −10
@@ -44,11 +44,11 @@ function spendNote(r: ClientRow): Html | null {
4444 function forgeRow(r: ClientRow): Html {
4545 const state = claimState(r);
4646 const meta = [
47 r.for === null ? null : html`for ${r.for}`,

Meta order changed (who it is for, then apps first). Pin it with a test so the 'one plain line each' reading cannot regress.

48 r.apps.length === 0 ? null : html`${r.apps.join(", ")}`,
4749 state === "unknown" ? html`it did not answer just now` : null,
4850 r.made ? badge(r.awake === true ? "awake" : "asleep", r.awake === true ? { tone: "success" } : { variant: "outline" }) : null,
4951 r.made ? html`${sizeWords(r.size)}` : null,
50 r.for === null ? null : html`for ${r.for}`,
51 r.apps.length === 0 ? null : html`${r.apps.join(", ")}`,
5252 runningNote(r),
5353 spendNote(r),
5454 r.sentAt === null ? null : html`sent ${time(r.sentAt)}`,
@@ -144,7 +144,7 @@ export async function forgesPage(ctx: Ctx): Promise<Response> {
144144 title: "Forges you sent",
145145 body: html`<div class="narrow-wide" data-forges>
146146 ${pageHead("Forges you sent", {
147 sub: "For whom each is, what it was given, and whether its person has claimed it.",
147 sub: "Each forge you sent: who it is for and what runs there.",
148148 actions: action,
149149 })}
150150 ${rows.length === 0
@@ -154,15 +154,12 @@ export async function forgesPage(ctx: Ctx): Promise<Response> {
154154 note: "A whole forge for someone: the apps you choose, a crew, and one link to send.",
155155 action,
156156 })
157 : card(sideBySide(rows), { pad: false, id: "forges-compare" })}
157 : html`<section id="forges-compare">${sideBySide(rows)}</section>`}
158158 ${rows.length === 0
159159 ? null
160 : card(
161 html`<ul class="list setting-list">
162 ${rows.map(forgeRow)}
163 </ul>`,
164 { pad: false },
165 )}
160 : html`<ul class="list setting-list">
161 ${rows.map(forgeRow)}
162 </ul>`}
166163 <p class="mt"><a href="/fleet/graph">Fleet graph</a></p>
167164 </div>`,
168165 });
src/web/pages/new-forge.ts +13 −7
@@ -66,6 +66,9 @@ const EMPTY: Values = {
6666 hadKey: false,
6767 };
6868
69 /** The fields behind "More options": an error on one opens it. */
70 const MORE_FIELDS = ["followMode", "crew", "keepAccess", "modelKey", "modelEndpoint", "budgetUsd", "template", "size"];
71
6972 type Errors = Partial<Record<string, string>>;
7073
7174 const all = (form: Form, name: string): string[] =>
@@ -152,11 +155,11 @@ function formView(
152155 return html`<div class="narrow-wide" data-forges>
153156 ${pageHead("New forge", {
154157 sub: anon
outdated

Principle 1: this sub-line, the hosting-terms paragraph below it and the 'Never copied' paragraph all explain the page. Keep one.

155 ? "Your own forge: pick a name, and the public apps to start from. Whoever opens the claim link becomes its admin."
156 : "Your own forge, or one for someone else: the apps you choose, a crew, and one link to claim.",
158 ? "Pick a name and the apps to start from. Whoever opens the claim link becomes its admin."
159 : "Pick a name and the apps to start from; you get one link to send.",
157160 actions: btn("Forges you sent", { href: "/forges", size: "sm" }),
158161 })}
159 <p class="muted small" id="hosting-terms">Free within limits while hosting is not sold: a few forges per person, and a forge sleeps when idle and wakes on its next visit. Its crew stays off until you bring your own model credential.</p>
162 <p class="muted small" id="hosting-terms">Free within limits: it sleeps when idle, and its crew stays off until you bring a model key.</p>
160163 ${quota ? html`<p role="status" id="quota-full">This forge cannot hold another child: ${quota}.</p>` : null}
161164 ${errors["form"]
162165 ? html`<p class="error mb" role="alert">${errors["form"]}</p>`
@@ -179,7 +182,7 @@ function formView(
179182 label: "For whom",
180183 name: "for",
181184 value: values.forWhom,
182 hint: `A person, a client or an organisation, for your list of forges sent (at most ${MAX_FOR_CHARS} characters). Never copied into the child.`,
185 hint: `A person or a client, for your list (at most ${MAX_FOR_CHARS} characters). Not copied into the forge.`,
183186 ...(errors["for"] ? { error: errors["for"] } : {}),
184187 })}
185188 ${formField({
@@ -209,6 +212,8 @@ function formView(
209212 hint: `One line on what it is; the child gets a small app to start from (at most ${MAX_FRESH_CHARS} characters).`,
outdated

Principle 3, and this is untested. Add a test: a bad hidden field gives <details open>, a name-only error does not, and the hidden fields stay inside the form. Also check the div.stack spacing here against the other fields.

210213 ...(errors["fresh"] ? { error: errors["fresh"] } : {}),
211214 })}
215 <details class="disclosure" ${Object.keys(errors).some((k) => MORE_FIELDS.includes(k)) ? html`open` : null}>
216 <summary>More options</summary>
212217 <fieldset class="field" ${errors["followMode"] ? html`data-invalid` : null}>
213218 <legend>How it follows this forge's code</legend>
214219 <label class="check">
@@ -307,9 +312,10 @@ function formView(
307312 options: sizeOptions(values.size),
308313 ...(errors["size"] ? { error: errors["size"] } : {}),
309314 })}
310 <p class="muted small">
311 Never copied: ${LEFT_BEHIND.join(", ")}. The child mints its own.
312 </p>
315 <p class="muted small">
316 Never copied: ${LEFT_BEHIND.join(", ")}. The child mints its own.
317 </p>
318 </details>
313319 <div class="form-actions">
314320 <button class="btn" type="submit" name="do" value="plan">
315321 See the plan
test/client-list.test.ts +5
@@ -199,6 +199,11 @@ describe("the client list", () => {
199199 const maria = html.slice(html.indexOf('data-forge="maria"'));
200200 expect(maria).toContain("Maria&#39;s bakery");
201201 expect(maria).toContain("alice/bakery");
202 // One plain line first: for whom, then the apps; the state facts follow.
203 const line = maria.slice(0, maria.indexOf("</li>"));
204 expect(line.indexOf("Maria&#39;s bakery")).toBeLessThan(line.indexOf("alice/bakery"));
205 expect(line.indexOf("alice/bakery")).toBeLessThan(line.indexOf("has not reported yet"));
206 expect(line).not.toContain('class="card');
202207 expect(maria).toContain('href="https://maria--forge.test"');
203208 expect(maria).toMatch(/data-claimed="no"/);
204209 expect(html).not.toContain("forges-sent");
test/new-forge.test.ts +46
@@ -382,3 +382,49 @@ describe("+ New forge", () => {
382382 expect(childrenForMemory(GiB)).toBe(5);
383383 });
384384 });
385
386 describe("/forges/new: More options (#9647)", () => {
387 let dir = "";
388 let app: App;
389 let servers: Servers;
390 let cookie = "";
391 beforeAll(async () => {
392 dir = await mkdtemp(join(tmpdir(), "forge-new-forge-more-"));
393 app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
394 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: PASSWORD, isAdmin: true });
395 if (!a.ok) throw new Error(a.error);
396 servers = await serve(app);
397 const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: PASSWORD }), redirect: "manual" });
398 cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!;
399 });
400 afterAll(async () => {
401 servers.stop();
402 app.close();
403 await rm(dir, { recursive: true, force: true });
404 });
405 const post = async (form: [string, string][]) =>
406 (await fetch(`${servers.url}/forges/new`, { method: "POST", headers: { cookie, origin: "https://forge.test" }, body: new URLSearchParams(form), redirect: "manual" })).text();
407 const opening = (html: string) => /<details class="disclosure"[^>]*>/.exec(html)?.[0] ?? "";
408
409 test("a fresh form keeps the rarer fields in a closed disclosure inside the form, so they submit", async () => {
410 const html = await (await fetch(`${servers.url}/forges/new`, { headers: { cookie } })).text();
411 expect(opening(html)).not.toContain("open");
412 const form = html.slice(html.indexOf('id="new-forge"'));
413 const more = form.slice(form.indexOf("<summary>More options</summary>"), form.indexOf("</details>"));
414 expect(more.length).toBeGreaterThan(0);
415 for (const name of ["followMode", "keepAccess", "modelKey", "modelEndpoint", "budgetUsd", "template", "size"])
416 expect(more).toContain(`name="${name}"`);
417 expect(form.indexOf("</details>")).toBeLessThan(form.indexOf("</form>"));
418 // The visible fields stay out of it.
419 expect(form.indexOf('name="direction"')).toBeLessThan(form.indexOf("<summary>More options</summary>"));
420 });
421 test("a bad value in a hidden field opens it", async () => {
422 const html = await post([["name", "okname"], ["budgetUsd", "not-a-number"], ["modelKey", KEY]]);
423 expect(opening(html)).toContain("open");
424 });
425 test("an error on the name alone leaves it closed", async () => {
426 const html = await post([["name", "Bad Name!"]]);
427 expect(html).toContain("name");
428 expect(opening(html)).not.toContain("open");
429 });
430 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs