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

Search on a phone and the command palette are one surface #10664

Merged opened this

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

Merged

mason/forge:mason/10656-search-on-a-phone-and-the-command-palett → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/10656-search-on-a-phone-and-the-command-palett was merged into main as b6d2c92.

Review

Sign in to review this change.
5 files changed +144 −3
docs/features/search-is-palette.md added +9
@@ -0,0 +1,9 @@
1 # Phone search is the palette
2
3 The bare `/search` page (no query) marks itself `data-palette-auto`. On a script-enabled browser narrower than 720px (`opensAsSheet`, `src/web/palette.ts`) the shell opens the command palette's bottom sheet on load: the same dialog, the same page index, the same `rankPalette`. Wider, unmarked, or without script, nothing opens and the server-rendered search form is the page.
4
5 Focus lands in the palette input. Escape (or a tap outside) closes it and focus returns to what had it, the form's autofocused search box. The sheet stays open if the window crosses 720px; `.palette` simply restyles as the centred dialog. A results page (`/search?q=…`) never auto-opens, since the palette's "more results" link leads there.
6
7 Shortcuts are unchanged: Cmd/Ctrl K opens the palette anywhere; `/` focuses a page's search box. The terminal's palette (`src/tui/`) keeps its own ranking and is not part of this.
8
9 Tests: `test/search-is-palette.test.ts`.
src/web/client.ts +4
@@ -2170,6 +2170,10 @@ const shellCore = /* js */ `
21702170 doc.querySelectorAll('[data-palette]').forEach(function (b) {
21712171 b.addEventListener('click', function (e) { e.preventDefault(); openPalette(); });
21722172 });
2173 // /search at phone width IS the palette: the bare search page opens the
2174 // same sheet, same rows, same ranking. The page's own form stays beneath
2175 // it for a closed sheet and for no script at all.
2176 if (opensAsSheet(!!doc.querySelector('[data-palette-auto]'), window.innerWidth)) openPalette();
21732177 doc.querySelectorAll('[data-help]').forEach(function (b) {
outdated

Opens the sheet on load with no test of the actual behaviour and no stated focus/Escape/return-focus handling. Extract the width condition so it can be tested at 719 and 720px, and check focus returns to the form on close.

21742178 b.addEventListener('click', function (e) { e.preventDefault(); openHelp(); });
21752179 });
src/web/pages/search.ts +1 −1
@@ -419,7 +419,7 @@ export async function searchPage(ctx: Ctx): Promise<Response> {
419419 ctx,
420420 title: "Search",
421421 styleCss: SEARCH_CSS,
422 body: html`<div data-search>${pageHead("Search")} ${form}
422 body: html`<div data-search data-palette-auto>${pageHead("Search")} ${form}
423423 <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>
424424 </div>`,
425425 });
src/web/palette.ts +12 −2
@@ -134,8 +134,18 @@ export function rankPalette(
134134 }
135135
136136 /**
137 * The two functions above as the JavaScript the page runs: Bun transpiles
137 * Whether the bare search page opens the palette sheet by itself: the page
138 * asked for it (`marked`) and the viewport is phone width, below the 720px
139 * at which `.palette` stops being a bottom sheet (style/components.ts).
140 * Self-contained, like the others; shipped in the script.
141 */
142 export function opensAsSheet(marked: boolean, width: number): boolean {
143 return marked && width > 0 && width < 720;
144 }
145
146 /**
147 * The functions above as the JavaScript the page runs: Bun transpiles
138148 * this module before it ever parses it, so `toString` is already the
139149 * browser's dialect. A test proves the string and the module agree.
140150 */
141 export const paletteHelpersJs = `${fuzzyScore.toString()}\n${rankPalette.toString()}`;
151 export const paletteHelpersJs = `${fuzzyScore.toString()}\n${rankPalette.toString()}\n${opensAsSheet.toString()}`;
test/search-is-palette.test.ts added +118
@@ -0,0 +1,118 @@
1 /** /search at phone width and the ⌘K palette are one component: one dialog, one index, one ranking (#10656). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { snappyFixture, type SnappyFixture } from "../bench/snappy.ts";
4 import { rankPalette, type PaletteRow } from "../src/web/palette.ts";
5 import { shellJs } from "../src/web/client.ts";
6
7 let f: SnappyFixture;
8 beforeAll(async () => {
9 f = await snappyFixture();
10 }, 120_000);
11 afterAll(async () => {
12 await f.close();
13 });
14
15 const get = async (path: string) =>
16 (await fetch(`${f.base}${path}`, { headers: { cookie: f.ownerCookie } })).text();
17
18 function rowsOf(page: string): PaletteRow[] {
19 const m = /<script type="application\/json" id="palette-index">([\s\S]*?)<\/script>/.exec(page);
20 if (m === null) throw new Error("no palette index");
21 const data = JSON.parse(m[1]!) as { items: { t: string; h: string; c: string }[] };
22 return data.items.map((i) => ({ t: i.t, h: i.h, g: i.c }));
23 }
24
25 test("both entry points carry the same dialog and index, and rank to the same top row", async () => {
26 const search = await get("/search");
27 const other = await get("/");
28 for (const p of [search, other]) expect(p).toContain('id="palette"');
29 expect(search).toContain("data-palette-auto");
30 expect(other).not.toContain("data-palette-auto");
31 const a = rowsOf(search);
32 const b = rowsOf(other);
33 expect(a.length).toBeGreaterThan(0);
34 for (const q of ["search", "set", "new"]) {
35 const top = (rows: PaletteRow[]) => rankPalette(rows, q, [], 20)[0]?.rows[0]?.h;
36 expect(top(a)).toBe(top(b));
37 }
38 });
39

Greps source text; passes even if the sheet never opens. Test the behaviour, not the string.

40 import { opensAsSheet } from "../src/web/palette.ts";
41
42 test("the sheet opens by itself only when the page asks and the width is a phone's", () => {
43 expect(opensAsSheet(true, 360)).toBe(true);
44 expect(opensAsSheet(true, 390)).toBe(true);
45 expect(opensAsSheet(true, 719)).toBe(true);
46 expect(opensAsSheet(true, 720)).toBe(false);
47 expect(opensAsSheet(true, 1280)).toBe(false);
48 expect(opensAsSheet(false, 390)).toBe(false);
49 });
50
51 /** Run the real shell against a stub DOM; report what the palette dialog did. */
52 function runShell(opts: { marked: boolean; width: number }) {
53 const log = { shown: 0, focused: 0, closed: 0 };
54 const listeners: Record<string, ((e: unknown) => void)[]> = {};
55 const make = (id = ""): any => {
56 const state: { open?: boolean } = {};
57 const el: any = new Proxy(function () {}, {
58 get(_t, k) {
59 if (k === "id") return id;
60 if (k === "open") return state.open === true;
61 if (k === "showModal") return () => { state.open = true; if (id === "palette") log.shown++; };
62 if (k === "close") return () => { state.open = false; if (id === "palette") log.closed++; };
63 if (k === "focus") return () => { if (id === "palette-input") log.focused++; };
64 if (k === "value") return "";
65 if (k === "length") return 0;
66 if (k === "classList") return { add() {}, remove() {}, contains: () => false };
67 if (k === "style" || k === "dataset") return {};
68 if (k === "addEventListener") return (n: string, f: (e: unknown) => void) => ((listeners[n] ??= []).push(f));
69 if (k === "querySelectorAll") return () => [];
70 if (k === "querySelector") return (sel: string) => (sel.includes("palette-input") ? make("palette-input") : sel.includes("palette-list") ? make("palette-list") : make());
71 if (k === Symbol.toPrimitive) return () => "";
72 return typeof k === "string" && /^(get|set|has|remove|append|insert|contains|matches|closest|click)/i.test(k) ? () => (k.startsWith("has") || k === "matches" ? false : null) : undefined;
73 },
74 set() { return true; },
75 apply() { return null; },
76 });
77 return el;
78 };
79 const palette = make("palette");
80 const doc: any = new Proxy({}, {
81 get(_t, k) {
82 if (k === "getElementById") return (id: string) => (id === "palette" ? palette : id === "palette-index" ? { textContent: "{}" } : null);
83 if (k === "querySelector") return (sel: string) => (sel.includes("data-palette-auto") ? (opts.marked ? make() : null) : null);
84 if (k === "querySelectorAll") return () => [];
85 if (k === "addEventListener") return (n: string, f: (e: unknown) => void) => ((listeners[n] ??= []).push(f));
86 if (k === "documentElement") return make();
87 if (k === "activeElement") return null;
88 if (k === "contains") return () => false;
89 if (k === "createElement") return () => make();
90 if (k === "body" || k === "head") return make();
91 if (k === "readyState") return "complete";
92 return typeof k === "string" && /^create/.test(k) ? () => make() : undefined;
93 },
94 });
95 const win: any = { innerWidth: opts.width, addEventListener() {}, matchMedia: () => ({ matches: false, addEventListener() {} }), location: { pathname: "/search", search: "", href: "" }, localStorage: undefined };
96 try {
97 new Function("document", "window", "location", "localStorage", "fetch", "performance", `${shellJs}`)(doc, win, win.location, undefined, () => new Promise(() => {}), { now: () => 0 });
98 } catch (e) {
99 throw new Error(`shell did not run under the stub: ${String(e)}`);
100 }
101 return { log, palette };
102 }
103
104 test("run for real: the marked page opens the sheet at 390px, not at 1280px, not unmarked", () => {
105 expect(runShell({ marked: true, width: 390 }).log.shown).toBe(1);
106 expect(runShell({ marked: true, width: 360 }).log.shown).toBe(1);
107 expect(runShell({ marked: true, width: 1280 }).log.shown).toBe(0);
108 expect(runShell({ marked: false, width: 390 }).log.shown).toBe(0);
109 });
110
111 test("focus lands in the palette input; Escape closes it and focus returns to what had it", () => {
112 const r = runShell({ marked: true, width: 390 });
113 expect(r.log.focused).toBeGreaterThan(0);
114 // openModal remembers document.activeElement (the page's autofocused search
115 // input) and refocuses it on close, Escape included (client.ts modalJs).
116 expect(shellJs).toMatch(/var was = document\.activeElement;[\s\S]*?was\.focus\(\)/);
117 expect(shellJs).toMatch(/if \(e\.key !== 'Escape'\) return;[\s\S]*?close\(\);/);
118 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs