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

An app's settings form, visitors and environment names speak the home page's language #9874

Merged opened this

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

Merged

scout/forge:scout/9854-an-app-s-settings-form-visitors-and-envi → main 7 commits fork 4 threads · 4 unresolved

  • Merged. scout/9854-an-app-s-settings-form-visitors-and-envi was merged into main as 2b72c80.

Review

Sign in to review this change.
4 files changed +94 −39
docs/design/areas/settings.md +10
@@ -150,8 +150,18 @@ Settings › Model (`modelPage()` in `src/web/pages/model.ts`, #9800) adds no cl
150150
151151 `/organizations/new` is one muted sentence (what an organization is) over the single name field, whose hint says it becomes the URL; "first owner" is said once, in the landing flash. A team's page (`/:owner/-/teams/:team`) is three `<section class="settings-block">`s, each an `<h2>` — Members, Add a member, Delete this team (not on Owners) — over a `list setting-list` of `row()`s or a plain form. No card, no empty-state box (an empty team is one muted line), no new class. Every change stays a plain POST form or a confirm page, so it works with JavaScript off. `test/org-team-rows.test.ts` holds the shape.
152152
153 ## An app's form, visitors and environment (#9854)

The #9854 heading now swallows the Flags (#9872) and General (#9853) paragraphs below it. Give each its own heading.

154
155 On an app's Settings page, `appSettingsView()`, `appVisitorsView()` and `appEnvView()` are each a `.settings-block` (a heading, one sentence, then the form or rows; env names in a `.setting-list`), not a `card()`. No new class.
156
157 ## Accounts (#9870)
158
153159 Settings › Accounts (`accountsPage()` in `src/web/pages/accounts.ts`, #9870) has no card: who is on the forge is a `<section class="settings-block" id="accounts">` under an `<h2>`, rows in `.list`, and a person's sign-ins page is the same (an `<h2>`, an `<h3>` for tokens, rows). No new class. Pinned by `test/accounts-language.test.ts`.
154160
161 ## Flags (#9872)
162
155163 Settings › Flags (`flagsPage()` in `src/web/pages/flags.ts`, #9872) has no card: one `<section class="settings-block">` under `<h2>Flags</h2>` holds the flags as `.list.setting-list` rows, or one plain sentence when none is declared. Pinned by `test/flags-language.test.ts`.
156164
165 ## General (#9853)
166
157167 Settings › General (`settingsPage()` and `provenanceCard()` in `src/web/pages/settings.ts`, #9853) adds no class and holds no `.card`: About, Your team, On its own and Held files (`src/web/studio/crew.ts`, `file_holds.ts`), Branch and gates, Mirror, Show where this came from, Follow upstream and Followed sources (`upstream-follow.ts`), and Cut a wave and Archive are each a `<section class="settings-block">` with an `<h2>`, one plain sentence and the form. The crew-readiness notices (`crewReadiness()` in `src/web/pages/crew.ts`) are `.toast` rows with `data-tone="danger"`. Every form keeps its action, method, field names and ids. Pinned by `test/settings.test.ts`, which asserts the whole page has no `class="card`.
src/web/pages/settings.ts +23 −38
@@ -668,8 +668,9 @@ function appSettingsView(
668668 ? undefined
669669 : readAppMembers(ctx.app, ctx.auth.user, repo);
670670 const named = listed?.ok === true ? listed.value.members : [];
671 const form = card(
672 html`${when(
671 const form = html`<section class="settings-block" id="app">
672 <h2>${record === undefined ? "Run as an app" : "App"}</h2>
673 ${when(
673674 record === undefined,
674675 () =>
675676 html`<p class="muted small mb">
@@ -744,12 +745,8 @@ function appSettingsView(
744745 }),
745746 )}
746747 </div>
747 </form>`,
748 {
749 head: html`${icon("rocket")}
750 <h2>${record === undefined ? "Run as an app" : "App"}</h2>`,
751 },
752 );
748 </form>
749 </section>`;
753750 if (record === undefined) return form;
754751 const domains = timePhase(ctx.request, "domains", () =>
755752 listAppDomains(ctx.app, ctx.auth.user, repo),
@@ -798,13 +795,12 @@ export function appVisitorsView(v: AppVitals): Html {
798795 ? "p75 load each day over the last 30 days"
799796 : `p75 load each day over the last 30 days, latest ${ms(latest ?? undefined)}, range ${ms(Math.min(...known))} to ${ms(Math.max(...known))}`,
outdated

Principle 1 and box 1: Visitors and speed has a link and a multi-sentence paragraph before the facts. Use one sentence.

800797 );
801 return card(
802 html`<p class="small mb"><a href="app/traffic">Where visitors come from and where they go</a></p>
803 <p class="muted small mb">
804 Counted by the beacon the template ships, through the forge: no
805 address is kept, and a visitor is the same only within a day, so the
806 7 day figure adds the days up.
807 </p>
798 return html`<section class="settings-block" id="visitors">
799 <h2>Visitors and speed</h2>
800 <p class="muted small mb">
801 Counted by the beacon, with no address kept; the 7 day figure adds
802 the days up. <a href="app/traffic">Where visitors come from</a>
803 </p>
808804 <dl class="stats">
809805 <div><dt>Visitors today</dt><dd>${v.today}</dd></div>
810806 <div><dt>Visitors, 7 days</dt><dd>${v.week}</dd></div>
@@ -814,9 +810,8 @@ export function appVisitorsView(v: AppVitals): Html {
814810 <div><dt>p75 INP</dt><dd>${ms(v.inpP75)}</dd></div>
815811 <div><dt>p75 CLS</dt><dd>${shiftText(v.clsP75)}</dd></div>
816812 </dl>
817 ${spark ? html`<div class="bar muted">${spark}</div>` : html`<p class="muted small mt">No load reports in the last 30 days</p>`}`,
818 { head: html`${icon("rocket")}<h2>Visitors and speed</h2>` },
819 );
813 ${spark ? html`<div class="bar muted">${spark}</div>` : html`<p class="muted small mt">No load reports in the last 30 days</p>`}
814 </section>`;
820815 }
821816
822817 /**
@@ -824,7 +819,7 @@ export function appVisitorsView(v: AppVitals): Html {
824819 * Values are write-only — the page shows a name and when it was set, and
825820 * the value input forgets what was typed the moment the form is sent.
826821 */
827 function appEnvView(base: string, names: AppEnvName[]): Html {
822 export function appEnvView(base: string, names: AppEnvName[]): Html {
828823 const rows = names.map((v) =>
829824 row({
830825 lead: icon("key"),
@@ -844,21 +839,16 @@ function appEnvView(base: string, names: AppEnvName[]): Html {
844839 </form>`,
845840 }),
846841 );
847 return card(
848 html`<div class="card-body">
849 <p class="muted small">
850 What the app reads from its environment at start-up — an admin
851 password, an API key. Values are write-only: set one, and it is never
852 shown again. A variable goes everywhere, to production only, or to
853 previews only; the same name may be set in each, and the narrower one
854 wins — a test key for previews, the live one for production. Changing
855 one redeploys what reads it, and nothing else.
856 </p>
857 </div>
842 return html`<section class="settings-block" id="environment">
843 <h2>Environment</h2>
844 <p class="muted small">
845 What the app reads at start-up; values are write-only, and one set
846 for production only or previews only wins there over one set everywhere.
847 </p>
858848 ${when(
outdated

Principle 1 and box 1: the Environment copy is several sentences. Make it one sentence.

859849 rows.length > 0,
860850 () =>
861 html`<ul class="list">
851 html`<ul class="list setting-list">
862852 ${rows}
863853 </ul>`,
864854 )}
@@ -890,13 +880,8 @@ function appEnvView(base: string, names: AppEnvName[]): Html {
890880 <option value="preview">Previews only</option>
891881 </select>
892882 ${btn("Set", { variant: "primary", size: "sm", icon: "plus" })}
893 </form>`,
894 {
895 head: html`${icon("key")}
896 <h2>Environment</h2>`,
897 pad: false,
898 },
899 );
883 </form>
884 </section>`;
900885 }
901886
902887 /**
test/app-settings-page.test.ts added +58
@@ -0,0 +1,58 @@
1 /** An app's settings form, visitors and environment are plain sections, not cards (#9854). */
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 { createRepo } from "../src/actions/repos.ts";
8 import { createApp as insertApp } from "../src/core/apps.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { seedAdmin } from "./helpers/admin.ts";
11
12 let dataDir = "";
13 let app: App, servers: Servers, bearer = "";
14
15 beforeAll(async () => {
16 dataDir = await mkdtemp(join(tmpdir(), "forge-asp-"));
17 app = createApp({ dataDir, port: 0 });
18 servers = await serve(app);
19 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
20 if (!admin.ok) throw new Error(admin.error);
21 const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" });
22 if (!made.ok) throw new Error(String(made.error));
23 const row = insertApp(app.db, { repoId: made.value.id, slug: "notes", access: "public" });
24 if (!row.ok) throw new Error(row.error);
25 const minted = await fetch(`${servers.url}/api/v1/tokens`, {
26 method: "POST",
27 headers: { "content-type": "application/json" },
28 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
29 });
30 bearer = ((await minted.json()) as { token: string }).token;
31 });
32
33 afterAll(async () => {
34 servers.stop();
35 app.close();
36 await rm(dataDir, { recursive: true, force: true });
37 });
38
39 const section = (page: string, id: string): string => {
40 const from = page.indexOf(`<section class="settings-block" id="${id}"`);
41 expect(from).toBeGreaterThan(-1);
42 return page.slice(from, page.indexOf("</section>", from));
43 };
44
45 test("form, visitors and environment are settings blocks without a card", async () => {
46 const res = await fetch(`${servers.url}/alice/notes/settings/app`, { headers: { authorization: `Bearer ${bearer}` } });
47 const page = await res.text();
48 expect(res.status).toBe(200);
49 for (const id of ["app", "visitors", "environment"]) {
50 const s = section(page, id);
51 expect(s).not.toContain('class="card');
52 expect(s).toMatch(/<h2>/);
53 }
54 expect(section(page, "app")).toContain('action="/alice/notes/settings/app"');
55 expect(section(page, "app")).toContain('name="slug"');
56 expect(section(page, "environment")).toContain('action="/alice/notes/settings/app/env"');
57 expect(section(page, "environment")).toContain('name="value"');
58 });
test/web-apps.test.ts +3 −1
@@ -609,7 +609,9 @@ describe("apps, runs and the crew on the web", () => {
609609 await (await web(alice, "/alice/hello/settings/app")).text(),
610610 );
611611 expect(before).toContain("Environment");
612 expect(before).toContain("never shown again");
612 expect(before).toContain("values are write-only");
613 expect(before).toContain("wins there over one set everywhere");
614 expect(before).not.toMatch(/name="scope"[^>]*title=/);
613615 expect(before).toContain('type="password"');
614616
615617 const set = await web(alice, "/alice/hello/settings/app/env", {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs