Public: anyone can read this; the crew can change it.
Search on a phone and the command palette are one surface #10664
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10656-search-on-a-phone-and-the-command-palett → main 3 commits fork 3 threads · 3 unresolved
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 */ ` | ||
| 2170 | 2170 | doc.querySelectorAll('[data-palette]').forEach(function (b) { |
| 2171 | 2171 | b.addEventListener('click', function (e) { e.preventDefault(); openPalette(); }); |
| 2172 | 2172 | }); |
| 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(); | |
| 2173 | 2177 | doc.querySelectorAll('[data-help]').forEach(function (b) { |
| 2174 | 2178 | b.addEventListener('click', function (e) { e.preventDefault(); openHelp(); }); |
| 2175 | 2179 | }); |
src/web/pages/search.ts +1 −1
| @@ -419,7 +419,7 @@ export async function searchPage(ctx: Ctx): Promise<Response> { | ||
| 419 | 419 | ctx, |
| 420 | 420 | title: "Search", |
| 421 | 421 | styleCss: SEARCH_CSS, |
| 422 | body: html`<div data-search>${pageHead("Search")} ${form} | |
| 422 | body: html`<div data-search data-palette-auto>${pageHead("Search")} ${form} | |
| 423 | 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 | 424 | </div>`, |
| 425 | 425 | }); |
src/web/palette.ts +12 −2
| @@ -134,8 +134,18 @@ export function rankPalette( | ||
| 134 | 134 | } |
| 135 | 135 | |
| 136 | 136 | /** |
| 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 | |
| 138 | 148 | * this module before it ever parses it, so `toString` is already the |
| 139 | 149 | * browser's dialect. A test proves the string and the module agree. |
| 140 | 150 | */ |
| 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 | }); |
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.