← 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
A app/api/search/public/route.tsM app/pulse/layout.tsxM app/supervisor/page.tsxM components/AppShell.tsxA components/PulseSearch.tsx
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>“{query}”</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 <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 →