Public: anyone can read this; the crew can change it.
Calm pass: settings are forms with one-line labels, and help on demand #10378
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10302-calm-pass-settings-are-forms-with-one-li → main 3 commits fork 4 threads · 4 unresolved
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 | ||
| 170 | 170 | `src/web/pages/detach.ts`, a fork only) and the delete are each a `<section class="settings-block">` with an `<h2>`, |
| 171 | 171 | no `.card`: the plan says what goes and what survives, its danger button goes through `/-/confirm`. A running detach is |
| 172 | 172 | 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 @@ | ||
| 13 | 13 | }, |
| 14 | 14 | "repository": { |
| 15 | 15 | "390": { |
| 16 | "words": 189, | |
| 17 | "blocks": 28, | |
| 16 | "words": 197, | |
| 17 | "blocks": 29, | |
| 18 | 18 | "colours": 2 |
| 19 | 19 | }, |
| 20 | 20 | "1280": { |
| 21 | "words": 189, | |
| 22 | "blocks": 28, | |
| 21 | "words": 197, | |
| 22 | "blocks": 29, | |
| 23 | 23 | "colours": 2 |
| 24 | 24 | } |
| 25 | 25 | }, |
| 26 | 26 | "app": { |
| 27 | 27 | "390": { |
| 28 | "words": 189, | |
| 29 | "blocks": 28, | |
| 28 | "words": 197, | |
| 29 | "blocks": 29, | |
| 30 | 30 | "colours": 2 |
| 31 | 31 | }, |
| 32 | 32 | "1280": { |
| 33 | "words": 189, | |
| 34 | "blocks": 28, | |
| 33 | "words": 197, | |
| 34 | "blocks": 29, | |
| 35 | 35 | "colours": 2 |
| 36 | 36 | } |
| 37 | 37 | }, |
| @@ -109,13 +109,13 @@ | ||
| 109 | 109 | }, |
| 110 | 110 | "settings": { |
| 111 | 111 | "390": { |
| 112 | "words": 274, | |
| 113 | "blocks": 37, | |
| 112 | "words": 137, | |
| 113 | "blocks": 32, | |
| 114 | 114 | "colours": 1 |
| 115 | 115 | }, |
| 116 | 116 | "1280": { |
| 117 | "words": 274, | |
| 118 | "blocks": 37, | |
| 117 | "words": 137, | |
| 118 | "blocks": 32, | |
| 119 | 119 | "colours": 1 |
| 120 | 120 | } |
| 121 | 121 | } |
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 | ||
| 30 | 30 | | pulls list | 31 / 7 / 1 | 31 / 7 / 1 | |
| 31 | 31 | | runs | 46 / 8 / 1 | 46 / 8 / 1 | |
| 32 | 32 | | 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 => | ||
| 85 | 85 | ${body} |
| 86 | 86 | </section>`; |
| 87 | 87 | |
| 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 | ||
| 88 | 100 | type Section = "profile" | "password" | "tokens" | "keys" | "passkeys"; |
| 89 | 101 | |
| 90 | 102 | // 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 { | ||
| 154 | 166 | type: "url", |
| 155 | 167 | autocomplete: "off", |
| 156 | 168 | 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.`, | |
| 161 | 170 | })} |
| 162 | 171 | <div class="form-actions"> |
| 163 | 172 | ${btn("Save notify URL", { variant: "primary" })} |
| @@ -293,24 +302,46 @@ export async function resendVerifyLink(ctx: Ctx): Promise<Response> { | ||
| 293 | 302 | return flashTo(ctx, "/settings", { message }); |
| 294 | 303 | } |
| 295 | 304 | |
| 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. */ | |
| 297 | 319 | 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>`); | |
| 309 | 321 | } |
| 310 | 322 | |
| 311 | 323 | function profileView(ctx: Ctx, values: ProfileForm): Html { |
| 312 | 324 | 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( | |
| 314 | 345 | "Public profile", |
| 315 | 346 | html`<form method="post" action="/settings"> |
| 316 | 347 | <div class="field"> |
| @@ -319,19 +350,13 @@ function profileView(ctx: Ctx, values: ProfileForm): Html { | ||
| 319 | 350 | class="input" |
| 320 | 351 | id="f-username" |
| 321 | 352 | value="${user.username}" |
| 322 | aria-describedby="f-username-hint" | |
| 323 | 353 | readonly |
| 324 | 354 | /> |
| 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 | > | |
| 329 | 355 | </div> |
| 330 | 356 | ${uiField({ |
| 331 | 357 | label: "Display name", |
| 332 | 358 | name: "displayName", |
| 333 | 359 | value: values.displayName, |
| 334 | hint: "Shown beside your username. Commits you make through the web use it.", | |
| 335 | 360 | ...(values.errors?.displayName |
| 336 | 361 | ? { error: values.errors.displayName } |
| 337 | 362 | : {}), |
| @@ -342,7 +367,6 @@ function profileView(ctx: Ctx, values: ProfileForm): Html { | ||
| 342 | 367 | type: "email", |
| 343 | 368 | value: values.email, |
| 344 | 369 | autocomplete: "email", |
| 345 | hint: "Where the forge reaches you. Never shown to other people, and never in a commit unless you choose it below.", | |
| 346 | 370 | ...(values.errors?.email ? { error: values.errors.email } : {}), |
| 347 | 371 | })} |
| 348 | 372 | ${selectField({ |
| @@ -360,7 +384,6 @@ function profileView(ctx: Ctx, values: ProfileForm): Html { | ||
| 360 | 384 | selected: values.commitEmail === "account", |
| 361 | 385 | }, |
| 362 | 386 | ], |
| 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.", | |
| 364 | 387 | })} |
| 365 | 388 | <div class="form-actions"> |
| 366 | 389 | ${btn("Save profile", { variant: "primary" })} |
| @@ -467,7 +490,7 @@ function passwordView(error?: { | ||
| 467 | 490 | field: "current" | "next"; |
| 468 | 491 | text: string; |
| 469 | 492 | }): 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( | |
| 471 | 494 | "Change password", |
| 472 | 495 | html`<form method="post" action="/settings/password"> |
| 473 | 496 | ${uiField({ |
| @@ -485,7 +508,7 @@ function passwordView(error?: { | ||
| 485 | 508 | autocomplete: "new-password", |
| 486 | 509 | minlength: 10, |
| 487 | 510 | required: true, |
| 488 | hint: "At least 10 characters. A passphrase beats a puzzle.", | |
| 511 | hint: "At least 10 characters.", | |
| 489 | 512 | ...(error?.field === "next" ? { error: error.text } : {}), |
| 490 | 513 | })} |
| 491 | 514 | ${uiField({ |
| @@ -499,7 +522,7 @@ function passwordView(error?: { | ||
| 499 | 522 | ${btn("Change password", { variant: "primary" })} |
| 500 | 523 | </div> |
| 501 | 524 | </form>`, |
| 502 | ); | |
| 525 | )}`; | |
| 503 | 526 | } |
| 504 | 527 | |
| 505 | 528 | // GET /settings/password |
| @@ -632,12 +655,14 @@ function tokensView(ctx: Ctx): Html { | ||
| 632 | 655 | }), |
| 633 | 656 | )} |
| 634 | 657 | </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)} | |
| 641 | 666 | ${block("Tokens", list)} |
| 642 | 667 | ${block( |
| 643 | 668 | "New token", |
| @@ -646,7 +671,6 @@ function tokensView(ctx: Ctx): Html { | ||
| 646 | 671 | label: "Token name", |
| 647 | 672 | name: "name", |
| 648 | 673 | required: true, |
| 649 | hint: "Name it after where it lives — laptop, CI, deploy box.", | |
| 650 | 674 | })} |
| 651 | 675 | ${selectField({ |
| 652 | 676 | label: "Scope", |
| @@ -658,7 +682,6 @@ function tokensView(ctx: Ctx): Html { | ||
| 658 | 682 | ? [{ value: "admin", label: "admin — everything, including settings" }] |
| 659 | 683 | : []), |
| 660 | 684 | ], |
| 661 | hint: "A token can never mint another token, whatever its scope.", | |
| 662 | 685 | })} |
| 663 | 686 | ${ctx.auth.user?.isAdmin |
| 664 | 687 | ? 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 | }); |
Budget raised for repository/app (189→197, 28→29). Principle 1: the ratchet only goes down; revert or explain.