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

The milestones page fits a 390 px phone: no sideways scroll, no milestone row taller than a screen #9341

Merged opened this

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

Merged

scout/forge:scout/9326-the-milestones-page-fits-a-390-px-phone → main 7 commits fork 3 threads · 3 unresolved

  • Merged. scout/9326-the-milestones-page-fits-a-390-px-phone was merged into main as 64e1aa2.

Review

Sign in to review this change.
6 files changed +190 −18
docs/design/areas/milestones.md added +18
@@ -0,0 +1,18 @@
1 # Milestones
2
3 ## Fits a 390 px phone (#9326)
4
5 `/:owner/:name/milestones` and `/:owner/:name/milestones/:number` are stacked
6 blocks and one-line rows; nothing in them fixes a width. The one addition is
7 the attribute `data-milestone` (not a class) on the page's wrapper, which wraps
8 long words anywhere (`overflow-wrap: anywhere`) so the longest unbroken title
9 or reason in the `h1`, the estimate or a trim note cannot push the page
10 sideways. Limb rows already wrap through `.setting-list`; issue rows ellipsize
11 on one line, so no row grows taller than a screen. Like `data-bet-card`, it is
12 on both the list and an opened milestone.
outdated

This line is far wider than the rest of the file; wrap it like the lines around it.

13
14 Issue titles cap at 256 characters, so the test seeds a 250-character
15 unbroken word as the longest title that can exist; the estimate and trim notes
16 are built from titles and counts, so no field takes 300. It is an attribute
17 because the area adds no component class. Every preview of the forge takes
18 `/preview-admin/sample/milestones` at desk and phone (`PREVIEW_OWNER_LOOKS`).
src/spawn/preview-looks.ts +9 −4
@@ -1,10 +1,11 @@
11 /**
22 * The looks every preview of the forge takes as its owner (#8974): home
33 * with its pins, /flow, the seeded repository's head, its pull's page and
4 * its Iterations page (#9182) and the Later gate's proposal (#9212), each
5 * at desk and phone width. The looks service adds them to whatever
6 * forge.json lists for a preview of the forge's own repository
7 * (`FORGE_REPO`), so no edit of forge.json can drop one.
4 * its Iterations page (#9182), the Later gate's proposal (#9212), its
5 * milestones (#9326) and its waves (#9325), each at desk and phone width.
6 * The looks service adds them to whatever forge.json lists for a preview
7 * of the forge's own repository (`FORGE_REPO`), so no edit of forge.json can
8 * drop one.
89 *
910 * Plain data and no imports beyond a type: the seed (`preview-seed.ts`)
1011 * makes what these pages show, and the looks service reads the list.
@@ -44,6 +45,9 @@ export const PREVIEW_FORKS: readonly { readonly owner: string; readonly of?: str
4445 /** The owner's bets page, whose seeded proposed bets hold the long-name worst case (#9275). */
4546 export const PREVIEW_BETS_PAGE = `${PREVIEW_REPO_HOME}/bets`;
4647
48 /** The owner's milestones page, seeded with one milestone; its phone look shows the fit (#9326). */
49 export const PREVIEW_MILESTONES_PAGE = `${PREVIEW_REPO_HOME}/milestones`;
50
4751 /** The waves page, whose long headlines must wrap at a 390 px phone (#9325). */
4852 export const PREVIEW_WAVES_PAGE = `${PREVIEW_REPO_HOME}/waves`;
4953
@@ -80,6 +84,7 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [
8084 PREVIEW_ITERATIONS_PAGE,
8185 PREVIEW_HELD_PAGE,
8286 PREVIEW_BETS_PAGE,
87 PREVIEW_MILESTONES_PAGE,
8388 PREVIEW_WAVES_PAGE,
8489 ].flatMap((page) => (["desktop", "phone"] as const).map((kind) => ({ page, kind })));
8590
src/web/pages/limb_view.ts +4 −4
@@ -29,14 +29,14 @@ export const list = (rows: Html[], wrap = false): Html =>
2929 export const wrappedCard = (rows: Html[]): Html =>
3030 card(list(rows, true), { pad: false });
3131
32 /** A card of rows that shows its first `shown` and folds the rest. */
32 /** A card of wrapping rows that shows its first `shown` and folds the rest: a long title wraps on a phone rather than running past its edge. */
3333 export function folded(rows: Html[], what: string, shown: number): Html {
34 if (rows.length <= shown) return card(list(rows), { pad: false });
35 return card(list(rows.slice(0, shown)), {
34 if (rows.length <= shown) return wrappedCard(rows);
35 return card(list(rows.slice(0, shown), true), {
3636 pad: false,
3737 foot: html`<details>
3838 <summary class="small">${rows.length - shown} more ${what}</summary>
39 ${list(rows.slice(shown))}
39 ${list(rows.slice(shown), true)}
4040 </details>`,
4141 });
4242 }
src/web/pages/milestones.ts +9 −9
@@ -59,7 +59,7 @@ import { html, when, type Html } from "../html.ts";
5959 import { field, readForm } from "../form.ts";
6060 import { page, redirect } from "../layout.ts";
6161 import { liveClientJs, liveMount } from "../live.ts";
62 import { badge, btn, card, listCard, pill, row, time } from "../ui.ts";
62 import { badge, btn, card, pill, row, time } from "../ui.ts";
6363 import { folded, issueRow, list, wrappedCard } from "./limb_view.ts";
6464
6565 /** Where a milestone is: its own page. */
@@ -230,7 +230,7 @@ export function milestonesPage(ctx: Ctx): Response {
230230 moverFor(ctx.app.db, repo, ctx.auth.user, plan.read),
231231 );
232232 const rendering = performance.now();
233 const body = html`${repoHeader(ctx, repo, "map")}
233 const body = html`${repoHeader(ctx, repo, "map")}<div data-milestone>
234234 <div class="page-head mt">
235235 <h1>Milestones</h1>
236236 <p class="muted">
@@ -250,7 +250,7 @@ export function milestonesPage(ctx: Ctx): Response {
250250 ),
251251 )}
252252 </div>`}
253 ${liveMount({ repo: base })}`;
253 ${liveMount({ repo: base })}</div>`;
254254 const response = page({
255255 ctx,
256256 title: `Milestones · ${repo.name}`,
@@ -325,7 +325,7 @@ function replanChanged(ctx: Ctx, repo: Repo, m: Milestone): Html | null {
325325 <span class="muted small">#${n.number}</span>`;
326326 return html`<section class="mt" id="ms-replanned">
327327 <h2 class="mb">Re-planned ${time(result.at)}</h2>
328 ${listCard(
328 ${wrappedCard(
329329 result.splits.map((s) =>
330330 row({
331331 title: link(s),
@@ -447,7 +447,7 @@ function milestoneBody(
447447 () =>
448448 html`<section class="mt" id="ms-building">
449449 <h2 class="mb">Building now</h2>
450 ${listCard(
450 ${wrappedCard(
451451 m.building.map((b) =>
452452 issueRow(
453453 base,
@@ -499,7 +499,7 @@ function milestoneBody(
499499 </p>
500500 ${spend.costliest.length === 0
501501 ? html`<p class="muted small">Nothing has cost anything yet.</p>`
502 : listCard(
502 : wrappedCard(
503503 spend.costliest.map((c) =>
504504 issueRow(base, c.issue, undefined, pill(dollars(c.usd))),
505505 ),
@@ -543,7 +543,7 @@ function milestoneBody(
543543 card(
544544 html`<details>
545545 <summary class="small">${closed.length} closed</summary>
546 ${list(closed.map((leaf) => leafRow(leaf, false)))}
546 ${list(closed.map((leaf) => leafRow(leaf, false)), true)}
547547 </details>`,
548548 { pad: false },
549549 ),
@@ -598,9 +598,9 @@ export function milestonePage(ctx: Ctx, now: Date = new Date()): Response {
598598 title: `${milestone.name} · ${repo.name}`,
599599 crumbs: crumbsFor(repo),
600600 palette: repoPalette(ctx, repo),
601 body: html`${repoHeader(ctx, repo, "map")}
601 body: html`${repoHeader(ctx, repo, "map")}<div data-milestone>
outdated

This wraps only the opened milestone page. Apply the same wrapper to the list page so its h1 and notes wrap too, and test it.

602602 ${milestoneBody(repo, milestone, owner, actions, move)}
603 ${replanChanged(ctx, repo, milestone)}
603 ${replanChanged(ctx, repo, milestone)}</div>
604604 ${liveMount({ repo: repoBase(repo) })}`,
605605 scriptJs: liveClientJs,
606606 });
src/web/style/content.ts +1 −1
@@ -145,7 +145,7 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
145145 font-variant-numeric: tabular-nums; letter-spacing: 0.14em;
146146 text-align: center; user-select: all; word-break: break-all;
147147 }
148 [data-apps], [data-bet-card] { overflow-wrap: anywhere; }
148 [data-apps], [data-bet-card], [data-milestone] { overflow-wrap: anywhere; }
149149 [data-bet-card] h2, [data-bet-card] ul, [data-bet-card] p { margin-block: 8px; }
outdated

Remove this rule, or justify it. Nothing in the diff shows h1 and h2 are flex or grid items here.

150150 [data-bet-card] ul { padding-inline-start: 16px; }
151151 [data-bet-card] { max-height: 80vh; overflow-x: hidden; overflow-y: auto; }
test/milestones-fit.test.ts added +149
@@ -0,0 +1,149 @@
1 /**
2 * The milestones page fits a 390 px phone (#9326): seeded with a 250-character
3 * unbroken word, a long title and many rows, neither the top page nor an
4 * opened milestone fixes a width over 390 px, and long words wrap.
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 { createApp, type App } from "../src/app.ts";
11 import { linkIssue } from "../src/actions/dependencies.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { PREVIEW_MILESTONES_PAGE, ownerLooksFor } from "../src/spawn/preview-looks.ts";
17 import { utilities as base } from "../src/web/style/base.ts";
18 import { components } from "../src/web/style/components.ts";
19 import { content } from "../src/web/style/content.ts";
20 import { findChromium } from "../src/looks/capture.ts";
21 import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts";
22
23 const TITLE_WORD = "T".repeat(250);
24 let dir: string;
25 let app: App;
26 let servers: Servers;
27 let top: string;
28 let opened: string;
29 let session: { name: string; value: string };
30 let openedPath: string;
31
32 beforeAll(async () => {
33 dir = await mkdtemp(join(tmpdir(), "forge-ms-fit-"));
34 app = createApp({ dataDir: dir, port: 0 });
35 servers = await serve(app);
36 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
37 if (!made.ok) throw new Error(made.error);
38 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]!;
39 const alice = getUserByName(app.db, "alice")!;
40 const r = await createRepo(app, alice, { name: "fit", visibility: "public" });
41 if (!r.ok) throw new Error(String(r.error));
42 const mk = (title: string) => {
43 const o = openIssue(app, alice, r.value, { title });
44 if (!o.ok) throw new Error(String(o.error));
45 return o.value;
46 };
47 const link = (a: ReturnType<typeof mk>, b: ReturnType<typeof mk>) => {
48 const l = linkIssue(app, alice, r.value, a, b);
49 if (!l.ok) throw new Error(String(l.error));
50 };
51 // Many top-level limbs, each waiting on a leaf; the first has many leaves.
52 const roots = Array.from({ length: 30 }, (_, n) => mk(n === 0 ? TITLE_WORD : n === 1 ? "A long and winding milestone title ".repeat(7) : `Limb ${n} ${TITLE_WORD.slice(0, 200)}`));
53 for (const [n, root] of roots.entries()) link(root, mk(`Leaf ${n}`));
54 for (let n = 0; n < 25; n++) link(roots[0]!, mk(`Leaf ${n} ${TITLE_WORD.slice(0, 200)}`));
55 const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
56 session = { name: cookie.slice(0, cookie.indexOf("=")), value: cookie.slice(cookie.indexOf("=") + 1) };
57 openedPath = `/alice/fit/milestones/${roots[0]!.number}`;
58 top = await get("/alice/fit/milestones");
59 opened = await get(openedPath);
60 }, 30_000);
61 afterAll(async () => {
62 servers.stop();
63 app.close();
64 await rm(dir, { recursive: true, force: true });
65 });
66
67 describe("the milestones pages at 390 px", () => {
68 test("both pages render the seeded long content", () => {
69 expect(top).toContain(TITLE_WORD);
70 expect(top.match(/<li/g)!.length).toBeGreaterThan(5);
71 expect(opened).toContain(TITLE_WORD);
72 expect(opened).toContain("data-milestone");
73 expect(top).toContain("setting-list");
74 expect(top).toMatch(/<div data-milestone>/);
75 expect(opened).toMatch(/<div data-milestone>/);
76 });
77
78 test("no inline width, and no rule the page ships fixes a width over 390 px", () => {
79 for (const page of [top, opened]) {
80 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s"])(?:min-)?width\s*:/i);
81 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
82 const over: string[] = [];
83 for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) {
84 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
85 const px = Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16);
86 if (px > 390) over.push(m[0]);
87 }
88 }
89 expect(over).toEqual([]);
90 }
91 });
92
93 test("every list of rows on both pages wraps: none truncates a long title past the edge", () => {
94 for (const page of [top, opened]) {
95 const lists = [...page.matchAll(/<ul class="(list[^"]*)"/g)].map((m) => m[1]);
96 expect(lists.length).toBeGreaterThan(0);
97 for (const cls of lists) expect(cls).toBe("list setting-list");
98 }
99 });
100
101 test("long-text containers wrap anywhere", () => {
102 // The rule selects the attribute the pages render, and sets the wrap.
103 const rule = content.match(/(?:^|\n)([^\n{]*\[data-milestone\][^\n{]*)\{([^}]*)\}/);
104 expect(rule).not.toBeNull();
105 expect(rule![1]).toMatch(/(?:^|,)\s*\[data-milestone\]\s*(?:,|$)/);
106 expect(rule![2]).toMatch(/overflow-wrap:\s*(?:anywhere|break-word)/);
107 expect(components).toMatch(/\.setting-list \.row-title[^{]*\{[^}]*overflow-wrap:\s*anywhere/);
108 });
109 });
110
111 test("a forge preview takes the owner's milestones page at desk and phone (#9326)", () => {
112 const looks = ownerLooksFor("trevato/forge", { ownerName: "trevato", name: "forge" }, "preview");
113 expect(PREVIEW_MILESTONES_PAGE).toBe("/preview-admin/sample/milestones");
114 for (const kind of ["desktop", "phone"] as const) expect(looks).toContainEqual({ page: PREVIEW_MILESTONES_PAGE, kind });
115 });
116
117 /**
118 * Measured, not read from the CSS: both pages, with the 250-character
119 * unbroken title, in a real Chromium at 390x844 do not scroll sideways, and
120 * no milestone row is taller than the screen. Skipped where no Chromium
121 * starts; the forge's check runs it and logs the numbers.
122 */
123 const found = findChromium();
124 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
125 if (chromium === undefined) console.log("SKIPPING: no chromium that starts on this machine, so the milestones phone look is not measured");
126
127 describe.skipIf(chromium === undefined)("the milestones pages, measured at 390x844", () => {
128 const measure = `(() => {
129 const vw = document.documentElement.clientWidth;
130 const scope = [...document.querySelectorAll("[data-milestone], [data-milestone] *")];
131 const overflowing = scope.filter((el) => el.getBoundingClientRect().right > vw + 1).slice(0, 8).map((el) => el.tagName.toLowerCase());
132 const rows = [...document.querySelectorAll("[data-milestone] li")].map((li) => Math.round(li.getBoundingClientRect().height));
133 return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, wrapped: scope.length > 0, overflowing, tallest: Math.max(0, ...rows), rows: rows.length });
134 })()`;
135 type Measured = { scrollWidth: number; viewportWidth: number; wrapped: boolean; overflowing: string[]; tallest: number; rows: number };
136
137 for (const which of ["list", "opened"] as const) {
138 test(`${which}: no sideways scroll, no row taller than the screen`, async () => {
139 const path = which === "list" ? "/alice/fit/milestones" : openedPath;
140 const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}${path}`, { width: 390, height: 844 }, [session], measure)) as Measured;
141 console.log(`milestones ${which} 390x844: ${JSON.stringify(m)}`);
142 expect(m.wrapped).toBe(true);
143 expect(m.rows).toBeGreaterThan(5);
144 expect(m.overflowing).toEqual([]);
145 expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth);
146 expect(m.tallest).toBeLessThanOrEqual(844);
147 }, 30_000);
148 }
149 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs