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

Settings, limits: every option is visible without hovering, at every width #2909

Merged opened this in answer to

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

Merged

scout/forge:scout/2908-settings-limits-every-option-is-visible → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/2908-settings-limits-every-option-is-visible was merged into main as c5c676d.

Review

Sign in to review this change.
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 file
src/web/pages/admin-limits.ts +7 −7
@@ -29,14 +29,14 @@ export function limitsPage(ctx: Ctx): Response {
2929 row({
3030 lead: icon("activity"),
3131 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
3333 ? html`Set here; the default is
3434 <span class="mono">${String(l.defaultValue)}</span>.`
3535 : html`The default.`}
3636 Whole number, ${l.unit}, from
3737 <span class="mono">${String(l.min)}</span> to
3838 <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">
4040 <input type="hidden" name="key" value="${l.key}" />
4141 <input
4242 class="input mono"
@@ -93,7 +93,7 @@ export function limitsPage(ctx: Ctx): Response {
9393 repositories plus app data). Empty is no limit; a forge admin is
9494 never held to any of them.
9595 </p>
96 <form class="row-actions" method="post" action="/admin/quotas">
96 <form class="row-fields" method="post" action="/admin/quotas">
9797 ${QUOTA_KEYS.map(
9898 (k) => html`<label class="small">${k === "storage" ? "storage (MB)" : k}
9999 <input
@@ -132,7 +132,7 @@ export function limitsPage(ctx: Ctx): Response {
132132 <p class="muted small">
133133 Who may sign up: open, invite or closed. Nothing reads this yet.
134134 </p>
135 <form class="row-actions" method="post" action="/admin/signup">
135 <form class="row-fields" method="post" action="/admin/signup">
136136 <label class="small">signup
137137 <select class="input mono" name="signup">
138138 ${SIGNUP_MODES.map(
@@ -160,7 +160,7 @@ export function limitsPage(ctx: Ctx): Response {
160160 meta: html`Made ${time(i.createdAt)} · expires ${time(i.expiresAt)}`,
161161 trail:
162162 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">
164164 ${btn("Revoke", { size: "sm", label: `Revoke invite ${i.id}` })}
165165 </form>`
166166 : html``,
@@ -172,7 +172,7 @@ export function limitsPage(ctx: Ctx): Response {
172172 A link is shown once, when it is made, and is good for one signup.
173173 Nothing reads these yet.
174174 </p>
175 <form class="row-actions" method="post" action="/admin/invites">
175 <form class="row-fields" method="post" action="/admin/invites">
176176 <label class="small">expires in (days)
177177 <input
178178 class="input mono"
@@ -203,7 +203,7 @@ export function limitsPage(ctx: Ctx): Response {
203203 the next request that meets the limit; nothing needs a restart.
204204 </p>
205205 </div>
206 <ul class="list">
206 <ul class="list setting-list">
207207 ${rows}
208208 </ul>`,
209209 {
src/web/pages/capacity.ts +3 −3
@@ -59,13 +59,13 @@ export function capacityPage(ctx: Ctx): Response {
5959 row({
6060 lead: icon("activity"),
6161 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"
6363 ? html`Set here; the boot default is
6464 <span class="mono">${s.default === "" ? "auto" : s.default}</span>
6565 (<span class="mono">${s.env}</span>).`
6666 : html`The boot default, from <span class="mono">${s.env}</span>.`}`,
6767 trail: html`<form
68 class="row-actions"
68 class="row-fields"
6969 method="post"
7070 action="/settings/capacity"
7171 >
@@ -146,7 +146,7 @@ export function capacityPage(ctx: Ctx): Response {
146146 and never needs a restart.
147147 </p>
148148 </div>
149 <ul class="list">
149 <ul class="list setting-list">
150150 ${rows}
151151 </ul>`,
152152 {
src/web/style/components.ts +9
@@ -324,6 +324,15 @@ a.row:hover .row-title { color: var(--accent); }
324324 /* Secondary actions are always in the DOM, revealed without layout shift. */
325325 .row-actions { opacity: 0; transition: opacity 120ms var(--ease); }
326326 .row:hover .row-actions, .row:focus-within .row-actions { opacity: 1; }
327 /* Settings controls are the point of their row, never revealed by hover:

Document .row-fields / .setting-list in docs/DESIGN.md (list row component, the inventory, and the exception to checklist item 4 and the one-line .row-meta rule).

328 they wrap under the text before they would clip, at any width. */

opacity: 1 is redundant here; nothing hides this class. Remove it.

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.

327336 @media (pointer: coarse) { .row-actions { opacity: 1; } .row { min-height: 44px; } }
328337 .row[data-state="closed"] .row-title, .row[data-state="merged"] .row-title { color: var(--muted-fg); }
329338
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs