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

The /forges and /forges/new pages fit a 390 px phone: no sideways scroll, the form's fields and long forge names wrap #9613

Merged opened this

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

Merged

scout/forge:scout/9372-the-forges-and-forges-new-pages-fit-a-39 → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/9372-the-forges-and-forges-new-pages-fit-a-39 was merged into main as 10ef8b9.

Review

Sign in to review this change.
4 files changed +127 −8
src/web/pages/forges.ts +4 −4
@@ -142,7 +142,7 @@ export async function forgesPage(ctx: Ctx): Promise<Response> {
142142 const response = page({
143143 ctx,
144144 title: "Forges you sent",
145 body: html`<div class="narrow-wide">
145 body: html`<div class="narrow-wide" data-forges>
146146 ${pageHead("Forges you sent", {
147147 sub: "For whom each is, what it was given, and whether its person has claimed it.",
148148 actions: action,
@@ -184,7 +184,7 @@ export async function forgeLinkSubmit(ctx: Ctx): Promise<Response> {
184184 {
185185 ctx,
186186 title: "A fresh link",
187 body: html`<div class="narrow-wide">
187 body: html`<div class="narrow-wide" data-forges>
188188 ${pageHead(typeof link === "string" ? `A fresh link for ${name}` : `No fresh link for ${name}`)}
189189 ${card(
190190 typeof link === "string"
@@ -212,7 +212,7 @@ function manageSubmit(act: (app: Ctx["app"], name: string) => Promise<Managed>,
212212 {
213213 ctx,
214214 title,
215 body: html`<div class="narrow-wide">
215 body: html`<div class="narrow-wide" data-forges>
216216 ${pageHead(title)}
217217 ${card(html`<p role="status">${done.says}</p><p class="mt">${btn("Back to your forges", { href: "/forges", size: "sm" })}</p>`)}
218218 </div>`,
@@ -236,7 +236,7 @@ export async function forgeRemoveConfirm(ctx: Ctx): Promise<Response> {
236236 const response = page({
237237 ctx,
238238 title: `Remove ${name}?`,
239 body: html`<div class="narrow-wide">
239 body: html`<div class="narrow-wide" data-forges>
240240 ${pageHead(`Remove ${name}?`)}
241241 ${card(html`<p>This deletes the forge <strong>${name}</strong> at ${child.url.replace(/^https?:\/\//, "")} with everything in it: its repositories, issues, accounts and settings. It cannot be undone.</p>
242242 <form method="post" action="/forges/${name}/remove" class="mt">
src/web/pages/new-forge.ts +3 −3
@@ -139,7 +139,7 @@ function formView(
139139 ): Html {
140140 const { full, quota, repos, crew } = reads;
141141 if (full)
142 return html`<div class="narrow-wide">
142 return html`<div class="narrow-wide" data-forges>
143143 ${pageHead("New forge")}
144144 ${card(
145145 html`<p role="status">This forge cannot hold another child: ${full}.</p>
@@ -149,7 +149,7 @@ function formView(
149149 )}
150150 </div>`;
151151 const anon = user === undefined;
152 return html`<div class="narrow-wide">
152 return html`<div class="narrow-wide" data-forges>
153153 ${pageHead("New forge", {
154154 sub: anon
155155 ? "Your own forge: pick a name, and the public apps to start from. Whoever opens the claim link becomes its admin."
@@ -389,7 +389,7 @@ function madeView(body: Record<string, unknown>): Html {
389389 const link = body["claimUrl"];
390390 const note = body["keyNote"];
391391 const name = String(body["name"] ?? "");
392 return html`<div class="narrow-wide">
392 return html`<div class="narrow-wide" data-forges>
393393 ${pageHead(
394394 body["existed"] === true ? `${name} already exists` : `${name} is ready`,
395395 )}
src/web/style/content.ts +8 −1
@@ -158,11 +158,18 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
158158 [data-tell] .page-head-sub { flex-wrap: wrap; }
159159 [data-tell] .comment-body img { max-width: 100%; height: auto; }
160160 [data-tell] pre { max-width: 100%; overflow-x: auto; }
161 [data-apps], [data-apps-page], [data-bet-card], [data-explore], [data-family], [data-milestone], [data-now], [data-people] { overflow-wrap: anywhere; }
161 [data-apps], [data-apps-page], [data-bet-card], [data-explore], [data-family], [data-forges], [data-milestone], [data-now], [data-people] { overflow-wrap: anywhere; }
162162 /* /explore (#9370): rows wrap rather than truncate a long name or description. */
163163 [data-explore] .row-title, [data-explore] .row-meta { white-space: normal; overflow: visible; }
164164 [data-explore] .row { flex-wrap: wrap; }
165165 [data-explore] .row-main { flex: 1 1 14rem; }
166 /* The forges pages (#9372): a row's trail of badges and buttons drops under its text on a phone and wraps. */
167 [data-forges] .row-title, [data-forges] .row-meta { white-space: normal; overflow: visible; }
outdated

Add the .row-title rule here: [data-forges] .row-title { white-space: normal; overflow: visible; }. Without it the long forge name is ellipsized, not wrapped.

168 [data-forges] .row-trail { flex-wrap: wrap; }
169 @media (max-width: 719px) {
170 [data-forges] .row { flex-wrap: wrap; }
171 [data-forges] .row-trail { flex: 1 1 100%; justify-content: flex-start; }
172 }
166173 [data-bet-card] h2, [data-bet-card] ul, [data-bet-card] p { margin-block: 8px; }
167174 [data-bet-card] ul { padding-inline-start: 16px; }
168175 [data-bet-card] { max-height: 80vh; overflow-x: hidden; overflow-y: auto; }
test/forges-fit.test.ts added +112
@@ -0,0 +1,112 @@
1 /**
2 * The forges pages fit a 390 px phone (#9372): /forges seeded with a
3 * 250-character unbroken name, "for whom" and app list and many rows, and
4 * /forges/new with a long name in its field, fix no width over 390 px, wrap
5 * long words, and (measured in Chromium where one runs) do not scroll sideways.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdir, 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 { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { utilities as base } from "../src/web/style/base.ts";
15 import { components } from "../src/web/style/components.ts";
16 import { content } from "../src/web/style/content.ts";
17 import { findChromium } from "../src/looks/capture.ts";
18 import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts";
19
20 const WORD = "w".repeat(250);
21 let dir: string;
22 let app: App;
23 let servers: Servers;
24 let list: string;
25 let form: string;
26 let session: { name: string; value: string };
27
28 beforeAll(async () => {
29 dir = await mkdtemp(join(tmpdir(), "forge-forges-fit-"));
30 app = createApp({ dataDir: dir, port: 0 });
31 servers = await serve(app);
32 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
33 if (!made.ok) throw new Error(made.error);
34 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]!;
35 session = { name: cookie.slice(0, cookie.indexOf("=")), value: cookie.slice(cookie.indexOf("=") + 1) };
36 await mkdir(join(dir, "spawn"), { recursive: true });
37 for (let n = 0; n < 25; n++) {
38 const name = n === 0 ? "n".repeat(240) : `forge-${n}`;
39 await Bun.write(
40 join(dir, "spawn", `${name}.json`),
41 JSON.stringify({ name, bodyId: `b${n}`, url: "", planHash: "x", done: n % 2 === 0, forWhom: n < 3 ? WORD : `Client ${n} with a rather long and winding description `.repeat(2), apps: n < 3 ? [WORD, `alice/${WORD}`] : ["alice/notes", "alice/todo"], createdAt: new Date().toISOString() }),
42 );
43 }
44 const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
45 list = await get("/forges");
46 form = await get(`/forges/new?name=${"n".repeat(38)}`);
47 }, 30_000);
48 afterAll(async () => {
49 servers.stop();
50 app.close();
51 await rm(dir, { recursive: true, force: true });
52 });
53
54 describe("the forges pages at 390 px", () => {
55 test("both pages render the seeded long content", () => {
56 expect(list).toContain(WORD);
57 expect(list).toContain("n".repeat(240));
58 expect(list.match(/data-forge="/g)!.length).toBeGreaterThan(20);
59 expect(form).toContain("n".repeat(38));
60 expect(list).toContain("data-forges");
61 expect(form).toContain("data-forges");
62 });
63
64 test("no inline width, and no rule the page ships fixes a width over 390 px", () => {
65 for (const page of [list, form]) {
66 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s"])(?:min-)?width\s*:/i);
67 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
68 const over: string[] = [];
69 for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) {
70 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
71 const px = Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16);
72 if (px > 390) over.push(m[0]);
73 }
74 }
75 expect(over).toEqual([]);
76 }
77 });
78
79 test("long-text containers wrap anywhere, and the table scrolls in its own box", () => {
80 const rule = content.match(/(?:^|\n)([^\n{]*\[data-forges\][^\n{]*)\{([^}]*)\}/);
81 expect(rule).not.toBeNull();
82 expect(rule![1]).toMatch(/(?:^|,)\s*\[data-forges\]\s*(?:,|$)/);
83 expect(rule![2]).toMatch(/overflow-wrap:\s*(?:anywhere|break-word)/);
84 expect(content).toMatch(/\[data-forges\] \.row-trail\s*\{[^}]*flex-wrap:\s*wrap/);

Also assert the [data-forges] .row-title rule sets white-space: normal, so the test fails if names truncate again.

85 expect(content).toMatch(/\[data-forges\] \.row-title[^{]*\{[^}]*white-space:\s*normal[^}]*overflow:\s*visible/);
86 expect(list).toContain('class="table-scroll"');
87 expect(components).toMatch(/\.table-scroll\s*\{[^}]*overflow-x:\s*auto/);
88 });
89 });
90
91 const found = findChromium();
92 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
93 if (chromium === undefined) console.log("SKIPPING: no chromium that starts on this machine, so the forges phone look is not measured");
94
95 describe.skipIf(chromium === undefined)("the forges pages, measured at 390x844", () => {
96 const measure = `(() => {
97 const vw = document.documentElement.clientWidth;
98 const scope = [...document.querySelectorAll("[data-forges], [data-forges] *")].filter((el) => !el.closest(".table-scroll"));
99 const overflowing = scope.filter((el) => el.getBoundingClientRect().right > vw + 1).slice(0, 8).map((el) => el.tagName.toLowerCase() + "." + el.className);
100 return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, scoped: scope.length, overflowing });
101 })()`;
102 type Measured = { scrollWidth: number; viewportWidth: number; scoped: number; overflowing: string[] };
103 for (const path of ["/forges", "/forges/new?name=" + "n".repeat(38)]) {
104 test(`${path.split("?")[0]}: no sideways scroll`, async () => {
105 const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}${path}`, { width: 390, height: 844 }, [session], measure)) as Measured;
106 console.log(`forges ${path.split("?")[0]} 390x844: ${JSON.stringify(m)}`);
107 expect(m.scoped).toBeGreaterThan(0);
108 expect(m.overflowing).toEqual([]);
109 expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth);
110 }, 30_000);
111 }
112 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs