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

The /changelog page fits a 390 px phone: no sideways scroll, long entries wrap #9363

Merged opened this

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

Merged

scout/forge:scout/9349-the-changelog-page-fits-a-390-px-phone-n → main 7 commits fork 3 threads · 3 unresolved

  • Merged. scout/9349-the-changelog-page-fits-a-390-px-phone-n was merged into main as a2daf7e.

Review

Sign in to review this change.
5 files changed +168 −4
docs/design/areas/changelog.md added +10
@@ -0,0 +1,10 @@
1 # Changelog
2
3 ## The page at 390 px (#9349)
4
5 `/changelog` is day `card()`s whose `<ul>` carries `list setting-list`, the shared rule in `style/components.ts` that
6 wraps a row, lets its title and meta break long words anywhere, and drops the trail onto its own line under 640px.
7 No page-local style, no fixed width; the page does not scroll sideways. No new class.
8
9 The preview seed merges one pull with a long title and an unbroken 120-character word (`PREVIEW_LANDING_TITLE`) into
10 the sample before its forks are made, so the preview's `/changelog` phone look shows a real row, not the empty state.
src/spawn/preview-looks.ts +3
@@ -57,6 +57,8 @@ export const PREVIEW_ISSUES_PAGE = `${PREVIEW_REPO_HOME}/issues`;
5757 /** The pulls list, whose long titles must wrap at a 390 px phone (#9346). */
outdated

Good: the phone look is registered. The description must link what this produces and say, in one line, whether /changelog scrolls sideways.

5858 export const PREVIEW_PULLS_PAGE = `${PREVIEW_REPO_HOME}/pulls`;
5959
60 /** The changelog, whose long landing titles must wrap at a 390 px phone (#9349). */
61 export const PREVIEW_CHANGELOG_PAGE = "/changelog";
6062 /** The next-up page, whose long titles and action buttons must fit a 390 px phone (#9324). */
6163 export const PREVIEW_NEXT_PAGE = `${PREVIEW_REPO_HOME}/next`;
6264
@@ -99,6 +101,7 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [
99101 PREVIEW_MILESTONES_PAGE,
100102 PREVIEW_WAVES_PAGE,
101103 PREVIEW_PULLS_PAGE,
104 PREVIEW_CHANGELOG_PAGE,
102105 PREVIEW_NEXT_PAGE,
103106 PREVIEW_COMMITS_PAGE,
104107 PREVIEW_ISSUES_PAGE,
src/spawn/preview-seed.ts +31 −1
@@ -22,7 +22,7 @@ import { writeBoard } from "../actions/boards.ts";
2222 import { backBet } from "../actions/bets.ts";
2323 import { openIssue, setIssueState } from "../actions/issues.ts";
2424 import { pinBuild } from "../actions/builds.ts";
25 import { openPull } from "../actions/pulls.ts";
25 import { mergePull, openPull } from "../actions/pulls.ts";
2626 import { commitToBranch, createRepo, forkRepo } from "../actions/repos.ts";
2727 import { reviewGate } from "../actions/gate_review.ts";
2828 import { linkIssue } from "../actions/dependencies.ts";
@@ -67,6 +67,12 @@ export {
6767 /** The seeded wave's version and headline, which the owner's /waves look shows. */
6868 export const PREVIEW_WAVE = "0.1.0";
6969 export const PREVIEW_WAVE_HEADLINE = "The sample says hello";
70 /**
71 * The title of the seeded pull that lands, so /changelog has a day of rows
72 * to look at: long, with one unbroken word, the case a 390 px phone must
73 * wrap rather than scroll (#9349).
74 */
75 export const PREVIEW_LANDING_TITLE = `The changelog wraps a long landing title on a phone ${"L".repeat(120)} and the words after it`;
7076 /** The board whose panels are pinned to home. */
7177 const PREVIEW_BOARD = "preview";
7278 const SEEDED = "preview_seeded";
@@ -218,6 +224,9 @@ async function seedWorld(app: App, admin: User): Promise<Result<void, string>> {
218224 if (!waved.ok) return waved;
219225 const bets = seedBets(app, admin, repo.value, open);
220226 if (!bets.ok) return bets;
227 // Before the forks, so the merge moves no fork; after the numbered issues, so they keep their numbers.
228 const landed = await seedLanding(app, admin, repo.value);
229 if (!landed.ok) return landed;
221230 const family = await seedForkFamily(app, admin, repo.value, start.value.commitOid);
222231 if (!family.ok) return family;
223232 const gated = seedLaterGate(app, admin, repo.value);
@@ -239,6 +248,27 @@ async function seedWorld(app: App, admin: User): Promise<Result<void, string>> {
239248 return ok();
240249 }
241250
251 /** One pull merged into the sample's default branch: a landing for /changelog (#9349). */
252 async function seedLanding(app: App, admin: User, repo: Repo): Promise<Result<void, string>> {
253 const branch = await commitToBranch(app, admin, repo, {
254 branch: "landed",
255 base: repo.defaultBranch,
256 message: "Land a note",
257 files: [{ path: "LANDED.md", content: "Landed.\n" }],
258 });
259 if (!branch.ok) return err(`the landing's branch: ${JSON.stringify(branch.error)}`);
260 const pull = await openPull(app, admin, repo, {
261 title: PREVIEW_LANDING_TITLE,
262 body: "Merged by the preview seed.",
263 baseBranch: repo.defaultBranch,
264 headBranch: "landed",
265 });
266 if (!pull.ok) return err(`the landing: ${String(pull.error)}`);
267 const merged = await mergePull(app, admin, repo, pull.value, "merge", undefined, { pastGate: true });
268 if (!merged.ok) return err(`the landing's merge: ${String(merged.error)}`);
269 return ok();
270 }
271
242272 /**
243273 * Finished issues that cost something, so `weekView` can price the open
244274 * ones, and a weekly capacity it can cut the list by: an inert account
src/web/pages/changelog.ts +3 −3
@@ -21,7 +21,7 @@ import { json } from "../../http/respond.ts";
2121 import { shippedAnswer } from "../../release/answer.ts";
2222 import { html, type Html } from "../html.ts";
2323 import { page } from "../layout.ts";
24 import { badge, card, empty, listCard, row, seg, sha, time } from "../ui.ts";
24 import { badge, card, empty, row, seg, sha, time } from "../ui.ts";
2525
2626 /** Where the live client looks: a merge lands, or production serves a commit. */
2727 const LIVE = "pull.merged,app.serving";
@@ -42,7 +42,7 @@ function byDay(list: readonly Landing[], own: string | undefined, showRepo: bool
4242 const days = new Map<string, Landing[]>();
4343 for (const l of list) days.set(dayOf(l.at, zone), [...(days.get(dayOf(l.at, zone)) ?? []), l]);
4444 return [...days].map(([day, items]) =>
45 card(html`<ul class="list">${items.map((l) => landingRow(l, own, showRepo))}</ul>`, {
45 card(html`<ul class="list setting-list">${items.map((l) => landingRow(l, own, showRepo))}</ul>`, {
4646 head: html`<h2><time datetime="${day}">${dayHeading(day, today)}</time>${zone === "UTC" ? html`<span data-zone>${badge("UTC", { variant: "outline" })}</span>` : null}</h2>${badge(String(items.length))}`,
4747 attrs: html`data-day="${day}"`,
4848 pad: false,
@@ -136,7 +136,7 @@ export function shippedRegion(
136136 const way =
137137 onItsWay.length === 0
138138 ? null
139 : html`<h2 class="mt">On its way</h2>${listCard(onItsWay.map((l) => landingRow(l, read.own, choice === "all")))}`;
139 : html`<h2 class="mt">On its way</h2>${card(html`<ul class="list setting-list">${onItsWay.map((l) => landingRow(l, read.own, choice === "all"))}</ul>`, { pad: false })}`;
140140 const gone =
141141 live.length === 0
142142 ? empty({ title: "Nothing has shipped yet", note: "Each pull appears here once the running commit contains it." })
test/changelog-fit.test.ts added +121
@@ -0,0 +1,121 @@
1 /**
2 * The changelog fits a 390 px phone (#9349): seeded long content (an unbroken
3 * 300-character word, a long title, many rows) ships no fixed width over
4 * 390 px, and the long-text container wraps anywhere.
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 { createRepo } from "../src/actions/repos.ts";
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 = "C".repeat(300);
21 let dir: string;
22 let app: App;
23 let servers: Servers;
24 let page: string;
25
26 beforeAll(async () => {
27 dir = await mkdtemp(join(tmpdir(), "forge-changelog-fit-"));
28 app = createApp({ dataDir: dir, port: 0 });
29 servers = await serve(app);
30 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
31 if (!r.ok) throw new Error("user");
32 const made = await createRepo(app, r.value, { name: "forge", visibility: "public" });
33 if (!made.ok) throw new Error("repo");
34 app.config.forgeRepo = "alice/forge";
35 const repoId = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name = 'forge'").get()!.id;
36 const day = new Date().toISOString().slice(0, 10);
37 for (let i = 0; i < 15; i++) {
38 const title = i === 0 ? WORD : i === 1 ? "A very long title ".repeat(12).trim() : `Pull number ${i}`;
39 app.db.query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, 'closed', ?6)`).run(`i${i}`, repoId, i + 1, title, r.value.id, `${day}T01:00:00.000Z`);
40 app.db.query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at, merged_oid) VALUES (?1, 'main', ?2, '', '', ?3, ?4, '')`).run(`i${i}`, `b/${i}`, repoId, `${day}T01:00:00.000Z`);
41 }
42 page = await (await fetch(`${servers.url}/changelog`)).text();
43 }, 60_000);
44 afterAll(async () => {
45 servers.stop();
46 app.close();
47 await rm(dir, { recursive: true, force: true });
48 });
49
50 describe("the changelog at 390 px", () => {
51 test("the long content is on the page, in many rows", () => {
52 expect(page).toContain(WORD);
53 expect(page).toContain("A very long title");
54 expect(page.match(/class="row[ "]/g)?.length ?? 0).toBeGreaterThanOrEqual(15);
55 });
56
57 test("no inline width and no shipped rule fixes a width over 390 px", () => {
58 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s])(?:min-|max-)?width\s*:/i);
59 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
60 const over: string[] = [];
61 for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) {
62 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
63 if (Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16) > 390) over.push(m[0]);
64 }
65 }
66 expect(over).toEqual([]);
67 });
68
69 test("the day lists reuse .setting-list, whose shared rule wraps titles and the trail", () => {
70 expect(page).not.toContain("<style>[data-changelog]");
71 expect(page).not.toContain("data-changelog");
72 expect(page.match(/<ul class="list setting-list">/g)?.length ?? 0).toBeGreaterThanOrEqual(1);
73 expect(components).toMatch(/\.setting-list \.row-title, \.setting-list \.row-meta \{[^}]*white-space: normal[^}]*overflow-wrap: anywhere/);
74 expect(components).toMatch(/\.setting-list \.row \{[^}]*flex-wrap: wrap/);
75 expect(components).toMatch(/@media \(max-width: 640px\) \{ \.setting-list \.row-trail \{ flex-basis: 100%/);
76 });
77 });
78
79 test("the preview takes a phone look of /changelog", async () => {
80 const { PREVIEW_CHANGELOG_PAGE, PREVIEW_OWNER_LOOKS } = await import("../src/spawn/preview-looks.ts");
81 expect(PREVIEW_CHANGELOG_PAGE).toBe("/changelog");
82 expect(PREVIEW_OWNER_LOOKS).toContainEqual({ page: "/changelog", kind: "phone" });
83 });
84
85 /** The preview's own /changelog: the seed lands one long-titled pull, the row its phone look shows (#9349). */
86 describe("the preview's changelog", () => {
87 let pdir: string;
88 let papp: App;
89 let pservers: Servers;
90 let ppage: string;
91 beforeAll(async () => {
92 const { seedPreview } = await import("../src/spawn/preview-seed.ts");
93 pdir = await mkdtemp(join(tmpdir(), "forge-changelog-preview-"));
94 papp = createApp({ dataDir: pdir, port: 0 });
95 const seeded = await seedPreview(papp, { FORGE_APP_ENV: "preview" });
96 if (!seeded.ok) throw new Error(seeded.error);
97 pservers = await serve(papp);
98 ppage = await (await fetch(`${pservers.url}/changelog`)).text();
99 }, 30_000);
100 afterAll(async () => {
101 pservers.stop();
102 papp.close();
103 await rm(pdir, { recursive: true, force: true });
104 });
105
106 test("lists the seeded landing in a .setting-list day", async () => {
107 const { PREVIEW_LANDING_TITLE } = await import("../src/spawn/preview-seed.ts");
108 const list = ppage.match(/<ul class="list setting-list">[\s\S]*?<\/ul>/)?.[0] ?? "";
109 expect(list).toContain(PREVIEW_LANDING_TITLE);
110 });
111
112 const found = findChromium();
113 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
114 if (chromium === undefined) console.log("SKIPPING: no chromium that starts here, so the changelog's phone look is not measured");
115 test.skipIf(chromium === undefined)("phone, 390x844: no sideways scroll", async () => {
116 const measure = `JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: document.documentElement.clientWidth })`;
117 const m = JSON.parse(await evaluateLoaded(chromium!, `${pservers.url}/changelog`, { width: 390, height: 844 }, [], measure)) as { scrollWidth: number; viewportWidth: number };
118 console.log(`changelog look 390x844: ${JSON.stringify(m)}`);
119 expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth);
120 }, 30_000);
121 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs