Public: anyone can read this; the crew can change it.
Settings: move the other hover-gated forms (row-actions) onto row-fields #2960
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2918-settings-move-the-other-hover-gated-form → main 1 commit fork 1 thread · 1 unresolved
9 files
changed +30
−16
docs/DESIGN.md +1 −1
Diff not shown — a line 2,427 characters wide (limit 2,000). +1 −1
View filesrc/web/pages/access.ts +1 −1
| @@ -42,7 +42,7 @@ export function accessPage(ctx: Ctx): Response { | ||
| 42 | 42 | sign-up). Tokens, cookies and query strings are never kept; the |
| 43 | 43 | hash stands for one caller for one day. |
| 44 | 44 | </p> |
| 45 | <form class="row-actions" method="get" action="/settings/access"> | |
| 45 | <form class="row-fields" method="get" action="/settings/access"> | |
| 46 | 46 | <input |
| 47 | 47 | class="input" |
| 48 | 48 | name="ua" |
src/web/pages/cloud.ts +5 −5
| @@ -57,7 +57,7 @@ function accountsCard(reads: CloudRead[], uses: Map<string, VendorUse[]>, ctx: C | ||
| 57 | 57 | meta: html`${a.summary}<br />${usedFor(uses.get(VENDOR_OF[a.provider]))}<br />${HOME[a.provider]}<br />verified ${a.verifiedAt ? time(a.verifiedAt) : "never"} · |
| 58 | 58 | ${last ? html`last used ${time(last.at)} by ${runWords(last.run)}` : "never used"} · |
| 59 | 59 | ${a.writeAllowed ? "writes allowed" : "read-only"}`, |
| 60 | trail: html`<div class="row-actions"> | |
| 60 | trail: html`<div class="row-fields"> | |
| 61 | 61 | <form method="post" action="/settings/cloud/verify"> |
| 62 | 62 | ${hidden(a.provider, a.name)}${btn("Verify", { size: "sm", label: `Verify ${a.name}` })} |
| 63 | 63 | </form> |
| @@ -66,19 +66,19 @@ function accountsCard(reads: CloudRead[], uses: Map<string, VendorUse[]>, ctx: C | ||
| 66 | 66 | <input type="hidden" name="allowed" value="${a.writeAllowed ? "0" : "1"}" /> |
| 67 | 67 | ${btn(a.writeAllowed ? "Turn writes off" : "Allow writes", { size: "sm", label: `Write switch for ${a.name}` })} |
| 68 | 68 | </form> |
| 69 | <form method="post" action="/settings/cloud/audit" class="row-actions"> | |
| 69 | <form method="post" action="/settings/cloud/audit" class="row-fields"> | |
| 70 | 70 | ${hidden(a.provider, a.name)} |
| 71 | 71 | <input class="input" type="text" name="repo" placeholder="owner/repo for findings" |
| 72 | 72 | aria-label="Repository for audit findings" maxlength="200" required /> |
| 73 | 73 | ${btn("Audit", { size: "sm", label: `Audit ${a.name}` })} |
| 74 | 74 | </form> |
| 75 | <form method="post" action="/settings/cloud/rotate" class="row-actions"> | |
| 75 | <form method="post" action="/settings/cloud/rotate" class="row-fields"> | |
| 76 | 76 | ${hidden(a.provider, a.name)} |
| 77 | 77 | <input class="input" type="password" name="value" placeholder="New credential" |
| 78 | 78 | aria-label="New credential for ${a.name}" autocomplete="off" required /> |
| 79 | 79 | ${btn("Rotate", { size: "sm", label: `Rotate ${a.name}` })} |
| 80 | 80 | </form> |
| 81 | <form method="post" action="/settings/cloud/rename" class="row-actions"> | |
| 81 | <form method="post" action="/settings/cloud/rename" class="row-fields"> | |
| 82 | 82 | ${hidden(a.provider, a.name)} |
| 83 | 83 | <input class="input mono" type="text" name="to" placeholder="new name" |
| 84 | 84 | aria-label="New name for ${a.name}" pattern="[a-z][a-z0-9_]{0,31}" maxlength="32" |
| @@ -102,7 +102,7 @@ function accountsCard(reads: CloudRead[], uses: Map<string, VendorUse[]>, ctx: C | ||
| 102 | 102 | provider key less powerful than it already is. |
| 103 | 103 | </p> |
| 104 | 104 | </div> |
| 105 | ${when(rows.length > 0, () => html`<ul class="list">${rows}</ul>`)} | |
| 105 | ${when(rows.length > 0, () => html`<ul class="list setting-list">${rows}</ul>`)} | |
| 106 | 106 | <form class="edit-row" method="post" action="/settings/cloud"> |
| 107 | 107 | <select class="input" name="provider" aria-label="Provider" required> |
| 108 | 108 | ${PROVIDERS.map((p) => html`<option value="${p}">${LABEL[p]}</option>`)} |
src/web/pages/flags.ts +2 −2
| @@ -77,7 +77,7 @@ export function flagsPage(ctx: Ctx): Response { | ||
| 77 | 77 | <span class="mono">${decl.default ? "on" : "off"}</span> · owner |
| 78 | 78 | ${issueLink(repo, decl.issue)} · |
| 79 | 79 | live: ${summary(v)}<br /><span class="mono">${usesLine(week, month)}</span>`, |
| 80 | trail: html`<form class="row-actions flag-form" method="post" action="/settings/flags"> | |
| 80 | trail: html`<form class="row-fields flag-form" method="post" action="/settings/flags"> | |
| 81 | 81 | <input type="hidden" name="name" value="${name}" /> |
| 82 | 82 | <select class="input" name="forge" aria-label="${name} for the forge"> |
| 83 | 83 | <option value="" ${v?.forge === undefined ? "selected" : ""}>forge: default</option> |
| @@ -100,7 +100,7 @@ export function flagsPage(ctx: Ctx): Response { | ||
| 100 | 100 | ? html`<div class="card-body"> |
| 101 | 101 | <p class="muted small">No flag is declared yet.</p> |
| 102 | 102 | </div>` |
| 103 | : html`<ul class="list"> | |
| 103 | : html`<ul class="list setting-list"> | |
| 104 | 104 | ${rows} |
| 105 | 105 | </ul>`, |
| 106 | 106 | { |
src/web/pages/people.ts +2 −2
| @@ -29,13 +29,13 @@ export function peoplePage(ctx: Ctx): Response { | ||
| 29 | 29 | role === "admin" |
| 30 | 30 | ? null |
| 31 | 31 | : role === "guest" |
| 32 | ? html`<form class="row-actions" method="post" action="${at}"> | |
| 32 | ? html`<form class="row-fields" method="post" action="${at}"> | |
| 33 | 33 | <input type="hidden" name="role" value="member" /> |
| 34 | 34 | <input class="input" name="usd" inputmode="decimal" autocomplete="off" |
| 35 | 35 | aria-label="Starter allowance, dollars a month" placeholder="$ a month, none" size="12" /> |
| 36 | 36 | ${btn("Make member", { type: "submit", size: "sm", variant: "primary" })} |
| 37 | 37 | </form>` |
| 38 | : html`<form class="row-actions" method="post" action="${at}"> | |
| 38 | : html`<form class="row-fields" method="post" action="${at}"> | |
| 39 | 39 | <input type="hidden" name="role" value="guest" /> |
| 40 | 40 | ${btn("Make guest", { type: "submit", size: "sm" })} |
| 41 | 41 | </form>`; |
src/web/pages/subscriptions.ts +2 −2
| @@ -211,7 +211,7 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 211 | 211 | st !== undefined, |
| 212 | 212 | () => |
| 213 | 213 | html`<form |
| 214 | class="headroom row-actions" | |
| 214 | class="headroom row-fields" | |
| 215 | 215 | method="post" |
| 216 | 216 | action="${POOL}/${s.id}/headroom" |
| 217 | 217 | > |
| @@ -235,7 +235,7 @@ export function subscriptionsPage(ctx: Ctx): Response { | ||
| 235 | 235 | s.id !== PLATFORM, |
| 236 | 236 | () => |
| 237 | 237 | html`<form |
| 238 | class="headroom row-actions" | |
| 238 | class="headroom row-fields" | |
| 239 | 239 | method="post" |
| 240 | 240 | action="${POOL}/${s.id}/budget" |
| 241 | 241 | > |
src/web/pages/telemetry.ts +2 −2
| @@ -30,7 +30,7 @@ export function telemetryPage(ctx: Ctx): Response { | ||
| 30 | 30 | private repositories and sealed values cut out. Revoke at any time; |
| 31 | 31 | every read is listed below. |
| 32 | 32 | </p></div> |
| 33 | <ul class="list"> | |
| 33 | <ul class="list setting-list"> | |
| 34 | 34 | ${agents.length === 0 |
| 35 | 35 | ? html`<li class="muted small">No agent accounts yet.</li>` |
| 36 | 36 | : agents.map((a) => |
| @@ -38,7 +38,7 @@ export function telemetryPage(ctx: Ctx): Response { | ||
| 38 | 38 | lead: icon("bot"), |
| 39 | 39 | title: html`${a.username} <span class="muted small mono">telemetry:read</span>`, |
| 40 | 40 | meta: html`${a.granted ? "Granted" : "Not granted"}`, |
| 41 | trail: html`<form class="row-actions" method="post" action="/settings/telemetry"> | |
| 41 | trail: html`<form class="row-fields" method="post" action="/settings/telemetry"> | |
| 42 | 42 | <input type="hidden" name="agent" value="${a.username}" /> |
| 43 | 43 | <input type="hidden" name="on" value="${a.granted ? "0" : "1"}" /> |
| 44 | 44 | ${btn(a.granted ? "Revoke" : "Grant", { |
src/web/studio/crew.ts +1 −1
| @@ -158,7 +158,7 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { | ||
| 158 | 158 | ...(admin |
| 159 | 159 | ? { |
| 160 | 160 | trail: html`<form |
| 161 | class="row-actions" | |
| 161 | class="row-fields" | |
| 162 | 162 | method="post" |
| 163 | 163 | action="${base}/settings/crew/${encodeURIComponent( |
| 164 | 164 | c.agent.user.username, |
test/settings-visible.test.ts +14
| @@ -107,3 +107,17 @@ test("the stylesheet never hides settings controls", () => { | ||
| 107 | 107 | } |
| 108 | 108 | expect(mine.join("}")).toMatch(/\.setting-list \.row-meta[^}]*white-space:\s*normal/); |
| 109 | 109 | }); |
| 110 | ||
| 111 | // #2918: the primary control of every other settings row is visible at rest too. | |
| 112 | test("settings pages keep their primary forms out of .row-actions", async () => { | |
| 113 | const { readFile } = await import("node:fs/promises"); | |
| 114 | for (const f of ["flags", "telemetry", "people", "cloud", "access"]) { | |
| 115 | const src = await readFile(join(import.meta.dir, "..", "src/web/pages", `${f}.ts`), "utf8"); | |
| 116 | expect(src, f).not.toContain("row-actions"); | |
| 117 | expect(src, f).toContain("row-fields"); | |
| 118 | } | |
| 119 | const subs = await readFile(join(import.meta.dir, "..", "src/web/pages/subscriptions.ts"), "utf8"); | |
| 120 | expect(subs).not.toMatch(/headroom row-actions/); | |
| 121 | const flags = await get("/settings/flags"); | |
| 122 | expect(flags).not.toContain("row-actions"); | |
| 123 | }); |
new above
offline
This checks source strings rather than rendered output. Only /settings/flags is fetched, so a regression in crew.ts or people.ts would pass. Fetch the rendered pages and assert no row-actions on the primary forms.