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

The /family page fits a 390 px phone: the fork tree scrolls inside its own box, the page never scrolls sideways #9614

Merged opened this

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

Merged

mason/forge:mason/9375-the-family-page-fits-a-390-px-phone-the → main 3 commits fork

  • Merged. mason/9375-the-family-page-fits-a-390-px-phone-the was merged into main as d338b5a.

Review

Sign in to review this change.
3 files changed +104 −3
src/web/pages/family.ts +2 −2
@@ -122,12 +122,12 @@ export async function familyPage(
122122 const response = page({
123123 ctx,
124124 title: "Family",
125 body: html`<div class="narrow-wide">
125 body: html`<div class="narrow-wide" data-family>
126126 ${pageHead("Family", {
127127 sub: "The forge this one came from, and the forges it sent. Children report in; nothing here reaches into one.",
128128 })}
129129 ${card(
130 html`<ul class="list" aria-label="Forges, by who sent whom">
130 html`<ul class="list setting-list" aria-label="Forges, by who sent whom">
131131 ${treeRow(root, 0)}
132132 </ul>
133133 ${kids.more > 0
src/web/style/content.ts +1 −1
@@ -158,7 +158,7 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
158158 [data-tell] .page-head-sub { flex-wrap: wrap; }
159159 [data-tell] .comment-body img { max-width: 100%; height: auto; }
160160 [data-tell] pre { max-width: 100%; overflow-x: auto; }
161 [data-apps], [data-apps-page], [data-bet-card], [data-explore], [data-milestone], [data-now], [data-people] { overflow-wrap: anywhere; }
161 [data-apps], [data-apps-page], [data-bet-card], [data-explore], [data-family], [data-milestone], [data-now], [data-people] { overflow-wrap: anywhere; }
162162 /* /explore (#9370): rows wrap rather than truncate a long name or description. */
163163 [data-explore] .row-title, [data-explore] .row-meta { white-space: normal; overflow: visible; }
164164 [data-explore] .row { flex-wrap: wrap; }
test/family-fit.test.ts added +101
@@ -0,0 +1,101 @@
1 /**
2 * The family page fits a 390 px phone (#9375): seeded with a 250-character
3 * unbroken forge name and many children, the page fixes no width over 390 px,
4 * long words wrap, and a real Chromium does not scroll it sideways.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdir, mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { findChromium } from "../src/looks/capture.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 { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19
20 quietLogs();
21
22 const WORD = "F".repeat(200);
23 let dir: string;
24 let app: App;
25 let servers: Servers;
26 let page: string;
27 let session: { name: string; value: string };
28
29 beforeAll(async () => {
30 dir = await mkdtemp(join(tmpdir(), "forge-family-fit-"));
31 app = createApp({ dataDir: dir, port: 0, parentUrl: `https://${"p".repeat(200)}.example.test` });
32 servers = await serve(app);
33 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
34 if (!made.ok) throw new Error(made.error);
35 await mkdir(join(dir, "spawn"), { recursive: true });
36 for (let n = 0; n < 30; n++) {
37 const name = `${n}${WORD}`.slice(0, 200);
38 await Bun.write(
39 join(dir, "spawn", `${name}.json`),
40 JSON.stringify({ name, bodyId: `b${n}`, url: `https://${name}.example.test`, planHash: "x", done: true, createdAt: new Date().toISOString() }),
41 );
42 }
43 const cookie = ((await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), redirect: "manual" })).headers.get("set-cookie") ?? "").split(";")[0]!;
44 session = { name: cookie.slice(0, cookie.indexOf("=")), value: cookie.slice(cookie.indexOf("=") + 1) };
45 page = await (await fetch(`${servers.url}/family`, { headers: { cookie } })).text();
46 }, 30_000);
47 afterAll(async () => {
48 servers.stop();
49 app.close();
50 await rm(dir, { recursive: true, force: true });
51 });
52
53 describe("the family page at 390 px", () => {
54 test("renders the seeded long names and many rows", () => {
55 expect(page).toContain(WORD.slice(0, 100));
56 expect(page.match(/<li/g)!.length).toBeGreaterThan(20);
57 expect(page).toContain("data-family");
58 });
59
60 test("no inline width, and no rule the page ships fixes a width over 390 px", () => {
61 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s"])(?:min-)?width\s*:/i);
62 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
63 const over: string[] = [];
64 for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) {
65 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
66 const px = Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16);
67 if (px > 390) over.push(m[0]);
68 }
69 }
70 expect(over).toEqual([]);
71 });
72
73 test("the tree wraps its long names instead of truncating them", () => {
74 expect(page).toContain('class="list setting-list"');
75 const rule = content.match(/(?:^|\n)([^\n{]*\[data-family\][^\n{]*)\{([^}]*)\}/);
76 expect(rule).not.toBeNull();
77 expect(rule![1]).toMatch(/(?:^|,)\s*\[data-family\]\s*(?:,|$)/);
78 expect(rule![2]).toMatch(/overflow-wrap:\s*(?:anywhere|break-word)/);
79 expect(components).toMatch(/\.setting-list \.row-title[^{]*\{[^}]*overflow-wrap:\s*anywhere/);
80 });
81 });
82
83 const found = findChromium();
84 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
85 if (chromium === undefined) console.log("SKIPPING: no chromium that starts on this machine, so the family phone look is not measured");
86
87 describe.skipIf(chromium === undefined)("the family page, measured at 390x844", () => {
88 test("no sideways scroll", async () => {
89 const measure = `(() => {
90 const vw = document.documentElement.clientWidth;
91 const scope = [...document.querySelectorAll("[data-family], [data-family] *")];
92 const overflowing = scope.filter((el) => el.getBoundingClientRect().right > vw + 1).slice(0, 8).map((el) => el.tagName.toLowerCase());
93 return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, scope: scope.length, overflowing, rows: document.querySelectorAll("[data-family] li").length });
94 })()`;
95 const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}/family`, { width: 390, height: 844 }, [session], measure)) as { scrollWidth: number; viewportWidth: number; scope: number; overflowing: string[]; rows: number };
96 console.log(`family 390x844: ${JSON.stringify(m)}`);
97 expect(m.rows).toBeGreaterThan(20);
98 expect(m.overflowing).toEqual([]);
99 expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth);
100 }, 30_000);
101 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs