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

Merged opened this

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

Merged

scout/forge:scout/9378-the-signed-in-home-timeline-fits-a-390-p → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/9378-the-signed-in-home-timeline-fits-a-390-p was merged into main as a31f040.

Review

Sign in to review this change.
2 files changed +126 −0
src/web/style/content.ts +7
@@ -75,6 +75,13 @@ a.where-place:hover, button.where-place:hover {
7575 .row-meta .where-place > span[aria-hidden="true"] { margin-right: 3px; }
7676 .row-title a { color: inherit; text-decoration: none; }
7777 .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; }
outdated

Redundant/inert: row titles are single-line clips, so overflow-wrap here changes nothing, and it contradicts the comment above. Remove or justify.

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.

7885 .row-trail form { display: inline-flex; }
7986
8087 /* "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 clipped filter excuses any element under any non-visible overflow ancestor; restrict it to genuine ellipsis clips inside main.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs