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

Settings › Crew fits a 390 px phone: no sideways scroll, each agent's row and its actions wrap #9615

Merged opened this

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

Merged

scout/forge:scout/9373-settings-crew-fits-a-390-px-phone-no-sid → main 2 commits fork

  • Merged. scout/9373-settings-crew-fits-a-390-px-phone-no-sid was merged into main as 803d2bd.

Review

Sign in to review this change.
3 files changed +108 −6
src/web/studio/crew.ts +3 −6
@@ -52,7 +52,6 @@ import {
5252 card,
5353 empty,
5454 field,
55 listCard,
5655 row,
5756 selectField,
5857 userLink,
@@ -273,8 +272,7 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html {
273272 note: "No agents exist on this forge yet. An admin can add one.",
274273 },
275274 )
276 : listCard(
277 crew.map((c) =>
275 : html`<ul class="list setting-list">${crew.map((c) =>
278276 row({
279277 lead: icon("bot"),
280278 title: html`${userLink(c.agent.user.username, {
@@ -299,8 +297,7 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html {
299297 }
300298 : {}),
301299 }),
302 ),
303 );
300 )}</ul>`;
304301
305302 // Whose money the crew spends here is the forge admin's to say.
306303 const grants =
@@ -315,7 +312,7 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html {
315312 });
316313 })()
317314 : null;
318 return html`<div id="crew">
315 return html`<div id="crew" data-crew-settings>
319316 ${teamCard(ctx, repo, base, admin, allListed.ok ? allListed.value : [])}
320317 ${admin ? crewReadiness(ctx) : null}
321318 ${card(html`${rows}`, {
src/web/style/content.ts +2
@@ -605,6 +605,8 @@ form.inline { display: inline; margin-left: auto; }
605605 .reviews-chip .bad { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); }
606606
607607 /* ---- apps, runs, and the crew ---- */
608 /* Settings › Crew on a phone (#9373): a long name or brief breaks, never widens the page. */
609 [data-crew-settings] { overflow-wrap: anywhere; min-width: 0; }
608610 /* The strip a repo home wears when it runs: state, commit, URL, previews,
609611 and the way to the App page — one line, in the repo header's own voice. */
610612 .app-strip {
test/crew-settings-fit.test.ts added +103
@@ -0,0 +1,103 @@
1 /**
2 * Settings › Crew fits a 390 px phone (#9373): seeded with agents whose name
3 * is long and whose brief holds a 250-character unbroken word, the page fixes
4 * no width over 390 px and its rows and long text wrap.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { addToCrew, createAgent } from "../src/actions/crew.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { createUser, getUserByName } 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
21 const WORD = "B".repeat(250);
22 let dir: string;
23 let app: App;
24 let servers: Servers;
25 let page: string;
26 let session: { name: string; value: string };
27 const PATH = "/alice/fit/settings";
28
29 beforeAll(async () => {
30 dir = await mkdtemp(join(tmpdir(), "forge-crew-fit-"));
31 app = createApp({ dataDir: dir, port: 0 });
32 servers = await serve(app);
33 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
34 if (!made.ok) throw new Error(made.error);
35 const cookie = ((await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), redirect: "manual" })).headers.get("set-cookie") ?? "").split(";")[0]!;
36 const alice = getUserByName(app.db, "alice")!;
37 const r = await createRepo(app, alice, { name: "fit", visibility: "public" });
38 if (!r.ok) throw new Error(String(r.error));
39 for (let n = 0; n < 12; n++) {
40 const name = n === 0 ? "a".repeat(39) : `agent-${n}`;
41 const a = await createAgent(app, alice, { username: name, model: "", brief: n < 3 ? WORD : "A long and winding brief about who this agent is. ".repeat(4) });
42 if (!a.ok) throw new Error(String(a.error));
43 const added = addToCrew(app, alice, r.value, name);
44 if (!added.ok) throw new Error(String(added.error));
45 }
46 session = { name: cookie.slice(0, cookie.indexOf("=")), value: cookie.slice(cookie.indexOf("=") + 1) };
47 page = await (await fetch(`${servers.url}${PATH}`, { headers: { cookie } })).text();
48 }, 30_000);
49 afterAll(async () => {
50 servers.stop();
51 app.close();
52 await rm(dir, { recursive: true, force: true });
53 });
54
55 describe("Settings › Crew at 390 px", () => {
56 test("the page renders the seeded crew", () => {
57 expect(page).toContain(WORD);
58 expect(page).toContain("data-crew-settings");
59 expect(page.match(/action="[^"]*\/settings\/crew\/[^"]*\/remove"/g)!.length).toBeGreaterThanOrEqual(12);
60 });
61
62 test("no inline width, and no rule the page ships fixes a width over 390 px", () => {
63 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s"])(?:min-)?width\s*:/i);
64 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
65 const over: string[] = [];
66 for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) {
67 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
68 const px = Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16);
69 if (px > 390) over.push(m[0]);
70 }
71 }
72 expect(over).toEqual([]);
73 });
74
75 test("the crew list wraps its rows, and its container wraps anywhere", () => {
76 const crew = page.slice(page.indexOf('id="crew"'));
77 expect(crew).toContain('<ul class="list setting-list">');
78 const rule = content.match(/(?:^|\n)([^\n{]*\[data-crew-settings\][^\n{]*)\{([^}]*)\}/);
79 expect(rule).not.toBeNull();
80 expect(rule![2]).toMatch(/overflow-wrap:\s*(?:anywhere|break-word)/);
81 expect(components).toMatch(/\.setting-list \.row-title[^{]*\{[^}]*overflow-wrap:\s*anywhere/);
82 });
83 });
84
85 const found = findChromium();
86 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
87 if (chromium === undefined) console.log("SKIPPING: no chromium that starts on this machine, so the crew settings phone look is not measured");
88
89 describe.skipIf(chromium === undefined)("Settings › Crew, measured at 390x844", () => {
90 const measure = `(() => {
91 const vw = document.documentElement.clientWidth;
92 const scope = [...document.querySelectorAll("[data-crew-settings], [data-crew-settings] *")];
93 const overflowing = scope.filter((el) => el.getBoundingClientRect().right > vw + 1).slice(0, 8).map((el) => el.tagName.toLowerCase());
94 return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, scoped: scope.length, overflowing });
95 })()`;
96 test("no sideways scroll", async () => {
97 const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}${PATH}`, { width: 390, height: 844 }, [session], measure)) as { scrollWidth: number; viewportWidth: number; scoped: number; overflowing: string[] };
98 console.log(`crew settings 390x844: ${JSON.stringify(m)}`);
99 expect(m.scoped).toBeGreaterThan(0);
100 expect(m.overflowing).toEqual([]);
101 expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth);
102 }, 30_000);
103 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs