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

The /search page fits a 390 px phone: the box and its results wrap, nothing scrolls sideways #9369

Merged opened this

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

Merged

mason/forge:mason/9351-the-search-page-fits-a-390-px-phone-the → main 3 commits fork 1 thread · 1 unresolved

  • Merged. mason/9351-the-search-page-fits-a-390-px-phone-the was merged into main as c7b3b9a.

Review

Sign in to review this change.
4 files changed +120 −5
docs/design/areas/search.md +7
@@ -1,2 +1,9 @@
11 # Search box
22
3 ## At 390 px (#9351)
outdated

Stray extra blank line; drop one.

4
5 What a search finds is other people's words, so inside `[data-search]` (an attribute, not a class; rules in
6 `SEARCH_CSS`, `src/web/pages/search.ts`) a result row's title and meta wrap and break long words anywhere in place of
7 the one-line ellipsis. A matching line of code keeps `white-space: pre` and scrolls inside its own box
8 (`overflow-x: auto`). The query box may shrink. No fixed width; the page does not scroll sideways. No new class.
9 The owner's phone look of `/search?q=sample` is attached to the pull.
src/spawn/preview-looks.ts +4
@@ -48,6 +48,9 @@ export const PREVIEW_BETS_PAGE = `${PREVIEW_REPO_HOME}/bets`;
4848 /** The owner's milestones page, seeded with one milestone; its phone look shows the fit (#9326). */
4949 export const PREVIEW_MILESTONES_PAGE = `${PREVIEW_REPO_HOME}/milestones`;
5050
51 /** The search page with a query the seed answers, whose phone look shows the fit (#9351). */
52 export const PREVIEW_SEARCH_PAGE = "/search?q=sample";
53
5154 /** The waves page, whose long headlines must wrap at a 390 px phone (#9325). */
5255 export const PREVIEW_WAVES_PAGE = `${PREVIEW_REPO_HOME}/waves`;
5356
@@ -94,6 +97,7 @@ export const PREVIEW_OWNER_LOOKS: readonly OwnerLook[] = [
9497 PREVIEW_WAVES_PAGE,
9598 PREVIEW_PULLS_PAGE,
9699 PREVIEW_ISSUES_PAGE,
100 PREVIEW_SEARCH_PAGE,
97101 ].flatMap((page) => (["desktop", "phone"] as const).map((kind) => ({ page, kind })));
98102
99103 /**
src/web/pages/search.ts +19 −5
@@ -57,6 +57,19 @@ import {
5757
5858 // ---- search ----
5959
60 /**
61 * What a search finds is other people's words (titles, descriptions, paths,
62 * lines of code), so inside `[data-search]` a row wraps them and breaks a long
63 * word anywhere, in place of the one-line ellipsis; the matching line of code
64 * keeps `white-space: pre` and scrolls inside its own box. No new class.
65 */
66 export const SEARCH_CSS = `[data-search] { overflow-wrap: anywhere; }
67 [data-search] .row-title, [data-search] .row-meta:not(.code-line) { white-space: normal; overflow: visible; text-overflow: clip; }
68 [data-search] .row { align-items: flex-start; }
69 [data-search] .row-main { min-width: 0; }
70 [data-search] .code-line { max-width: 100%; overflow-x: auto; overflow-wrap: normal; }
71 [data-search] form .input { min-width: 0; }`;
72
6073 /** What was asked — everything a link back needs. */
6174 interface Asked {
6275 readonly query: string;
@@ -409,12 +422,13 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
409422 const blank = page({
410423 ctx,
411424 title: "Search",
412 body: html`${pageHead("Search")} ${form}
425 styleCss: SEARCH_CSS,
426 body: html`<div data-search>${pageHead("Search")} ${form}
413427 ${empty({
414428 icon: "search",
415429 title: "Search this forge",
416430 note: "An app or repository by name or description, a word from what was told or changed, or a line of the code itself — across everything you can see, here and on every origin you have connected.",
417 })}`,
431 })}</div>`,
418432 });
419433 notePhase(ctx.request, "render", performance.now() - rendering);
420434 return blank;
@@ -497,7 +511,7 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
497511 const nothing =
498512 a.kind === "all" &&
499513 found.repos.length + found.issues.length + found.code.length === 0;
500 const body = html`${pageHead(html`Results for “${a.query}”`)} ${form}
514 const body = html`<div data-search>${pageHead(html`Results for “${a.query}”`)} ${form}
501515 <div class="bar mb">${chips}</div>
502516 ${liveToggle}
503517 ${when(
@@ -530,8 +544,8 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
530544 )
531545 .map((w) =>
532546 section(a, w, ...rows[w[0]](), w[0] === "code" ? moreCode : null),
533 )}`;
534 const response = page({ ctx, title: `Search · ${a.query}`, body });
547 )}</div>`;
548 const response = page({ ctx, title: `Search · ${a.query}`, body, styleCss: SEARCH_CSS });
535549 notePhase(ctx.request, "render", performance.now() - rendering);
536550 return response;
537551 }
test/search-fit.test.ts added +90
@@ -0,0 +1,90 @@
1 /**
2 * The search page fits a 390 px phone (#9351): seeded long content (a 246-character (the longest a title may be)
3 * unbroken word in a title, a long title, a long code path and line, many rows)
4 * ships no fixed width over 390 px, long text wraps anywhere, and a line of code
5 * scrolls inside its own box.
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 { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { createApp, type App } from "../src/app.ts";
14 import { createSession } from "../src/core/sessions.ts";
15 import { createUser } from "../src/core/users.ts";
16 import { serve, type Servers } from "../src/http/server.ts";
17 import { PREVIEW_OWNER_LOOKS, PREVIEW_SEARCH_PAGE } from "../src/spawn/preview-looks.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
22 const WORD = "sample" + "P".repeat(240);
23 let dir: string;
24 let app: App;
25 let servers: Servers;
26 let page: string;
27 let blank: string;
28
29 const must = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
30 if (!r.ok) throw new Error(JSON.stringify(r.error));
31 return r.value;
32 };
33
34 beforeAll(async () => {
35 dir = await mkdtemp(join(tmpdir(), "forge-search-fit-"));
36 app = createApp({ dataDir: dir, port: 0 });
37 servers = await serve(app);
38 const u = must(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }));
39 const repo = must(await createRepo(app, u, { name: "fit", visibility: "public", description: "sample " + "D".repeat(300) }));
40 must(await commitToBranch(app, u, repo, { message: "init", files: [{ path: `dir/${"q".repeat(120)}.txt`, content: `sample ${"x".repeat(300)}\n` }] }));
41 for (let i = 0; i < 12; i++) {
42 const title = i === 0 ? WORD : i === 1 ? "sample very long title ".repeat(8).trim() : `sample issue number ${i}`;
43 must(openIssue(app, u, repo, { title }));
44 }
45 const cookie = `forge_session=${createSession(app.db, u.id).token}; forge_mode=dev`;
46 page = await (await fetch(`${servers.url}/search?q=sample`, { headers: { cookie } })).text();
47 blank = await (await fetch(`${servers.url}/search`, { headers: { cookie } })).text();
48 }, 60_000);
49 afterAll(async () => {
50 servers.stop();
51 app.close();
52 await rm(dir, { recursive: true, force: true });
53 });
54
55 describe("/search at 390 px", () => {
56 test("the long content is on the page, in many rows", () => {
57 expect(page).toContain(WORD);
58 expect(page).toContain("sample very long title");
59 expect(page).toContain("q".repeat(120));
60 expect(page.match(/class="row[ "]/g)?.length ?? 0).toBeGreaterThanOrEqual(12);
61 });
62
63 test("no inline width and no shipped rule fixes a width over 390 px", () => {
64 for (const html of [page, blank]) {
65 expect(html).not.toMatch(/style="[^"]*(?:^|[;\s])(?:min-|max-)?width\s*:/i);
66 for (const m of html.matchAll(/\swidth=["']?(\d+)/gi)) expect(Number(m[1])).toBeLessThanOrEqual(390);
67 }
68 const over: string[] = [];
69 for (const css of [base, components, content, ...(page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? [])]) {
70 for (const m of css.matchAll(/(?<![-\w(])(?:min-)?width\s*:\s*(-?[\d.]+)(px|rem|em|ch)\b/g)) {
71 if (Number(m[1]) * (m[2] === "px" ? 1 : m[2] === "ch" ? 8 : 16) > 390) over.push(m[0]);
72 }
73 }
74 expect(over).toEqual([]);
75 });
76
77 test("the results' container wraps anywhere; a code line scrolls in its own box", () => {
78 expect(page).toContain("data-search");
79 expect(blank).toContain("data-search");
80 const css = (page.match(/<style[^>]*>[\s\S]*?<\/style>/g) ?? []).join("\n");
81 expect(css).toMatch(/\[data-search\]\s*\{[^}]*overflow-wrap:\s*(anywhere|break-word)/);
82 expect(css).toMatch(/\[data-search\] \.row-title[^{]*\{[^}]*white-space:\s*normal/);
83 expect(css).toMatch(/\[data-search\] \.code-line\s*\{[^}]*overflow-x:\s*auto/);
84 });
85 });
86
87 test("the owner's phone look takes the search page", () => {
88 expect(PREVIEW_SEARCH_PAGE).toBe("/search?q=sample");
89 expect(PREVIEW_OWNER_LOOKS).toContainEqual({ page: PREVIEW_SEARCH_PAGE, kind: "phone" });
90 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs