Public: anyone can read this; the crew can change it.
The /now/:stage pages fit a 390 px phone: no sideways scroll, long titles and run names wrap #9393
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9377-the-now-stage-pages-fit-a-390-px-phone-n → main 2 commits fork
3 files
changed +148
−14
src/web/pages/now.ts +16 −13
| @@ -11,7 +11,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 11 | 11 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 12 | 12 | import { html } from "../html.ts"; |
| 13 | 13 | import { page } from "../layout.ts"; |
| 14 | import { empty, listCard, row, timeAgo } from "../ui.ts"; | |
| 14 | import { card, empty, row, timeAgo } from "../ui.ts"; | |
| 15 | 15 | |
| 16 | 16 | /** Where a word of the Now line goes. */ |
| 17 | 17 | export const nowHref = (stage: NowStage): string => `/now/${stage}`; |
| @@ -28,23 +28,26 @@ export function nowPage(ctx: Ctx): Response { | ||
| 28 | 28 | const body = |
| 29 | 29 | total === 0 |
| 30 | 30 | ? empty({ icon: "activity", title: `Nothing in ${NOW_WORDS[stage].toLowerCase()} right now`, note: NOW_HOLDS[stage] }) |
| 31 | : listCard( | |
| 32 | rows.map((r) => | |
| 33 | row({ | |
| 34 | href: r.href, | |
| 35 | nav: true, | |
| 36 | title: html`<span class="truncate">${r.title}</span>`, | |
| 37 | meta: html`${r.repo} · ${r.note}`, | |
| 38 | trail: html`<span class="muted small">${timeAgo(r.at)}</span>`, | |
| 39 | }), | |
| 40 | ), | |
| 31 | : card( | |
| 32 | html`<ul class="list setting-list"> | |
| 33 | ${rows.map((r) => | |
| 34 | row({ | |
| 35 | href: r.href, | |
| 36 | nav: true, | |
| 37 | title: html`${r.title}`, | |
| 38 | meta: html`${r.repo} · ${r.note}`, | |
| 39 | trail: html`<span class="muted small">${timeAgo(r.at)}</span>`, | |
| 40 | }), | |
| 41 | )} | |
| 42 | </ul>`, | |
| 43 | { pad: false }, | |
| 41 | 44 | ); |
| 42 | 45 | notePhase(ctx.request, "render", performance.now() - reading); |
| 43 | 46 | return page({ |
| 44 | 47 | ctx, |
| 45 | 48 | title: NOW_WORDS[stage], |
| 46 | body: html`<div class="page-head"><h1>${NOW_WORDS[stage]}</h1></div> | |
| 49 | body: html`<div data-now><div class="page-head"><h1>${NOW_WORDS[stage]}</h1></div> | |
| 47 | 50 | <p class="muted small" id="now-count"><strong>${String(total)}</strong> · ${NOW_HOLDS[stage]}${total > rows.length ? ` Showing the newest ${NOW_LISTED}.` : ""}</p> |
| 48 | ${body}`, | |
| 51 | ${body}</div>`, | |
| 49 | 52 | }); |
| 50 | 53 | } |
src/web/style/content.ts +1 −1
| @@ -152,7 +152,7 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); } | ||
| 152 | 152 | font-variant-numeric: tabular-nums; letter-spacing: 0.14em; |
| 153 | 153 | text-align: center; user-select: all; word-break: break-all; |
| 154 | 154 | } |
| 155 | [data-apps], [data-bet-card], [data-milestone] { overflow-wrap: anywhere; } | |
| 155 | [data-apps], [data-bet-card], [data-milestone], [data-now] { overflow-wrap: anywhere; } | |
| 156 | 156 | [data-bet-card] h2, [data-bet-card] ul, [data-bet-card] p { margin-block: 8px; } |
| 157 | 157 | [data-bet-card] ul { padding-inline-start: 16px; } |
| 158 | 158 | [data-bet-card] { max-height: 80vh; overflow-x: hidden; overflow-y: auto; } |
test/now-fit.test.ts added +131
| @@ -0,0 +1,131 @@ | ||
| 1 | /** | |
| 2 | * The /now/:stage pages fit a 390 px phone (#9377): seeded with a 250-character | |
| 3 | * unbroken title and many rows, no stage fixes a width over 390 px, and long | |
| 4 | * 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 { createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createIssue } from "../src/core/issues.ts"; | |
| 13 | import { createSession } from "../src/core/sessions.ts"; | |
| 14 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { NOW_STAGES, type NowStage } from "../src/flow/now_rows.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 | import { promote } from "./helpers/members.ts"; | |
| 23 | ||
| 24 | const WORD = "T".repeat(250); | |
| 25 | let dir: string; | |
| 26 | let app: App; | |
| 27 | let servers: Servers; | |
| 28 | let session: { name: string; value: string }; | |
| 29 | const pages = {} as Record<NowStage, string>; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | dir = await mkdtemp(join(tmpdir(), "forge-now-fit-")); | |
| 33 | app = createApp({ dataDir: dir, port: 0 }); | |
| 34 | servers = await serve(app); | |
| 35 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "root", allowReserved: true, email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 36 | if (!made.ok) throw new Error(made.error); | |
| 37 | promote(app.db, made.value); | |
| 38 | const token = createSession(app.db, made.value.id).token; | |
| 39 | session = { name: "forge_session", value: token }; | |
| 40 | const alice = getUserByName(app.db, "root")!; | |
| 41 | const r = await createRepo(app, alice, { name: "fit", visibility: "public" }); | |
| 42 | if (!r.ok) throw new Error(String(r.error)); | |
| 43 | const pull = (title: string) => { | |
| 44 | const i = createIssue(app.db, { repoId: r.value.id, authorId: alice.id, title, kind: "pull" }); | |
| 45 | if (!i.ok) throw new Error(i.error); | |
| 46 | app.db.query("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?, 'main', ?, 'b', 'h')").run(i.value.id, `h-${crypto.randomUUID()}`); | |
| 47 | return i.value; | |
| 48 | }; | |
| 49 | const run = (kind: string, state: string, issueId: string | null, extra: { reason?: string; env?: string; serving?: boolean } = {}) => | |
| 50 | app.db | |
| 51 | .query("INSERT INTO run (id, repo_id, kind, issue_id, env, state, reason, serving_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") | |
| 52 | .run(crypto.randomUUID(), r.value.id, kind, issueId, extra.env ?? "", state, extra.reason ?? "", extra.serving ? new Date().toISOString() : null); | |
| 53 | for (let n = 0; n < 12; n++) { | |
| 54 | const title = n === 0 ? WORD : n === 1 ? "A long and winding pull title ".repeat(8) : `Pull ${n} ${WORD.slice(0, 200)}`; | |
| 55 | const p = pull(title); | |
| 56 | run("check", n % 2 ? "queued" : "running", p.id); | |
| 57 | run("agent", "running", p.id, { reason: "review" }); | |
| 58 | app.db.query("INSERT INTO review (id, issue_id, author_id, state) VALUES (?, ?, ?, 'approved')").run(crypto.randomUUID(), p.id, alice.id); | |
| 59 | run("deploy", "ok", null, { env: "production", serving: true }); | |
| 60 | } | |
| 61 | for (const stage of NOW_STAGES) pages[stage] = await (await fetch(`${servers.url}/now/${stage}`, { headers: { cookie: `forge_session=${token}` } })).text(); | |
| 62 | }, 30_000); | |
| 63 | afterAll(async () => { | |
| 64 | servers.stop(); | |
| 65 | app.close(); | |
| 66 | await rm(dir, { recursive: true, force: true }); | |
| 67 | }); | |
| 68 | ||
| 69 | describe("the /now/:stage pages at 390 px", () => { | |
| 70 | test("every stage renders many rows, the long ones carrying the seeded word", () => { | |
| 71 | for (const stage of NOW_STAGES) { | |
| 72 | expect(pages[stage].match(/<li/g)!.length).toBeGreaterThan(5); | |
| 73 | expect(pages[stage]).toContain("data-now"); | |
| 74 | } | |
| 75 | for (const stage of ["check", "review", "land"] as const) expect(pages[stage]).toContain(WORD); | |
| 76 | }); | |
| 77 | ||
| 78 | test("no inline width, and no rule the page ships fixes a width over 390 px", () => { | |
| 79 | for (const stage of NOW_STAGES) { | |
| 80 | const page = pages[stage]; | |
| 81 | expect(page).not.toMatch(/style="[^"]*(?:^|[;\s"])(?:min-)?width\s*:/i); | |
| 82 | for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390); | |
| 83 | const over: string[] = []; | |
| 84 | for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) { | |
| 85 | for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) { | |
| 86 | const px = Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16); | |
| 87 | if (px > 390) over.push(m[0]); | |
| 88 | } | |
| 89 | } | |
| 90 | expect(over).toEqual([]); | |
| 91 | } | |
| 92 | }); | |
| 93 | ||
| 94 | test("the list wraps its rows instead of truncating a long title", () => { | |
| 95 | for (const stage of NOW_STAGES) { | |
| 96 | expect(pages[stage]).toContain('<ul class="list setting-list">'); | |
| 97 | expect(pages[stage]).not.toContain('class="truncate"'); | |
| 98 | } | |
| 99 | }); | |
| 100 | ||
| 101 | test("long-text containers wrap anywhere", () => { | |
| 102 | const rule = content.match(/(?:^|\n)([^\n{]*\[data-now\][^\n{]*)\{([^}]*)\}/); | |
| 103 | expect(rule).not.toBeNull(); | |
| 104 | expect(rule![1]).toMatch(/(?:^|,)\s*\[data-now\]\s*(?:,|$)/); | |
| 105 | expect(rule![2]).toMatch(/overflow-wrap:\s*(?:anywhere|break-word)/); | |
| 106 | expect(components).toMatch(/\.setting-list \.row-title[^{]*\{[^}]*overflow-wrap:\s*anywhere/); | |
| 107 | }); | |
| 108 | }); | |
| 109 | ||
| 110 | const found = findChromium(); | |
| 111 | const chromium = found !== undefined && chromiumRuns(found) ? found : undefined; | |
| 112 | if (chromium === undefined) console.log("SKIPPING: no chromium that starts on this machine, so the now phone look is not measured"); | |
| 113 | ||
| 114 | describe.skipIf(chromium === undefined)("the /now/:stage pages, measured at 390x844", () => { | |
| 115 | const measure = `(() => { | |
| 116 | const vw = document.documentElement.clientWidth; | |
| 117 | const scope = [...document.querySelectorAll("[data-now], [data-now] *")]; | |
| 118 | const overflowing = scope.filter((el) => el.getBoundingClientRect().right > vw + 1).slice(0, 8).map((el) => el.tagName.toLowerCase()); | |
| 119 | return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, wrapped: scope.length > 0, overflowing, rows: document.querySelectorAll("[data-now] li").length }); | |
| 120 | })()`; | |
| 121 | for (const stage of NOW_STAGES) { | |
| 122 | test(`${stage}: no sideways scroll`, async () => { | |
| 123 | const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}/now/${stage}`, { width: 390, height: 844 }, [session], measure)) as { scrollWidth: number; viewportWidth: number; wrapped: boolean; overflowing: string[]; rows: number }; | |
| 124 | console.log(`now ${stage} 390x844: ${JSON.stringify(m)}`); | |
| 125 | expect(m.wrapped).toBe(true); | |
| 126 | expect(m.rows).toBeGreaterThan(5); | |
| 127 | expect(m.overflowing).toEqual([]); | |
| 128 | expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth); | |
| 129 | }, 30_000); | |
| 130 | } | |
| 131 | }); |
new above
offline