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

The /apps page fits a 390 px phone: no sideways scroll, long app names and their actions wrap #9396

Merged opened this

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

Merged

scout/forge:scout/9371-the-apps-page-fits-a-390-px-phone-no-sid → main 3 commits fork 1 thread · 1 unresolved

  • Merged. scout/9371-the-apps-page-fits-a-390-px-phone-no-sid was merged into main as 09e7120.

Review

Sign in to review this change.
3 files changed +103 −4
src/web/pages/apps.ts +2 −3
@@ -81,7 +81,6 @@ import {
8181 envFacts,
8282 envStateBadge,
8383 icon,
84 listCard,
8584 previewDataNote,
8685 seg,
8786 selectField,
@@ -235,7 +234,7 @@ export async function appsList(ctx: Ctx): Promise<Response> {
235234 costLine(ctx.app.db, writableAppsOf(ctx, ctx.auth.user).repos),
236235 );
237236 const rendering = performance.now();
238 const body = html`${when(ctx.auth.user, (user) => homeTabs("apps", unshippedAsks(ctx.app.db, user)))}
237 const body = html`<div data-apps-page>${when(ctx.auth.user, (user) => homeTabs("apps", unshippedAsks(ctx.app.db, user)))}
239238 ${pageHead("Apps", { actions: create })}
240239 ${cost}
241240 ${filters}
@@ -259,7 +258,7 @@ export async function appsList(ctx: Ctx): Promise<Response> {
259258 : "A new app is a repository with a small working app in it, served at its own hostname a few seconds later.",
260259 action: create,
261260 })
262 : listCard(rows)}`;
261 : card(html`<ul class="list setting-list">${rows}</ul>`, { pad: false })}</div>`;

Hand-rolled card(ul.list.setting-list) replaces listCard. Prefer a listCard option so the named component stays the one place that builds this markup.

263262 const response = page({ ctx, title: "Apps", body });
264263 notePhase(ctx.request, "render", performance.now() - rendering);
265264 return response;
src/web/style/content.ts +1 −1
@@ -152,7 +152,7 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
152152 font-variant-numeric: tabular-nums; letter-spacing: 0.14em;
153153 text-align: center; user-select: all; word-break: break-all;
154154 }
155 [data-apps], [data-bet-card], [data-milestone], [data-now] { overflow-wrap: anywhere; }
155 [data-apps], [data-apps-page], [data-bet-card], [data-milestone], [data-now] { overflow-wrap: anywhere; }
156156 [data-bet-card] h2, [data-bet-card] ul, [data-bet-card] p { margin-block: 8px; }
157157 [data-bet-card] ul { padding-inline-start: 16px; }
158158 [data-bet-card] { max-height: 80vh; overflow-x: hidden; overflow-y: auto; }
test/apps-fit.test.ts added +100
@@ -0,0 +1,100 @@
1 /**
2 * The /apps page fits a 390 px phone (#9371): seeded with long names, a
3 * 250-character unbroken description and many rows, it fixes no width over
4 * 390 px, its containers wrap anywhere, and (where a Chromium starts) it does
5 * not scroll sideways.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { createRepo } from "../src/actions/repos.ts";
13 import { createApp as makeAppRecord } from "../src/core/apps.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16 import { utilities as base } from "../src/web/style/base.ts";
17 import { components } from "../src/web/style/components.ts";
18 import { content } from "../src/web/style/content.ts";
19 import { findChromium } from "../src/looks/capture.ts";
20 import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts";
21
22 const WORD = "W".repeat(250);
23 let dir: string;
24 let app: App;
25 let servers: Servers;
26 let page: string;
27 let session: { name: string; value: string };
28
29 beforeAll(async () => {
30 dir = await mkdtemp(join(tmpdir(), "forge-apps-fit-"));
31 app = createApp({ dataDir: dir, port: 0 });
32 servers = await serve(app);
33 const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
34 if (!made.ok) throw new Error(made.error);
35 const cookie = ((await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), redirect: "manual" })).headers.get("set-cookie") ?? "").split(";")[0]!;
36 const alice = getUserByName(app.db, "alice")!;
37 for (let n = 0; n < 25; n++) {
38 const name = n === 0 ? "n".repeat(60) : `app-${n}-${"x".repeat(40)}`;
39 const r = await createRepo(app, alice, { name, visibility: "public", description: n < 3 ? WORD : "A long and winding description of an app ".repeat(5) });
40 if (!r.ok) throw new Error(String(r.error));
41 const rec = makeAppRecord(app.db, { repoId: r.value.id, slug: `a${n}`, access: "public" });
42 if (!rec.ok) throw new Error(String(rec.error));
43 }
44 session = { name: cookie.slice(0, cookie.indexOf("=")), value: cookie.slice(cookie.indexOf("=") + 1) };
45 page = await (await fetch(`${servers.url}/apps`, { headers: { cookie } })).text();
46 }, 60_000);
47 afterAll(async () => {
48 servers.stop();
49 app.close();
50 await rm(dir, { recursive: true, force: true });
51 });
52
53 describe("the /apps page at 390 px", () => {
54 test("renders the seeded long content as many rows", () => {
55 expect(page).toContain(WORD);
56 expect(page).toContain("data-apps-page");
57 expect(page.match(/class="row"/g)!.length).toBeGreaterThan(20);
58 });
59
60 test("no inline width, and no rule the page ships fixes a width over 390 px", () => {
61 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s"])(?:min-)?width\s*:/i);
62 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
63 const over: string[] = [];
64 for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) {
65 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
66 const px = Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16);
67 if (px > 390) over.push(m[0]);
68 }
69 }
70 expect(over).toEqual([]);
71 });
72
73 test("the list of rows wraps, and the page's container wraps anywhere", () => {
74 expect(page).toContain('<ul class="list setting-list">');
75 const rule = content.match(/(?:^|\n)([^\n{]*\[data-apps-page\][^\n{]*)\{([^}]*)\}/);
76 expect(rule).not.toBeNull();
77 expect(rule![2]).toMatch(/overflow-wrap:\s*(?:anywhere|break-word)/);
78 expect(components).toMatch(/\.setting-list \.row-title[^{]*\{[^}]*overflow-wrap:\s*anywhere/);
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 /apps phone look is not measured");
85
86 describe.skipIf(chromium === undefined)("the /apps page, measured at 390x844", () => {
87 test("no sideways scroll", async () => {
88 const measure = `(() => {
89 const vw = document.documentElement.clientWidth;
90 const scope = [...document.querySelectorAll("[data-apps-page], [data-apps-page] *")];
91 const overflowing = scope.filter((el) => el.getBoundingClientRect().right > vw + 1).slice(0, 8).map((el) => el.tagName.toLowerCase());
92 return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, wrapped: scope.length > 0, overflowing });
93 })()`;
94 const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}/apps`, { width: 390, height: 844 }, [session], measure)) as { scrollWidth: number; viewportWidth: number; wrapped: boolean; overflowing: string[] };
95 console.log(`apps 390x844: ${JSON.stringify(m)}`);
96 expect(m.wrapped).toBe(true);
97 expect(m.overflowing).toEqual([]);
98 expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth);
99 }, 30_000);
100 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs