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

Settings › Cloud speaks the home page's language #9803

Merged opened this

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

Merged

scout/forge:scout/9793-settings-cloud-speaks-the-home-page-s-la → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/9793-settings-cloud-speaks-the-home-page-s-la was merged into main as 902f53c.

Review

Sign in to review this change.
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
9494 (Sign-ins, Verify, Suspend, Remove) in the row's `after` slot; the mail, ownerless-organization, sign-in reach and
9595 confirm/refusal notices are plain `<section class="settings-block">` blocks with an `<h2>`. `test/accounts-rows.test.ts` holds the shape.
9696
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
97102 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`.
98103
99104 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
2828 import type { Ctx } from "../../http/context.ts";
2929 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
3030 import { field, readForm } from "../form.ts";
31 import { html, when, type Html } from "../html.ts";
31 import { html, type Html } from "../html.ts";
3232 import { flashTo, page } from "../layout.ts";
3333 import { btn, card, confirmAction, icon, row, time } from "../ui.ts";
3434 import { accountSections } from "./origins.ts";
@@ -94,21 +94,18 @@ function accountsCard(accounts: CloudAccount[], reads: CloudRead[], uses: Map<st
9494 </div>`,
9595 });
9696 });
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>

Principle 1 and 8: five sentences explaining themselves. One confident sentence here; move the write-switch caveat to where it applies.

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>
109106 <form class="edit-row" method="post" action="/settings/cloud">
110107 <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>`)}
112109 </select>
113110 <input class="input mono" type="text" name="name" placeholder="name, e.g. main"
114111 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
117114 aria-label="Credential" autocomplete="off" required />
118115 ${btn("Verify and seal", { variant: "primary", size: "sm", icon: "plus" })}
119116 </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>`;
127119 }
128120
129121 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>`
133126 : html`<ul class="list">
134127 ${reads.map((r) =>
135128 row({
@@ -138,9 +131,8 @@ function readsCard(reads: CloudRead[]): Html {
138131 meta: html`${runWords(r.run)} · ${time(r.at)}`,
139132 }),
140133 )}
141 </ul>`,
142 { head: html`${icon("eye")}<h2>Every read</h2>`, pad: false },
143 );
134 </ul>`}
135 </section>`;
144136 }
145137
146138 // GET /settings/cloud
@@ -160,8 +152,8 @@ export async function cloudPage(ctx: Ctx): Promise<Response> {
160152 body: html`<div class="page-head"><h1>Cloud accounts</h1></div>
161153 ${settingsLayout(
162154 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>`,
165157 "Account settings",
166158 )}`,
167159 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs