[object Object]

← back to Norma

Global search: mount on Supervisor + Pulse tiers

3b2f226cefdda9bf3db5f83f8bf8cbbdc79093b5 · 2026-06-01 07:39:35 -0700 · Steve Abrams

Norma had GlobalSearch wired only into AppShell (admin) and NikkiShell
(staff). Extend to the remaining two tiers:

- Supervisor: import GlobalSearch and render it top-right of
  app/supervisor/page.tsx. onNavigate pushes /?tab=<id>&id=<id>; teach
  AppShell to read those params on mount so deep-links land on the right
  tab.

- Pulse: build a new PulseSearch component that matches the Pulse
  Georgia-serif / cream / dark-green / gold theme (the existing
  GlobalSearch is dark-themed and would clash). Hits a new
  /api/search/public route that requires no auth and only returns
  entities safe for end-users: petitions, politicians, organizations,
  topics, articles. Intentionally excludes journalists, contacts,
  grants, foundations — those are staff/admin tooling.

Files:
  app/api/search/public/route.ts  (new — 5-entity public search)
  components/PulseSearch.tsx       (new — Pulse-themed search bar + modal)
  components/AppShell.tsx          (read ?tab=&id= deep-link params)
  app/supervisor/page.tsx          (mount GlobalSearch top-right)
  app/pulse/layout.tsx             (mount PulseSearch in nav)

Cmd+K / Ctrl+K opens search on both surfaces. Esc closes, arrow keys
navigate, Enter selects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 3b2f226cefdda9bf3db5f83f8bf8cbbdc79093b5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 07:39:35 2026 -0700

    Global search: mount on Supervisor + Pulse tiers
    
    Norma had GlobalSearch wired only into AppShell (admin) and NikkiShell
    (staff). Extend to the remaining two tiers:
    
    - Supervisor: import GlobalSearch and render it top-right of
      app/supervisor/page.tsx. onNavigate pushes /?tab=<id>&id=<id>; teach
      AppShell to read those params on mount so deep-links land on the right
      tab.
    
    - Pulse: build a new PulseSearch component that matches the Pulse
      Georgia-serif / cream / dark-green / gold theme (the existing
      GlobalSearch is dark-themed and would clash). Hits a new
      /api/search/public route that requires no auth and only returns
      entities safe for end-users: petitions, politicians, organizations,
      topics, articles. Intentionally excludes journalists, contacts,
      grants, foundations — those are staff/admin tooling.
    
    Files:
      app/api/search/public/route.ts  (new — 5-entity public search)
      components/PulseSearch.tsx       (new — Pulse-themed search bar + modal)
      components/AppShell.tsx          (read ?tab=&id= deep-link params)
      app/supervisor/page.tsx          (mount GlobalSearch top-right)
      app/pulse/layout.tsx             (mount PulseSearch in nav)
    
    Cmd+K / Ctrl+K opens search on both surfaces. Esc closes, arrow keys
    navigate, Enter selects.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 app/api/search/public/route.ts | 130 ++++++++++++++
 app/pulse/layout.tsx           |   4 +
 app/supervisor/page.tsx        |  15 ++
 components/AppShell.tsx        |  14 ++
 components/PulseSearch.tsx     | 376 +++++++++++++++++++++++++++++++++++++++++
 5 files changed, 539 insertions(+)

diff --git a/app/api/search/public/route.ts b/app/api/search/public/route.ts
new file mode 100644
index 0000000..cf05aff
--- /dev/null
+++ b/app/api/search/public/route.ts
@@ -0,0 +1,130 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { query } from '@/lib/db';
+
+/* ═══════════════════════════════════════════════════════════════════════════
+   GET /api/search/public?q=jillian+berman&limit=20
+
+   PUBLIC global search — no auth required, only returns entities that are
+   safe for unauthenticated end-users (Pulse tier) to see:
+
+     · petitions     (the public petition feed)
+     · politicians   (public-record figures)
+     · organizations (public partner orgs)
+     · topics        (public pulse_topics — the issue radar)
+     · articles      (linkable news coverage)
+
+   Intentionally EXCLUDES: journalists, contacts, grants, foundations — those
+   are staff/admin tooling and should never appear in a public search index.
+
+   This route is the public counterpart to /api/search (which requires
+   admin/staff role + applies org scoping). Pulse uses it.
+   ═══════════════════════════════════════════════════════════════════════════ */
+
+interface PublicResult {
+  id: string;
+  type: 'petition' | 'politician' | 'organization' | 'topic' | 'article';
+  title: string;
+  subtitle: string;
+  meta?: string;
+  url?: string;
+}
+
+export async function GET(request: NextRequest) {
+  const { searchParams } = new URL(request.url);
+  const q = searchParams.get('q')?.trim();
+  const limit = Math.min(parseInt(searchParams.get('limit') || '20', 10), 40);
+
+  if (!q || q.length < 2) {
+    return NextResponse.json({ results: [], query: q, counts: {}, total: 0 });
+  }
+  const pattern = `%${q}%`;
+
+  try {
+    const [petitions, politicians, organizations, topics, articles] = await Promise.all([
+      // Petitions — public petition feed (no org_id filter; the Pulse feed shows all orgs' petitions)
+      query<{ id: string; title: string; category: string; url: string }>(
+        `SELECT id, title, COALESCE(category, '') as category, COALESCE(url, '') as url
+         FROM petitions
+         WHERE title ILIKE $1 OR description ILIKE $1 OR category ILIKE $1
+         ORDER BY created_at DESC NULLS LAST
+         LIMIT $2`,
+        [pattern, limit]
+      ),
+
+      // Politicians — public record figures
+      query<{ id: string; name: string; party: string; state: string; title: string }>(
+        `SELECT id, name, COALESCE(party, '') as party, COALESCE(state, '') as state, COALESCE(title, '') as title
+         FROM politicians
+         WHERE name ILIKE $1 OR state ILIKE $1 OR title ILIKE $1
+         ORDER BY CASE WHEN name ILIKE $1 THEN 0 ELSE 1 END, name
+         LIMIT $2`,
+        [pattern, limit]
+      ),
+
+      // Organizations
+      query<{ id: string; name: string; category: string; state: string }>(
+        `SELECT id, name, COALESCE(category, '') as category, COALESCE(state, '') as state
+         FROM organizations
+         WHERE name ILIKE $1 OR category ILIKE $1 OR description ILIKE $1
+         ORDER BY CASE WHEN name ILIKE $1 THEN 0 ELSE 1 END, name
+         LIMIT $2`,
+        [pattern, limit]
+      ),
+
+      // Topics — public issue radar
+      query<{ id: string; name: string; urgency: string; category: string }>(
+        `SELECT id, name, COALESCE(urgency, '') as urgency, COALESCE(category, '') as category
+         FROM pulse_topics
+         WHERE name ILIKE $1 OR category ILIKE $1 OR description ILIKE $1
+         ORDER BY CASE WHEN name ILIKE $1 THEN 0 ELSE 1 END, name
+         LIMIT $2`,
+        [pattern, limit]
+      ),
+
+      // Articles — linkable coverage
+      query<{ id: string; title: string; outlet: string; published_date: string; url: string }>(
+        `SELECT ja.id, ja.title, ja.outlet, ja.published_date::text,
+                COALESCE(ja.url, '') as url
+         FROM journalist_articles ja
+         WHERE ja.title ILIKE $1 OR ja.outlet ILIKE $1 OR ja.summary ILIKE $1
+         ORDER BY ja.published_date DESC NULLS LAST
+         LIMIT $2`,
+        [pattern, limit]
+      ),
+    ]);
+
+    const results: PublicResult[] = [];
+
+    for (const r of petitions.rows) {
+      results.push({ id: r.id, type: 'petition', title: r.title, subtitle: r.category, url: r.url });
+    }
+    for (const r of politicians.rows) {
+      results.push({ id: r.id, type: 'politician', title: r.name, subtitle: r.title || `${r.party}-${r.state}`, meta: r.state });
+    }
+    for (const r of organizations.rows) {
+      results.push({ id: r.id, type: 'organization', title: r.name, subtitle: r.category, meta: r.state });
+    }
+    for (const r of topics.rows) {
+      results.push({ id: r.id, type: 'topic', title: r.name, subtitle: r.category, meta: r.urgency });
+    }
+    for (const r of articles.rows) {
+      results.push({ id: r.id, type: 'article', title: r.title, subtitle: r.outlet, meta: r.published_date, url: r.url });
+    }
+
+    return NextResponse.json({
+      results,
+      query: q,
+      counts: {
+        petitions: petitions.rows.length,
+        politicians: politicians.rows.length,
+        organizations: organizations.rows.length,
+        topics: topics.rows.length,
+        articles: articles.rows.length,
+      },
+      total: results.length,
+    });
+  } catch (err) {
+    console.error('[search/public] error:', (err as Error).message);
+    return NextResponse.json({ error: 'Search failed' }, { status: 500 });
+  }
+}
diff --git a/app/pulse/layout.tsx b/app/pulse/layout.tsx
index d3bfa4b..19296fb 100644
--- a/app/pulse/layout.tsx
+++ b/app/pulse/layout.tsx
@@ -8,6 +8,7 @@ import {
   Home, Activity, Bookmark, User, LogOut, ArrowLeft,
   LogIn,
 } from 'lucide-react';
+import PulseSearch from '@/components/PulseSearch';
 
 /* ────────────────────────── Types ────────────────────────── */
 
@@ -168,6 +169,9 @@ export default function PulseLayout({ children }: { children: React.ReactNode })
                 {themeKey === 'classic' ? '🏛' : '🌿'} {THEMES[themeKey === 'classic' ? 'institutional' : 'classic'].name}
               </button>
 
+              {/* Global search (⌘K) — public-tier search over petitions/politicians/orgs/topics/articles */}
+              <PulseSearch theme={C} />
+
               {/* Separator */}
               <div style={{ width: 1, height: 28, backgroundColor: 'rgba(255,255,255,0.15)', margin: '0 8px' }} />
 
diff --git a/app/supervisor/page.tsx b/app/supervisor/page.tsx
index c9dbf42..5dd8cc8 100644
--- a/app/supervisor/page.tsx
+++ b/app/supervisor/page.tsx
@@ -9,6 +9,7 @@ import {
   BarChart3, Settings, Zap, Eye, ArrowRight, Bot,
   UserPlus, FileText, MessageCircle, Database,
 } from 'lucide-react';
+import GlobalSearch from '@/components/GlobalSearch';
 
 /* ─── Types ────────────────────────────────────────────────────────────── */
 
@@ -440,6 +441,20 @@ export default function SupervisorPage() {
       minHeight: '100vh', background: 'var(--color-bg)',
       padding: '40px 32px', position: 'relative',
     }}>
+      {/* Top-right global search — supervisors jump to any entity (Cmd+K) */}
+      <div style={{
+        position: 'absolute', top: 24, right: 32, zIndex: 40,
+        display: 'flex', alignItems: 'center', gap: 8,
+      }}>
+        <GlobalSearch onNavigate={(tab, id) => {
+          // Supervisor lives at /supervisor; entity tabs live in Norma admin shell (/).
+          // Forward to the admin shell with the target tab + entity id as query params.
+          const params = new URLSearchParams({ tab });
+          if (id) params.set('id', id);
+          router.push(`/?${params.toString()}`);
+        }} />
+      </div>
+
       {/* Page header */}
       <div style={{ maxWidth: 1200, margin: '0 auto 40px', textAlign: 'center' }}>
         <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12, marginBottom: 8 }}>
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
index 88160f8..acca466 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -1,6 +1,7 @@
 'use client';
 
 import { useState, useEffect } from 'react';
+import { useSearchParams } from 'next/navigation';
 import { LogOut, Menu, LayoutGrid, LayoutDashboard, Newspaper, Megaphone, Award, MoreHorizontal } from 'lucide-react';
 import GlobalSearch from './GlobalSearch';
 import { AuthProvider, useAuth } from './AuthProvider';
@@ -125,6 +126,19 @@ function Shell() {
     setJournalistNavId(null);
   }
 
+  /* ── Deep-link from Supervisor's GlobalSearch: ?tab=foo&id=bar ─────────── */
+  const searchParams = useSearchParams();
+  useEffect(() => {
+    const tab = searchParams.get('tab') as TabId | null;
+    const id = searchParams.get('id');
+    if (!tab) return;
+    // For journalist deep-links, set the journalistNavId so the JournalistsTab can focus it
+    if (tab === 'journalists' && id) setJournalistNavId(id);
+    setActiveTab(tab);
+    // Note: we don't strip the params from the URL — the user can refresh and land back here.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
   /* ── Keyboard shortcuts for sidebar navigation ──────────────────────────── */
   useEffect(() => {
     const KBD_NAV: Record<string, TabId> = {
diff --git a/components/PulseSearch.tsx b/components/PulseSearch.tsx
new file mode 100644
index 0000000..5b5b462
--- /dev/null
+++ b/components/PulseSearch.tsx
@@ -0,0 +1,376 @@
+'use client';
+
+/* ═══════════════════════════════════════════════════════════════════════════
+   PulseSearch — public-tier global search bar for the Pulse layout.
+
+   Distinct from <GlobalSearch /> (which is dark-themed for the Norma admin /
+   Nikki staff shells). PulseSearch matches the Pulse "Pulse of America"
+   theme: Georgia serif, cream / dark-green / gold palette, lighter typography.
+
+   Hits /api/search/public — NO auth required. Only returns entities that are
+   safe for unauthenticated end-users (petitions, politicians, organizations,
+   topics, articles). Never journalists/contacts/grants/foundations.
+
+   Keyboard: ⌘K / Ctrl+K opens, Esc closes, ↑/↓ navigate, Enter selects.
+   ═══════════════════════════════════════════════════════════════════════════ */
+
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import {
+  Search, X, FileText, Vote, Building2, Radio, Newspaper, ArrowRight,
+} from 'lucide-react';
+
+interface SearchResult {
+  id: string;
+  type: 'petition' | 'politician' | 'organization' | 'topic' | 'article';
+  title: string;
+  subtitle: string;
+  meta?: string;
+  url?: string;
+}
+
+interface SearchResponse {
+  results: SearchResult[];
+  query: string;
+  counts: Record<string, number>;
+  total: number;
+}
+
+interface PulseTheme {
+  darkGreen: string;
+  gold: string;
+  goldHover: string;
+  cream: string;
+  textLight: string;
+}
+
+const TYPE_CONFIG: Record<SearchResult['type'], { icon: React.ElementType; color: string; label: string }> = {
+  petition:     { icon: FileText,   color: '#e11d48', label: 'Petition'     },
+  politician:   { icon: Vote,       color: '#1d4ed8', label: 'Politician'   },
+  organization: { icon: Building2,  color: '#0a7c59', label: 'Organization' },
+  topic:        { icon: Radio,      color: '#0891b2', label: 'Topic'        },
+  article:      { icon: Newspaper,  color: '#6366f1', label: 'Article'      },
+};
+
+export default function PulseSearch({ theme }: { theme: PulseTheme }) {
+  const [open, setOpen] = useState(false);
+  const [query, setQuery] = useState('');
+  const [results, setResults] = useState<SearchResult[]>([]);
+  const [counts, setCounts] = useState<Record<string, number>>({});
+  const [loading, setLoading] = useState(false);
+  const [activeFilter, setActiveFilter] = useState('');
+  const [selectedIndex, setSelectedIndex] = useState(0);
+  const inputRef = useRef<HTMLInputElement>(null);
+  const panelRef = useRef<HTMLDivElement>(null);
+  const router = useRouter();
+
+  /* ── Keyboard shortcut: Cmd+K / Ctrl+K ────────────────────────────────── */
+  useEffect(() => {
+    function handleKey(e: KeyboardEvent) {
+      if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
+        e.preventDefault();
+        setOpen(true);
+      }
+      if (e.key === 'Escape') {
+        setOpen(false);
+        setQuery('');
+        setResults([]);
+      }
+    }
+    window.addEventListener('keydown', handleKey);
+    return () => window.removeEventListener('keydown', handleKey);
+  }, []);
+
+  /* ── Focus input when opened ──────────────────────────────────────────── */
+  useEffect(() => {
+    if (open) setTimeout(() => inputRef.current?.focus(), 50);
+  }, [open]);
+
+  /* ── Close on outside click ───────────────────────────────────────────── */
+  useEffect(() => {
+    if (!open) return;
+    function handleClick(e: MouseEvent) {
+      if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
+        setOpen(false);
+      }
+    }
+    document.addEventListener('mousedown', handleClick);
+    return () => document.removeEventListener('mousedown', handleClick);
+  }, [open]);
+
+  /* ── Debounced search ─────────────────────────────────────────────────── */
+  useEffect(() => {
+    if (query.length < 2) {
+      setResults([]);
+      setCounts({});
+      return;
+    }
+    setLoading(true);
+    const timer = setTimeout(async () => {
+      try {
+        const res = await fetch(`/api/search/public?q=${encodeURIComponent(query)}&limit=20`);
+        if (res.ok) {
+          const data: SearchResponse = await res.json();
+          setResults(data.results);
+          setCounts(data.counts);
+          setSelectedIndex(0);
+        }
+      } catch { /* ignore */ }
+      setLoading(false);
+    }, 250);
+    return () => clearTimeout(timer);
+  }, [query]);
+
+  const filtered = activeFilter ? results.filter(r => r.type === activeFilter) : results;
+
+  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
+    if (e.key === 'ArrowDown') {
+      e.preventDefault();
+      setSelectedIndex(i => Math.min(i + 1, filtered.length - 1));
+    } else if (e.key === 'ArrowUp') {
+      e.preventDefault();
+      setSelectedIndex(i => Math.max(i - 1, 0));
+    } else if (e.key === 'Enter' && filtered[selectedIndex]) {
+      e.preventDefault();
+      handleSelect(filtered[selectedIndex]);
+    }
+  }, [filtered, selectedIndex]);
+
+  function handleSelect(r: SearchResult) {
+    // Articles with URLs open externally
+    if (r.type === 'article' && r.url) {
+      window.open(r.url, '_blank', 'noopener,noreferrer');
+      setOpen(false);
+      setQuery('');
+      return;
+    }
+    // Petitions deep-link to /pulse/petitions/<id>
+    if (r.type === 'petition') {
+      router.push(`/pulse/petitions/${r.id}`);
+    } else if (r.type === 'topic') {
+      // Topics filter the petition feed by category
+      router.push(`/pulse/petitions?category=${encodeURIComponent(r.subtitle || r.title)}`);
+    } else if (r.type === 'organization') {
+      // Orgs: search-filter the petition feed
+      router.push(`/pulse/petitions?search=${encodeURIComponent(r.title)}`);
+    } else if (r.type === 'politician') {
+      // Politicians: pulse doesn't have a politician detail page yet; surface as search filter
+      router.push(`/pulse/petitions?search=${encodeURIComponent(r.title)}`);
+    } else if (r.url) {
+      window.open(r.url, '_blank', 'noopener,noreferrer');
+    }
+    setOpen(false);
+    setQuery('');
+    setResults([]);
+  }
+
+  const activeTypes = Object.entries(counts).filter(([, c]) => c > 0);
+
+  return (
+    <>
+      {/* Trigger button — sits in the Pulse nav between Theme toggle and Auth section */}
+      <button
+        onClick={() => setOpen(true)}
+        title="Search petitions, politicians, orgs (⌘K)"
+        aria-label="Open search"
+        style={{
+          display: 'inline-flex', alignItems: 'center', gap: 8,
+          background: 'rgba(255,255,255,0.10)', color: theme.textLight,
+          border: '1px solid rgba(255,255,255,0.18)',
+          padding: '6px 12px', borderRadius: 8,
+          fontFamily: 'system-ui, -apple-system, sans-serif',
+          fontSize: 13, fontWeight: 500, cursor: 'pointer',
+          transition: 'background-color 150ms',
+        }}
+        onMouseEnter={e => (e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.18)')}
+        onMouseLeave={e => (e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.10)')}
+      >
+        <Search size={14} />
+        <span>Search</span>
+        <kbd style={{
+          fontSize: 10, padding: '1px 6px', borderRadius: 4,
+          backgroundColor: 'rgba(255,255,255,0.15)',
+          color: theme.textLight,
+          fontFamily: 'inherit',
+        }}>⌘K</kbd>
+      </button>
+
+      {/* Modal overlay */}
+      {open && (
+        <div
+          role="dialog"
+          aria-modal="true"
+          aria-label="Global search"
+          style={{
+            position: 'fixed', inset: 0, zIndex: 1000,
+            background: 'rgba(27, 67, 50, 0.40)',
+            display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
+            paddingTop: 80, padding: '80px 16px 16px',
+          }}
+        >
+          <div
+            ref={panelRef}
+            style={{
+              width: '100%', maxWidth: 640, maxHeight: '78vh',
+              background: theme.cream, borderRadius: 12,
+              boxShadow: '0 24px 60px rgba(0,0,0,0.28)',
+              border: `1px solid rgba(0,0,0,0.06)`,
+              display: 'flex', flexDirection: 'column',
+              overflow: 'hidden',
+              fontFamily: 'Georgia, "Times New Roman", serif',
+            }}
+          >
+            {/* Search input */}
+            <div style={{
+              display: 'flex', alignItems: 'center', gap: 10,
+              padding: '14px 18px',
+              borderBottom: `1px solid rgba(0,0,0,0.08)`,
+              background: theme.cream,
+            }}>
+              <Search size={18} color={theme.darkGreen} />
+              <input
+                ref={inputRef}
+                type="text"
+                value={query}
+                onChange={e => setQuery(e.target.value)}
+                onKeyDown={handleKeyDown}
+                placeholder="Search petitions, politicians, organizations…"
+                style={{
+                  flex: 1, border: 'none', background: 'transparent', outline: 'none',
+                  fontSize: 16, fontFamily: 'Georgia, "Times New Roman", serif',
+                  color: '#1a1a1a',
+                }}
+              />
+              <button
+                onClick={() => { setOpen(false); setQuery(''); setResults([]); }}
+                aria-label="Close search"
+                style={{
+                  background: 'none', border: 'none', cursor: 'pointer', padding: 4,
+                  color: '#6b7280',
+                }}
+              >
+                <X size={18} />
+              </button>
+            </div>
+
+            {/* Filter chips */}
+            {activeTypes.length > 0 && (
+              <div style={{
+                display: 'flex', gap: 6, padding: '10px 18px', flexWrap: 'wrap',
+                borderBottom: `1px solid rgba(0,0,0,0.06)`,
+              }}>
+                <button
+                  onClick={() => setActiveFilter('')}
+                  style={{
+                    fontSize: 12, padding: '4px 10px', borderRadius: 999,
+                    background: activeFilter === '' ? theme.darkGreen : 'transparent',
+                    color: activeFilter === '' ? theme.gold : theme.darkGreen,
+                    border: `1px solid ${theme.darkGreen}`,
+                    cursor: 'pointer', fontFamily: 'system-ui, sans-serif', fontWeight: 600,
+                  }}
+                >
+                  All ({results.length})
+                </button>
+                {activeTypes.map(([type, count]) => {
+                  // counts are keyed plural (petitions, politicians, ...) — map to singular for TYPE_CONFIG.
+                  const singularType = type.slice(0, -1) as SearchResult['type'];
+                  const cfg = TYPE_CONFIG[singularType];
+                  if (!cfg) return null;
+                  const isActive = activeFilter === singularType;
+                  return (
+                    <button
+                      key={type}
+                      onClick={() => setActiveFilter(isActive ? '' : singularType)}
+                      style={{
+                        fontSize: 12, padding: '4px 10px', borderRadius: 999,
+                        background: isActive ? cfg.color : 'transparent',
+                        color: isActive ? '#fff' : cfg.color,
+                        border: `1px solid ${cfg.color}`,
+                        cursor: 'pointer', fontFamily: 'system-ui, sans-serif', fontWeight: 600,
+                      }}
+                    >
+                      {cfg.label} ({count})
+                    </button>
+                  );
+                })}
+              </div>
+            )}
+
+            {/* Results list */}
+            <div style={{ overflow: 'auto', flex: 1, padding: '6px 0' }}>
+              {loading && (
+                <div style={{ padding: '24px', textAlign: 'center', color: '#6b7280', fontFamily: 'system-ui, sans-serif', fontSize: 14 }}>
+                  Searching…
+                </div>
+              )}
+              {!loading && query.length < 2 && (
+                <div style={{ padding: '24px', textAlign: 'center', color: '#6b7280', fontFamily: 'system-ui, sans-serif', fontSize: 14 }}>
+                  Type at least 2 characters to search.
+                </div>
+              )}
+              {!loading && query.length >= 2 && filtered.length === 0 && (
+                <div style={{ padding: '24px', textAlign: 'center', color: '#6b7280', fontFamily: 'system-ui, sans-serif', fontSize: 14 }}>
+                  No results for <strong>&ldquo;{query}&rdquo;</strong>.
+                </div>
+              )}
+              {filtered.map((r, i) => {
+                const cfg = TYPE_CONFIG[r.type];
+                const Icon = cfg.icon;
+                const isSelected = i === selectedIndex;
+                return (
+                  <button
+                    key={`${r.type}-${r.id}-${i}`}
+                    onClick={() => handleSelect(r)}
+                    onMouseEnter={() => setSelectedIndex(i)}
+                    style={{
+                      width: '100%', display: 'flex', alignItems: 'center', gap: 12,
+                      padding: '10px 18px', textAlign: 'left',
+                      background: isSelected ? `${cfg.color}14` : 'transparent',
+                      border: 'none', borderLeft: `3px solid ${isSelected ? cfg.color : 'transparent'}`,
+                      cursor: 'pointer', fontFamily: 'Georgia, "Times New Roman", serif',
+                    }}
+                  >
+                    <div style={{
+                      width: 32, height: 32, borderRadius: 8,
+                      background: `${cfg.color}1a`, color: cfg.color,
+                      display: 'flex', alignItems: 'center', justifyContent: 'center',
+                      flexShrink: 0,
+                    }}>
+                      <Icon size={16} />
+                    </div>
+                    <div style={{ flex: 1, minWidth: 0 }}>
+                      <div style={{
+                        fontSize: 15, color: '#1a1a1a', fontWeight: 600,
+                        overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
+                      }}>
+                        {r.title}
+                      </div>
+                      <div style={{
+                        fontSize: 12, color: '#6b7280',
+                        fontFamily: 'system-ui, sans-serif',
+                        overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
+                      }}>
+                        {cfg.label}{r.subtitle ? ` · ${r.subtitle}` : ''}{r.meta ? ` · ${r.meta}` : ''}
+                      </div>
+                    </div>
+                    {isSelected && <ArrowRight size={14} color={cfg.color} />}
+                  </button>
+                );
+              })}
+            </div>
+
+            {/* Footer hint */}
+            <div style={{
+              padding: '8px 18px', borderTop: `1px solid rgba(0,0,0,0.06)`,
+              fontSize: 11, color: '#6b7280', fontFamily: 'system-ui, sans-serif',
+              display: 'flex', justifyContent: 'space-between',
+            }}>
+              <span><kbd style={{ padding: '1px 5px', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 3 }}>↑</kbd> <kbd style={{ padding: '1px 5px', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 3 }}>↓</kbd> navigate &nbsp; <kbd style={{ padding: '1px 5px', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 3 }}>↵</kbd> select</span>
+              <span><kbd style={{ padding: '1px 5px', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 3 }}>esc</kbd> close</span>
+            </div>
+          </div>
+        </div>
+      )}
+    </>
+  );
+}

← eb3fcd7 fix: add rel=noopener noreferrer to 32 target=_blank links i  ·  back to Norma  ·  Norma agents: add read-only status dashboard at GET / (behin 6f74352 →