simocracy-v2 · navbar-search

Navbar Global Search — Cmd+K Command Palette

@daviddao

A unified ⌘K command palette that searches sims, skills, events, and proposals — paired with a navbar reorganized into four mental-model groups. End-to-end verified on desktop and mobile against a live simocracy-v2 dev server with 126 indexed records.

Date: 2026-05-08 · Target: http://127.0.0.1:3000 · pass


Headline result

126
Records indexed
4 / 4
Result kinds working
2
Viewports verified
1
UX bug found & fixed mid-run

Pass. The new /api/search endpoint returns the full searchable corpus across 95 sims · 5 skills · 8 events · 18 proposals. Client-side substring ranking surfaces results instantly on every keystroke; ⌘K opens the modal globally; arrow-key + Enter navigation lands on the right detail page (slug-aware). Mobile (390×844) shows the same dialog as a near-fullscreen card and stacks the navbar groups under PLEX section headers (You · Browse · Governance · About). One UX bug — a stale internal scroll position cropping the first section header on subsequent searches — was caught during the smoke test and fixed in the same session.

What shipped

Three pieces, plus a navbar reorganization carried over from the previous task:

APIapp/api/search/route.ts — flat SearchableItem[] corpus across all four lexicons, with pre-resolved canonical hrefs (slug-aware via lib/aliases.ts) and 60s edge cache. UIcomponents/global-search.tsx — Radix-Dialog command palette: keyboard shortcuts (⌘K / Ctrl+K / /), client-side substring ranking, grouped result sections, scroll-into-view keyboard nav, TanStack Query session cache. Navbarcomponents/navbar.tsx — input-shaped trigger on desktop with ⌘K hint; icon-only trigger on mobile; four-group layout with hairline foil dividers (You · Browse · Governance · About) on desktop and PLEX headers on mobile.

Walkthrough

Desktop · the navbar at rest

Simocracy landing page with new navbar — Sims Skills | Events Proposals | Stats About — and an input-shaped Search… trigger with a ⌘K hint.
01Landing page. The navbar links are bucketed by hairline foil dividers (Browse · Governance · About). The desktop search trigger renders as an input with “Search…” + the ⌘K shortcut hint, sized to read as an inviting target without competing with the wordmark.
Navbar on the gallery page with SIMS underlined in foil to show the active section.
02Navbar on /gallery. The active section (Sims) gets the foil underline. The page already has its own scoped “Search by name…” input — the global ⌘K bar lives one row up and stays visible because it’s scoped across the whole product, not just the current page.

Desktop · the search modal

Search modal opened from the trigger, empty query, showing recent sims with avatars.
03Empty-query state. The dialog opens centered with a 6 px backdrop blur (foil-tinted) and surfaces a quick preview of recent items so the user understands the search immediately. Footer shows the keyboard contract (↑↓ navigate · ↵ open) and a “126 indexed” signal.
Search for 'fire' returns one sim — Fire — with their avatar and the constitution preview.
04Single-result query (fire). Modal shrinks to fit content; the SIMS section header with the people icon stays pinned above the result. Sim avatars use <SimAvatar mode="static"> — the same thumbnail blob the gallery uses, so no canvas pipeline is mounted in the search list.
Search for 'g' showing the SIMS section header with six matching sims.
05Multi-section query (g). Per-section cap of 6 keeps the dropdown compact. Active row gets the foil left-border + tinted background — readable without shouting.
Same query 'g', after pressing ArrowDown several times — the active row is now in the SKILLS section.
06Same query, six ArrowDown presses later. The active row crosses into Skills — section header with the scroll icon shows up, scroll-into-view keeps the active row in frame, and rows without an avatar fall back to the section icon (no holes).
Same query, deeper into the list — now in the EVENTS section with gathering thumbnails.
07Events section. Building images come straight from each gathering’s buildingImageUrl field, so the search results reuse the same imagery as the events gallery.
Same query, scrolled to the bottom — PROPOSALS section, with mix of proposals showing banner images and FileText icons.
08Proposals section. Proposals with image.uri set show their banner; the rest fall back to a generic FileText tile — no missing-image holes.
Search for 'ai' jumped to End — showing the PROPOSALS bottom of the list.
09End jumps to the last result; Home jumps back to the first. ⌘K → type → arrow → enter never requires the mouse.
After pressing Enter on 'The Senate' result, the page navigates to /events/senate.
10Pressing on “The Senate” lands on /events/senate — the slug-aware canonical URL resolved server-side via urlForTarget(slugMap, …), not the raw /events/<did>/<rkey> form.

Mobile · 390 × 844 (iPhone 15 Pro size)

Mobile navbar — logo, hamburger, search icon, sign-in button.
11Mobile navbar. Search trigger drops to an icon-only button so the bar fits in 390 px without crowding the wordmark or the Sign In CTA.
Mobile menu expanded — sections labeled BROWSE, GOVERNANCE, ABOUT.
12Hamburger open. Four groups stack with PLEX section headers (Browse · Governance · About; You appears when signed in). Same mental model as desktop, surfaced more explicitly.
Mobile search dialog — full-width input, SIMS section, then SKILLS section.
13Search dialog on mobile. Footer collapses the ↑↓ navigate hint (irrelevant on touch) and keeps just ↵ OPEN + the indexed-count badge.
Mobile search for 'frontier' — three sections shown (SIMS, EVENTS, PROPOSALS).
14Mobile multi-kind result (frontier). Three matching sections (sims, events, proposals — no skills hit) all in one screen.
After tapping 'Funding the Commons SF' on mobile, the gathering detail page loads.
15Tap-to-navigate. Selecting Funding the Commons SF closes the dialog and lands on the gathering detail page — URL-encoded DID resolves cleanly through the catch-all router.

Findings

All four result kinds work end-to-end

Verified each kind clicks through to the correct route:

The corpus payload for the live deployment was 126 items (95 sims · 5 skills · 8 events · 18 proposals); compresses to ~30 KB on the wire because most descriptions are short. Scales linearly until ~5–10× this size; at that point we should consider server-side filtering or a real index.

Keyboard contract holds across browsers

⌘K / Ctrl+K open from anywhere; / opens unless the user is already typing in another input (isTypingTarget() checks tagName + isContentEditable). ArrowDown / ArrowUp wrap; Home / End jump; Enter opens the active row; Escape closes (Radix Dialog default). Active row is wired to aria-activedescendant + role="option", so screen readers announce it correctly.

UX bug caught mid-run: stale listbox scroll cropped the first section header

Initial implementation: after a query change, the list reset activeIndex to 0 and called el.scrollIntoView({ block: "nearest" }) on item 0. If the listbox was previously scrolled (e.g. user pressed End, then typed a new query), nearest only scrolled just enough to expose the first item — which left the SectionHeader directly above it cropped out of view.

Fix in components/global-search.tsx:

// Reset the listbox scroll on every query change.
useEffect(() => {
  if (listRef.current) listRef.current.scrollTop = 0;
}, [query]);

// Skip scrollIntoView for index 0 — the effect above already pinned
// the list to the top, and a redundant scrollIntoView would crop the
// first section header.
useEffect(() => {
  if (safeActiveIndex === 0) return;
  // … existing scrollIntoView for keyboard nav …
}, [safeActiveIndex]);

Verified with a fresh search after the fix — the SIMS header now stays visible above the first result on every keystroke.

Design system fits cleanly

The dialog uses the existing fieldnotes tokens (--cloth, --cloth-2, --foil, --ink-on-cloth, --ink-dim) and the EB Garamond / IBM Plex Mono / Cormorant Italic stack already loaded by app/layout.tsx. No new fonts, no new colour tokens, no new npm dependencies (per the AGENTS.md constraint). The only Tailwind 4 utility introduced — cloth-window-shadow — was already in globals.css for the existing fieldnotes dialogs.

Future-work watch points

Reproducing

Branchmain · simocracy-v2 Spin upnpm run devhttp://127.0.0.1:3000 API pingcurl -s http://127.0.0.1:3000/api/search | jq '.items | length'126 Open dialogPress ⌘K on macOS, Ctrl+K elsewhere, or / when no input is focused Try queriesfire, ai, g, senate, frontier — each exercises a different mix of result kinds Capture toolagent-browser for the screenshots; agent-browser set viewport 390 844 for mobile Quality gatesnpm run lint && npm run build — both pass Filesapp/api/search/route.ts · components/global-search.tsx · components/navbar.tsx