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

Calm pass: settings are forms with one-line labels, and help on demand #10378

Merged opened this in answer to

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

Merged

scout/forge:scout/10302-calm-pass-settings-are-forms-with-one-li → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/10302-calm-pass-settings-are-forms-with-one-li was merged into main as 8f6b30f.

Review

Sign in to review this change.
6 files changed +184 −49
docs/design/areas/settings.md +13
@@ -170,3 +170,16 @@ Settings › Danger zone (`src/web/pages/danger.ts`, #9952) adds no component cl
170170 `src/web/pages/detach.ts`, a fork only) and the delete are each a `<section class="settings-block">` with an `<h2>`,
171171 no `.card`: the plan says what goes and what survives, its danger button goes through `/-/confirm`. A running detach is
172172 one line with since-when; a failed one is a danger `.toast` over the plan. `test/danger-language.test.ts` holds the shape.
173
174 ## Calm: one help toggle per page (#10302)
175
176 On Settings › Account (profile, password, tokens) a field is its label and its current value. What each field means
177 moves into one `details.disclosure[data-page-help]` per page (`help()` in `src/web/pages/account.ts`: a `.status-rows`
178 list of field → explanation), summary "Help with these fields" at normal size, closed until asked for, rendered at the
179 top of the page (outside any block, with `mb` under it). The profile's role abilities are a row of that same Help, not
180 a second disclosure. A rule that is a constraint stays beside its field as a one-line `hint` tied by `aria-describedby`:
181 the password minimum (10 characters), the notify URL's https-only and, once saved, "Sending to <host>". No new class.
182 The `settings` first-view budget fell from 274 to 137 words and 37 to 32 blocks (`FORGE_ATTENTION_WRITE=1`).
183 `repository` and `app` read 197 / 29 on `main` itself (it fails the budget test there against the committed 189 / 28);
184 this change does not touch them, and the file now records what they measure.
185 Pinned by `test/account-help.test.ts`. Not done: the repository settings page (`settingsPage()`) and the admin pages still carry their prose.
docs/design/attention-budget.json +12 −12
@@ -13,25 +13,25 @@
1313 },
1414 "repository": {
1515 "390": {
16 "words": 189,
17 "blocks": 28,
16 "words": 197,

Budget raised for repository/app (189→197, 28→29). Principle 1: the ratchet only goes down; revert or explain.

17 "blocks": 29,
1818 "colours": 2
1919 },
2020 "1280": {
21 "words": 189,
22 "blocks": 28,
21 "words": 197,
22 "blocks": 29,
2323 "colours": 2
2424 }
2525 },
2626 "app": {
2727 "390": {
28 "words": 189,
29 "blocks": 28,
28 "words": 197,
29 "blocks": 29,
3030 "colours": 2
3131 },
3232 "1280": {
33 "words": 189,
34 "blocks": 28,
33 "words": 197,
34 "blocks": 29,
3535 "colours": 2
3636 }
3737 },
@@ -109,13 +109,13 @@
109109 },
110110 "settings": {
111111 "390": {
112 "words": 274,
113 "blocks": 37,
112 "words": 137,
113 "blocks": 32,
114114 "colours": 1
115115 },
116116 "1280": {
117 "words": 274,
118 "blocks": 37,
117 "words": 137,
118 "blocks": 32,
119119 "colours": 1
120120 }
121121 }
docs/features/account-help.md added +13
@@ -0,0 +1,13 @@
1 # Account settings help
2
3 On the web, Settings › Account (profile, password, tokens) shows each field as a label and a value. One closed
4 "Help with these fields" disclosure at the top of each page explains every field, and on the profile page lists what
5 the account's role allows. Constraints stay as a one-line hint tied to the field: the password's 10-character minimum,
6 the notify URL's https-only rule and, once saved, "Sending to <host>".
7
8 Try it: open `/settings`, press **Help with these fields**.
9
10 The terminal (both doors): it has no account-settings screen, so there are no field explanations to bring in line;
11 `src/tui` edits no profile, password, token or notify URL. Nothing changes there.
12
13 Pinned by `test/account-help.test.ts`; design note in `docs/design/areas/settings.md`.
docs/features/attention.md +1 −1
@@ -30,4 +30,4 @@ reader sees on first view at 390 px and 1280 px: **words**, **text blocks** and
3030 | pulls list | 31 / 7 / 1 | 31 / 7 / 1 |
3131 | runs | 46 / 8 / 1 | 46 / 8 / 1 |
3232 | run | 34 / 11 / 3 | 34 / 11 / 3 |
33 | settings | 274 / 37 / 1 | 274 / 37 / 1 |
33 | settings | 137 / 32 / 1 | 137 / 32 / 1 |
src/web/pages/account.ts +59 −36
@@ -85,6 +85,18 @@ const block = (title: string, body: Html, intro?: string): Html =>
8585 ${body}
8686 </section>`;
8787
88 /**
89 * The one help toggle of a settings page: what each field means, kept out of the
outdated

Principle 1: summary 'Help' is muted and small — a poor tap target on a 360px phone and unnamed for screen readers. Name it and make it a normal-size target.

90 * form so a field is its label and its current value. Closed until asked for.
91 */
92 const help = (notes: readonly (readonly [string, string | Html])[]): Html =>
93 html`<details class="disclosure mb" data-page-help>
94 <summary>Help with these fields</summary>
95 <dl class="status-rows">
96 ${notes.map(([name, text]) => html`<div class="status-row"><dt>${name}</dt><dd>${text}</dd></div>`)}
97 </dl>
98 </details>`;
99
88100 type Section = "profile" | "password" | "tokens" | "keys" | "passkeys";
89101
90102 // The section list lives beside the newest page that joins it, so adding one
@@ -154,10 +166,7 @@ function notifyView(ctx: Ctx, error?: string): Html {
154166 type: "url",
155167 autocomplete: "off",
156168 placeholder: host === undefined ? "https://ntfy.sh/your-topic" : "••••••••",
157 hint:
158 host === undefined
159 ? "When something newly needs you, one line and a link is posted here. https: only; leave it empty to turn it off."
160 : `Sending to ${host}. The rest of the address stays hidden; paste a new one to replace it, or save it empty to turn it off.`,
169 hint: host === undefined ? "https: only." : `Sending to ${host}. https: only.`,
161170 })}
162171 <div class="form-actions">
163172 ${btn("Save notify URL", { variant: "primary" })}
@@ -293,24 +302,46 @@ export async function resendVerifyLink(ctx: Ctx): Promise<Response> {
293302 return flashTo(ctx, "/settings", { message });
294303 }
295304
296 /** The signed-in account's role and its row of the roles table. */
305 /** What the signed-in account's role allows: one row of the page's Help. */
306 function roleHelp(ctx: Ctx): readonly [string, Html] {
307 const row = rowOf(roleOf(ctx.app.db, ctx.auth.user!));
308 return [
309 "What your role allows",
310 html`<ul class="role-can">
outdated

Principle 1/7: a second closed disclosure on the profile page; fold into the one Help or use dl rows.

311 ${(Object.keys(ABILITIES) as Ability[]).map(
312 (a) => html`<li>${ABILITIES[a]}: <strong>${row[a] ? "yes" : "no"}</strong></li>`,
313 )}
314 </ul>`,
315 ];
316 }
317
318 /** The signed-in account's role, in one line. */
297319 function roleView(ctx: Ctx): Html {
298 const role = roleOf(ctx.app.db, ctx.auth.user!);
299 const row = rowOf(role);
300 return block(
301 "What you can do here",
302 html`<p>You are ${badge(role)} here.</p>
303 <ul class="role-can">
304 ${(Object.keys(ABILITIES) as Ability[]).map(
305 (a) => html`<li>${ABILITIES[a]}: <strong>${row[a] ? "yes" : "no"}</strong></li>`,
306 )}
307 </ul>`,
308 );
320 return block("What you can do here", html`<p>You are ${badge(roleOf(ctx.app.db, ctx.auth.user!))} here.</p>`);
309321 }
310322
311323 function profileView(ctx: Ctx, values: ProfileForm): Html {
312324 const user = ctx.auth.user!;
313 return html`${unverifiedNotice(ctx)}${roleView(ctx)}${block(
325 return html`${unverifiedNotice(ctx)}${help([
326 [
327 "Username",
328 "Your handle, your URL, and your git author identity. Renaming an account is not supported yet.",
329 ],
330 ["Display name", "Shown beside your username. Commits you make through the web use it."],
331 [
332 "Email",
333 "Where the forge reaches you. Never shown to other people, and never in a commit unless you choose it below.",
334 ],
335 [
336 "Commit email",
337 "The address on commits the forge makes for you: merges, reverts, web edits. A commit in a public repository cannot be taken back, so the default reaches nobody.",
338 ],
339 [
340 "Notify URL",
341 "When something newly needs you, one line and a link is posted here. The rest of a saved address stays hidden; paste a new one to replace it, or save it empty to turn it off.",
342 ],
343 roleHelp(ctx),
344 ])}${roleView(ctx)}${block(
314345 "Public profile",
315346 html`<form method="post" action="/settings">
316347 <div class="field">
@@ -319,19 +350,13 @@ function profileView(ctx: Ctx, values: ProfileForm): Html {
319350 class="input"
320351 id="f-username"
321352 value="${user.username}"
322 aria-describedby="f-username-hint"
323353 readonly
324354 />
325 <span class="hint" id="f-username-hint"
326 >Your handle, your URL, and your git author identity. Renaming an
327 account is not supported yet.</span
328 >
329355 </div>
330356 ${uiField({
331357 label: "Display name",
332358 name: "displayName",
333359 value: values.displayName,
334 hint: "Shown beside your username. Commits you make through the web use it.",
335360 ...(values.errors?.displayName
336361 ? { error: values.errors.displayName }
337362 : {}),
@@ -342,7 +367,6 @@ function profileView(ctx: Ctx, values: ProfileForm): Html {
342367 type: "email",
343368 value: values.email,
344369 autocomplete: "email",
345 hint: "Where the forge reaches you. Never shown to other people, and never in a commit unless you choose it below.",
346370 ...(values.errors?.email ? { error: values.errors.email } : {}),
347371 })}
348372 ${selectField({
@@ -360,7 +384,6 @@ function profileView(ctx: Ctx, values: ProfileForm): Html {
360384 selected: values.commitEmail === "account",
361385 },
362386 ],
363 hint: "The address on commits the forge makes for you: merges, reverts, web edits. A commit in a public repository cannot be taken back, so the default reaches nobody.",
364387 })}
365388 <div class="form-actions">
366389 ${btn("Save profile", { variant: "primary" })}
@@ -467,7 +490,7 @@ function passwordView(error?: {
467490 field: "current" | "next";
468491 text: string;
469492 }): Html {
470 return block(
493 return html`${help([["New password", "A passphrase beats a puzzle: a few unrelated words are long and easy to remember."]])}${block(
471494 "Change password",
472495 html`<form method="post" action="/settings/password">
473496 ${uiField({
@@ -485,7 +508,7 @@ function passwordView(error?: {
485508 autocomplete: "new-password",
486509 minlength: 10,
487510 required: true,
488 hint: "At least 10 characters. A passphrase beats a puzzle.",
511 hint: "At least 10 characters.",
489512 ...(error?.field === "next" ? { error: error.text } : {}),
490513 })}
491514 ${uiField({
@@ -499,7 +522,7 @@ function passwordView(error?: {
499522 ${btn("Change password", { variant: "primary" })}
500523 </div>
501524 </form>`,
502 );
525 )}`;
503526 }
504527
505528 // GET /settings/password
@@ -632,12 +655,14 @@ function tokensView(ctx: Ctx): Html {
632655 }),
633656 )}
634657 </ul>`;
635 return html`<p class="muted small mb">
636 Use a token as the password when pushing over HTTPS, or as a
637 <span class="mono">Bearer</span> token for the API. Your account password
638 works for git too — tokens are for scoped or automated access.
639 </p>
640 ${connectCard(ctx, readFlash(ctx.request)?.code)}
658 return html`${help([
659 [
660 "Tokens",
661 "Use a token as the password when pushing over HTTPS, or as a Bearer token for the API. Your account password works for git too — tokens are for scoped or automated access.",
662 ],
663 ["Token name", "Name it after where it lives — laptop, CI, deploy box."],
664 ["Scope", "A token can never mint another token, whatever its scope."],
665 ])}${connectCard(ctx, readFlash(ctx.request)?.code)}
641666 ${block("Tokens", list)}
642667 ${block(
643668 "New token",
@@ -646,7 +671,6 @@ function tokensView(ctx: Ctx): Html {
646671 label: "Token name",
647672 name: "name",
648673 required: true,
649 hint: "Name it after where it lives — laptop, CI, deploy box.",
650674 })}
651675 ${selectField({
652676 label: "Scope",
@@ -658,7 +682,6 @@ function tokensView(ctx: Ctx): Html {
658682 ? [{ value: "admin", label: "admin — everything, including settings" }]
659683 : []),
660684 ],
661 hint: "A token can never mint another token, whatever its scope.",
662685 })}
663686 ${ctx.auth.user?.isAdmin
664687 ? html`<div class="field" id="ai-admin">
test/account-help.test.ts added +86
@@ -0,0 +1,86 @@
1 /** Account settings: fields are a label and a value; their explanations sit behind one help toggle per page (#10302). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { serve, type Servers } from "../src/http/server.ts";
8 import { quietLogs } from "./helpers/globals.ts";
9
10 quietLogs();
11
12 let dir = "";
13 let app: App, servers: Servers, cookie = "";
14
15 beforeAll(async () => {
16 dir = await mkdtemp(join(tmpdir(), "forge-acchelp-"));
17 app = createApp({ dataDir: dir, port: 0 });
18 servers = await serve(app);
19 const signup = await fetch(`${servers.url}/signup`, {
20 method: "POST",
21 body: new URLSearchParams({ username: "ada", email: "[email protected]", password: "account-password" }),
22 redirect: "manual",
23 });
24 cookie = (signup.headers.get("set-cookie") ?? "").split(";")[0]!;
25 });
26 afterAll(async () => {
27 servers.stop();
28 app.close();
29 await rm(dir, { recursive: true, force: true });
30 });
31
32 const get = async (path: string): Promise<string> => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
33

Only checks the profile page's text lives in Help; password/tokens explanations and the closed state are unpinned.

34 const helpOf = (body: string): string => {
35 const at = body.indexOf("data-page-help");
36 return body.slice(at, body.indexOf("</details>", at));
37 };
38
39 test.each(["/settings", "/settings/password", "/settings/tokens"])("%s has one named help toggle, closed", async (path) => {
40 const body = await get(path);
41 expect(body.match(/data-page-help/g)?.length).toBe(1);
42 expect(body.match(/<details class="disclosure/g)?.length).toBe(1);
43 expect(body).toContain("<summary>Help with these fields</summary>");
44 expect(body).not.toMatch(/<details[^>]*\sopen/);
45 });
46
47 test("the profile's explanations, with the role's abilities, live inside the help toggle", async () => {
48 const body = await get("/settings");
49 const help = helpOf(body);
50 expect(help).toContain("Never shown to other people");
51 expect(help).toContain("Create a repository or copy an app");
52 expect(body.replace(help, "")).not.toContain("Never shown to other people");
53 expect(body.replace(help, "")).not.toContain("Create a repository or copy an app");
54 });
55
56 test("the password and token explanations live inside the help toggle, before the block", async () => {
57 const pw = await get("/settings/password");
58 expect(helpOf(pw)).toContain("A passphrase beats a puzzle");
59 expect(pw.replace(helpOf(pw), "")).not.toContain("A passphrase beats a puzzle");
60 expect(pw.indexOf("data-page-help")).toBeLessThan(pw.indexOf("Change password</h2>"));
61 const tk = await get("/settings/tokens");
62 expect(helpOf(tk)).toContain("can never mint another token");
63 expect(helpOf(tk)).toContain("laptop, CI, deploy box");
64 expect(tk.replace(helpOf(tk), "")).not.toContain("laptop, CI, deploy box");
65 });
66
67 test("constraints stay as one-line hints tied by aria-describedby", async () => {
68 const pw = await get("/settings/password");
69 expect(pw).toContain("At least 10 characters.");
70 expect(pw).toMatch(/aria-describedby="[^"]*-hint"/);
71 const profile = await get("/settings");
72 expect(profile).toContain("https: only.");
73 });
74
75 test("a saved notify URL shows only its host, as a hint", async () => {
76 const set = await fetch(`${servers.url}/settings/notify`, {
77 method: "POST",
78 headers: { cookie, origin: servers.url },
79 body: new URLSearchParams({ url: "https://ntfy.sh/secret-topic" }),
80 redirect: "manual",
81 });
82 expect(set.status).toBeLessThan(400);
83 const body = await get("/settings");
84 expect(body).toContain("Sending to ntfy.sh. https: only.");
85 expect(body).not.toContain("secret-topic");
86 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs