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

The /explore page fits a 390 px phone: no sideways scroll, long app and forge names wrap #9612

Merged opened this

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

Merged

scout/forge:scout/9370-the-explore-page-fits-a-390-px-phone-no → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/9370-the-explore-page-fits-a-390-px-phone-no was merged into main as a35a0b8.

Review

Sign in to review this change.
3 files changed +112 −3
src/web/studio/explore.ts +2 −2
@@ -162,7 +162,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
162162 { label: "This week", updated: "7d" as const },
163163 { label: "This month", updated: "30d" as const },
164164 ];
165 const body = html`${pageHead("Explore", {
165 const body = html`<div data-explore>${pageHead("Explore", {
166166 sub: "Apps and their makers, ranked by forks, ships, use and how lately — the numbers on every row.",
167167 })}
168168 <form method="get" action="/explore" class="bar mb">
@@ -214,7 +214,7 @@ export async function explorePage(ctx: Ctx): Promise<Response> {
214214 </div>
215215 ${rows.length === 0
216216 ? emptyState(ctx, query)
217 : listCard(rows.map(rowHtml))}`;
217 : listCard(rows.map(rowHtml))}</div>`;
218218 const response = page({ ctx, title: "Explore", body });
219219 notePhase(ctx.request, "render", performance.now() - rendering);
220220 return response;
src/web/style/content.ts +5 −1
@@ -158,7 +158,11 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
158158 [data-tell] .page-head-sub { flex-wrap: wrap; }
159159 [data-tell] .comment-body img { max-width: 100%; height: auto; }
160160 [data-tell] pre { max-width: 100%; overflow-x: auto; }
161 [data-apps], [data-apps-page], [data-bet-card], [data-milestone], [data-now], [data-people] { overflow-wrap: anywhere; }
161 [data-apps], [data-apps-page], [data-bet-card], [data-explore], [data-milestone], [data-now], [data-people] { overflow-wrap: anywhere; }
162 /* /explore (#9370): rows wrap rather than truncate a long name or description. */
163 [data-explore] .row-title, [data-explore] .row-meta { white-space: normal; overflow: visible; }
164 [data-explore] .row { flex-wrap: wrap; }
165 [data-explore] .row-main { flex: 1 1 14rem; }
162166 [data-bet-card] h2, [data-bet-card] ul, [data-bet-card] p { margin-block: 8px; }
163167 [data-bet-card] ul { padding-inline-start: 16px; }
164168 [data-bet-card] { max-height: 80vh; overflow-x: hidden; overflow-y: auto; }
test/explorer-fit.test.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * The /explore page fits a 390 px phone (#9370): seeded with a 250-character
3 * unbroken word in a description, a long name and many rows, it fixes no
4 * width over 390 px, long text wraps, and nothing scrolls sideways.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createApp as insertApp } from "../src/core/apps.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { utilities as base } from "../src/web/style/base.ts";
16 import { components } from "../src/web/style/components.ts";
17 import { content } from "../src/web/style/content.ts";
18 import { findChromium } from "../src/looks/capture.ts";
19 import { proveAll } from "./helpers/verified.ts";
20 import { promote } from "./helpers/members.ts";
21 import { chromiumRuns, evaluateLoaded } from "./helpers/chrome.ts";
22
23 const WORD = "W".repeat(250);
24 const PATHS = ["/explore", "/explore?q=kind:person", "/explore?q=" + encodeURIComponent(`${WORD} nothing`)];
25 let dir: string;
26 let app: App;
27 let servers: Servers;
28 let pages: string[];
29
30 beforeAll(async () => {
31 dir = await mkdtemp(join(tmpdir(), "forge-explore-fit-"));
32 app = createApp({ dataDir: dir, port: 0 });
33 servers = await serve(app);
34 const made = await createUser(app.db, { origin: "seed", username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
35 if (!made.ok) throw new Error(made.error);
36 proveAll(app);
37 promote(app.db, made.value);
38 for (let n = 0; n < 25; n++) {
39 const name = n === 0 ? "n".repeat(60) : `app-${n}`;
40 const r = await createRepo(app, made.value, { name, visibility: "public", description: n < 3 ? `${WORD} ${n}` : `An app, number ${n}, with a fairly long description that goes on`.repeat(3) });
41 if (!r.ok) throw new Error(String(r.error));
42 const rec = insertApp(app.db, { repoId: r.value.id, slug: `alice-${n}`, access: "public" });
43 if (!rec.ok) throw new Error(rec.error);
44 }
45 pages = await Promise.all(PATHS.map(async (p) => (await fetch(`${servers.url}${p}`)).text()));
46 }, 30_000);
47 afterAll(async () => {
48 servers.stop();
49 app.close();
50 await rm(dir, { recursive: true, force: true });
51 });
52
53 describe("/explore at 390 px", () => {
54 test("renders the seeded long content, many rows", () => {
55 expect(pages[0]).toContain(WORD);
56 expect(pages[0]!.match(/<li/g)!.length).toBeGreaterThan(20);
57 expect(pages[0]).toContain("data-explore");
58 expect(pages[2]).toContain("data-explore");
59 });
60
61 test("no inline width, and no rule the page ships fixes a width over 390 px", () => {
62 for (const page of pages) {
63 expect(page).not.toMatch(/style="[^"]*(?:^|[;\s"])(?:min-)?width\s*:/i);
64 for (const m of page.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
65 const over: string[] = [];
66 for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) {
67 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
68 const px = Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16);
69 if (px > 390) over.push(m[0]);
70 }
71 }
72 expect(over).toEqual([]);
73 }
74 });
75
76 test("long-text containers wrap anywhere, and rows wrap instead of truncating", () => {
77 const rule = content.match(/(?:^|\n)([^\n{]*\[data-explore\][^\n{]*)\{([^}]*)\}/);
78 expect(rule).not.toBeNull();
79 expect(rule![1]).toMatch(/(?:^|,)\s*\[data-explore\]\s*(?:,|$)/);
80 expect(rule![2]).toMatch(/overflow-wrap:\s*(?:anywhere|break-word)/);
81 expect(content).toMatch(/\[data-explore\] \.row-title[^{]*\{[^}]*white-space:\s*normal/);
82 });
83 });
84
85 const found = findChromium();
86 const chromium = found !== undefined && chromiumRuns(found) ? found : undefined;
87 if (chromium === undefined) console.log("SKIPPING: no chromium that starts on this machine, so the explore phone look is not measured");
88
89 describe.skipIf(chromium === undefined)("/explore, measured at 390x844", () => {
90 const measure = `(() => {
91 const vw = document.documentElement.clientWidth;
92 const scope = [...document.querySelectorAll("[data-explore], [data-explore] *")];
93 const overflowing = scope.filter((el) => el.getBoundingClientRect().right > vw + 1).slice(0, 8).map((el) => el.tagName.toLowerCase());
94 return JSON.stringify({ scrollWidth: document.documentElement.scrollWidth, viewportWidth: vw, wrapped: scope.length > 0, overflowing });
95 })()`;
96 for (const path of PATHS) {
97 test(`${path.slice(0, 40)}: no sideways scroll`, async () => {
98 const m = JSON.parse(await evaluateLoaded(chromium!, `${servers.url}${path}`, { width: 390, height: 844 }, [], measure)) as { scrollWidth: number; viewportWidth: number; wrapped: boolean; overflowing: string[] };
99 console.log(`explore ${path.slice(0, 40)} 390x844: ${JSON.stringify(m)}`);
100 expect(m.wrapped).toBe(true);
101 expect(m.overflowing).toEqual([]);
102 expect(m.scrollWidth).toBeLessThanOrEqual(m.viewportWidth);
103 }, 30_000);
104 }
105 });

The Chromium cases print their measurement, but the pull still reports no number. Run them once where Chromium starts and put the printed line in the pull description.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs