Public: anyone can read this; the crew can change it.
The /search results page speaks the home page's language #9875
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9862-the-search-results-page-speaks-the-home → main 4 commits fork 1 thread · 1 unresolved
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. | ||
| 10 | 10 | |
| 11 | 11 | ## Rows under a heading (#9689) |
| 12 | 12 | |
| 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"; | ||
| 42 | 42 | import { |
| 43 | 43 | badge, |
| 44 | 44 | btn, |
| 45 | card, | |
| 46 | 45 | dot, |
| 47 | empty, | |
| 48 | 46 | field as formField, |
| 49 | 47 | repoCounts, |
| 50 | 48 | row, |
| @@ -89,7 +87,7 @@ interface Apps { | ||
| 89 | 87 | |
| 90 | 88 | /** A section's rows on the page's one background: a heading over rows, no box. */ |
| 91 | 89 | const rowsUnderHeading = (rows: Html[]): Html => |
| 92 | card(html`<ul class="list">${rows}</ul>`, { pad: false, extra: "bare" }); | |
| 90 | html`<ul class="list">${rows}</ul>`; | |
| 93 | 91 | |
| 94 | 92 | const privateBadge = (yes: boolean): Html => |
| 95 | 93 | when(yes, () => badge("Private", { icon: "lock", variant: "secondary" })); |
| @@ -302,9 +300,8 @@ function section( | ||
| 302 | 300 | truncated: boolean, |
| 303 | 301 | after: Html | null = null, |
| 304 | 302 | ): 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}"> | |
| 304 | <h2 class="mt" id="h-${kind}"> | |
| 308 | 305 | ${title}${rows.length === 0 |
| 309 | 306 | ? null |
| 310 | 307 | : html` <span class="muted small num" |
| @@ -312,18 +309,13 @@ function section( | ||
| 312 | 309 | >`} |
| 313 | 310 | </h2> |
| 314 | 311 | ${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>` | |
| 327 | 319 | : rowsUnderHeading(rows)} |
| 328 | 320 | ${after} |
| 329 | 321 | ${when( |
| @@ -335,7 +327,8 @@ function section( | ||
| 335 | 327 | >Show only ${title.toLowerCase()}</a |
| 336 | 328 | >. |
| 337 | 329 | </p>`, |
| 338 | )}`; | |
| 330 | )} | |
| 331 | </section>`; | |
| 339 | 332 | } |
| 340 | 333 | |
| 341 | 334 | /** 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> { | ||
| 427 | 420 | title: "Search", |
| 428 | 421 | styleCss: SEARCH_CSS, |
| 429 | 422 | 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>`, | |
| 435 | 425 | }); |
| 436 | 426 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 437 | 427 | return blank; |
| @@ -535,12 +525,11 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 535 | 525 | </p>`, |
| 536 | 526 | )} |
| 537 | 527 | ${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>` | |
| 544 | 533 | : WORDS |
| 545 | 534 | .filter( |
| 546 | 535 | ([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", () => { | ||
| 197 | 197 | |
| 198 | 198 | test("results are rows under a heading, not a boxed card", async () => { |
| 199 | 199 | 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">/); | |
| 204 | 203 | }); |
| 205 | 204 | |
| 206 | 205 | test("an empty query is an empty result, not an error page", async () => { |
Box 1: return a <section> here wrapping the h2, the list or empty sentence,
afterand 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.