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

The commits list and a commit's page fit a 390 px phone: messages wrap, a diff scrolls inside its box, never the page #9361

Merged opened this

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

Merged

mason/forge:mason/9348-the-commits-list-and-a-commit-s-page-fit → main 4 commits fork

  • Merged. mason/9348-the-commits-list-and-a-commit-s-page-fit was merged into main as 0312fcc.

Review

Sign in to review this change.
4 files changed +136 −3
docs/design/areas/diff.md +2
@@ -23,3 +23,5 @@ file.
2323 - `.wd` — a changed word inside an added or deleted line, underlined on a stronger tint.
2424 - `.tok-inserted` — an inserted token in highlighted code, in the add colour.
2525 - `.tok-deleted` — a deleted token in highlighted code, in the delete colour.
26
27 The commits list and a commit's page (#9348) add no class: inside `[data-commits]` (an attribute, `COMMITS_CSS` in `src/web/pages/commits.ts`) a row's subject and author wrap, a long word breaks anywhere, and the diff scrolls inside its own `.diff-body`.
src/spawn/preview-looks.ts +4
@@ -53,6 +53,9 @@ export const PREVIEW_ISSUES_PAGE = `${PREVIEW_REPO_HOME}/issues`;
5353 /** The pulls list, whose long titles must wrap at a 390 px phone (#9346). */
5454 export const PREVIEW_PULLS_PAGE = `${PREVIEW_REPO_HOME}/pulls`;
5555
56 /** The commits list, whose long messages must wrap at a 390 px phone (#9348). */
57 export const PREVIEW_COMMITS_PAGE = `${PREVIEW_REPO_HOME}/commits`;
58
5659 /** The Later gate's proposal on the sample (#9212): what the gate holds, grouped by theme. */
5760 export const PREVIEW_HELD_PAGE = `${PREVIEW_REPO_HOME}/held`;
5861 /**
@@ -88,6 +91,7 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [
8891 PREVIEW_BETS_PAGE,
8992 PREVIEW_WAVES_PAGE,
9093 PREVIEW_PULLS_PAGE,
94 PREVIEW_COMMITS_PAGE,
9195 PREVIEW_ISSUES_PAGE,
9296 ].flatMap((page) => (["desktop", "phone"] as const).map((kind) => ({ page, kind })));
9397
src/web/pages/commits.ts +19 −3
@@ -51,6 +51,17 @@ import {
5151
5252 const PER_PAGE = 30;
5353
54 /**
55 * A commit's subject, body and author are the writer's words, so inside
56 * `[data-commits]` a row wraps them (a long word breaks anywhere) in place of
57 * the list's one-line ellipsis, and the page never scrolls sideways at 390 px.
58 * A diff keeps scrolling inside its own `.diff-body`. No new class.
59 */
60 export const COMMITS_CSS = `[data-commits] { overflow-wrap: anywhere; }
61 [data-commits] .row-title, [data-commits] .row-meta { white-space: normal; overflow: visible; text-overflow: clip; }
62 [data-commits] .row { align-items: flex-start; }
63 [data-commits] .card-head h2 { min-width: 0; }`;
64
5465 /** Infinity, 1e300 and NaN all collapse to page 1 — never a bogus skip. */
5566 function pageNumber(raw: string | null): number {
5667 const requested = Math.floor(Number(raw));
@@ -87,7 +98,7 @@ export function commitRows(
8798 () => html` · ${badge("merge", { variant: "secondary" })}`,
8899 )}</span
89100 >`;
90 return listCard(
101 return html`<div data-commits>${listCard(
91102 commits.map((commit) => {
92103 const verdict = verdicts.get(commit.oid);
93104 const step = verdict?.summary.split("\n")[0]?.trim() ?? "";
@@ -122,7 +133,7 @@ export function commitRows(
122133 </div>
123134 </li>`;
124135 }),
125 );
136 )}</div>`;
126137 }
127138
128139 /** A commit's check in a sentence; on red, the first failing step as the check named it. */
@@ -194,7 +205,7 @@ export function renderCommit(
194205 </div>`,
195206 { head: html`<h2>${commit.subject}</h2>`, pad: false },
196207 );
197 return html`<div class="mt">${head}</div>
208 return html`<div data-commits><div class="mt">${head}</div></div>
198209 <div class="mt">
199210 ${renderDiff(diff, {
200211 syntax,
@@ -281,6 +292,7 @@ export async function commitsPage(ctx: Ctx): Promise<Response> {
281292 body: html`${scopeHeader(ctx, scope, "commits")}${switcher}
282293 ${noCommits(num > 1)} ${liveMount({ repo: base })}`,
283294 scriptJs: liveClientJs,
295 styleCss: COMMITS_CSS,
284296 });
285297 notePhase(ctx.request, "render", performance.now() - rendering);
286298 return response;
@@ -308,6 +320,7 @@ export async function commitsPage(ctx: Ctx): Promise<Response> {
308320 palette: scope.palette,
309321 body,
310322 scriptJs: liveClientJs,
323 styleCss: COMMITS_CSS,
311324 });
312325 notePhase(ctx.request, "render", performance.now() - rendering);
313326 return response;
@@ -349,6 +362,7 @@ export async function commitPage(ctx: Ctx): Promise<Response> {
349362 crumbs: scope.crumbs,
350363 palette: scope.palette,
351364 body,
365 styleCss: COMMITS_CSS,
352366 });
353367 notePhase(ctx.request, "render", performance.now() - rendering);
354368 return immutableWhenAddressed(ctx.auth.user === undefined, repo, rev, response);
@@ -404,6 +418,7 @@ export async function browseCommits(
404418 crumbs: scope.crumbs,
405419 palette: scope.palette,
406420 body,
421 styleCss: COMMITS_CSS,
407422 });
408423 }
409424
@@ -428,5 +443,6 @@ export async function browseCommit(
428443 crumbs: scope.crumbs,
429444 palette: scope.palette,
430445 body,
446 styleCss: COMMITS_CSS,
431447 });
432448 }
test/commits-fit.test.ts added +111
@@ -0,0 +1,111 @@
1 /**
2 * The commits list and a commit's page fit a 390 px phone (#9348): seeded long
3 * content (a 300-character unbroken word, a long title, many rows) ships no
4 * fixed width over 390 px, long text wraps anywhere, and a diff scrolls in its box.
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 { commitToBranch, createRepo } from "../src/actions/repos.ts";
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 { PREVIEW_COMMITS_PAGE, PREVIEW_OWNER_LOOKS } from "../src/spawn/preview-looks.ts";
16 import { utilities as base } from "../src/web/style/base.ts";
17 import { components } from "../src/web/style/components.ts";
18 import { content } from "../src/web/style/content.ts";
19
20 const WORD = "W".repeat(300);
21 let dir: string;
22 let app: App;
23 let servers: Servers;
24 let list: string;
25 let one: string;
26
27 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
28 if (!r.ok) throw new Error(JSON.stringify(r.error));
29 return r.value;
30 };
31
32 beforeAll(async () => {
33 dir = await mkdtemp(join(tmpdir(), "forge-commits-fit-"));
34 app = createApp({ dataDir: dir, port: 0 });
35 servers = await serve(app);
36 const u = must(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }));
37 const repo = must(await createRepo(app, u, { name: "fit", visibility: "public" }));
38 for (let i = 0; i < 12; i++) {
39 const message = i === 0 ? `${WORD}\n\n${WORD}` : i === 1 ? "A very long subject ".repeat(10).trim() : `Commit ${i}`;
40 must(await commitToBranch(app, u, repo, { message, files: [{ path: `f${i}.txt`, content: `${"x".repeat(400)}\n` }] }));
41 }
42 const cookie = `forge_session=${createSession(app.db, u.id).token}; forge_mode=dev`;
43 list = await (await fetch(`${servers.url}/alice/fit/commits`, { headers: { cookie } })).text();
44 const newest = list.match(/\/alice\/fit\/commit\/([0-9a-f]{40})/)![1]!;
45 // The oldest-but-one commit carries the unbroken word; find it by its page.
46 for (const m of list.matchAll(/\/alice\/fit\/commit\/([0-9a-f]{40})/g)) {
47 const text = await (await fetch(`${servers.url}${m[0]}`, { headers: { cookie } })).text();
48 if (text.includes(WORD)) { one = text; break; }
49 }
50 one ??= await (await fetch(`${servers.url}/alice/fit/commit/${newest}`, { headers: { cookie } })).text();
51 }, 60_000);
52 afterAll(async () => {
53 servers.stop();
54 app.close();
55 await rm(dir, { recursive: true, force: true });
56 });
57
58 const styles = (html: string) => (html.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? []).join("\n");
59
60 describe("the commits list and a commit's page at 390 px", () => {
61 test("the long content is on both pages", () => {
62 expect(list).toContain(WORD);
63 expect(list).toContain("A very long subject");
64 expect(list.match(/class="row[ "]/g)?.length ?? 0).toBeGreaterThanOrEqual(12);
65 expect(one).toContain(WORD);
66 expect(one).toContain("diff-body");
67 });
68
69 for (const [name, get] of [["list", () => list], ["commit", () => one]] as const) {
70 test(`the ${name} ships no width over 390 px`, () => {
71 const page = get();
72 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s])(?:min-|max-)?width\s*:/i);
73 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
74 const over: string[] = [];
75 for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) {
76 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
77 if (Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16) > 390) over.push(m[0]);
78 }
79 }
80 expect(over).toEqual([]);
81 });
82
83 test(`the ${name}'s long-text container wraps anywhere, rows may wrap`, () => {
84 const page = get();
85 expect(page).toContain("data-commits");
86 expect(styles(page)).toMatch(/\[data-commits\]\s*\{[^}]*overflow-wrap:\s*(anywhere|break-word)/);
87 expect(styles(page)).toMatch(/\[data-commits\] \.row-title[^{]*\{[^}]*white-space:\s*normal/);
88 });
89 }
90
91 test("the commit body (the 300-character word) sits inside [data-commits] and wraps", () => {
92 const start = one.indexOf("<div data-commits>");
93 const body = one.indexOf('data-text="message"');
94 const diff = one.indexOf("diff-body");
95 expect(start).toBeGreaterThanOrEqual(0);
96 expect(body).toBeGreaterThan(start);
97 expect(one.indexOf(WORD, body)).toBeGreaterThan(body);
98 // The container closes before the diff, which has its own scrolling box.
99 expect(one.indexOf(WORD, body)).toBeLessThan(diff);
100 expect(content).toMatch(/\.prose\[data-text="message"\] pre \{[^}]*white-space:\s*pre-wrap[^}]*overflow-wrap:\s*anywhere/);
101 });
102
103 test("a diff scrolls inside its own box", () => {
104 expect(content).toMatch(/\.diff-body \{[^}]*overflow-x:\s*auto/);
105 });
106 });
107
108 test("the owner's phone look takes the commits list", () => {
109 expect(PREVIEW_COMMITS_PAGE).toBe("/preview-admin/sample/commits");
110 expect(PREVIEW_OWNER_LOOKS).toContainEqual({ page: PREVIEW_COMMITS_PAGE, kind: "phone" });
111 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs