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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9350-the-releases-page-fits-a-390-px-phone-no → main 5 commits fork 2 threads · 2 unresolved
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 | |
| 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 { | ||
| 31 | 31 | </p>`; |
| 32 | 32 | } |
| 33 | 33 | |
| 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 | ||
| 34 | 41 | export async function releasesPage(ctx: Ctx): Promise<Response> { |
| 35 | 42 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 36 | 43 | const user = ctx.auth.user; |
| @@ -73,7 +80,8 @@ export async function releasesPage(ctx: Ctx): Promise<Response> { | ||
| 73 | 80 | const response = page({ |
| 74 | 81 | ctx, |
| 75 | 82 | 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, | |
| 77 | 85 | }); |
| 78 | 86 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 79 | 87 | 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 |
||
| 80 | }); | |
| 81 | }); |
Stale: there is no
.tableor.table-scrollon /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.