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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9854-an-app-s-settings-form-visitors-and-envi → main 7 commits fork 4 threads · 4 unresolved
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 | ||
| 150 | 150 | |
| 151 | 151 | `/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. |
| 152 | 152 | |
| 153 | ## An app's form, visitors and environment (#9854) | |
| 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 | ||
| 153 | 159 | 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`. |
| 154 | 160 | |
| 161 | ## Flags (#9872) | |
| 162 | ||
| 155 | 163 | 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`. |
| 156 | 164 | |
| 165 | ## General (#9853) | |
| 166 | ||
| 157 | 167 | 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( | ||
| 668 | 668 | ? undefined |
| 669 | 669 | : readAppMembers(ctx.app, ctx.auth.user, repo); |
| 670 | 670 | 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( | |
| 673 | 674 | record === undefined, |
| 674 | 675 | () => |
| 675 | 676 | html`<p class="muted small mb"> |
| @@ -744,12 +745,8 @@ function appSettingsView( | ||
| 744 | 745 | }), |
| 745 | 746 | )} |
| 746 | 747 | </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>`; | |
| 753 | 750 | if (record === undefined) return form; |
| 754 | 751 | const domains = timePhase(ctx.request, "domains", () => |
| 755 | 752 | listAppDomains(ctx.app, ctx.auth.user, repo), |
| @@ -798,13 +795,12 @@ export function appVisitorsView(v: AppVitals): Html { | ||
| 798 | 795 | ? "p75 load each day over the last 30 days" |
| 799 | 796 | : `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. |
||
| 800 | 797 | ); |
| 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> | |
| 808 | 804 | <dl class="stats"> |
| 809 | 805 | <div><dt>Visitors today</dt><dd>${v.today}</dd></div> |
| 810 | 806 | <div><dt>Visitors, 7 days</dt><dd>${v.week}</dd></div> |
| @@ -814,9 +810,8 @@ export function appVisitorsView(v: AppVitals): Html { | ||
| 814 | 810 | <div><dt>p75 INP</dt><dd>${ms(v.inpP75)}</dd></div> |
| 815 | 811 | <div><dt>p75 CLS</dt><dd>${shiftText(v.clsP75)}</dd></div> |
| 816 | 812 | </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>`; | |
| 820 | 815 | } |
| 821 | 816 | |
| 822 | 817 | /** |
| @@ -824,7 +819,7 @@ export function appVisitorsView(v: AppVitals): Html { | ||
| 824 | 819 | * Values are write-only — the page shows a name and when it was set, and |
| 825 | 820 | * the value input forgets what was typed the moment the form is sent. |
| 826 | 821 | */ |
| 827 | function appEnvView(base: string, names: AppEnvName[]): Html { | |
| 822 | export function appEnvView(base: string, names: AppEnvName[]): Html { | |
| 828 | 823 | const rows = names.map((v) => |
| 829 | 824 | row({ |
| 830 | 825 | lead: icon("key"), |
| @@ -844,21 +839,16 @@ function appEnvView(base: string, names: AppEnvName[]): Html { | ||
| 844 | 839 | </form>`, |
| 845 | 840 | }), |
| 846 | 841 | ); |
| 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> | |
| 858 | 848 | ${when( |
outdated
Principle 1 and box 1: the Environment copy is several sentences. Make it one sentence. |
||
| 859 | 849 | rows.length > 0, |
| 860 | 850 | () => |
| 861 | html`<ul class="list"> | |
| 851 | html`<ul class="list setting-list"> | |
| 862 | 852 | ${rows} |
| 863 | 853 | </ul>`, |
| 864 | 854 | )} |
| @@ -890,13 +880,8 @@ function appEnvView(base: string, names: AppEnvName[]): Html { | ||
| 890 | 880 | <option value="preview">Previews only</option> |
| 891 | 881 | </select> |
| 892 | 882 | ${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>`; | |
| 900 | 885 | } |
| 901 | 886 | |
| 902 | 887 | /** |
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", () => { | ||
| 609 | 609 | await (await web(alice, "/alice/hello/settings/app")).text(), |
| 610 | 610 | ); |
| 611 | 611 | 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=/); | |
| 613 | 615 | expect(before).toContain('type="password"'); |
| 614 | 616 | |
| 615 | 617 | const set = await web(alice, "/alice/hello/settings/app/env", { |
The #9854 heading now swallows the Flags (#9872) and General (#9853) paragraphs below it. Give each its own heading.