Public: anyone can read this; the crew can change it.
The owner's /flow page fits a 390 px phone: no sideways scroll, long names wrap #9360
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9323-the-owner-s-flow-page-fits-a-390-px-phon → main 4 commits fork 2 threads · 2 unresolved
4 files
changed +113
−4
docs/design/areas/flow-first.md +4
| @@ -3,3 +3,7 @@ | ||
| 3 | 3 | The `flow` label puts ready work first, and one button applies it: "Flow first" on an issue's header and on each row of the issues list (`src/web/flow_button.ts`), a small outline form that posts to the issue's own `/flow` and lands back where it was pressed. On the terminal the issue screen has `f` ("flow first"), a toggle through the same action. |
| 4 | 4 | |
| 5 | 5 | On the list the form cannot sit inside the row's link, so a row that has a control beside it is `li.has-after` with a `.row-after` span: the link keeps the width, the control keeps its own. `.row-after` is the only new class. |
| 6 | ||
| 7 | ## /flow on a 390 px phone (#9323) | |
| 8 | ||
| 9 | A valve row's title and meta are one clipped line by default (`.row-title`, `.row-meta`), which cut a long repository name and the valve's sentences short. Two classes undo that inside the row: `.valve-name` wraps the repository link and its week's numbers, `.valve-body` wraps the valve form and the sentences under it; both set `white-space: normal` and `overflow-wrap: anywhere` (content.ts). `test/flow-phone-fit.test.ts` holds it. |
src/web/pages/flow.ts +4 −4
| @@ -50,9 +50,9 @@ function valveRow(v: ValveRow) { | ||
| 50 | 50 | const turned = v.turned; |
| 51 | 51 | return row({ |
| 52 | 52 | lead: icon("activity"), |
| 53 | title: html`<a href="/${v.slug}">${v.slug}</a> | |
| 54 | <span class="muted small">${money(v.usd)} this week · ${plural(v.runs, "run")} · ${plural(v.shipped, "pull")} shipped${v.notes > 0 ? html` · ${plural(v.notes, "note")}` : ""}</span>`, | |
| 55 | meta: html`<form class="valve-form" method="post" action="/flow/valve"> | |
| 53 | title: html`<span class="valve-name"><a href="/${v.slug}">${v.slug}</a> | |
| 54 | <span class="muted small">${money(v.usd)} this week · ${plural(v.runs, "run")} · ${plural(v.shipped, "pull")} shipped${v.notes > 0 ? html` · ${plural(v.notes, "note")}` : ""}</span></span>`, | |
| 55 | meta: html`<span class="valve-body"><form class="valve-form" method="post" action="/flow/valve"> | |
| 56 | 56 | <input type="hidden" name="repo" value="${v.slug}" /> |
| 57 | 57 | <input class="valve-range" type="range" name="level" min="0" max="100" step="5" value="${String(v.level)}" aria-label="Valve for ${v.slug}: 0 shut, 100 open" /> |
| 58 | 58 | <output class="muted small">${levelWords(v.level)}</output> |
| @@ -70,7 +70,7 @@ function valveRow(v: ValveRow) { | ||
| 70 | 70 | <span class="small">You looked here ${plural(v.looks, "time")} this week: ${String(v.suggestion)}% would match. It moves nothing until you tap.</span> |
| 71 | 71 | ${btn(`Take ${String(v.suggestion)}%`, { size: "sm", label: `Set the valve for ${v.slug} to ${String(v.suggestion)}%` })} |
| 72 | 72 | </form>`, |
| 73 | )}`, | |
| 73 | )}</span>`, | |
| 74 | 74 | }); |
| 75 | 75 | } |
| 76 | 76 |
src/web/style/content.ts +3
| @@ -869,6 +869,9 @@ textarea.editor:focus-visible { outline: none; } | ||
| 869 | 869 | .pipe-label { font-size: 12.5px; fill: var(--fg); } |
| 870 | 870 | .pipe-repo { font-size: 12.5px; fill: var(--muted-fg); } |
| 871 | 871 | .valve-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0; } |
| 872 | /* A valve row's name and body are sentences, not one clipped line (#9323): they wrap inside the row. */ | |
| 873 | .valve-name, .valve-body { white-space: normal; overflow-wrap: anywhere; } | |
Non-blocking: fold this into the rule above, the same selector twice. |
||
| 874 | .valve-name, .valve-body { display: block; } | |
| 872 | 875 | .valve-range { flex: 1 1 12rem; min-width: 8rem; } |
| 873 | 876 | /* The week on Next up (#8704): the cut line and a row's cost range. */ |
| 874 | 877 | .next-week { opacity: 0.55; } |
test/flow-phone-fit.test.ts added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | /** | |
| 2 | * The owner's /flow page fits a 390 px phone (#9323): a repository with a | |
| 3 | * 300-character unbroken name and many valves ships no fixed width over | |
| 4 | * 390 px, wraps its long text, and (where a Chromium starts) does not | |
| 5 | * scroll sideways. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { createHmac } from "node:crypto"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | import { createSession } from "../src/core/sessions.ts"; | |
| 15 | import { findChromium } from "../src/looks/capture.ts"; | |
| 16 | import { previewAdmin, seedPreview } from "../src/spawn/preview-seed.ts"; | |
| 17 | import { base as elements, 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 { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts"; | |
| 21 | ||
| 22 | const KEY = "flow-phone-fit-key"; | |
| 23 | const WORD = "w".repeat(300); | |
| 24 | let dir: string; | |
| 25 | let app: App; | |
| 26 | let servers: Servers; | |
| 27 | let page: string; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dir = await mkdtemp(join(tmpdir(), "forge-flow-fit-")); | |
| 31 | app = createApp({ dataDir: dir, port: 0, previewIdentityKey: KEY }); | |
| 32 | const seeded = await seedPreview(app, { FORGE_APP_ENV: "preview" }); | |
| 33 | if (!seeded.ok) throw new Error(seeded.error); | |
| 34 | const admin = previewAdmin(app)!; | |
| 35 | const repos = app.db.query("SELECT id FROM repo").all() as { id: string }[]; | |
| 36 | app.db.query("UPDATE repo SET name = ?1 WHERE id = ?2").run(WORD, repos[0]!.id); | |
| 37 | for (let i = 0; i < 12; i++) { | |
| 38 | const id = `fit-${String(i)}`; | |
| 39 | app.db.query("INSERT INTO repo (id, owner_id, name, created_at) SELECT ?1, owner_id, ?2, created_at FROM repo WHERE id = ?3").run(id, `r${String(i)}-${WORD}`, repos[0]!.id); | |
| 40 | app.db.query("INSERT INTO flow_valve (repo_id, level, set_by, set_at) VALUES (?1, ?2, ?3, ?4)").run(id, 35, admin.id, new Date().toISOString()); | |
| 41 | } | |
| 42 | servers = await serve(app); | |
| 43 | const said = `u1|trevato|admin|${Date.now() + 60_000}`; | |
| 44 | const headers = { "x-forge-identity": said, "x-forge-signature": createHmac("sha256", KEY).update(said).digest("base64url") }; | |
| 45 | page = await (await fetch(`${servers.url}/flow`, { headers })).text(); | |
| 46 | }, 30_000); | |
| 47 | afterAll(async () => { | |
| 48 | servers.stop(); | |
| 49 | app.close(); | |
| 50 | await rm(dir, { recursive: true, force: true }); | |
| 51 | }); | |
| 52 | ||
| 53 | const widthsOver390 = (css: string): string[] => | |
| 54 | [...css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)] | |
| 55 | .filter((m) => Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16) > 390) | |
| 56 | .map((m) => m[0]); | |
| 57 | ||
| 58 | describe("the owner's flow page at 390 px", () => { | |
| 59 | test("the seeded long content is on the page, in many rows", () => { | |
| 60 | expect(page).toContain(WORD); | |
| 61 | expect(page.match(/class="valve-form"/g)!.length).toBeGreaterThan(10); | |
| 62 | }); | |
| 63 | ||
| 64 | test("no inline width and no rule the page ships fixes a width over 390 px", () => { | |
| 65 | for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390); | |
| 66 | expect(page).not.toMatch(/style="[^"]*(?:^|[;\s])min-width\s*:/i); | |
| 67 | const inline = [...page.matchAll(/style="([^"]*)"/g)].map((m) => m[1]!); | |
| 68 | const sheets = [elements, base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]; | |
| 69 | expect([...inline, ...sheets].flatMap(widthsOver390)).toEqual([]); | |
| 70 | }); | |
| 71 | ||
| 72 | test("the long-text containers wrap anywhere, and a valve's body is not one clipped line", () => { | |
| 73 | expect(content).toMatch(/\.valve-name[^{]*\{[^}]*overflow-wrap:\s*(anywhere|break-word)/); | |
| 74 | expect(content).toMatch(/\.valve-name[^{]*\{[^}]*white-space:\s*normal/); | |
| 75 | expect(content).toMatch(/\.valve-body[^{]*\{[^}]*overflow-wrap:\s*(anywhere|break-word)/); | |
| 76 | expect(content).toMatch(/\.valve-body[^{]*\{[^}]*white-space:\s*normal/); | |
| 77 | expect(page).toContain('class="valve-name"'); | |
| 78 | expect(page).toContain('class="valve-body"'); | |
| 79 | }); | |
| 80 | }); | |
| 81 | ||
| 82 | const found = findChromium(); | |
| 83 | const chromium = found !== undefined && chromiumRuns(found) ? found : undefined; | |
| 84 | if (chromium === undefined) console.log("SKIPPING: no chromium that starts on this machine, so the flow phone look is not measured"); | |
| 85 | ||
| 86 | describe.skipIf(chromium === undefined)("the owner's flow page, measured", () => { | |
| 87 | const measure = `(() => { | |
| 88 | const vw = document.documentElement.clientWidth; | |
| 89 | // The pipes SVG clips its own long labels to its box; the box is what is measured. | |
| 90 | const over = [...document.querySelectorAll("main *")].filter((el) => !el.closest("svg") || el.tagName.toLowerCase() === "svg").filter((el) => el.getBoundingClientRect().right > vw + 1).slice(0, 8).map((el) => el.tagName.toLowerCase()); | |
| 91 | const clipped = [...document.querySelectorAll(".valve-body, .valve-name")].filter((el) => el.scrollWidth > el.clientWidth + 1).length; | |
| 92 | return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, over, clipped }); | |
| 93 | })()`; | |
| 94 | test("phone, 390x844: no sideways scroll", async () => { | |
| 95 | const cookies = [{ name: "forge_session", value: createSession(app.db, previewAdmin(app)!.id).token }]; | |
| 96 | const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}/flow`, { width: 390, height: 844 }, cookies, measure)) as { scrollWidth: number; viewportWidth: number; over: string[]; clipped: number }; | |
| 97 | console.log(`flow look 390x844: ${JSON.stringify(m)}`); | |
| 98 | expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth); | |
| 99 | expect(m.over).toEqual([]); | |
| 100 | expect(m.clipped).toBe(0); | |
| 101 | }, 30_000); | |
| 102 | }); |
new above
offline
A div inside the span.row-meta is invalid HTML. Use <span class="valve-body"> and give it display:block in content.ts.