Public: anyone can read this; the crew can change it.
Settings › Cloud speaks the home page's language #9803
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9793-settings-cloud-speaks-the-home-page-s-la → main 5 commits fork 2 threads · 2 unresolved
3 files
changed +74
−29
docs/design/areas/settings.md +5
| @@ -94,6 +94,11 @@ accounts are `row()`s in `<ul class="list setting-list" id="accounts-list">` und | ||
| 94 | 94 | (Sign-ins, Verify, Suspend, Remove) in the row's `after` slot; the mail, ownerless-organization, sign-in reach and |
| 95 | 95 | confirm/refusal notices are plain `<section class="settings-block">` blocks with an `<h2>`. `test/accounts-rows.test.ts` holds the shape. |
| 96 | 96 | |
| 97 | Settings › Cloud (`src/web/pages/cloud.ts`) adds no component class. As on the home page it is headings over rows: Cloud accounts, | |
| 98 | Add an account and Every read are each a `<section class="settings-block">` with an `<h2>`; each account is a `row()` in | |
| 99 | `<ul class="list setting-list">`; with none, one plain sentence says how to add one. `/settings/cloud/audit` is unchanged. | |
| 100 | `test/cloud-page-shape.test.ts` holds the shape. | |
| 101 | ||
| 97 | 102 | Settings › Telemetry (`telemetryPage()` in `src/web/pages/telemetry.ts`, #9806) adds no class: two `<section class="settings-block">` blocks, Telemetry grants (one plain paragraph, then each agent a `row()` with its Grant/Revoke form in a `.setting-list`) and Reads; an empty list is one plain sentence. Pinned by `test/telemetry-blocks.test.ts`. |
| 98 | 103 | |
| 99 | 104 | Settings › Trusted (`trustedPage()` in `src/web/pages/trusted.ts`, #9792) adds no component class. As on the home page it is |
src/web/pages/cloud.ts +21 −29
| @@ -28,7 +28,7 @@ import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from | ||
| 28 | 28 | import type { Ctx } from "../../http/context.ts"; |
| 29 | 29 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 30 | 30 | import { field, readForm } from "../form.ts"; |
| 31 | import { html, when, type Html } from "../html.ts"; | |
| 31 | import { html, type Html } from "../html.ts"; | |
| 32 | 32 | import { flashTo, page } from "../layout.ts"; |
| 33 | 33 | import { btn, card, confirmAction, icon, row, time } from "../ui.ts"; |
| 34 | 34 | import { accountSections } from "./origins.ts"; |
| @@ -94,21 +94,18 @@ function accountsCard(accounts: CloudAccount[], reads: CloudRead[], uses: Map<st | ||
| 94 | 94 | </div>`, |
| 95 | 95 | }); |
| 96 | 96 | }); |
| 97 | return card( | |
| 98 | html`<div class="card-body"> | |
| 99 | <p class="muted small"> | |
| 100 | Paste a credential once: the forge checks it with read-only calls, | |
| 101 | says what it can see, then seals it. It is never shown again. Give it | |
| 102 | the narrowest scope the provider offers — an audit only reads. A run | |
| 103 | that declares <span class="mono">writes: yes</span> is refused a | |
| 104 | credential until its write switch is on; the switch cannot make a | |
| 105 | provider key less powerful than it already is. | |
| 106 | </p> | |
| 107 | </div> | |
| 108 | ${when(rows.length > 0, () => html`<ul class="list setting-list">${rows}</ul>`)} | |
| 97 | return html`<section class="settings-block"> | |
| 98 | <h2>Cloud accounts</h2> | |
| 99 | <p class="muted small">Paste a credential once; the forge checks it read-only, seals it, and never shows it again.</p> | |
| 100 | ${rows.length > 0 | |
| 101 | ? html`<ul class="list setting-list">${rows}</ul>` | |
| 102 | : html`<p class="muted small">No cloud account is sealed yet. Add one below: pick a provider, name it, paste its credential.</p>`} | |
| 103 | </section> | |
| 104 | <section class="settings-block"> | |
| 105 | <h2>Add an account</h2> | |
| 109 | 106 | <form class="edit-row" method="post" action="/settings/cloud"> |
| 110 | 107 | <select class="input" name="provider" aria-label="Provider" required> |
| 111 | ${PROVIDERS.map((p) => html`<option value="${p}">${LABEL[p]}</option>`)} | |
| 108 | ${PROVIDERS.map((p) => html`<option value="${p}">${LABEL[p]} — ${shape[p]}</option>`)} | |
| 112 | 109 | </select> |
| 113 | 110 | <input class="input mono" type="text" name="name" placeholder="name, e.g. main" |
| 114 | 111 | aria-label="Name" pattern="[a-z][a-z0-9_]{0,31}" maxlength="32" |
| @@ -117,19 +114,15 @@ function accountsCard(accounts: CloudAccount[], reads: CloudRead[], uses: Map<st | ||
| 117 | 114 | aria-label="Credential" autocomplete="off" required /> |
| 118 | 115 | ${btn("Verify and seal", { variant: "primary", size: "sm", icon: "plus" })} |
| 119 | 116 | </form> |
| 120 | <div class="card-body"> | |
| 121 | <p class="muted small"> | |
| 122 | ${PROVIDERS.map((p) => html`${LABEL[p]}: ${shape[p]}. `)} | |
| 123 | </p> | |
| 124 | </div>`, | |
| 125 | { head: html`${icon("key")}<h2>Cloud accounts</h2>`, pad: false }, | |
| 126 | ); | |
| 117 | <p class="muted small">A run that declares <span class="mono">writes: yes</span> gets no credential until you allow writes on its account.</p> | |
| 118 | </section>`; | |
| 127 | 119 | } |
| 128 | 120 | |
| 129 | 121 | function readsCard(reads: CloudRead[]): Html { |
| 130 | return card( | |
| 131 | reads.length === 0 | |
| 132 | ? html`<p class="muted small card-body">No credential has been read yet.</p>` | |
| 122 | return html`<section class="settings-block"> | |
| 123 | <h2>Every read</h2> | |
| 124 | ${reads.length === 0 | |
outdated
Principle 1: a second explanatory paragraph that lists provider credential shapes. Put each shape beside its provider (option hint or placeholder) so nothing explains itself twice. |
||
| 125 | ? html`<p class="muted small">No credential has been read yet.</p>` | |
| 133 | 126 | : html`<ul class="list"> |
| 134 | 127 | ${reads.map((r) => |
| 135 | 128 | row({ |
| @@ -138,9 +131,8 @@ function readsCard(reads: CloudRead[]): Html { | ||
| 138 | 131 | meta: html`${runWords(r.run)} · ${time(r.at)}`, |
| 139 | 132 | }), |
| 140 | 133 | )} |
| 141 | </ul>`, | |
| 142 | { head: html`${icon("eye")}<h2>Every read</h2>`, pad: false }, | |
| 143 | ); | |
| 134 | </ul>`} | |
| 135 | </section>`; | |
| 144 | 136 | } |
| 145 | 137 | |
| 146 | 138 | // GET /settings/cloud |
| @@ -160,8 +152,8 @@ export async function cloudPage(ctx: Ctx): Promise<Response> { | ||
| 160 | 152 | body: html`<div class="page-head"><h1>Cloud accounts</h1></div> |
| 161 | 153 | ${settingsLayout( |
| 162 | 154 | accountSections("cloud", { admin: true }), |
| 163 | html`${accountsCard(accounts, reads, uses)}<div class="mt">${readsCard(reads)}</div> | |
| 164 | <p class="muted small mt"><a href="/settings/cloud/audit">Every vendor call today</a> · <a href="/deploy-to">Deploy a pinned forge</a></p>`, | |
| 155 | html`${accountsCard(accounts, reads, uses)}${readsCard(reads)} | |
| 156 | <p class="muted small"><a href="/settings/cloud/audit">Every vendor call today</a> · <a href="/deploy-to">Deploy a pinned forge</a></p>`, | |
| 165 | 157 | "Account settings", |
| 166 | 158 | )}`, |
| 167 | 159 | }); |
test/cloud-page-shape.test.ts added +48
| @@ -0,0 +1,48 @@ | ||
| 1 | /** Settings › Cloud is headings over rows, not cards (#9793). */ | |
| 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 { createUser } from "../src/core/users.ts"; | |
| 8 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 9 | import { quietLogs } from "./helpers/globals.ts"; | |
| 10 | ||
| 11 | quietLogs(); | |
| 12 | ||
| 13 | let dir = ""; | |
| 14 | let app: App; | |
| 15 | let servers: Servers; | |
| 16 | let cookie = ""; | |
| 17 | ||
| 18 | beforeAll(async () => { | |
| 19 | dir = await mkdtemp(join(tmpdir(), "forge-cloud-shape-")); | |
| 20 | app = createApp({ dataDir: dir, port: 0 }); | |
| 21 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 22 | if (!made.ok) throw new Error(made.error); | |
| 23 | servers = await serve(app); | |
| 24 | const r = await fetch(`${servers.url}/signin`, { | |
| 25 | method: "POST", | |
| 26 | redirect: "manual", | |
| 27 | headers: { origin: servers.url }, | |
| 28 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 29 | }); | |
| 30 | cookie = (r.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 31 | }); | |
| 32 | ||
| 33 | afterAll(async () => { | |
| 34 | servers.stop(); | |
| 35 | app.close(); | |
| 36 | await rm(dir, { recursive: true, force: true }); | |
| 37 | }); | |
| 38 | ||
| 39 | test("an admin sees headed sections and no card; with no account one sentence says how to add one", async () => { | |
| 40 | const page = await (await fetch(`${servers.url}/settings/cloud`, { headers: { cookie } })).text(); | |
| 41 | const main = page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 42 | for (const h of ["Cloud accounts", "Add an account", "Every read"]) expect(main).toContain(`<h2>${h}</h2>`); | |
| 43 | expect(main).toContain('<section class="settings-block">'); | |
| 44 | expect(main).not.toContain('class="card'); | |
| 45 | expect(main).not.toContain("card-body"); | |
| 46 | expect(main).toContain("No cloud account is sealed yet. Add one below"); | |
| 47 | expect(main).toContain('action="/settings/cloud"'); | |
| 48 | }); |
Principle 1 and 8: five sentences explaining themselves. One confident sentence here; move the write-switch caveat to where it applies.