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

The /now/:stage pages fit a 390 px phone: no sideways scroll, long titles and run names wrap #9393

Merged opened this

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

Merged

mason/forge:mason/9377-the-now-stage-pages-fit-a-390-px-phone-n → main 2 commits fork

  • Merged. mason/9377-the-now-stage-pages-fit-a-390-px-phone-n was merged into main as 2ae49a6.

Review

Sign in to review this change.
3 files changed +148 −14
src/web/pages/now.ts +16 −13
@@ -11,7 +11,7 @@ import type { Ctx } from "../../http/context.ts";
1111 import { notFoundPage, signInFirst } from "../chrome.ts";
1212 import { html } from "../html.ts";
1313 import { page } from "../layout.ts";
14 import { empty, listCard, row, timeAgo } from "../ui.ts";
14 import { card, empty, row, timeAgo } from "../ui.ts";
1515
1616 /** Where a word of the Now line goes. */
1717 export const nowHref = (stage: NowStage): string => `/now/${stage}`;
@@ -28,23 +28,26 @@ export function nowPage(ctx: Ctx): Response {
2828 const body =
2929 total === 0
3030 ? empty({ icon: "activity", title: `Nothing in ${NOW_WORDS[stage].toLowerCase()} right now`, note: NOW_HOLDS[stage] })
31 : listCard(
32 rows.map((r) =>
33 row({
34 href: r.href,
35 nav: true,
36 title: html`<span class="truncate">${r.title}</span>`,
37 meta: html`${r.repo} · ${r.note}`,
38 trail: html`<span class="muted small">${timeAgo(r.at)}</span>`,
39 }),
40 ),
31 : card(
32 html`<ul class="list setting-list">
33 ${rows.map((r) =>
34 row({
35 href: r.href,
36 nav: true,
37 title: html`${r.title}`,
38 meta: html`${r.repo} · ${r.note}`,
39 trail: html`<span class="muted small">${timeAgo(r.at)}</span>`,
40 }),
41 )}
42 </ul>`,
43 { pad: false },
4144 );
4245 notePhase(ctx.request, "render", performance.now() - reading);
4346 return page({
4447 ctx,
4548 title: NOW_WORDS[stage],
46 body: html`<div class="page-head"><h1>${NOW_WORDS[stage]}</h1></div>
49 body: html`<div data-now><div class="page-head"><h1>${NOW_WORDS[stage]}</h1></div>
4750 <p class="muted small" id="now-count"><strong>${String(total)}</strong> · ${NOW_HOLDS[stage]}${total > rows.length ? ` Showing the newest ${NOW_LISTED}.` : ""}</p>
48 ${body}`,
51 ${body}</div>`,
4952 });
5053 }
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-bet-card], [data-milestone] { overflow-wrap: anywhere; }
155 [data-apps], [data-bet-card], [data-milestone], [data-now] { 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/now-fit.test.ts added +131
@@ -0,0 +1,131 @@
1 /**
2 * The /now/:stage pages fit a 390 px phone (#9377): seeded with a 250-character
3 * unbroken title and many rows, no stage fixes a width over 390 px, and long
4 * words 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 { createRepo } from "../src/actions/repos.ts";
12 import { createIssue } from "../src/core/issues.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { NOW_STAGES, type NowStage } from "../src/flow/now_rows.ts";
17 import { utilities as base } from "../src/web/style/base.ts";
18 import { components } from "../src/web/style/components.ts";
19 import { content } from "../src/web/style/content.ts";
20 import { findChromium } from "../src/looks/capture.ts";
21 import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts";
22 import { promote } from "./helpers/members.ts";
23
24 const WORD = "T".repeat(250);
25 let dir: string;
26 let app: App;
27 let servers: Servers;
28 let session: { name: string; value: string };
29 const pages = {} as Record<NowStage, string>;
30
31 beforeAll(async () => {
32 dir = await mkdtemp(join(tmpdir(), "forge-now-fit-"));
33 app = createApp({ dataDir: dir, port: 0 });
34 servers = await serve(app);
35 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "root", allowReserved: true, email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
36 if (!made.ok) throw new Error(made.error);
37 promote(app.db, made.value);
38 const token = createSession(app.db, made.value.id).token;
39 session = { name: "forge_session", value: token };
40 const alice = getUserByName(app.db, "root")!;
41 const r = await createRepo(app, alice, { name: "fit", visibility: "public" });
42 if (!r.ok) throw new Error(String(r.error));
43 const pull = (title: string) => {
44 const i = createIssue(app.db, { repoId: r.value.id, authorId: alice.id, title, kind: "pull" });
45 if (!i.ok) throw new Error(i.error);
46 app.db.query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', ?, 'b', 'h')").run(i.value.id, `h-${crypto.randomUUID()}`);
47 return i.value;
48 };
49 const run = (kind: string, state: string, issueId: string | null, extra: { reason?: string; env?: string; serving?: boolean } = {}) =>
50 app.db
51 .query("INSERT INTO run (id, repo_id, kind, issue_id, env, state, reason, serving_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
52 .run(crypto.randomUUID(), r.value.id, kind, issueId, extra.env ?? "", state, extra.reason ?? "", extra.serving ? new Date().toISOString() : null);
53 for (let n = 0; n < 12; n++) {
54 const title = n === 0 ? WORD : n === 1 ? "A long and winding pull title ".repeat(8) : `Pull ${n} ${WORD.slice(0, 200)}`;
55 const p = pull(title);
56 run("check", n % 2 ? "queued" : "running", p.id);
57 run("agent", "running", p.id, { reason: "review" });
58 app.db.query("INSERT INTO review (id, issue_id, author_id, state) VALUES (?, ?, ?, 'approved')").run(crypto.randomUUID(), p.id, alice.id);
59 run("deploy", "ok", null, { env: "production", serving: true });
60 }
61 for (const stage of NOW_STAGES) pages[stage] = await (await fetch(`${servers.url}/now/${stage}`, { headers: { cookie: `forge_session=${token}` } })).text();
62 }, 30_000);
63 afterAll(async () => {
64 servers.stop();
65 app.close();
66 await rm(dir, { recursive: true, force: true });
67 });
68
69 describe("the /now/:stage pages at 390 px", () => {
70 test("every stage renders many rows, the long ones carrying the seeded word", () => {
71 for (const stage of NOW_STAGES) {
72 expect(pages[stage].match(/<li/g)!.length).toBeGreaterThan(5);
73 expect(pages[stage]).toContain("data-now");
74 }
75 for (const stage of ["check", "review", "land"] as const) expect(pages[stage]).toContain(WORD);
76 });
77
78 test("no inline width, and no rule the page ships fixes a width over 390 px", () => {
79 for (const stage of NOW_STAGES) {
80 const page = pages[stage];
81 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s"])(?:min-)?width\s*:/i);
82 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
83 const over: string[] = [];
84 for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) {
85 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
86 const px = Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16);
87 if (px > 390) over.push(m[0]);
88 }
89 }
90 expect(over).toEqual([]);
91 }
92 });
93
94 test("the list wraps its rows instead of truncating a long title", () => {
95 for (const stage of NOW_STAGES) {
96 expect(pages[stage]).toContain('<ul class="list setting-list">');
97 expect(pages[stage]).not.toContain('class="truncate"');
98 }
99 });
100
101 test("long-text containers wrap anywhere", () => {
102 const rule = content.match(/(?:^|\n)([^\n{]*\[data-now\][^\n{]*)\{([^}]*)\}/);
103 expect(rule).not.toBeNull();
104 expect(rule![1]).toMatch(/(?:^|,)\s*\[data-now\]\s*(?:,|$)/);
105 expect(rule![2]).toMatch(/overflow-wrap:\s*(?:anywhere|break-word)/);
106 expect(components).toMatch(/\.setting-list \.row-title[^{]*\{[^}]*overflow-wrap:\s*anywhere/);
107 });
108 });
109
110 const found = findChromium();
111 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
112 if (chromium === undefined) console.log("SKIPPING: no chromium that starts on this machine, so the now phone look is not measured");
113
114 describe.skipIf(chromium === undefined)("the /now/:stage pages, measured at 390x844", () => {
115 const measure = `(() => {
116 const vw = document.documentElement.clientWidth;
117 const scope = [...document.querySelectorAll("[data-now], [data-now] *")];
118 const overflowing = scope.filter((el) => el.getBoundingClientRect().right > vw + 1).slice(0, 8).map((el) => el.tagName.toLowerCase());
119 return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, wrapped: scope.length > 0, overflowing, rows: document.querySelectorAll("[data-now] li").length });
120 })()`;
121 for (const stage of NOW_STAGES) {
122 test(`${stage}: no sideways scroll`, async () => {
123 const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}/now/${stage}`, { width: 390, height: 844 }, [session], measure)) as { scrollWidth: number; viewportWidth: number; wrapped: boolean; overflowing: string[]; rows: number };
124 console.log(`now ${stage} 390x844: ${JSON.stringify(m)}`);
125 expect(m.wrapped).toBe(true);
126 expect(m.rows).toBeGreaterThan(5);
127 expect(m.overflowing).toEqual([]);
128 expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth);
129 }, 30_000);
130 }
131 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs