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

The /search results page speaks the home page's language #9875

Merged opened this

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

Merged

scout/forge:scout/9862-the-search-results-page-speaks-the-home → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/9862-the-search-results-page-speaks-the-home was merged into main as c417c29.

Review

Sign in to review this change.
4 files changed +79 −35
docs/design/areas/search.md +1 −1
@@ -10,4 +10,4 @@ The owner's phone look of `/search?q=sample` is attached to the pull.
1010
1111 ## Rows under a heading (#9689)
1212
13 Each section of the results (apps and repositories, issues and pull requests, code) is a heading over a `.list` of `.row`s in a `.card.bare` (#9588): rows on the page's one background, no box. No new class; `test/studio-search.test.ts` holds it.
13 Each section of the results (apps and repositories, issues and pull requests, code) is a heading over a `.list` of `.row`s in a plain `<section>` (#9862): rows on the page's one background, no box; an empty query or section is one muted sentence. No new class; `test/studio-search.test.ts` holds it.
src/web/pages/search.ts +19 −30
@@ -42,9 +42,7 @@ import { changeHref, tellHref } from "../studio/nouns.ts";
4242 import {
4343 badge,
4444 btn,
45 card,
4645 dot,
47 empty,
4846 field as formField,
4947 repoCounts,
5048 row,
@@ -89,7 +87,7 @@ interface Apps {
8987
9088 /** A section's rows on the page's one background: a heading over rows, no box. */
9189 const rowsUnderHeading = (rows: Html[]): Html =>
92 card(html`<ul class="list">${rows}</ul>`, { pad: false, extra: "bare" });
90 html`<ul class="list">${rows}</ul>`;
9391
9492 const privateBadge = (yes: boolean): Html =>
9593 when(yes, () => badge("Private", { icon: "lock", variant: "secondary" }));
@@ -302,9 +300,8 @@ function section(
302300 truncated: boolean,
303301 after: Html | null = null,
304302 ): Html {
305 const emptyIcon =
306 kind === "repos" ? "repo" : kind === "issues" ? "issue" : "code";
307 return html`<h2 class="mt" id="${kind}">
303 return html`<section id="${kind}" aria-labelledby="h-${kind}">
outdated

Box 1: return a <section> here wrapping the h2, the list or empty sentence, after and the truncated note. Today the h2 and ul are siblings with no section, so a screen reader gets no region per result kind. Keep id on the h2 or move it to the section so the #repos anchors still work.

304 <h2 class="mt" id="h-${kind}">
308305 ${title}${rows.length === 0
309306 ? null
310307 : html` <span class="muted small num"
@@ -312,18 +309,13 @@ function section(
312309 >`}
313310 </h2>
314311 ${rows.length === 0
315 ? empty({
316 icon: emptyIcon,
317 title: emptyText,
318 ...(a.kind === "all"
319 ? {}
320 : {
321 action: btn("Search everything", {
322 href: searchHref(a, { kind: "all" }),
323 size: "sm",
324 }),
325 }),
326 })
312 ? html`<p class="muted">
313 ${emptyText}${a.kind === "all"
314 ? null
315 : html` <a href="${searchHref(a, { kind: "all" })}"
316 >Search everything</a
317 >`}
318 </p>`
327319 : rowsUnderHeading(rows)}
328320 ${after}
329321 ${when(
@@ -335,7 +327,8 @@ function section(
335327 >Show only ${title.toLowerCase()}</a
336328 >.
337329 </p>`,
338 )}`;
330 )}
331 </section>`;
339332 }
340333
341334 /** The same `search()` the API and the palette call, so the sources can
@@ -427,11 +420,8 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
427420 title: "Search",
428421 styleCss: SEARCH_CSS,
429422 body: html`<div data-search>${pageHead("Search")} ${form}
430 ${empty({
431 icon: "search",
432 title: "Search this forge",
433 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.",
434 })}</div>`,
423 <p class="muted">Search this forge: 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.</p>
424 </div>`,
435425 });
436426 notePhase(ctx.request, "render", performance.now() - rendering);
437427 return blank;
@@ -535,12 +525,11 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
535525 </p>`,
536526 )}
537527 ${nothing
538 ? empty({
539 icon: "search",
540 title: `Nothing matches “${a.query}”`,
541 note: "Search reads app and repository names and descriptions, issues, pull requests and the code you can see.",
542 action: btn("Explore", { href: "/explore", size: "sm" }),
543 })
528 ? html`<p class="muted">
529 Nothing matches “${a.query}”. Search reads app and repository names
530 and descriptions, issues, pull requests and the code you can see.
531 <a href="/explore">Explore</a> instead.
532 </p>`
544533 : WORDS
545534 .filter(
546535 ([kind]) => kind !== "all" && (a.kind === "all" || a.kind === kind),
test/search-language.test.ts added +56
@@ -0,0 +1,56 @@
1 /** /search speaks the home page's language (#9862): headings over rows, no boxes. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp } from "../src/app.ts";
7 import { createToken } from "../src/core/tokens.ts";
8 import { serve } from "../src/http/server.ts";
9 import { seedAdmin } from "./helpers/admin.ts";
10
11 let dir = "";
12 let app: ReturnType<typeof createApp>;
13 let servers: Awaited<ReturnType<typeof serve>>;
14 let bearer = "";
15 const headers = () => ({ authorization: `Bearer ${bearer}`, "content-type": "application/json" });
16 const post = (path: string, body: unknown) =>
17 fetch(servers.url + path, { method: "POST", headers: headers(), body: JSON.stringify(body) });
18 const get = async (path: string) => (await fetch(servers.url + path, { headers: headers() })).text();
19 const main = (h: string) => h.slice(h.indexOf("<main"), h.indexOf("</main>"));
20
21 beforeAll(async () => {
22 dir = await mkdtemp(join(tmpdir(), "forge-search-language-"));
23 app = createApp({ dataDir: dir, port: 0 });
24 servers = await serve(app);
25 const made = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
26 if (!made.ok) throw new Error(made.error);
27 bearer = createToken(app.db, made.value.id, "t", "admin").token;
28 await post("/api/v1/repos", { name: "zephyr", visibility: "public", description: "a zephyr thing" });
29 await post("/api/v1/repos/alice/zephyr/contents", {
30 branch: "main", message: "first", files: [{ path: "src/z.ts", content: "export const zephyr = 1;\n" }],
31 });
32 await post("/api/v1/repos/alice/zephyr/issues", { title: "zephyr is slow", body: "" });
33 });
34
35 afterAll(async () => {
36 servers.stop();
37 app.close();
38 await rm(dir, { recursive: true, force: true });
39 });
40
41 test("results are sections of rows with no card", async () => {
42 const html = main(await get("/search?q=zephyr"));
43 expect(html).not.toContain('class="card');
44 const h2 = (html.match(/<h2[ >]/g) ?? []).length;
45 expect(h2).toBe(3);
46 expect((html.match(/<section[ >]/g) ?? []).length).toBe(h2);
47 expect(html).toContain('<ul class="list">');
48 });
49
50 test("an empty query and no results are one plain sentence", async () => {
51 for (const path of ["/search?q=", "/search?q=qqqnothingqqq"]) {
52 const html = main(await get(path));
53 expect(html).not.toContain('class="card');
54 expect(html).toContain('<p class="muted">');
55 }
56 });
test/studio-search.test.ts +3 −4
@@ -197,10 +197,9 @@ describe("finding things in the studio: /search", () => {
197197
198198 test("results are rows under a heading, not a boxed card", async () => {
199199 const html = mainOf(await (await asAlice("/search?q=chess")).text());
200 const lists = html.match(/<div\s+class="card bare"[^>]*>\s*<ul class="list">/g) ?? [];
201 expect(lists.length).toBeGreaterThanOrEqual(2);
202 expect(html).not.toMatch(/<div\s+class="card"[^>]*>\s*<ul class="list">/);
203 expect(html).toMatch(/<h2 class="mt" id="repos">/);
200 expect((html.match(/<ul class="list">/g) ?? []).length).toBeGreaterThanOrEqual(2);
201 expect(html).not.toContain('class="card');
202 expect(html).toMatch(/<section id="repos"[^>]*>\s*<h2 class="mt" id="h-repos">/);
204203 });
205204
206205 test("an empty query is an empty result, not an error page", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs