Public: anyone can read this; the crew can change it.
Settings, limits: every option is visible without hovering, at every width #2909
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2908-settings-limits-every-option-is-visible → main 2 commits fork 3 threads · 3 unresolved
5 files
changed +133
−12
docs/DESIGN.md +5 −2
Diff not shown — a line 2,427 characters wide (limit 2,000). +5 −2
View filesrc/web/pages/admin-limits.ts +7 −7
| @@ -29,14 +29,14 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 29 | 29 | row({ |
| 30 | 30 | lead: icon("activity"), |
| 31 | 31 | title: html`${l.label} <span class="muted small mono">${l.key}</span>`, |
| 32 | meta: html`${l.reason} <br />${l.isSet | |
| 32 | meta: html`${l.reason} <br />Now <span class="mono">${String(l.value)}</span>. ${l.isSet | |
| 33 | 33 | ? html`Set here; the default is |
| 34 | 34 | <span class="mono">${String(l.defaultValue)}</span>.` |
| 35 | 35 | : html`The default.`} |
| 36 | 36 | Whole number, ${l.unit}, from |
| 37 | 37 | <span class="mono">${String(l.min)}</span> to |
| 38 | 38 | <span class="mono">${String(l.max)}</span>.`, |
| 39 | trail: html`<form class="row-actions" method="post" action="/admin/limits"> | |
| 39 | trail: html`<form class="row-fields" method="post" action="/admin/limits"> | |
| 40 | 40 | <input type="hidden" name="key" value="${l.key}" /> |
| 41 | 41 | <input |
| 42 | 42 | class="input mono" |
| @@ -93,7 +93,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 93 | 93 | repositories plus app data). Empty is no limit; a forge admin is |
| 94 | 94 | never held to any of them. |
| 95 | 95 | </p> |
| 96 | <form class="row-actions" method="post" action="/admin/quotas"> | |
| 96 | <form class="row-fields" method="post" action="/admin/quotas"> | |
| 97 | 97 | ${QUOTA_KEYS.map( |
| 98 | 98 | (k) => html`<label class="small">${k === "storage" ? "storage (MB)" : k} |
| 99 | 99 | <input |
| @@ -132,7 +132,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 132 | 132 | <p class="muted small"> |
| 133 | 133 | Who may sign up: open, invite or closed. Nothing reads this yet. |
| 134 | 134 | </p> |
| 135 | <form class="row-actions" method="post" action="/admin/signup"> | |
| 135 | <form class="row-fields" method="post" action="/admin/signup"> | |
| 136 | 136 | <label class="small">signup |
| 137 | 137 | <select class="input mono" name="signup"> |
| 138 | 138 | ${SIGNUP_MODES.map( |
| @@ -160,7 +160,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 160 | 160 | meta: html`Made ${time(i.createdAt)} · expires ${time(i.expiresAt)}`, |
| 161 | 161 | trail: |
| 162 | 162 | i.state === "open" |
| 163 | ? html`<form class="row-actions" method="post" action="/admin/invites/${i.id}/revoke"> | |
| 163 | ? html`<form class="row-fields" method="post" action="/admin/invites/${i.id}/revoke"> | |
| 164 | 164 | ${btn("Revoke", { size: "sm", label: `Revoke invite ${i.id}` })} |
| 165 | 165 | </form>` |
| 166 | 166 | : html``, |
| @@ -172,7 +172,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 172 | 172 | A link is shown once, when it is made, and is good for one signup. |
| 173 | 173 | Nothing reads these yet. |
| 174 | 174 | </p> |
| 175 | <form class="row-actions" method="post" action="/admin/invites"> | |
| 175 | <form class="row-fields" method="post" action="/admin/invites"> | |
| 176 | 176 | <label class="small">expires in (days) |
| 177 | 177 | <input |
| 178 | 178 | class="input mono" |
| @@ -203,7 +203,7 @@ export function limitsPage(ctx: Ctx): Response { | ||
| 203 | 203 | the next request that meets the limit; nothing needs a restart. |
| 204 | 204 | </p> |
| 205 | 205 | </div> |
| 206 | <ul class="list"> | |
| 206 | <ul class="list setting-list"> | |
| 207 | 207 | ${rows} |
| 208 | 208 | </ul>`, |
| 209 | 209 | { |
src/web/pages/capacity.ts +3 −3
| @@ -59,13 +59,13 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 59 | 59 | row({ |
| 60 | 60 | lead: icon("activity"), |
| 61 | 61 | title: html`${s.label} <span class="muted small mono">${s.key}</span>`, |
| 62 | meta: html`${s.help} <br />${s.source === "setting" | |
| 62 | meta: html`${s.help} <br />Now <span class="mono">${s.value === "" ? "auto" : s.value}</span>. ${s.source === "setting" | |
| 63 | 63 | ? html`Set here; the boot default is |
| 64 | 64 | <span class="mono">${s.default === "" ? "auto" : s.default}</span> |
| 65 | 65 | (<span class="mono">${s.env}</span>).` |
| 66 | 66 | : html`The boot default, from <span class="mono">${s.env}</span>.`}`, |
| 67 | 67 | trail: html`<form |
| 68 | class="row-actions" | |
| 68 | class="row-fields" | |
| 69 | 69 | method="post" |
| 70 | 70 | action="/settings/capacity" |
| 71 | 71 | > |
| @@ -146,7 +146,7 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 146 | 146 | and never needs a restart. |
| 147 | 147 | </p> |
| 148 | 148 | </div> |
| 149 | <ul class="list"> | |
| 149 | <ul class="list setting-list"> | |
| 150 | 150 | ${rows} |
| 151 | 151 | </ul>`, |
| 152 | 152 | { |
src/web/style/components.ts +9
| @@ -324,6 +324,15 @@ a.row:hover .row-title { color: var(--accent); } | ||
| 324 | 324 | /* Secondary actions are always in the DOM, revealed without layout shift. */ |
| 325 | 325 | .row-actions { opacity: 0; transition: opacity 120ms var(--ease); } |
| 326 | 326 | .row:hover .row-actions, .row:focus-within .row-actions { opacity: 1; } |
| 327 | /* Settings controls are the point of their row, never revealed by hover: | |
| 328 | they wrap under the text before they would clip, at any width. */ | |
|
||
| 329 | .row-fields { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } | |
| 330 | .row-fields .input { min-width: 0; max-width: 100%; } | |
| 331 | .setting-list .row { flex-wrap: wrap; align-items: flex-start; } | |
| 332 | .setting-list .row-main { flex: 1 1 18rem; } | |
| 333 | .setting-list .row-title, .setting-list .row-meta { white-space: normal; overflow: visible; overflow-wrap: anywhere; } | |
| 334 | .setting-list .row-trail { flex: 0 1 auto; max-width: 100%; } | |
| 335 | @media (max-width: 640px) { .setting-list .row-trail { flex-basis: 100%; } } | |
This breakpoint is 640px, but DESIGN.md stacks .edit-row at 720px. Use one number for settings rows, or say in the docs why they differ. |
||
| 327 | 336 | @media (pointer: coarse) { .row-actions { opacity: 1; } .row { min-height: 44px; } } |
| 328 | 337 | .row[data-state="closed"] .row-title, .row[data-state="merged"] .row-title { color: var(--muted-fg); } |
| 329 | 338 |
test/settings-visible.test.ts added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | /** | |
| 2 | * #2908: every limit and capacity setting shows its name, current value, | |
| 3 | * default and control at rest. Nothing waits on hover, and the stylesheet | |
| 4 | * never hides a settings control. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { listLimits } from "../src/actions/limits.ts"; | |
| 13 | import { listCapacity } from "../src/actions/capacity.ts"; | |
| 14 | import { createSession } from "../src/core/sessions.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { css } from "../src/web/style.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dir = ""; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | let admin: User; | |
| 26 | ||
| 27 | beforeAll(async () => { | |
| 28 | dir = await mkdtemp(join(tmpdir(), "forge-visible-")); | |
| 29 | app = createApp({ dataDir: dir, port: 0 }); | |
| 30 | servers = await serve(app); | |
| 31 | const r = await createUser(app.db, { | |
| 32 | origin: "seed", | |
| 33 | trusted: true, | |
| 34 | username: "alice", | |
| 35 | email: "[email protected]", | |
| 36 | password: "a-good-passphrase", | |
| 37 | adminIfFirst: true, | |
| 38 | }); | |
| 39 | if (!r.ok) throw new Error(r.error); | |
| 40 | admin = r.value; | |
| 41 | }, 60_000); | |
| 42 | ||
| 43 | afterAll(async () => { | |
| 44 | servers.stop(); | |
| 45 | app.close(); | |
| 46 | await rm(dir, { recursive: true, force: true }); | |
| 47 | }); | |
| 48 | ||
| 49 | const get = async (path: string) => | |
| 50 | ( | |
| 51 | await fetch(`${servers.url}${path}`, { | |
| 52 | headers: { cookie: `forge_session=${createSession(app.db, admin.id).token}`, accept: "text/html" }, | |
| 53 | }) | |
| 54 | ).text(); | |
| 55 | ||
| 56 | // The list item of each setting, from its key to the next row. | |
| 57 | const rowsOf = (page: string) => page.split("<li>").slice(1); | |
| 58 | ||
| 59 | test("every limit row shows name, value, default, bounds and a control, hover-free", async () => { | |
| 60 | const page = await get("/admin/limits"); | |
| 61 | const listed = listLimits(app, admin); | |
| 62 | if (!listed.ok) throw new Error("no limits"); | |
| 63 | expect(listed.value.limits.length).toBeGreaterThan(0); | |
| 64 | for (const l of listed.value.limits) { | |
| 65 | const row = rowsOf(page).find((r) => r.includes(`name="key" value="${l.key}"`)); | |
| 66 | expect(row, l.key).toBeDefined(); | |
| 67 | expect(row).toContain(l.label); | |
| 68 | expect(row).toContain(`Now <span class="mono">${l.value}</span>`); | |
| 69 | expect(row).toContain(`<span class="mono">${l.min}</span>`); | |
| 70 | expect(row).toContain(`<span class="mono">${l.max}</span>`); | |
| 71 | expect(row).toMatch(new RegExp(`<input[^>]*name="value"[^>]*value="${l.value}"`, "s")); | |
| 72 | expect(row).toContain("Save"); | |
| 73 | expect(row).not.toContain("row-actions"); | |
| 74 | } | |
| 75 | }); | |
| 76 | ||
| 77 | test("every capacity row shows its value and control, hover-free", async () => { | |
| 78 | const page = await get("/settings/capacity"); | |
| 79 | const listed = listCapacity(app, admin); | |
| 80 | if (!listed.ok) throw new Error("no capacity"); | |
| 81 | for (const s of listed.value.settings) { | |
| 82 | const row = rowsOf(page).find((r) => r.includes(`name="key" value="${s.key}"`)); | |
| 83 | expect(row, s.key).toBeDefined(); | |
| 84 | expect(row).toContain(s.label); | |
| 85 | expect(row).toContain("Now <span"); | |
| 86 | expect(row).not.toContain("row-actions"); | |
| 87 | } | |
| 88 | }); | |
| 89 | ||
| 90 | test("the quota, signup and invite forms are not hover-gated", async () => { | |
| 91 | const page = await get("/admin/limits"); | |
| 92 | for (const action of ["/admin/quotas", "/admin/signup", "/admin/invites"]) { | |
| 93 | const form = page.match(new RegExp(`<form[^>]*action="${action}"[^>]*>`))?.[0]; | |
| 94 | expect(form, action).toBeDefined(); | |
| 95 | expect(form).not.toContain("row-actions"); | |
| 96 | } | |
| 97 | }); | |
| 98 | ||
| 99 | test("the stylesheet never hides settings controls", () => { | |
| 100 | const rules = (css as string).split("}"); | |
| 101 | const mine = rules.filter((r) => /\.row-fields|\.setting-list/.test(r)); | |
| 102 | expect(mine.length).toBeGreaterThan(0); | |
| 103 | for (const r of mine) { | |
| 104 | expect(r).not.toMatch(/opacity:\s*0\b/); | |
| 105 | expect(r).not.toContain("content-visibility"); | |
| 106 | expect(r).not.toMatch(/visibility:\s*hidden/); | |
| 107 | } | |
| 108 | expect(mine.join("}")).toMatch(/\.setting-list \.row-meta[^}]*white-space:\s*normal/); | |
| 109 | }); |
new above
offline
Document
.row-fields/.setting-listin docs/DESIGN.md (list row component, the inventory, and the exception to checklist item 4 and the one-line.row-metarule).