Public: anyone can read this; the crew can change it.
A pull's page fits a 390 px phone: its title, branch names and conversation wrap instead of scrolling sideways #9356
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9327-a-pull-s-page-fits-a-390-px-phone-its-ti → main 8 commits fork
5 files
changed +122
−3
docs/design/areas/pull.md +4
| @@ -9,3 +9,7 @@ At rest: the state is a dot (`.dot`, `role=img`, one `aria-label`, no `title`), | ||
| 9 | 9 | Rare actions (Hold, Take it back) sit in a `<details id="more" class="mt mb">`, the same disclosure Hold always used. Their palette rows link to `?more=1#hold` / `?more=1#take-back`: the server renders the details open for `?more`, so the target is visible with no script. Every other action is a palette row too (`pullActions`, "This pull request"). No new class. |
| 10 | 10 | |
| 11 | 11 | The terminal's pull screen (`src/tui/screens/pull.ts`) draws its own rows and shares no markup with this page; it needs no change. |
| 12 | ||
| 13 | On a 390 px phone the page does not scroll sideways (#9327): the title (`.page-head h1`) and every `.mono` (branch names) wrap with `overflow-wrap: anywhere`, comments already do through `.prose`. The diff scrolls inside its own box. No new class; `test/pull-phone-fit.test.ts` holds it. | |
| 14 | ||
| 15 | `.split` (content.ts) has one `minmax(0, 1fr)` column below 960 px, so a long unbroken line in a comment cannot widen the page. It is global to every `.split` page; the `min-width: 960px` and `data-rail="wide"` rules come after it in the same file and still set their two columns, so desk layouts are unchanged. |
src/web/style/base.ts +1
| @@ -57,6 +57,7 @@ body { | ||
| 57 | 57 | a { color: inherit; text-decoration: none; transition: color 120ms var(--ease); } |
| 58 | 58 | a:hover { color: var(--accent); } |
| 59 | 59 | code, pre, .mono { font-family: var(--mono); font-size: 12.5px; } |
| 60 | .mono { overflow-wrap: anywhere; } | |
| 60 | 61 | |
| 61 | 62 | h1 { font-size: 20px; font-weight: 620; letter-spacing: -0.02em; line-height: 1.25; text-wrap: balance; } |
| 62 | 63 | h2 { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; line-height: 1.3; text-wrap: balance; } |
src/web/style/components.ts +2 −2
| @@ -814,7 +814,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 814 | 814 | display: flex; align-items: center; gap: 10px; flex-wrap: wrap; |
| 815 | 815 | margin: 20px 0 14px; |
| 816 | 816 | } |
| 817 | .page-head h1 { min-width: 0; } | |
| 817 | .page-head h1 { min-width: 0; overflow-wrap: anywhere; } | |
| 818 | 818 | .page-head > .page-head-sub { flex-basis: 100%; margin-top: -4px; } |
| 819 | 819 | |
| 820 | 820 | /* ---- breadcrumbs ---- */ |
| @@ -847,7 +847,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 847 | 847 | content: ""; position: absolute; left: 13px; top: 8px; bottom: 8px; |
| 848 | 848 | width: 2px; background: var(--border); |
| 849 | 849 | } |
| 850 | .tl-item { position: relative; padding-left: 36px; } | |
| 850 | .tl-item { position: relative; padding-left: 36px; min-width: 0; } | |
| 851 | 851 | .tl-item .tl-dot { |
| 852 | 852 | position: absolute; left: 4px; top: 6px; |
| 853 | 853 | display: inline-flex; align-items: center; justify-content: center; |
src/web/style/content.ts +2 −1
| @@ -217,6 +217,7 @@ form.inline { display: inline; margin-left: auto; } | ||
| 217 | 217 | than the phone rather than letting its own ellipsis do the work. A zero |
| 218 | 218 | min-width is what makes truncation inside a grid actually truncate. */ |
| 219 | 219 | .split > * { min-width: 0; } |
| 220 | .split { grid-template-columns: minmax(0, 1fr); } | |
| 220 | 221 | |
| 221 | 222 | /* The generic two-column page: content, then a sidebar that sticks. */ |
| 222 | 223 | .split { display: grid; gap: 16px; margin-top: 16px; align-items: start; } |
| @@ -330,7 +331,7 @@ form.inline { display: inline; margin-left: auto; } | ||
| 330 | 331 | .imgview-pane.is-actual .imgview-img { max-width: none; cursor: zoom-out; } |
| 331 | 332 | |
| 332 | 333 | /* ---- prose (markdown) ---- */ |
| 333 | .prose { padding: 16px 20px; font-size: 15px; line-height: 1.65; overflow-wrap: break-word; } | |
| 334 | .prose { padding: 16px 20px; font-size: 15px; line-height: 1.65; overflow-wrap: anywhere; } | |
| 334 | 335 | .prose > * { max-width: var(--measure); } |
| 335 | 336 | .prose h1, .prose h2, .prose h3, .prose h4 { margin: 24px 0 8px; } |
| 336 | 337 | .prose h1 { font-size: 20px; } |
test/pull-phone-fit.test.ts added +113
| @@ -0,0 +1,113 @@ | ||
| 1 | /** | |
| 2 | * A pull's page fits a 390 px phone (#9327): the preview seed's pull, given a | |
| 3 | * long title, a 200-character branch name and a comment holding a 300-character | |
| 4 | * unbroken word, ships no fixed width over 390 px, wraps its long text, and | |
| 5 | * (where a Chromium starts) does not scroll sideways. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { createHmac } from "node:crypto"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { addComment } from "../src/core/issues.ts"; | |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { findChromium } from "../src/looks/capture.ts"; | |
| 17 | import { PREVIEW_PULL_PAGE } from "../src/spawn/preview-looks.ts"; | |
| 18 | import { previewAdmin, seedPreview } from "../src/spawn/preview-seed.ts"; | |
| 19 | import { base as elements, utilities as base } from "../src/web/style/base.ts"; | |
| 20 | import { components } from "../src/web/style/components.ts"; | |
| 21 | import { content } from "../src/web/style/content.ts"; | |
| 22 | import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts"; | |
| 23 | ||
| 24 | const KEY = "pull-phone-fit-key"; | |
| 25 | const WORD = "w".repeat(300); | |
| 26 | const BRANCH = `feature/${"b".repeat(192)}`; | |
| 27 | const TITLE = `Make every long and winding pull title wrap ${"t".repeat(120)} and many words after it`; | |
| 28 | let dir: string; | |
| 29 | let app: App; | |
| 30 | let servers: Servers; | |
| 31 | let page: string; | |
| 32 | let files: string; | |
| 33 | ||
| 34 | beforeAll(async () => { | |
| 35 | dir = await mkdtemp(join(tmpdir(), "forge-pull-fit-")); | |
| 36 | app = createApp({ dataDir: dir, port: 0, previewIdentityKey: KEY }); | |
| 37 | const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 38 | if (!seeded.ok) throw new Error(seeded.error); | |
| 39 | const admin = previewAdmin(app)!; | |
| 40 | const number = Number(PREVIEW_PULL_PAGE.split("/").pop()); | |
| 41 | const issue = app.db.query("SELECT id FROM issue WHERE number = ?1 AND id IN (SELECT issue_id FROM pull)").get(number) as { id: string }; | |
| 42 | app.db.query("UPDATE issue SET title = ?1 WHERE id = ?2").run(TITLE, issue.id); | |
| 43 | app.db.query("UPDATE pull SET head_branch = ?1 WHERE issue_id = ?2").run(BRANCH, issue.id); | |
| 44 | for (let i = 0; i < 12; i++) addComment(app.db, { issueId: issue.id, authorId: admin.id, body: `Row ${String(i)} ${WORD} https://example.com/${WORD}` }); | |
| 45 | servers = await serve(app); | |
| 46 | const said = `u1|trevato|admin|${Date.now() + 60_000}`; | |
| 47 | const headers = { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") }; | |
| 48 | page = await (await fetch(`${servers.url}${PREVIEW_PULL_PAGE}`, { headers })).text(); | |
| 49 | files = await (await fetch(`${servers.url}${PREVIEW_PULL_PAGE}/files`, { headers })).text(); | |
| 50 | }, 30_000); | |
| 51 | afterAll(async () => { | |
| 52 | servers.stop(); | |
| 53 | app.close(); | |
| 54 | await rm(dir, { recursive: true, force: true }); | |
| 55 | }); | |
| 56 | ||
| 57 | /** Every `width`/`min-width` in `css` that fixes more than 390 px, as written. */ | |
| 58 | const widthsOver390 = (css: string): string[] => | |
| 59 | [...css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)] | |
| 60 | .filter((m) => Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16) > 390) | |
| 61 | .map((m) => m[0]); | |
| 62 | ||
| 63 | describe("a pull's conversation page at 390 px", () => { | |
| 64 | test("the seeded long content is on the page", () => { | |
| 65 | expect(page).toContain(TITLE); | |
| 66 | expect(page).toContain(WORD); | |
| 67 | // The branch is drawn where the pull says where it came from: the Files step. | |
| 68 | expect(files).toContain(BRANCH); | |
| 69 | }); | |
| 70 | ||
| 71 | test("no inline width and no rule the page ships fixes a width over 390 px", () => { | |
| 72 | for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390); | |
| 73 | expect(page).not.toMatch(/style="[^"]*(?:^|[;\s])min-width\s*:/i); | |
| 74 | const sheets = [base, components, content, ...[page, files].flatMap((html) => html.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]; | |
| 75 | const inline = [...page.matchAll(/style="([^"]*)"/g)].map((m) => m[1]!); | |
| 76 | expect([...inline, ...sheets].flatMap(widthsOver390)).toEqual([]); | |
| 77 | }); | |
| 78 | ||
| 79 | test("the long-text containers wrap anywhere", () => { | |
| 80 | expect(components).toMatch(/\.page-head h1[^{]*\{[^}]*overflow-wrap:\s*(anywhere|break-word)/); | |
| 81 | expect(elements).toMatch(/\.mono[^{]*\{[^}]*overflow-wrap:\s*(anywhere|break-word)/); | |
| 82 | expect(content).toMatch(/\.prose\s*\{[^}]*overflow-wrap:\s*(anywhere|break-word)/); | |
| 83 | }); | |
| 84 | }); | |
| 85 | ||
| 86 | const found = findChromium(); | |
| 87 | const chromium = found !== undefined && chromiumRuns(found) ? found : undefined; | |
| 88 | if (chromium === undefined) console.log("SKIPPING: no chromium that starts here, so the pull's phone look is not measured"); | |
| 89 | ||
| 90 | describe.skipIf(chromium === undefined)("a pull's conversation page, measured", () => { | |
| 91 | const measure = `(() => { | |
| 92 | const vw = document.documentElement.clientWidth; | |
| 93 | const clipped = (el) => { for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) { const o = getComputedStyle(p).overflowX; if (o !== "visible") return true; } return false; }; | |
| 94 | const overflowing = [...document.querySelectorAll(".page-head, .page-head *, .split, .split *")].filter((el) => el.getBoundingClientRect().right > vw + 1 && !clipped(el) && getComputedStyle(el).clipPath === "none" && getComputedStyle(el).position !== "fixed").slice(0, 8).map((el) => el.tagName.toLowerCase() + "." + String(el.className)); | |
| 95 | return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, overflowing }); | |
| 96 | })()`; | |
| 97 | // The layout's own chrome (tabs, off-canvas menus) is web-phone.test.ts's to hold; the page's content is measured here. | |
| 98 | const look = async (width: number, height: number) => { | |
| 99 | const cookies = [{ name: "forge_session", value: createSession(app.db, previewAdmin(app)!.id).token }]; | |
| 100 | const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}${PREVIEW_PULL_PAGE}`, { width, height }, cookies, measure)) as { scrollWidth: number; viewportWidth: number; overflowing: string[] }; | |
| 101 | console.log(`pull look ${width}x${height}: ${JSON.stringify(m)}`); | |
| 102 | return m; | |
| 103 | }; | |
| 104 | test("phone, 390x844: no sideways scroll", async () => { | |
| 105 | const m = await look(390, 844); | |
| 106 | expect(m.overflowing).toEqual([]); | |
| 107 | expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth); | |
| 108 | }, 30_000); | |
| 109 | test("desk, 1280x800: no sideways scroll", async () => { | |
| 110 | const m = await look(1280, 800); | |
| 111 | expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth); | |
| 112 | }, 30_000); | |
| 113 | }); |
new above
offline