home: star + independently-sort saved spaces (#334)
The home page's "Your Eleven spaces" list gains starring and sorting. Star a space and it jumps into a Starred list; each list (Starred and the rest) reorders independently with ↑/↓ — reordering one never disturbs the other. This is the list the native apps read: starred spaces populate the rail, the rest go behind a "more" affordance. Blob schema (client-side, additive): servers entries gain an optional `starred: true`; the array order is the display order. Partitioning starred-vs-rest preserves each subgroup's relative order, so the two lists sort independently within one array — no schema-level ordering duplication, backward compatible with existing blobs. The partition/star/move logic is a pure, framework-free module (web/static/serverlist.js) with a Node test (home/test/serverlist.test.mjs, wired into npm test) — home.js just renders it and persists on change with inline "Saving…/Saved." feedback. Schema documented in docs/onboarding-and-home-server.md as the contract for native clients.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+268
−23
docs/onboarding-and-home-server.md+12 −0home/test/serverlist.test.mjs+89 −0home/web/pages/index.html+25 −4home/web/static/home.js+94 −18home/web/static/serverlist.js+47 −0package.json+1 −1
diff --git a/docs/onboarding-and-home-server.md b/docs/onboarding-and-home-server.md| index a7c624e..33f895c 100644 |
| --- a/docs/onboarding-and-home-server.md |
| +++ b/docs/onboarding-and-home-server.md |
| @@ -112,6 +112,18 @@ Per user: WebAuthn credential public key(s), one **opaque padded blob** |
| device link tokens. No email, no username, no display name. The credential ID |
| *is* the identity. |
| +**Blob schema (the decrypted plaintext — the contract every client reads).** |
| +`{ v: 1, servers: [{ url, name?, starred? }] }`. The `servers` **array order is |
| +the display order**; each entry may carry `starred: true`. Rendering (web and |
| +native) partitions it into a **Starred** list and the rest, *each keeping its |
| +array-order* — so the two lists sort independently: reordering one never |
| +disturbs the other. Native apps map this straight through — starred spaces |
| +populate the rail, the rest live behind a "more" affordance. The pure helpers |
| +(`web/static/serverlist.js`, unit-tested) are the canonical partition/star/move |
| +logic; keep the native implementations behaviourally identical. Merges on a |
| +version race are last-writer-wins per `url` (the list is tiny), which can drop a |
| +concurrent reorder — acceptable until per-entry ordering earns a CRDT. |
| + |
| ### Crypto |
| - Passkey at RP `home.elevenmessenger.com`, created with the **PRF extension**. |
diff --git a/home/test/serverlist.test.mjs b/home/test/serverlist.test.mjs| new file mode 100644 |
| index 0000000..497dfd8 |
| --- /dev/null |
| +++ b/home/test/serverlist.test.mjs |
| @@ -0,0 +1,89 @@ |
| +// Tests for the saved-spaces list helpers (star + independent sort), run in Node. |
| +// Same harness style as home/test/crypto.test.mjs. |
| +// node home/test/serverlist.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { |
| + partitionServers, |
| + toggleStar, |
| + moveInGroup, |
| +} from "../web/static/serverlist.js"; |
| + |
| +let failures = 0; |
| +async function test(name, fn) { |
| + try { |
| + await fn(); |
| + console.log("PASS " + name); |
| + } catch (e) { |
| + console.log("FAIL " + name + " — " + (e && e.message)); |
| + failures++; |
| + } |
| +} |
| + |
| +const urls = (list) => list.map((s) => s.url); |
| + |
| +await test("partitionServers splits starred from the rest, keeping array order", () => { |
| + const servers = [ |
| + { url: "a", starred: true }, |
| + { url: "b" }, |
| + { url: "c", starred: true }, |
| + { url: "d" }, |
| + ]; |
| + const { starred, others } = partitionServers(servers); |
| + assert.deepEqual(urls(starred), ["a", "c"]); |
| + assert.deepEqual(urls(others), ["b", "d"]); |
| +}); |
| + |
| +await test("partitionServers tolerates null/empty", () => { |
| + assert.deepEqual(partitionServers(null), { starred: [], others: [] }); |
| + assert.deepEqual(partitionServers([]), { starred: [], others: [] }); |
| +}); |
| + |
| +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(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"); |
| +}); |
| + |
| +await test("toggleStar leaves unknown urls unchanged", () => { |
| + const servers = [{ url: "a", name: "A" }]; |
| + assert.deepEqual(toggleStar(servers, "zzz"), servers); |
| +}); |
| + |
| +await test("moveInGroup reorders within a group and never disturbs the other", () => { |
| + // starred: [a, c] others: [b, d] (interleaved in the master array) |
| + const servers = [ |
| + { url: "a", starred: true }, |
| + { url: "b" }, |
| + { url: "c", starred: true }, |
| + { url: "d" }, |
| + ]; |
| + // Move starred 'c' up: it swaps with 'a' (its nearest starred neighbour), |
| + // jumping over the non-starred 'b'. Others keep their order. |
| + const moved = moveInGroup(servers, "c", -1); |
| + assert.deepEqual(urls(partitionServers(moved).starred), ["c", "a"]); |
| + assert.deepEqual(urls(partitionServers(moved).others), ["b", "d"], "other list unchanged"); |
| +}); |
| + |
| +await test("moveInGroup down within the 'others' group skips over starred entries", () => { |
| + const servers = [ |
| + { url: "b" }, |
| + { url: "a", starred: true }, |
| + { url: "d" }, |
| + ]; |
| + const moved = moveInGroup(servers, "b", +1); // b swaps with d, over starred a |
| + assert.deepEqual(urls(partitionServers(moved).others), ["d", "b"]); |
| + assert.deepEqual(urls(partitionServers(moved).starred), ["a"]); |
| +}); |
| + |
| +await test("moveInGroup at a group boundary is a no-op", () => { |
| + const servers = [{ url: "a", starred: true }, { url: "b" }]; |
| + assert.deepEqual(moveInGroup(servers, "a", -1), servers, "top of its group can't move up"); |
| + assert.deepEqual(moveInGroup(servers, "b", +1), servers, "bottom of its group can't move down"); |
| +}); |
| + |
| +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 3a3c9d0..eedf6d9 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -41,16 +41,37 @@ |
| .vault-head { display: flex; align-items: baseline; justify-content: space-between; gap: .5rem; } |
| .vault-head #signout { font-size: .85rem; font-weight: 600; } |
| #vault h2 { font-size: 15px; margin: 0 0 4px; font-family: var(--font-display); } |
| + /* Section headings: "Starred" and "More" — only shown once some are starred. */ |
| + .spaces-head { |
| + font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; |
| + color: var(--muted); font-family: var(--font-display); margin: 16px 0 2px; |
| + } |
| + .spaces-head:first-child { margin-top: 0; } |
| + #spaces > p.empty { color: var(--muted); font-size: .9rem; margin: 6px 0 0; } |
| ul.servers { list-style: none; margin: 0; padding: 0; } |
| ul.servers li { |
| - display: flex; flex-direction: column; gap: 2px; |
| - padding: .55rem 0; border-top: 1px solid var(--border); |
| + display: flex; flex-direction: row; align-items: center; gap: 10px; |
| + padding: .5rem 0; border-top: 1px solid var(--border); |
| } |
| ul.servers li:first-child { border-top: none; } |
| + .srv-main { flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0; } |
| ul.servers li a.name { font-weight: 600; color: var(--ink); text-decoration: none; } |
| ul.servers li a.name:hover { text-decoration: underline; } |
| ul.servers li .url { color: var(--muted); font-size: .8rem; overflow-wrap: anywhere; } |
| - ul.servers li.empty { color: var(--muted); font-size: .9rem; } |
| + /* Star toggle: a quiet outline star that fills gold when on. */ |
| + .star { |
| + background: none; border: none; padding: 2px; cursor: pointer; |
| + font-size: 18px; line-height: 1; color: var(--muted); |
| + } |
| + .star.on { color: #f2b705; } |
| + .star:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; } |
| + /* Up/down reorder within a list. */ |
| + .moves { display: flex; gap: 3px; } |
| + .move { |
| + background: var(--quiet); border: none; border-radius: 7px; |
| + width: 26px; height: 26px; font-size: 13px; line-height: 1; cursor: pointer; color: var(--ink); |
| + } |
| + .move:disabled { opacity: .35; cursor: default; } |
| #space-section { margin-top: 16px; } |
| #space-section p.lead { color: var(--muted); font-size: .85rem; margin: 0 0 8px; } |
| #new-space { width: 100%; } |
| @@ -81,7 +102,7 @@ |
| <h2>Your Eleven spaces</h2> |
| <button class="linkish" id="signout">Sign out</button> |
| </div> |
| - <ul class="servers" id="server-list"></ul> |
| + <div id="spaces"></div> |
| <!-- Create a new space — only where this home server offers provisioning |
| (revealed by /api/features) and only once you're signed in. --> |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 88c7781..47fcc3c 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -16,6 +16,9 @@ import { |
| import { |
| apiJSON, beginLogin, runLogin, fetchBlob, putBlob, |
| } from "/static/backup-core.js"; |
| +import { |
| + partitionServers, toggleStar, moveInGroup, |
| +} from "/static/serverlist.js"; |
| // ---- session state ---------------------------------------------------------- |
| // Session-only on purpose (the design doc's web default): closing the tab |
| @@ -138,7 +141,7 @@ function signOut() { |
| $("vault").classList.add("hidden"); |
| $("space-section").classList.add("hidden"); |
| $("new-space-result").classList.add("hidden"); |
| - $("server-list").textContent = ""; |
| + $("spaces").textContent = ""; |
| $("signed-out").classList.remove("hidden"); |
| $("reassure").classList.remove("hidden"); |
| $("signin").disabled = false; |
| @@ -153,26 +156,99 @@ function showVault() { |
| $("reassure").classList.add("hidden"); |
| $("vault").classList.remove("hidden"); |
| if (spacesEnabled) $("space-section").classList.remove("hidden"); |
| - const list = $("server-list"); |
| - list.textContent = ""; |
| + |
| + const box = $("spaces"); |
| + box.textContent = ""; |
| const servers = vault.obj.servers || []; |
| if (!servers.length) { |
| - const li = document.createElement("li"); |
| - li.className = "empty"; |
| - li.textContent = "No spaces saved yet — save one from its chat page and it'll appear here."; |
| - list.append(li); |
| + 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); |
| + return; |
| + } |
| + // 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)); |
| + if (others.length) box.append(sectionHeading("More")); |
| } |
| - for (const s of servers) { |
| - const li = document.createElement("li"); |
| - const name = document.createElement("a"); |
| - name.className = "name"; |
| - name.href = s.url; |
| - name.textContent = s.name || new URL(s.url).host; |
| - const url = document.createElement("span"); |
| - url.className = "url"; |
| - url.textContent = s.url; |
| - li.append(name, url); |
| - list.append(li); |
| + if (others.length) box.append(serverList(others)); |
| +} |
| + |
| +function sectionHeading(text) { |
| + const h = document.createElement("div"); |
| + h.className = "spaces-head"; |
| + h.textContent = text; |
| + return h; |
| +} |
| + |
| +function serverList(group) { |
| + const ul = document.createElement("ul"); |
| + ul.className = "servers"; |
| + group.forEach((s, i) => ul.append(serverRow(s, i, group.length))); |
| + return ul; |
| +} |
| + |
| +function serverRow(s, idxInGroup, groupLen) { |
| + const li = document.createElement("li"); |
| + |
| + const star = document.createElement("button"); |
| + star.type = "button"; |
| + star.className = "star" + (s.starred ? " on" : ""); |
| + star.textContent = s.starred ? "★" : "☆"; |
| + star.setAttribute("aria-label", s.starred ? "Unstar " + (s.name || s.url) : "Star " + (s.name || s.url)); |
| + star.addEventListener("click", () => { applyServers(toggleStar(vault.obj.servers, s.url)); }); |
| + |
| + const main = document.createElement("div"); |
| + main.className = "srv-main"; |
| + const name = document.createElement("a"); |
| + name.className = "name"; |
| + name.href = s.url; |
| + name.textContent = s.name || new URL(s.url).host; |
| + const url = document.createElement("span"); |
| + url.className = "url"; |
| + url.textContent = s.url; |
| + main.append(name, url); |
| + |
| + const moves = document.createElement("div"); |
| + moves.className = "moves"; |
| + const up = moveButton("↑", "Move up", idxInGroup === 0, () => applyServers(moveInGroup(vault.obj.servers, s.url, -1))); |
| + const down = moveButton("↓", "Move down", idxInGroup === groupLen - 1, () => applyServers(moveInGroup(vault.obj.servers, s.url, +1))); |
| + moves.append(up, down); |
| + |
| + li.append(star, main, moves); |
| + return li; |
| +} |
| + |
| +function moveButton(glyph, label, disabled, onClick) { |
| + const b = document.createElement("button"); |
| + b.type = "button"; |
| + b.className = "move"; |
| + b.textContent = glyph; |
| + b.setAttribute("aria-label", label); |
| + b.disabled = disabled; |
| + if (!disabled) b.addEventListener("click", onClick); |
| + return b; |
| +} |
| + |
| +// applyServers swaps in a new servers array (star/reorder returns a fresh one), |
| +// re-renders immediately (optimistic), then persists to the backup in the |
| +// background with inline feedback. |
| +function applyServers(next) { |
| + vault.obj.servers = next; |
| + showVault(); |
| + persistVault(); |
| +} |
| + |
| +async function persistVault() { |
| + vaultStatus("Saving…", "busy"); |
| + try { |
| + await saveVault(); |
| + vaultStatus("Saved.", "ok"); |
| + } catch (e) { |
| + vaultStatus(e.message || "Couldn't save your changes.", "error"); |
| } |
| } |
diff --git a/home/web/static/serverlist.js b/home/web/static/serverlist.js| new file mode 100644 |
| index 0000000..b13fe0d |
| --- /dev/null |
| +++ b/home/web/static/serverlist.js |
| @@ -0,0 +1,47 @@ |
| +// Pure helpers for the saved-spaces list — framework-free and side-effect-free, |
| +// so they're unit-testable in Node (home/test/serverlist.test.mjs) without a DOM. |
| +// |
| +// The backup blob's `servers` array is the master order; each entry is |
| +// { url, name?, starred? }. Rendering partitions it into a **Starred** list and |
| +// the rest, each keeping its own relative array order — so the two lists sort |
| +// independently (reordering one never disturbs the other). This is the contract |
| +// the native apps read too: starred spaces populate the rail, the rest live |
| +// behind a "more" affordance. |
| + |
| +// partitionServers splits into { starred, others }, each in array order. |
| +export function partitionServers(servers) { |
| + const starred = []; |
| + const others = []; |
| + for (const s of servers || []) (s.starred ? starred : others).push(s); |
| + return { starred, others }; |
| +} |
| + |
| +// 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. |
| +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 }; |
| + }); |
| +} |
| + |
| +// moveInGroup returns a NEW servers array with the entry at `url` swapped with its |
| +// nearest **same-group** (same starred-ness) neighbour in direction `dir` |
| +// (-1 = up, +1 = down), skipping over entries from the other group. Returns the |
| +// array unchanged if there's no such neighbour (already at an end of its list). |
| +export function moveInGroup(servers, url, dir) { |
| + const arr = (servers || []).slice(); |
| + const i = arr.findIndex((s) => s.url === url); |
| + if (i < 0) return arr; |
| + const group = !!arr[i].starred; |
| + let j = i + dir; |
| + while (j >= 0 && j < arr.length && !!arr[j].starred !== group) j += dir; |
| + if (j < 0 || j >= arr.length) return arr; |
| + [arr[i], arr[j]] = [arr[j], arr[i]]; |
| + return arr; |
| +} |
diff --git a/package.json b/package.json| index 65e7571..cf4badf 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node home/test/crypto.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/flow.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node test/check-app.test.mjs && node home/test/crypto.test.mjs && node home/test/serverlist.test.mjs" |
| } |
| } |