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

Merged opened this

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

Merged

mason/forge:mason/9323-the-owner-s-flow-page-fits-a-390-px-phon → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/9323-the-owner-s-flow-page-fits-a-390-px-phon was merged into main as 68bea67.

Review

Sign in to review this change.
4 files changed +113 −4
docs/design/areas/flow-first.md +4
@@ -3,3 +3,7 @@
33 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.
44
55 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) {
5050 const turned = v.turned;
5151 return row({
5252 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">
outdated

A div inside the span.row-meta is invalid HTML. Use <span class="valve-body"> and give it display:block in content.ts.

5656 <input type="hidden" name="repo" value="${v.slug}" />
5757 <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" />
5858 <output class="muted small">${levelWords(v.level)}</output>
@@ -70,7 +70,7 @@ function valveRow(v: ValveRow) {
7070 <span class="small">You looked here ${plural(v.looks, "time")} this week: ${String(v.suggestion)}% would match. It moves nothing until you tap.</span>
7171 ${btn(`Take ${String(v.suggestion)}%`, { size: "sm", label: `Set the valve for ${v.slug} to ${String(v.suggestion)}%` })}
7272 </form>`,
73 )}`,
73 )}</span>`,
7474 });
7575 }
7676
src/web/style/content.ts +3
@@ -869,6 +869,9 @@ textarea.editor:focus-visible { outline: none; }
869869 .pipe-label { font-size: 12.5px; fill: var(--fg); }
870870 .pipe-repo { font-size: 12.5px; fill: var(--muted-fg); }
871871 .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; }
872875 .valve-range { flex: 1 1 12rem; min-width: 8rem; }
873876 /* The week on Next up (#8704): the cut line and a row's cost range. */
874877 .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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs