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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9326-the-milestones-page-fits-a-390-px-phone → main 7 commits fork 3 threads · 3 unresolved
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. | |
| 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 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The looks every preview of the forge takes as its owner (#8974): home |
| 3 | 3 | * 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. | |
| 8 | 9 | * |
| 9 | 10 | * Plain data and no imports beyond a type: the seed (`preview-seed.ts`) |
| 10 | 11 | * 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 | ||
| 44 | 45 | /** The owner's bets page, whose seeded proposed bets hold the long-name worst case (#9275). */ |
| 45 | 46 | export const PREVIEW_BETS_PAGE = `${PREVIEW_REPO_HOME}/bets`; |
| 46 | 47 | |
| 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 | ||
| 47 | 51 | /** The waves page, whose long headlines must wrap at a 390 px phone (#9325). */ |
| 48 | 52 | export const PREVIEW_WAVES_PAGE = `${PREVIEW_REPO_HOME}/waves`; |
| 49 | 53 | |
| @@ -80,6 +84,7 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [ | ||
| 80 | 84 | PREVIEW_ITERATIONS_PAGE, |
| 81 | 85 | PREVIEW_HELD_PAGE, |
| 82 | 86 | PREVIEW_BETS_PAGE, |
| 87 | PREVIEW_MILESTONES_PAGE, | |
| 83 | 88 | PREVIEW_WAVES_PAGE, |
| 84 | 89 | ].flatMap((page) => (["desktop", "phone"] as const).map((kind) => ({ page, kind }))); |
| 85 | 90 |
src/web/pages/limb_view.ts +4 −4
| @@ -29,14 +29,14 @@ export const list = (rows: Html[], wrap = false): Html => | ||
| 29 | 29 | export const wrappedCard = (rows: Html[]): Html => |
| 30 | 30 | card(list(rows, true), { pad: false }); |
| 31 | 31 | |
| 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. */ | |
| 33 | 33 | 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), { | |
| 36 | 36 | pad: false, |
| 37 | 37 | foot: html`<details> |
| 38 | 38 | <summary class="small">${rows.length - shown} more ${what}</summary> |
| 39 | ${list(rows.slice(shown))} | |
| 39 | ${list(rows.slice(shown), true)} | |
| 40 | 40 | </details>`, |
| 41 | 41 | }); |
| 42 | 42 | } |
src/web/pages/milestones.ts +9 −9
| @@ -59,7 +59,7 @@ import { html, when, type Html } from "../html.ts"; | ||
| 59 | 59 | import { field, readForm } from "../form.ts"; |
| 60 | 60 | import { page, redirect } from "../layout.ts"; |
| 61 | 61 | 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"; | |
| 63 | 63 | import { folded, issueRow, list, wrappedCard } from "./limb_view.ts"; |
| 64 | 64 | |
| 65 | 65 | /** Where a milestone is: its own page. */ |
| @@ -230,7 +230,7 @@ export function milestonesPage(ctx: Ctx): Response { | ||
| 230 | 230 | moverFor(ctx.app.db, repo, ctx.auth.user, plan.read), |
| 231 | 231 | ); |
| 232 | 232 | const rendering = performance.now(); |
| 233 | const body = html`${repoHeader(ctx, repo, "map")} | |
| 233 | const body = html`${repoHeader(ctx, repo, "map")}<div data-milestone> | |
| 234 | 234 | <div class="page-head mt"> |
| 235 | 235 | <h1>Milestones</h1> |
| 236 | 236 | <p class="muted"> |
| @@ -250,7 +250,7 @@ export function milestonesPage(ctx: Ctx): Response { | ||
| 250 | 250 | ), |
| 251 | 251 | )} |
| 252 | 252 | </div>`} |
| 253 | ${liveMount({ repo: base })}`; | |
| 253 | ${liveMount({ repo: base })}</div>`; | |
| 254 | 254 | const response = page({ |
| 255 | 255 | ctx, |
| 256 | 256 | title: `Milestones · ${repo.name}`, |
| @@ -325,7 +325,7 @@ function replanChanged(ctx: Ctx, repo: Repo, m: Milestone): Html | null { | ||
| 325 | 325 | <span class="muted small">#${n.number}</span>`; |
| 326 | 326 | return html`<section class="mt" id="ms-replanned"> |
| 327 | 327 | <h2 class="mb">Re-planned ${time(result.at)}</h2> |
| 328 | ${listCard( | |
| 328 | ${wrappedCard( | |
| 329 | 329 | result.splits.map((s) => |
| 330 | 330 | row({ |
| 331 | 331 | title: link(s), |
| @@ -447,7 +447,7 @@ function milestoneBody( | ||
| 447 | 447 | () => |
| 448 | 448 | html`<section class="mt" id="ms-building"> |
| 449 | 449 | <h2 class="mb">Building now</h2> |
| 450 | ${listCard( | |
| 450 | ${wrappedCard( | |
| 451 | 451 | m.building.map((b) => |
| 452 | 452 | issueRow( |
| 453 | 453 | base, |
| @@ -499,7 +499,7 @@ function milestoneBody( | ||
| 499 | 499 | </p> |
| 500 | 500 | ${spend.costliest.length === 0 |
| 501 | 501 | ? html`<p class="muted small">Nothing has cost anything yet.</p>` |
| 502 | : listCard( | |
| 502 | : wrappedCard( | |
| 503 | 503 | spend.costliest.map((c) => |
| 504 | 504 | issueRow(base, c.issue, undefined, pill(dollars(c.usd))), |
| 505 | 505 | ), |
| @@ -543,7 +543,7 @@ function milestoneBody( | ||
| 543 | 543 | card( |
| 544 | 544 | html`<details> |
| 545 | 545 | <summary class="small">${closed.length} closed</summary> |
| 546 | ${list(closed.map((leaf) => leafRow(leaf, false)))} | |
| 546 | ${list(closed.map((leaf) => leafRow(leaf, false)), true)} | |
| 547 | 547 | </details>`, |
| 548 | 548 | { pad: false }, |
| 549 | 549 | ), |
| @@ -598,9 +598,9 @@ export function milestonePage(ctx: Ctx, now: Date = new Date()): Response { | ||
| 598 | 598 | title: `${milestone.name} · ${repo.name}`, |
| 599 | 599 | crumbs: crumbsFor(repo), |
| 600 | 600 | 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. |
||
| 602 | 602 | ${milestoneBody(repo, milestone, owner, actions, move)} |
| 603 | ${replanChanged(ctx, repo, milestone)} | |
| 603 | ${replanChanged(ctx, repo, milestone)}</div> | |
| 604 | 604 | ${liveMount({ repo: repoBase(repo) })}`, |
| 605 | 605 | scriptJs: liveClientJs, |
| 606 | 606 | }); |
src/web/style/content.ts +1 −1
| @@ -145,7 +145,7 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 145 | 145 | font-variant-numeric: tabular-nums; letter-spacing: 0.14em; |
| 146 | 146 | text-align: center; user-select: all; word-break: break-all; |
| 147 | 147 | } |
| 148 | [data-apps], [data-bet-card] { overflow-wrap: anywhere; } | |
| 148 | [data-apps], [data-bet-card], [data-milestone] { overflow-wrap: anywhere; } | |
| 149 | 149 | [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. |
||
| 150 | 150 | [data-bet-card] ul { padding-inline-start: 16px; } |
| 151 | 151 | [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 | }); |
new above
offline
This line is far wider than the rest of the file; wrap it like the lines around it.