home: fuzzy search, smarter star placement, admin setup link on create (#345)
Three fixes to the saved-spaces UI: - Fuzzy search filters the list. A search box appears once there are a few spaces; typing subsequence-matches name + host across both Starred and the rest. While a query is active, reordering is off (the grips hide) — you can't meaningfully drag a filtered subset. filterServers/fuzzyMatch are pure + unit-tested. - Star placement follows intent: starring an item sends it to the END of the Starred list; unstarring sends it to the TOP of the rest (both land at the group boundary). toggleStar now repositions, not just flips. - The create result links to the ADMIN SETUP flow, not the bare instance. The CTA is relabelled "Set up your space →" and points at the one-time setup link (claim → keyholder → Eleven setup/administration). The old `setup_link || url` fallback is gone: if the setup link can't be minted we say so rather than hand over the instance URL (which lands on a signed-out card, not the admin flow). npm test (incl. the new serverlist cases) + go build ./home green.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+180
−55
home/test/serverlist.test.mjs+50 −2home/web/pages/index.html+11 −0home/web/static/home.js+81 −44home/web/static/serverlist.js+38 −9
diff --git a/home/test/serverlist.test.mjs b/home/test/serverlist.test.mjs| index 58f4776..e7d6237 100644 |
| --- a/home/test/serverlist.test.mjs |
| +++ b/home/test/serverlist.test.mjs |
| @@ -7,6 +7,8 @@ import { |
| toggleStar, |
| moveInGroup, |
| orderServers, |
| + filterServers, |
| + fuzzyMatch, |
| } from "../web/static/serverlist.js"; |
| let failures = 0; |
| @@ -42,11 +44,38 @@ await test("partitionServers tolerates null/empty", () => { |
| await test("toggleStar adds then removes the flag, and doesn't mutate the input", () => { |
| const servers = [{ url: "a" }, { url: "b" }]; |
| const on = toggleStar(servers, "a"); |
| - assert.equal(on[0].starred, true); |
| + assert.equal(on.find((s) => s.url === "a").starred, true); |
| assert.equal(servers[0].starred, undefined, "input array/entry must be untouched"); |
| const off = toggleStar(on, "a"); |
| - assert.equal("starred" in off[0], false, "unstarred entry carries no flag"); |
| + assert.equal("starred" in off.find((s) => s.url === "a"), false, "unstarred entry carries no flag"); |
| +}); |
| + |
| +await test("starring sends the item to the END of the Starred list", () => { |
| + // x, y already starred; star 'b' → it lands after them (end of Starred). |
| + const servers = [ |
| + { url: "x", starred: true }, |
| + { url: "y", starred: true }, |
| + { url: "a" }, |
| + { url: "b" }, |
| + { url: "c" }, |
| + ]; |
| + const { starred, others } = partitionServers(toggleStar(servers, "b")); |
| + assert.deepEqual(urls(starred), ["x", "y", "b"], "b appended to Starred"); |
| + assert.deepEqual(urls(others), ["a", "c"], "others keep order minus b"); |
| +}); |
| + |
| +await test("unstarring sends the item to the TOP of the rest", () => { |
| + // x, y starred; unstar 'x' → it becomes the FIRST of the rest. |
| + const servers = [ |
| + { url: "x", starred: true }, |
| + { url: "y", starred: true }, |
| + { url: "a" }, |
| + { url: "b" }, |
| + ]; |
| + const { starred, others } = partitionServers(toggleStar(servers, "x")); |
| + assert.deepEqual(urls(starred), ["y"], "x left Starred"); |
| + assert.deepEqual(urls(others), ["x", "a", "b"], "x is now the top of the rest"); |
| }); |
| await test("toggleStar leaves unknown urls unchanged", () => { |
| @@ -105,5 +134,24 @@ await test("orderServers ignores unknown urls and keeps unlisted entries at the |
| assert.deepEqual(urls(next), ["c", "a", "b"]); |
| }); |
| +await test("fuzzyMatch is a case-insensitive subsequence match", () => { |
| + assert.equal(fuzzyMatch("fam", "family space"), true); |
| + assert.equal(fuzzyMatch("fly", "family"), true, "f-l-y appear in order"); |
| + assert.equal(fuzzyMatch("xyz", "family"), false); |
| + assert.equal(fuzzyMatch("", "anything"), true, "empty query matches"); |
| +}); |
| + |
| +await test("filterServers keeps fuzzy matches over name + host, order preserved", () => { |
| + const servers = [ |
| + { url: "https://family.example.com", name: "Family", starred: true }, |
| + { url: "https://work.example.com", name: "Work" }, |
| + { url: "https://a4f.11ch.at" }, |
| + ]; |
| + assert.deepEqual(urls(filterServers(servers, "fam")), ["https://family.example.com"]); |
| + assert.deepEqual(urls(filterServers(servers, "11ch")), ["https://a4f.11ch.at"], "matches the host"); |
| + assert.deepEqual(urls(filterServers(servers, "")), urls(servers), "empty query = everything"); |
| + assert.deepEqual(filterServers(servers, "zzzz"), [], "no matches"); |
| +}); |
| + |
| console.log(failures ? `\n${failures} FAILED` : "\nALL PASSED"); |
| process.exit(failures ? 1 : 0); |
diff --git a/home/web/pages/index.html b/home/web/pages/index.html| index 62d4292..1bcd864 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -48,6 +48,15 @@ |
| } |
| .spaces-head:first-child { margin-top: 0; } |
| #spaces > p.empty { color: var(--muted); font-size: .9rem; margin: 6px 0 0; } |
| + /* Fuzzy search over the list — appears once there are a few spaces to sift. */ |
| + #space-search { |
| + width: 100%; margin: 4px 0 2px; padding: 8px 12px; |
| + font: inherit; font-size: .9rem; |
| + color: var(--ink); background: var(--quiet); |
| + border: 1px solid transparent; border-radius: 10px; |
| + } |
| + #space-search::placeholder { color: var(--muted); } |
| + #space-search:focus-visible { outline: none; border-color: var(--accent); background: var(--panel); } |
| ul.servers { list-style: none; margin: 0; padding: 0; } |
| ul.servers li { |
| display: flex; align-items: center; gap: 8px; |
| @@ -122,6 +131,8 @@ |
| <h2>Your Eleven spaces</h2> |
| <button class="linkish" id="signout">Sign out</button> |
| </div> |
| + <input id="space-search" class="hidden" type="search" inputmode="search" |
| + autocomplete="off" spellcheck="false" placeholder="Search spaces" aria-label="Search your spaces"> |
| <div id="spaces"></div> |
| <!-- Create a new space — only where this home server offers provisioning |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 4be2e68..3cb8a4b 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -17,7 +17,7 @@ import { |
| apiJSON, beginLogin, runLogin, fetchBlob, putBlob, |
| } from "/static/backup-core.js"; |
| import { |
| - partitionServers, toggleStar, moveInGroup, orderServers, |
| + partitionServers, toggleStar, moveInGroup, orderServers, filterServers, |
| } from "/static/serverlist.js"; |
| // ---- session state ---------------------------------------------------------- |
| @@ -159,22 +159,41 @@ function showVault() { |
| const box = $("spaces"); |
| box.textContent = ""; |
| - const servers = vault.obj.servers || []; |
| + const all = vault.obj.servers || []; |
| + if (!all.length) { |
| + $("space-search").classList.add("hidden"); |
| + box.append(emptyLine("No spaces saved yet — save one from its chat page and it'll appear here.")); |
| + return; |
| + } |
| + |
| + // Fuzzy search filters both lists. It only appears once there's enough to sift |
| + // through; while a query is active, reordering is off (dragging a filtered |
| + // subset would be ambiguous) so the grips are hidden. |
| + const search = $("space-search"); |
| + search.classList.toggle("hidden", all.length < 4); |
| + const query = search.classList.contains("hidden") ? "" : search.value.trim(); |
| + const servers = query ? filterServers(all, query) : all; |
| if (!servers.length) { |
| - const p = document.createElement("p"); |
| - p.className = "empty"; |
| - p.textContent = "No spaces saved yet — save one from its chat page and it'll appear here."; |
| - box.append(p); |
| + box.append(emptyLine("No spaces match “" + query + "”.")); |
| return; |
| } |
| + const sortable = !query; |
| + |
| // Two independently-sorted lists: Starred (→ the app's rail) and the rest |
| // (→ a "more" affordance). Headings appear only once something's starred. |
| const { starred, others } = partitionServers(servers); |
| if (starred.length) { |
| - box.append(sectionHeading("Starred"), serverList(starred)); |
| + box.append(sectionHeading("Starred"), serverList(starred, sortable)); |
| if (others.length) box.append(sectionHeading("More")); |
| } |
| - if (others.length) box.append(serverList(others)); |
| + if (others.length) box.append(serverList(others, sortable)); |
| +} |
| + |
| +function emptyLine(text) { |
| + const p = document.createElement("p"); |
| + p.className = "empty"; |
| + p.textContent = text; |
| + return p; |
| } |
| function sectionHeading(text) { |
| @@ -184,10 +203,10 @@ function sectionHeading(text) { |
| return h; |
| } |
| -function serverList(group) { |
| +function serverList(group, sortable) { |
| const ul = document.createElement("ul"); |
| ul.className = "servers"; |
| - for (const s of group) ul.append(serverRow(s)); |
| + for (const s of group) ul.append(serverRow(s, sortable)); |
| return ul; |
| } |
| @@ -199,7 +218,7 @@ const GRIP_SVG = |
| '<circle cx="2" cy="8" r="1.25"/><circle cx="8" cy="8" r="1.25"/>' + |
| '<circle cx="2" cy="13" r="1.25"/><circle cx="8" cy="13" r="1.25"/></svg>'; |
| -function serverRow(s) { |
| +function serverRow(s, sortable) { |
| const li = document.createElement("li"); |
| li.dataset.url = s.url; |
| @@ -221,23 +240,28 @@ function serverRow(s) { |
| url.textContent = s.url; |
| main.append(name, url); |
| + li.append(star, main); |
| + |
| // The drag handle: grab to reorder (pointer), or ↑/↓ when focused (keyboard) — |
| - // the a11y fallback that replaces the old visible up/down buttons. |
| - const handle = document.createElement("button"); |
| - handle.type = "button"; |
| - handle.className = "drag-handle"; |
| - handle.setAttribute("aria-label", "Reorder " + (s.name || s.url)); |
| - handle.innerHTML = GRIP_SVG; |
| - handle.addEventListener("pointerdown", (e) => startDrag(e, li)); |
| - handle.addEventListener("keydown", (e) => { |
| - const dir = e.key === "ArrowUp" ? -1 : e.key === "ArrowDown" ? 1 : 0; |
| - if (!dir) return; |
| - e.preventDefault(); |
| - applyServers(moveInGroup(vault.obj.servers, s.url, dir)); |
| - focusHandle(s.url); // re-render dropped focus; put it back on the moved row |
| - }); |
| - |
| - li.append(star, main, handle); |
| + // the a11y reorder fallback. Omitted while a search filter is active (you can't |
| + // meaningfully reorder a filtered subset). |
| + if (sortable) { |
| + const handle = document.createElement("button"); |
| + handle.type = "button"; |
| + handle.className = "drag-handle"; |
| + handle.setAttribute("aria-label", "Reorder " + (s.name || s.url)); |
| + handle.innerHTML = GRIP_SVG; |
| + handle.addEventListener("pointerdown", (e) => startDrag(e, li)); |
| + handle.addEventListener("keydown", (e) => { |
| + const dir = e.key === "ArrowUp" ? -1 : e.key === "ArrowDown" ? 1 : 0; |
| + if (!dir) return; |
| + e.preventDefault(); |
| + applyServers(moveInGroup(vault.obj.servers, s.url, dir)); |
| + focusHandle(s.url); // re-render dropped focus; put it back on the moved row |
| + }); |
| + li.append(handle); |
| + } |
| + |
| return li; |
| } |
| @@ -400,14 +424,14 @@ async function createSpace() { |
| saved = false; |
| } |
| showVault(); |
| - showNewSpaceResult(host, body.setup_link || url, saved); |
| + showNewSpaceResult(host, body.setup_link, saved); |
| spaceStatus(""); |
| } finally { |
| btn.disabled = false; |
| } |
| } |
| -function showNewSpaceResult(host, openLink, saved) { |
| +function showNewSpaceResult(host, setupLink, saved) { |
| const box = $("new-space-result"); |
| box.classList.remove("hidden"); |
| box.textContent = ""; |
| @@ -415,21 +439,31 @@ function showNewSpaceResult(host, openLink, saved) { |
| const ready = document.createElement("p"); |
| ready.className = "status ok"; |
| ready.textContent = "Your space " + host + " is ready."; |
| - |
| - const a = document.createElement("a"); |
| - a.className = "btn-link"; |
| - a.href = openLink; |
| - a.target = "_blank"; |
| - a.rel = "noopener"; |
| - a.textContent = "Open your new space →"; |
| - |
| - const note = document.createElement("p"); |
| - note.className = "sub"; |
| - note.textContent = saved |
| - ? "This one-time link makes you the keyholder. It's saved to your account below." |
| - : "This one-time link makes you the keyholder. Couldn't save it just now — add it below to be safe."; |
| - |
| - box.append(ready, a, note); |
| + box.append(ready); |
| + |
| + if (setupLink) { |
| + // The setup link (not the bare instance): claiming it makes you the space's |
| + // keyholder and hands you to Eleven to set it up and administer it. |
| + const a = document.createElement("a"); |
| + a.className = "btn-link"; |
| + a.href = setupLink; |
| + a.target = "_blank"; |
| + a.rel = "noopener"; |
| + a.textContent = "Set up your space →"; |
| + const note = document.createElement("p"); |
| + note.className = "sub"; |
| + note.textContent = saved |
| + ? "This one-time link makes you the keyholder and starts setup with Eleven. It's saved below so you can find it again." |
| + : "This one-time link makes you the keyholder and starts setup with Eleven. Couldn't save it just now — add it below to be safe."; |
| + box.append(a, note); |
| + } else { |
| + // No setup link means the mint failed — deliberately don't offer the bare |
| + // instance URL (it lands on a signed-out card, not the admin flow). |
| + const note = document.createElement("p"); |
| + note.className = "sub"; |
| + note.textContent = "Your space is saved below, but the setup link couldn't be created just now — reload and create again, or reach the operator to mint one."; |
| + box.append(note); |
| + } |
| } |
| // ---- add-space form ----------------------------------------------------------- |
| @@ -478,6 +512,9 @@ $("new-space").addEventListener("click", () => createSpace().catch((err) => { |
| else spaceStatus((err && err.message) || String(err), "error"); |
| })); |
| $("add-form").addEventListener("submit", (e) => addServer(e).catch((err) => vaultStatus(err.message || String(err), "error"))); |
| +// Live fuzzy filter: re-render the (unlocked) list on each keystroke. The input |
| +// lives outside #spaces, so re-rendering the lists never steals its focus. |
| +$("space-search").addEventListener("input", () => { if (vault) showVault(); }); |
| // Learn whether this home server offers self-serve space creation. If we're |
| // already signed in (session rehydrate below), reveal the controls too. |
diff --git a/home/web/static/serverlist.js b/home/web/static/serverlist.js| index 82f50f4..a638396 100644 |
| --- a/home/web/static/serverlist.js |
| +++ b/home/web/static/serverlist.js |
| @@ -17,19 +17,48 @@ export function partitionServers(servers) { |
| } |
| // toggleStar returns a NEW servers array with the entry's starred flag flipped |
| -// (added as `starred: true`, or removed entirely so an unstarred entry carries no |
| -// flag). Unknown urls pass through unchanged. |
| +// AND repositioned: starring sends it to the END of the Starred list, unstarring |
| +// sends it to the TOP of the rest — both of which are the boundary between the |
| +// two groups, so the move is a single insert there. (The array is normalised to |
| +// starred-first-then-others, which display-partitioning treats identically.) |
| +// Unknown urls pass through unchanged. |
| export function toggleStar(servers, url) { |
| - return (servers || []).map((s) => { |
| - if (s.url !== url) return s; |
| - if (s.starred) { |
| - const { starred, ...rest } = s; |
| - return rest; |
| - } |
| - return { ...s, starred: true }; |
| + const list = servers || []; |
| + const target = list.find((s) => s.url === url); |
| + if (!target) return list; |
| + const updated = target.starred |
| + ? (({ starred, ...rest }) => rest)(target) // unstar: drop the flag |
| + : { ...target, starred: true }; // star |
| + const starred = list.filter((s) => s.url !== url && s.starred); |
| + const others = list.filter((s) => s.url !== url && !s.starred); |
| + return [...starred, updated, ...others]; |
| +} |
| + |
| +// filterServers returns the subset whose name/host fuzzily matches `query` (a |
| +// subsequence match, case-insensitive), preserving array order. Empty query → |
| +// everything. Pure so the fuzzy logic is unit-tested without a DOM. |
| +export function filterServers(servers, query) { |
| + const q = (query || "").trim().toLowerCase(); |
| + if (!q) return servers || []; |
| + return (servers || []).filter((s) => { |
| + let host = ""; |
| + try { host = new URL(s.url).host; } catch { host = s.url || ""; } |
| + return fuzzyMatch(q, ((s.name || "") + " " + host).toLowerCase()); |
| }); |
| } |
| +// fuzzyMatch reports whether every character of `q` appears in `text` in order |
| +// (a subsequence — the familiar command-palette style of fuzzy match). `q` is |
| +// assumed already lowercased; callers lowercase `text` to match. |
| +export function fuzzyMatch(q, text) { |
| + if (!q) return true; |
| + let i = 0; |
| + for (const ch of text) { |
| + if (ch === q[i] && ++i === q.length) return true; |
| + } |
| + return false; |
| +} |
| + |
| // orderServers returns a NEW servers array reordered to match `orderedUrls` (the |
| // full top-to-bottom display order across both lists — what a drag leaves behind). |
| // Entries whose url isn't listed keep their relative order at the end (defensive); |