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

The /releases page fits a 390 px phone: no sideways scroll, version rows and their actions wrap #9364

Merged opened this

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

Merged

mason/forge:mason/9350-the-releases-page-fits-a-390-px-phone-no → main 5 commits fork 2 threads · 2 unresolved

  • Merged. mason/9350-the-releases-page-fits-a-390-px-phone-no was merged into main as 0b8da22.

Review

Sign in to review this change.
3 files changed +100 −1
docs/design/areas/releases.md added +10
@@ -0,0 +1,10 @@
1 # Releases
2
3 ## The releases page at 390 px (#9350)
4
5 `/releases` lists the kept releases as rows in a `ul.list` (one `row` each: the short sha and its badges, then the
outdated

Stale: there is no .table or .table-scroll on /releases any more (main replaced the table with rows). Describe the list of rows that ships. Principle 8: plain words, and say only what is true.

6 pull, when it landed and since when it has been healthy, then the Roll back button when there is one). On a
7 narrow screen a row's text column shrinks and its title and meta lines end in an ellipsis, so the row never pushes the
8 page wider. Free text on the page (an owner's hold and its reason, a keeper's reason) sits inside
9 `[data-releases]` (an attribute, not a class), which breaks long words anywhere. The hold's reason input is capped at
10 100% of its box. The keeper panel's own table keeps the existing `.table-scroll` box. No fixed width. No new class.
src/web/pages/releases.ts +9 −1
@@ -31,6 +31,13 @@ export function liveLine(ctx: Ctx): Html | undefined {
3131 </p>`;
3232 }
3333
34 /**
35 * An owner's hold reason and a keeper's reason are free text: inside
36 * `[data-releases]` they break anywhere, so the page never scrolls sideways at 390 px. No new class.
37 */
38 export const RELEASES_CSS = `[data-releases] { overflow-wrap: anywhere; }
39 [data-releases] input[name="reason"] { max-width: 100%; }`;
40
3441 export async function releasesPage(ctx: Ctx): Promise<Response> {
3542 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
3643 const user = ctx.auth.user;
@@ -73,7 +80,8 @@ export async function releasesPage(ctx: Ctx): Promise<Response> {
7380 const response = page({
7481 ctx,
7582 title: "Releases",
76 body: html`<div class="page-head"><h1>Releases</h1></div>${keeperPanel(ctx.app.db, writer)}${body}`,
83 body: html`<div data-releases><div class="page-head"><h1>Releases</h1></div>${keeperPanel(ctx.app.db, writer)}${body}</div>`,
84 styleCss: RELEASES_CSS,
7785 });
7886 notePhase(ctx.request, "render", performance.now() - rendering);
7987 return response;
test/releases-fit.test.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * The releases page fits a 390 px phone (#9350): seeded long content (a
3 * 300-character unbroken hold reason, many release rows) ships no fixed width
4 * over 390 px, the free text wraps anywhere, and the releases are a list of
5 * rows, not a table.
6 */
7 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
8 import { mkdtempSync, rmSync } from "node:fs";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { markVerified } from "../src/core/verify.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { holdAs } from "../src/keeper/hold.ts";
17 import { recordRelease } from "../src/release/record.ts";
18 import { utilities as base } from "../src/web/style/base.ts";
19 import { components } from "../src/web/style/components.ts";
20 import { content } from "../src/web/style/content.ts";
21 import { quietLogs } from "./helpers/globals.ts";
22
23 quietLogs();
24 setDefaultTimeout(30_000);
25
26 const WORD = "R".repeat(300);
27 let root = "";
28 let app: App;
29 let servers: Servers;
30 let page = "";
31 const was = process.env["FORGE_COMMIT"];
32
33 beforeAll(async () => {
34 process.env["FORGE_COMMIT"] = "a".repeat(40);
35 root = mkdtempSync(join(tmpdir(), "forge-releases-fit-"));
36 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
37 app.db.query("INSERT INTO user (id, username, email, password_hash, is_admin) VALUES ('u-alice', 'alice', '[email protected]', 'x', 1)").run();
38 markVerified(app.db, "u-alice", new Date().toISOString());
39 const made = await createRepo(app, { id: "u-alice", username: "alice" } as never, { name: "forge" });
40 if (!made.ok) throw new Error(made.error);
41 const repoId = app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name='forge'").get()!.id;
42 for (let i = 0; i < 30; i++)
43 recordRelease(app.db, { sha: (i === 0 ? "a" : i.toString(16).padStart(2, "0")).repeat(i === 0 ? 40 : 20).slice(0, 40), repoId, pullNumber: i, ok: i % 5 !== 0, line: WORD });
44 const user = { id: "u-alice", username: "alice", isAdmin: true } as never;
45 holdAs(app, user, WORD);
46 servers = await serve(app);
47 const cookie = `forge_session=${createSession(app.db, "u-alice").token}`;
48 page = await (await fetch(`${servers.url}/releases`, { headers: { cookie } })).text();
49 }, 60_000);
50 afterAll(() => {
51 if (was === undefined) delete process.env["FORGE_COMMIT"];
52 else process.env["FORGE_COMMIT"] = was;
53 servers.stop();
54 app.close();
55 rmSync(root, { recursive: true, force: true });
56 });
57
58 describe("the releases page at 390 px", () => {
59 test("the long content is on the page, in many rows", () => {
60 expect(page).toContain(WORD);
61 expect(page.match(/<li\b/g)?.length ?? 0).toBeGreaterThanOrEqual(15);
62 });
63
64 test("no inline width and no shipped rule fixes a width over 390 px", () => {
65 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s])(?:min-|max-)?width\s*:/i);
66 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
67 const over: string[] = [];
68 for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) {
69 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
70 if (Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16) > 390) over.push(m[0]);
71 }
72 }
73 expect(over).toEqual([]);
74 });
75
76 test("the long-text container wraps anywhere", () => {
77 expect(page).toContain("data-releases");
78 const css = (page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? []).join("\n");
79 expect(css).toMatch(/\[data-releases\]\s*\{[^}]*overflow-wrap:\s*(anywhere|break-word)/);

Run this file against the base releases.ts and confirm it fails there. The description says that was not done.

80 });
81 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs