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

Settings › People fits a 390 px phone: no sideways scroll, long usernames and role controls wrap #9399

Merged opened this

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

Merged

mason/forge:mason/9374-settings-people-fits-a-390-px-phone-no-s → main 4 commits fork 1 thread · 1 unresolved

  • Merged. mason/9374-settings-people-fits-a-390-px-phone-no-s was merged into main as 932b4ac.

Review

Sign in to review this change.
4 files changed +149 −5
docs/design/areas/settings.md +10
@@ -38,6 +38,16 @@ arguments in a `messageText()` block, with the Yes form beside the row (`after`)
3838
3939 Each seat row (`row`) holds one child row: its title says how much the seat may do at once ("2 builds at once", "Paused"), its meta says what it is doing and spending, and its trail holds a one-line form: a number `input input-sm` (the same small control as the Crew card's role select, in a `row-fields` form) and a small Set button. No new class. At phone width the row wraps as every row does; the form stays on one line because it is a number and a button.
4040
41 ## People fits a 390 px phone (#9374)
42
43 `/settings/people` is one table of four columns: account, role, allowance and the role form. On a phone it does not
44 fit beside the settings nav, so the table sits in the existing `.table-scroll` box and scrolls inside it, never the
45 page. The box carries the attribute `data-people` (not a class), which wraps long words anywhere
46 (`overflow-wrap: anywhere`, the rule shared with `data-milestone` in `src/web/style/content.ts`), so an unbroken
47 username shrinks its column instead of widening the table. The role form is a `.row-fields`, which already wraps its
48 input over its button. Pinned by `test/people-fit.test.ts`, with a 250-character username written straight to the
49 table, longer than the 100 a new account may take.
50
4151 ## The settings shell
4252
4353 `settingsLayout()` in `src/web/chrome.ts` draws §5's settings shell. A section may name a group (`accountSections()` in
src/web/pages/people.ts +8 −4
@@ -3,6 +3,8 @@
33 * An admin promotes a guest to member (with a starter model allowance, none
44 * by default) or demotes a member, in one same-origin form post. The guards
55 * refuse what `CAN` withholds; the card says so in `roleLimits` (`docs/ROLES.md`).
6 * On a phone the table scrolls inside its own `data-people` box and a long
7 * username wraps anywhere, so the page never scrolls sideways (#9374).
68 */
79
810 import { setRole } from "../../actions/roles.ts";
@@ -58,10 +60,12 @@ export function peoplePage(ctx: Ctx): Response {
5860 card(
5961 rows.length === 0
6062 ? empty({ icon: "users", title: "Nobody has joined yet." })
61 : html`<table class="table" id="people">
62 <thead><tr><th>Account</th><th>Role</th><th>Allowance</th><th></th></tr></thead>
63 <tbody>${rows.map(line)}</tbody>
64 </table>`,
63 : html`<div class="table-scroll" data-people>

Next: add role="region" tabindex="0" aria-label="People" here, matching src/web/pages/forges.ts:89, so the scrolling box is keyboard-reachable and announced.

64 <table class="table" id="people">
65 <thead><tr><th>Account</th><th>Role</th><th>Allowance</th><th></th></tr></thead>
66 <tbody>${rows.map(line)}</tbody>
67 </table>
68 </div>`,
6569 {
6670 head: html`<h2>Guests, members and admins</h2>
6771 <p class="muted">A member may be given a starter model allowance (up to
src/web/style/content.ts +1 −1
@@ -152,7 +152,7 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
152152 font-variant-numeric: tabular-nums; letter-spacing: 0.14em;
153153 text-align: center; user-select: all; word-break: break-all;
154154 }
155 [data-apps], [data-apps-page], [data-bet-card], [data-milestone], [data-now] { overflow-wrap: anywhere; }
155 [data-apps], [data-apps-page], [data-bet-card], [data-milestone], [data-now], [data-people] { overflow-wrap: anywhere; }
156156 [data-bet-card] h2, [data-bet-card] ul, [data-bet-card] p { margin-block: 8px; }
157157 [data-bet-card] ul { padding-inline-start: 16px; }
158158 [data-bet-card] { max-height: 80vh; overflow-x: hidden; overflow-y: auto; }
test/people-fit.test.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * Settings › People fits a 390 px phone (#9374): seeded with a 250-character
3 * unbroken username, a long one and many rows, the page fixes no width over
4 * 390 px, the usernames wrap anywhere, and the table — four columns and a role
5 * form — scrolls inside its own box, never the page.
6 */
7 import { afterAll, beforeAll, describe, 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 { createSession } from "../src/core/sessions.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { utilities as base } from "../src/web/style/base.ts";
16 import { components } from "../src/web/style/components.ts";
17 import { content } from "../src/web/style/content.ts";
18 import { findChromium } from "../src/looks/capture.ts";
19 import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21
22 quietLogs();
23
24 const WORD = "U".repeat(250);
25 const LONG = `bob-${"long-name.".repeat(9)}x`;
26 let dir: string;
27 let app: App;
28 let servers: Servers;
29 let page: string;
30 let session: { name: string; value: string };
31
32 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
33 if (!r.ok) throw new Error(JSON.stringify(r.error));
34 return r.value;
35 };
36
37 beforeAll(async () => {
38 dir = await mkdtemp(join(tmpdir(), "forge-people-fit-"));
39 app = createApp({ dataDir: dir, port: 0 });
40 servers = await serve(app);
41 const alice = must(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }));
42 const names = [LONG, ...Array.from({ length: 24 }, (_, n) => `guest${String(n).padStart(2, "0")}`)];
43 for (const [n, username] of names.entries()) {
44 must(await createUser(app.db, { origin: "seed", trusted: true, username, email: `p${n}@forge.local`, password: "a-good-passphrase" }));
45 }
46 // A username caps at 100 characters; an older row may hold more, and the
47 // page must still fit, so the longest is written straight to the table.
48 const widest = must(await createUser(app.db, { origin: "seed", trusted: true, username: "widest", email: "[email protected]", password: "a-good-passphrase" }));
49 app.db.query("UPDATE user SET username = ?1 WHERE id = ?2").run(WORD, widest.id);
50 // One member with an allowance, so every column shows its widest words.
51 const at = `/settings/people/${LONG}/role`;
52 const token = createSession(app.db, alice.id).token;
53 const cookie = `forge_session=${token}`;
54 await fetch(`${servers.url}${at}`, { method: "POST", headers: { cookie, origin: servers.url }, body: new URLSearchParams({ role: "member", usd: "12.50" }), redirect: "manual" });
55 session = { name: "forge_session", value: token };
56 page = await (await fetch(`${servers.url}/settings/people`, { headers: { cookie } })).text();
57 }, 60_000);
58 afterAll(async () => {
59 servers.stop();
60 app.close();
61 await rm(dir, { recursive: true, force: true });
62 });
63
64 describe("Settings › People at 390 px", () => {
65 test("the page renders the seeded long content in many rows", () => {
66 expect(page).toContain(WORD);
67 expect(page).toContain(LONG);
68 expect(page.match(/<tr>/g)!.length).toBeGreaterThan(20);
69 expect(page).toContain("Make member");
70 expect(page).toContain("Make guest");
71 });
72
73 test("no inline width, and no rule the page ships fixes a width over 390 px", () => {
74 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s"])(?:min-)?width\s*:/i);
75 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
76 const over: string[] = [];
77 for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) {
78 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
79 if (Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16) > 390) over.push(m[0]);
80 }
81 }
82 expect(over).toEqual([]);
83 });
84
85 test("the table scrolls inside its own box, and its text wraps anywhere", () => {
86 // The table sits in a `.table-scroll` box carrying `data-people`.
87 expect(page).toMatch(/<div class="table-scroll" data-people>\s*<table class="table" id="people">/);
88 expect(components).toMatch(/\.table-scroll\s*\{[^}]*overflow-x:\s*auto/);
89 const rule = content.match(/(?:^|\n)([^\n{]*\[data-people\][^\n{]*)\{([^}]*)\}/);
90 expect(rule).not.toBeNull();
91 expect(rule![1]).toMatch(/(?:^|,)\s*\[data-people\]\s*(?:,|$)/);
92 expect(rule![2]).toMatch(/overflow-wrap:\s*(?:anywhere|break-word)/);
93 // The role form wraps its input over its button rather than widen the row.
94 expect(components).toMatch(/\.row-fields\s*\{[^}]*flex-wrap:\s*wrap/);
95 });
96 });
97
98 /**
99 * Measured, not read from the CSS: the page in a real Chromium at 390x844
100 * does not scroll sideways. Skipped where no Chromium starts; the forge's
101 * check runs it and logs the numbers.
102 */
103 const found = findChromium();
104 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
105 if (chromium === undefined) console.log("SKIPPING: no chromium that starts on this machine, so the people phone look is not measured");
106
107 describe.skipIf(chromium === undefined)("Settings › People, measured at 390x844", () => {
108 const measure = `(() => {
109 const vw = document.documentElement.clientWidth;
110 const box = document.querySelector("[data-people]");
111 const names = [...document.querySelectorAll("#people td:first-child")];
112 return JSON.stringify({
113 scrollWidth: document.documentElement.scrollWidth, innerWidth: window.innerWidth, viewportWidth: vw,
114 boxRight: box ? Math.round(box.getBoundingClientRect().right) : -1,
115 widestName: Math.round(Math.max(0, ...names.map((td) => td.getBoundingClientRect().width))),
116 rows: names.length,
117 });
118 })()`;
119 type Measured = { scrollWidth: number; innerWidth: number; viewportWidth: number; boxRight: number; widestName: number; rows: number };
120
121 test("no sideways scroll; the table's box ends inside the screen", async () => {
122 const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}/settings/people`, { width: 390, height: 844 }, [session], measure)) as Measured;
123 console.log(`people 390x844: ${JSON.stringify(m)}`);
124 expect(m.rows).toBeGreaterThan(20);
125 expect(m.boxRight).toBeGreaterThan(0);
126 expect(m.boxRight).toBeLessThanOrEqual(m.viewportWidth);
127 expect(m.widestName).toBeLessThan(m.viewportWidth);
128 expect(m.scrollWidth).toBeLessThanOrEqual(m.innerWidth);
129 }, 30_000);
130 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs