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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9373-settings-crew-fits-a-390-px-phone-no-sid → main 2 commits fork
3 files
changed +108
−6
src/web/studio/crew.ts +3 −6
| @@ -52,7 +52,6 @@ import { | ||
| 52 | 52 | card, |
| 53 | 53 | empty, |
| 54 | 54 | field, |
| 55 | listCard, | |
| 56 | 55 | row, |
| 57 | 56 | selectField, |
| 58 | 57 | userLink, |
| @@ -273,8 +272,7 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { | ||
| 273 | 272 | note: "No agents exist on this forge yet. An admin can add one.", |
| 274 | 273 | }, |
| 275 | 274 | ) |
| 276 | : listCard( | |
| 277 | crew.map((c) => | |
| 275 | : html`<ul class="list setting-list">${crew.map((c) => | |
| 278 | 276 | row({ |
| 279 | 277 | lead: icon("bot"), |
| 280 | 278 | title: html`${userLink(c.agent.user.username, { |
| @@ -299,8 +297,7 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { | ||
| 299 | 297 | } |
| 300 | 298 | : {}), |
| 301 | 299 | }), |
| 302 | ), | |
| 303 | ); | |
| 300 | )}</ul>`; | |
| 304 | 301 | |
| 305 | 302 | // Whose money the crew spends here is the forge admin's to say. |
| 306 | 303 | const grants = |
| @@ -315,7 +312,7 @@ function crewCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html { | ||
| 315 | 312 | }); |
| 316 | 313 | })() |
| 317 | 314 | : null; |
| 318 | return html`<div id="crew"> | |
| 315 | return html`<div id="crew" data-crew-settings> | |
| 319 | 316 | ${teamCard(ctx, repo, base, admin, allListed.ok ? allListed.value : [])} |
| 320 | 317 | ${admin ? crewReadiness(ctx) : null} |
| 321 | 318 | ${card(html`${rows}`, { |
src/web/style/content.ts +2
| @@ -605,6 +605,8 @@ form.inline { display: inline; margin-left: auto; } | ||
| 605 | 605 | .reviews-chip .bad { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); } |
| 606 | 606 | |
| 607 | 607 | /* ---- 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; } | |
| 608 | 610 | /* The strip a repo home wears when it runs: state, commit, URL, previews, |
| 609 | 611 | and the way to the App page — one line, in the repo header's own voice. */ |
| 610 | 612 | .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 | }); |