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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9349-the-changelog-page-fits-a-390-px-phone-n → main 7 commits fork 3 threads · 3 unresolved
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`; | ||
| 57 | 57 | /** The pulls list, whose long titles must wrap at a 390 px phone (#9346). */ |
| 58 | 58 | export const PREVIEW_PULLS_PAGE = `${PREVIEW_REPO_HOME}/pulls`; |
| 59 | 59 | |
| 60 | /** The changelog, whose long landing titles must wrap at a 390 px phone (#9349). */ | |
| 61 | export const PREVIEW_CHANGELOG_PAGE = "/changelog"; | |
| 60 | 62 | /** The next-up page, whose long titles and action buttons must fit a 390 px phone (#9324). */ |
| 61 | 63 | export const PREVIEW_NEXT_PAGE = `${PREVIEW_REPO_HOME}/next`; |
| 62 | 64 | |
| @@ -99,6 +101,7 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [ | ||
| 99 | 101 | PREVIEW_MILESTONES_PAGE, |
| 100 | 102 | PREVIEW_WAVES_PAGE, |
| 101 | 103 | PREVIEW_PULLS_PAGE, |
| 104 | PREVIEW_CHANGELOG_PAGE, | |
| 102 | 105 | PREVIEW_NEXT_PAGE, |
| 103 | 106 | PREVIEW_COMMITS_PAGE, |
| 104 | 107 | PREVIEW_ISSUES_PAGE, |
src/spawn/preview-seed.ts +31 −1
| @@ -22,7 +22,7 @@ import { writeBoard } from "../actions/boards.ts"; | ||
| 22 | 22 | import { backBet } from "../actions/bets.ts"; |
| 23 | 23 | import { openIssue, setIssueState } from "../actions/issues.ts"; |
| 24 | 24 | import { pinBuild } from "../actions/builds.ts"; |
| 25 | import { openPull } from "../actions/pulls.ts"; | |
| 25 | import { mergePull, openPull } from "../actions/pulls.ts"; | |
| 26 | 26 | import { commitToBranch, createRepo, forkRepo } from "../actions/repos.ts"; |
| 27 | 27 | import { reviewGate } from "../actions/gate_review.ts"; |
| 28 | 28 | import { linkIssue } from "../actions/dependencies.ts"; |
| @@ -67,6 +67,12 @@ export { | ||
| 67 | 67 | /** The seeded wave's version and headline, which the owner's /waves look shows. */ |
| 68 | 68 | export const PREVIEW_WAVE = "0.1.0"; |
| 69 | 69 | 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`; | |
| 70 | 76 | /** The board whose panels are pinned to home. */ |
| 71 | 77 | const PREVIEW_BOARD = "preview"; |
| 72 | 78 | const SEEDED = "preview_seeded"; |
| @@ -218,6 +224,9 @@ async function seedWorld(app: App, admin: User): Promise<Result<void, string>> { | ||
| 218 | 224 | if (!waved.ok) return waved; |
| 219 | 225 | const bets = seedBets(app, admin, repo.value, open); |
| 220 | 226 | 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; | |
| 221 | 230 | const family = await seedForkFamily(app, admin, repo.value, start.value.commitOid); |
| 222 | 231 | if (!family.ok) return family; |
| 223 | 232 | const gated = seedLaterGate(app, admin, repo.value); |
| @@ -239,6 +248,27 @@ async function seedWorld(app: App, admin: User): Promise<Result<void, string>> { | ||
| 239 | 248 | return ok(); |
| 240 | 249 | } |
| 241 | 250 | |
| 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 | ||
| 242 | 272 | /** |
| 243 | 273 | * Finished issues that cost something, so `weekView` can price the open |
| 244 | 274 | * 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"; | ||
| 21 | 21 | import { shippedAnswer } from "../../release/answer.ts"; |
| 22 | 22 | import { html, type Html } from "../html.ts"; |
| 23 | 23 | 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"; | |
| 25 | 25 | |
| 26 | 26 | /** Where the live client looks: a merge lands, or production serves a commit. */ |
| 27 | 27 | const LIVE = "pull.merged,app.serving"; |
| @@ -42,7 +42,7 @@ function byDay(list: readonly Landing[], own: string | undefined, showRepo: bool | ||
| 42 | 42 | const days = new Map<string, Landing[]>(); |
| 43 | 43 | for (const l of list) days.set(dayOf(l.at, zone), [...(days.get(dayOf(l.at, zone)) ?? []), l]); |
| 44 | 44 | 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>`, { | |
| 46 | 46 | 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))}`, |
| 47 | 47 | attrs: html`data-day="${day}"`, |
| 48 | 48 | pad: false, |
| @@ -136,7 +136,7 @@ export function shippedRegion( | ||
| 136 | 136 | const way = |
| 137 | 137 | onItsWay.length === 0 |
| 138 | 138 | ? 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 })}`; | |
| 140 | 140 | const gone = |
| 141 | 141 | live.length === 0 |
| 142 | 142 | ? 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 | }); |
new above
offline
Good: the phone look is registered. The description must link what this produces and say, in one line, whether /changelog scrolls sideways.