Public: anyone can read this; the crew can change it.
The signed-in home timeline fits a 390 px phone: a post with a 250-character word, a long title and a code span wraps, nothing scrolls sideways #9382
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9378-the-signed-in-home-timeline-fits-a-390-p → main 4 commits fork 3 threads · 3 unresolved
2 files
changed +126
−0
src/web/style/content.ts +7
| @@ -75,6 +75,13 @@ a.where-place:hover, button.where-place:hover { | ||
| 75 | 75 | .row-meta .where-place > span[aria-hidden="true"] { margin-right: 3px; } |
| 76 | 76 | .row-title a { color: inherit; text-decoration: none; } |
| 77 | 77 | .row-title a:hover { color: var(--accent); } |
| 78 | /* The signed-in home (#9378). Its rows already clip one line and its | |
| 79 | sections are grid cells with min-width 0 (feed_style.ts); what is left is a | |
| 80 | long app name, which wraps inside its chip, and the tell box's input and | |
| 81 | select, which may shrink. The chip row is server-rendered, so a wrapped | |
| 82 | chip is its final height on first paint. */ | |
| 83 | #live-chips .btn { max-width: 100%; height: auto; white-space: normal; overflow-wrap: anywhere; text-align: left; } | |
| 84 | .tell-box .input { min-width: 0; max-width: 100%; } | |
outdated
Untested: no app is seeded, so #live-chips never renders in home-fit.test.ts. Seed an app with a 250-char name and measure it; also confirm wrapping a chip does not shift layout. |
||
| 78 | 85 | .row-trail form { display: inline-flex; } |
| 79 | 86 | |
| 80 | 87 | /* "Where it lives": the origin, when it was last fetched, the button that |
test/home-fit.test.ts added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | /** | |
| 2 | * The signed-in home fits a 390 px phone (#9378): seeded with a 250-character | |
| 3 | * unbroken word, a long title and many shipped rows, the page fixes no width | |
| 4 | * over 390 px, the rows that hold long text wrap, and in a real Chromium it | |
| 5 | * does not scroll sideways. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { enableApp } from "../src/actions/apps.ts"; | |
| 13 | import { openPull } from "../src/actions/pulls.ts"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { createSession } from "../src/core/sessions.ts"; | |
| 16 | import { createUser } from "../src/core/users.ts"; | |
| 17 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 18 | import { utilities as base } from "../src/web/style/base.ts"; | |
| 19 | import { components } from "../src/web/style/components.ts"; | |
| 20 | import { content } from "../src/web/style/content.ts"; | |
| 21 | import { findChromium } from "../src/looks/capture.ts"; | |
| 22 | import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts"; | |
| 23 | ||
| 24 | const WORD = "H".repeat(250); | |
| 25 | const APP = "a".repeat(100); | |
| 26 | let dir: string; | |
| 27 | let app: App; | |
| 28 | let servers: Servers; | |
| 29 | let page: string; | |
| 30 | let session: { name: string; value: string }; | |
| 31 | ||
| 32 | const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 33 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 34 | return r.value; | |
| 35 | }; | |
| 36 | ||
| 37 | beforeAll(async () => { | |
| 38 | dir = await mkdtemp(join(tmpdir(), "forge-home-fit-")); | |
| 39 | app = createApp({ dataDir: dir, port: 0 }); | |
| 40 | servers = await serve(app); | |
| 41 | const u = must(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true })); | |
| 42 | const repo = must(await createRepo(app, u, { name: "fit", visibility: "public" })); | |
| 43 | must(await commitToBranch(app, u, repo, { message: "init", files: [{ path: "README.md", content: "x" }] })); | |
| 44 | const long = must(await createRepo(app, u, { name: APP, visibility: "public" })); | |
| 45 | must(await commitToBranch(app, u, long, { message: "init", files: [{ path: "README.md", content: "x" }] })); | |
| 46 | must(enableApp(app, u, long)); | |
| 47 | for (let i = 0; i < 8; i++) { | |
| 48 | must(await commitToBranch(app, u, repo, { branch: `t-${i}`, message: "c", files: [{ path: `f${i}.txt`, content: "x" }] })); | |
| 49 | const title = i === 0 ? WORD : i === 1 ? "A very long title ".repeat(12).trim() : `Pull ${i} \`code span\``; | |
| 50 | const pr = must(await openPull(app, u, repo, { title, body: "", baseBranch: "main", headBranch: `t-${i}` })); | |
| 51 | app.db.run("UPDATE pull SET merged_at=?2 WHERE issue_id=?1", [pr.id, `2026-10-0${8 - i}T10:00:00.000Z`]); | |
| 52 | } | |
| 53 | const token = createSession(app.db, u.id).token; | |
| 54 | session = { name: "forge_session", value: token }; | |
| 55 | page = await (await fetch(`${servers.url}/`, { headers: { cookie: `forge_session=${token}` } })).text(); | |
| 56 | }, 60_000); | |
| 57 | afterAll(async () => { | |
| 58 | servers.stop(); | |
| 59 | app.close(); | |
| 60 | await rm(dir, { recursive: true, force: true }); | |
| 61 | }); | |
| 62 | ||
| 63 | describe("the signed-in home at 390 px", () => { | |
| 64 | test("the seeded long content is on the page", () => { | |
| 65 | expect(page).toContain("Just shipped"); | |
| 66 | expect(page).toContain("A very long title"); | |
| 67 | expect(page).toContain(WORD.slice(0, 40)); | |
| 68 | }); | |
| 69 | ||
| 70 | test("no inline width, and no rule the page ships fixes a width over 390 px", () => { | |
| 71 | expect(page).not.toMatch(/style="[^"]*(?:^|[;\s"])(?:min-)?width\s*:/i); | |
| 72 | for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390); | |
| 73 | const over: string[] = []; | |
| 74 | for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) { | |
| 75 | for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) { | |
| 76 | if (Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16) > 390) over.push(m[0]); | |
| 77 | } | |
| 78 | } | |
| 79 | expect(over).toEqual([]); | |
| 80 | }); | |
| 81 | ||
| 82 | test("the long-named app is a chip and a tell-box option in the served HTML (first paint)", () => { | |
| 83 | expect(page).toMatch(new RegExp(`id="live-chips"[\\s\\S]*?<a[^>]*>[^<]*${APP}`)); | |
| 84 | expect(page).toMatch(new RegExp(`<option value="alice/${APP}">`)); | |
| 85 | }); | |
| 86 | ||
| 87 | test("the chip wraps and the tell box shrinks: the rules exist, scoped to the home", () => { | |
| 88 | expect(content).toMatch(/#live-chips \.btn\s*\{[^}]*height:\s*auto[^}]*white-space:\s*normal[^}]*overflow-wrap:\s*anywhere/); | |
| 89 | expect(content).toMatch(/\.tell-box \.input\s*\{[^}]*min-width:\s*0/); | |
| 90 | }); | |
| 91 | }); | |
| 92 | ||
| 93 | const found = findChromium(); | |
| 94 | const chromium = found !== undefined && chromiumRuns(found) ? found : undefined; | |
| 95 | if (chromium === undefined) console.log("SKIPPING: no chromium that starts on this machine, so the home phone look is not measured"); | |
outdated
The |
||
| 96 | ||
| 97 | describe.skipIf(chromium === undefined)("the home, measured at 390x844", () => { | |
| 98 | test("no sideways scroll", async () => { | |
| 99 | const measure = `(() => { | |
| 100 | const vw = document.documentElement.clientWidth; | |
| 101 | // Only a one-line ellipsis title inside main is excused: it clips by design. | |
| 102 | const clipped = (el) => { for (let p = el.parentElement; p && p.tagName !== "MAIN"; p = p.parentElement) { const c = getComputedStyle(p); if (c.textOverflow === "ellipsis" && c.overflowX === "hidden") return true; } return false; }; | |
| 103 | const overflowing = [...document.querySelectorAll("main *")].filter((el) => el.getBoundingClientRect().right > vw + 1 && !clipped(el)).slice(0, 8).map((el) => el.tagName.toLowerCase() + "." + el.className); | |
| 104 | const chip = document.querySelector("#live-chips .btn"); | |
| 105 | const chipRight = chip ? Math.round(chip.getBoundingClientRect().right) : -1; | |
| 106 | const sel = document.querySelector("#tell-box select"); | |
| 107 | const selRight = sel ? Math.round(sel.getBoundingClientRect().right) : -1; | |
| 108 | return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, overflowing, chipRight, selRight }); | |
| 109 | })()`; | |
| 110 | const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}/`, { width: 390, height: 844 }, [session], measure)) as { scrollWidth: number; viewportWidth: number; overflowing: string[]; chipRight: number; selRight: number }; | |
| 111 | console.log(`home 390x844: ${JSON.stringify(m)}`); | |
| 112 | expect(m.chipRight).toBeGreaterThan(0); | |
| 113 | expect(m.selRight).toBeGreaterThan(0); | |
| 114 | expect(m.chipRight).toBeLessThanOrEqual(m.viewportWidth); | |
| 115 | expect(m.selRight).toBeLessThanOrEqual(m.viewportWidth); | |
| 116 | expect(m.overflowing).toEqual([]); | |
| 117 | expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth); | |
| 118 | }, 30_000); | |
| 119 | }); |
new above
offline
Redundant/inert: row titles are single-line clips, so overflow-wrap here changes nothing, and it contradicts the comment above. Remove or justify.