From 197f06c0d2858c9421dbdba3d4f9ca59dccae01a Mon Sep 17 00:00:00 2001 From: A-PachecoT Date: Fri, 22 May 2026 12:11:46 -0500 Subject: [PATCH 1/2] fix(command-palette): 18 functional + a11y + safety fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library-side fixes before consumer wiring. All 7 existing tests still pass. Functional: - Reset query/results/selectedIndex on close (regression vs source hook) - InitialState recents: drop fake "selected" on first row, fix ARIA - Remove onMouseEnter selection hijack (kept keyboard sticky) - Snippet now 2-line clamps so isn't truncated past viewport - minQueryLength prop (default 2) + stale-results indicator during debounce - retryNonce wires the error-state Retry button to actually re-fire - emptyActions extended with `onSelect` — chips render as buttons when wired - Clear (×) button in input A11y: - Cmd/Ctrl+Enter (and Cmd-click) opens hit in a new tab - Focus trap on the dialog (Tab cycles inside) - aria-controls only points at the listbox when it's mounted - Option IDs namespaced via useId so two palettes don't collide - Focus restore guarded with isConnected - prefers-reduced-motion fallback for all 4 animations Library boundary: - Dev warning when searchFn is missing instead of silent no-op - SearchHit.role now optional, rendered as a small role badge if present - Same-origin guard: external URLs auto-open in a new tab with noopener - Snippet sanitizer (allow-only-) is the default; opt-in trustSnippetHtml - CSS variable defaults via :where(...) so the component renders safely standalone - Trigger pill min-height: 44px moved behind @media (pointer: coarse) Tests touched to keep passing with the new defaults: - Harness sets minQueryLength={1} so single-char fixtures still fire fetches --- .../command-palette/CommandPalette.test.tsx | 1 + .../command-palette/CommandPalette.tsx | 392 ++++++++++++++++-- 2 files changed, 364 insertions(+), 29 deletions(-) diff --git a/src/__tests__/components/command-palette/CommandPalette.test.tsx b/src/__tests__/components/command-palette/CommandPalette.test.tsx index 7c26617..5a7b576 100644 --- a/src/__tests__/components/command-palette/CommandPalette.test.tsx +++ b/src/__tests__/components/command-palette/CommandPalette.test.tsx @@ -54,6 +54,7 @@ function Harness({ onNavigate={onNavigate} scope="team" debounceMs={0} + minQueryLength={1} /> ); } diff --git a/src/components/command-palette/CommandPalette.tsx b/src/components/command-palette/CommandPalette.tsx index fd4fdec..0820d3e 100644 --- a/src/components/command-palette/CommandPalette.tsx +++ b/src/components/command-palette/CommandPalette.tsx @@ -31,7 +31,12 @@ export interface SearchHit { project: string; slug: string; title: string; - role: DocRole; + /** + * Doc role taxonomy (team/client/public/custom). Optional — rendered as a + * small badge next to the title when present. Consumers can omit when their + * vault has no role concept. + */ + role?: DocRole; /** FTS5 snippet() output — HTML-escaped except for `` wrapper. */ snippet: string; /** BM25 rank — lower is better. */ @@ -65,8 +70,15 @@ export interface RecentSearch { export interface EmptyAction { label: string; - /** Single-key hint shown as a kbd chip, e.g. "1", "I". */ + /** + * Single-key hint shown as a kbd chip, e.g. "1", "I". Decorative only — + * the library does not bind global single-character keys from the search + * input (would conflict with typing). Consumers needing keyboard activation + * should attach their own listener at the document level. + */ kbd?: string; + /** Click/Enter handler. The chip is rendered as a button when supplied. */ + onSelect?: () => void; } export interface CommandPaletteProps { @@ -87,6 +99,12 @@ export interface CommandPaletteProps { mobileVariant?: 'auto' | 'modal' | 'sheet'; /** Debounce in ms before firing searchFn. Default 200. */ debounceMs?: number; + /** + * Minimum query length before the palette fires `searchFn`. Sub-min queries + * render the idle state instead of firing the backend or showing a stale + * "no matches" empty state. Default 2. + */ + minQueryLength?: number; /** Input placeholder. Default 'Search docs, briefs, notes…'. */ placeholder?: string; /** Idle-state recent docs list. Empty by default. */ @@ -95,6 +113,13 @@ export interface CommandPaletteProps { recentSearches?: RecentSearch[]; /** No-results action chips. Empty by default. */ emptyActions?: EmptyAction[]; + /** + * Pass `true` to render `hit.snippet` as raw HTML via `dangerouslySetInnerHTML`. + * Default `false`: the palette runs the snippet through an allow-only-`` + * sanitizer that strips every other tag. Only opt in when your backend is + * trusted (e.g. you control the search index and emit FTS5 snippets directly). + */ + trustSnippetHtml?: boolean; } export interface CommandPaletteTriggerProps { @@ -145,6 +170,31 @@ export function CommandPaletteTrigger({ const DEFAULT_DEBOUNCE = 200; +/** + * `true` when `url` resolves to a different origin than the current page. + * Returns `false` in SSR (no `window`) so server-rendered markup is stable. + * Relative URLs and malformed inputs are treated as same-origin (safe default). + */ +function isExternalUrl(url: string): boolean { + if (typeof window === 'undefined') return false; + try { + return new URL(url, window.location.origin).origin !== window.location.origin; + } catch { + return false; + } +} + +/** + * Sanitize an FTS5 snippet so only `` survives. Drops any other tags + * (incl. attributes) and leaves text content as-is. The backend is expected + * to have HTML-escaped &/ already; this is a defense-in-depth pass for + * library consumers wiring untrusted backends. Opt out via `trustSnippetHtml`. + */ +function sanitizeSnippetHtml(snippet: string): string { + // Strip every tag except `` / `` (case-insensitive, no attrs). + return snippet.replace(/<(?!\/?mark>)[^>]*>/gi, ''); +} + export function CommandPalette({ open, onOpenChange, @@ -154,33 +204,69 @@ export function CommandPalette({ project, mobileVariant = 'auto', debounceMs = DEFAULT_DEBOUNCE, + minQueryLength = 2, placeholder = 'Search docs, briefs, notes…', recents = [], recentSearches = [], emptyActions = [], + trustSnippetHtml = false, }: CommandPaletteProps) { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [errored, setErrored] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); + // `stale` covers the window between a query change and the next fetch + // resolving — the previous results are still on screen but no longer + // reflect the input. PaletteSurface dims them via [data-stale]. + const [stale, setStale] = useState(false); + // Bump to force the search effect to re-fire for the same query (retry on + // error). `setQuery(query)` is a no-op because React bails on equal state. + const [retryNonce, setRetryNonce] = useState(0); + + // Dev-only warning if the palette is mounted without a fetcher. Silent + // breakage is worse than a console line that points at the missing prop. + useEffect(() => { + if (open && !searchFn && process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn( + '[@cofoundy/ui] opened without a `searchFn` prop — ' + + 'typing will not surface any results. Pass `searchFn` to wire a backend.', + ); + } + }, [open, searchFn]); // Debounced fetch useEffect(() => { if (!open || !searchFn) return; - setErrored(false); + const trimmed = query.trim(); + + // Below the min-query threshold: clear results, surface the idle state, + // do not fire the backend (saves bandwidth + avoids "no matches for a"). + if (trimmed.length < minQueryLength) { + setResults([]); + setErrored(false); + setLoading(false); + setStale(false); + return; + } + + setStale(true); const controller = new AbortController(); const t = setTimeout(async () => { setLoading(true); + setErrored(false); try { - const response = await searchFn(query, controller.signal); + const response = await searchFn(trimmed, controller.signal); if (controller.signal.aborted) return; setResults(Array.isArray(response?.hits) ? response.hits : []); setSelectedIndex(0); + setStale(false); } catch { if (!controller.signal.aborted) { setErrored(true); setResults([]); + setStale(false); } } finally { if (!controller.signal.aborted) setLoading(false); @@ -190,10 +276,30 @@ export function CommandPalette({ clearTimeout(t); controller.abort(); }; - }, [query, open, searchFn, debounceMs]); + }, [query, open, searchFn, debounceMs, minQueryLength, retryNonce]); + + // Reset transient state when the palette closes. Matches the docs-ai + // useCommandPalette hook teardown (use-command-palette.ts §setOpen) so + // reopening starts clean instead of showing stale query + results. + useEffect(() => { + if (!open) { + setQuery(''); + setResults([]); + setLoading(false); + setErrored(false); + setSelectedIndex(0); + setStale(false); + } + }, [open]); const handleNavigate = useCallback( (hit: SearchHit) => { + // External URLs always escape to a new tab regardless of onNavigate — + // a consumer's router can't safely handle cross-origin nav anyway. + if (isExternalUrl(hit.url) && typeof window !== 'undefined') { + window.open(hit.url, '_blank', 'noopener,noreferrer'); + return; + } if (onNavigate) { onNavigate(hit.url, hit); } else if (typeof window !== 'undefined') { @@ -211,7 +317,9 @@ export function CommandPalette({ results={results} loading={loading} errored={errored} - onRetry={() => setQuery((q) => q)} + stale={stale} + minQueryLength={minQueryLength} + onRetry={() => setRetryNonce((n) => n + 1)} selectedIndex={selectedIndex} setSelectedIndex={setSelectedIndex} onClose={() => onOpenChange(false)} @@ -223,6 +331,7 @@ export function CommandPalette({ recents={recents} recentSearches={recentSearches} emptyActions={emptyActions} + trustSnippetHtml={trustSnippetHtml} /> ); } @@ -236,6 +345,8 @@ interface PaletteSurfaceProps { results: SearchHit[]; loading: boolean; errored: boolean; + stale: boolean; + minQueryLength: number; onRetry: () => void; selectedIndex: number; setSelectedIndex: (n: number) => void; @@ -248,6 +359,7 @@ interface PaletteSurfaceProps { recents: RecentDoc[]; recentSearches: RecentSearch[]; emptyActions: EmptyAction[]; + trustSnippetHtml: boolean; } function PaletteSurface({ @@ -257,6 +369,8 @@ function PaletteSurface({ results, loading, errored, + stale, + minQueryLength, onRetry, selectedIndex, setSelectedIndex, @@ -269,9 +383,11 @@ function PaletteSurface({ recents, recentSearches, emptyActions, + trustSnippetHtml, }: PaletteSurfaceProps) { const inputRef = useRef(null); const listRef = useRef(null); + const surfaceRef = useRef(null); const lastFocusRef = useRef(null); const reactId = useId(); const listboxId = `cp-listbox-${reactId}`; @@ -290,7 +406,12 @@ function PaletteSurface({ const t = setTimeout(() => inputRef.current?.focus(), 0); return () => clearTimeout(t); } else { - lastFocusRef.current?.focus(); + // Only restore focus if the prior element is still in the document — + // route changes / sidebar collapses can unmount the trigger while the + // palette is open, and `.focus()` on a detached node is a silent no-op + // (or throws on legacy WebKit). + const last = lastFocusRef.current; + if (last && last.isConnected) last.focus(); } }, [open]); @@ -303,6 +424,33 @@ function PaletteSurface({ }; }, [open]); + // Focus trap — keep Tab cycling inside the dialog. role="dialog" + // aria-modal="true" promises modality; the markup has to enforce it. + useEffect(() => { + if (!open) return; + function onTab(e: KeyboardEvent) { + if (e.key !== 'Tab') return; + const root = surfaceRef.current; + if (!root) return; + const focusable = root.querySelectorAll( + 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement as HTMLElement | null; + if (e.shiftKey && active === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + } + document.addEventListener('keydown', onTab); + return () => document.removeEventListener('keydown', onTab); + }, [open]); + const onInputKey = useCallback( (e: ReactKeyboardEvent) => { if (e.key === 'Escape') { @@ -318,6 +466,11 @@ function PaletteSurface({ const hit = results[selectedIndex]; if (hit) { e.preventDefault(); + // Cmd/Ctrl + Enter → open in a new tab without leaving the palette. + if ((e.metaKey || e.ctrlKey) && typeof window !== 'undefined') { + window.open(hit.url, '_blank', 'noopener,noreferrer'); + return; + } onNavigate(hit); } } else if (e.key === 'Home') { @@ -342,10 +495,14 @@ function PaletteSurface({ if (!portalReady) return null; const isSheet = mobileVariant === 'sheet'; + const trimmedLength = query.trim().length; + // showEmpty fires only when we actually ran a search (query >= min) and it + // returned no hits. Below-min queries fall into showInitial so the user + // doesn't see a misleading "No matches for `a`" before the search runs. const showEmpty = - !loading && !errored && results.length === 0 && query.trim().length > 0; + !loading && !errored && results.length === 0 && trimmedLength >= minQueryLength; const showInitial = - !loading && !errored && results.length === 0 && query.trim().length === 0; + !loading && !errored && results.length === 0 && trimmedLength < minQueryLength; return createPortal(
0 ? listboxId : undefined} aria-activedescendant={ - results[selectedIndex] ? `cp-opt-${selectedIndex}` : undefined + results[selectedIndex] ? `cp-opt-${reactId}-${selectedIndex}` : undefined } aria-autocomplete="list" className="cp-input" @@ -397,9 +557,36 @@ function PaletteSurface({ autoCorrect="off" spellCheck={false} /> + {query.length > 0 && ( + + )}
-
+
{loading && } {errored && } {showInitial && } @@ -427,6 +614,7 @@ function PaletteSurface({ > {results.map((hit, i) => { const selected = i === selectedIndex; + const external = isExternalUrl(hit.url); const breadcrumb = hit.url .replace(/^\/+/, '') .replace(/\/[^/]+$/, '') @@ -434,15 +622,18 @@ function PaletteSurface({ return (
  • setSelectedIndex(i)} onClick={(e) => { + // Let Cmd/Ctrl-click open in a new tab (browser default). + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); onNavigate(hit); onClose(); @@ -465,13 +656,23 @@ function PaletteSurface({
    -
    {hit.title}
    +
    + {hit.title} + {hit.role && ( + + {hit.role} + + )} +
    {breadcrumb}
    {hit.snippet && (
    . - dangerouslySetInnerHTML={{ __html: hit.snippet }} + dangerouslySetInnerHTML={{ + __html: trustSnippetHtml + ? hit.snippet + : sanitizeSnippetHtml(hit.snippet), + }} /> )}
    @@ -566,9 +767,7 @@ function InitialState({ recents }: { recents: RecentDoc[] }) {
  • { if (!row.url) e.preventDefault(); }} @@ -621,12 +820,24 @@ function EmptyState({
  • {emptyActions.length > 0 && (
    - {emptyActions.map((a) => ( - - {a.label} - {a.kbd && {a.kbd}} - - ))} + {emptyActions.map((a) => + a.onSelect ? ( + + ) : ( + + {a.label} + {a.kbd && {a.kbd}} + + ), + )}
    )} {recentSearches.length > 0 && ( @@ -676,8 +887,24 @@ function ErrorState({ onRetry }: { onRetry: () => void }) { // ─── Styles ─────────────────────────────────────────────────────────────────── // Source preserved from docs-ai-vault-search verbatim. `:global(...)` wrappers // dropped (now plain global CSS, all class names are `cp-*` prefixed). +// +// CSS variables consumed: --cf-primary, --cf-bg, --cf-fg, --cf-muted, --cf-card, +// --font-sans, --font-mono. When @cofoundy/ui's brand-tokens stylesheet is +// loaded, these resolve from :root. The :where(...) defaults below provide a +// safe-but-bland baseline for consumers using this component in isolation — +// zero specificity, so any consumer-defined value overrides cleanly. const COMMAND_PALETTE_CSS = ` +:where(.cp-overlay, .cp-trigger) { + --cf-primary: #14b8a6; + --cf-bg: #0a0e1a; + --cf-fg: #e2e8f0; + --cf-muted: #94a3b8; + --cf-card: #1e293b; + --font-sans: ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; +} + .cp-result-snippet mark, .cp-result-title mark { background: color-mix(in srgb, var(--cf-primary) 22%, transparent); @@ -822,6 +1049,35 @@ const COMMAND_PALETTE_CSS = ` opacity: 1; font-weight: 400; } +.cp-input-clear { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex-shrink: 0; + border: 0; + border-radius: 6px; + background: rgba(255, 255, 255, 0.04); + color: var(--cf-muted); + cursor: pointer; + padding: 0; + transition: background 100ms ease, color 100ms ease; +} +.cp-input-clear:hover { + background: rgba(255, 255, 255, 0.10); + color: var(--cf-fg); +} +.cp-input-clear:focus-visible { + outline: 2px solid color-mix(in srgb, var(--cf-primary) 60%, transparent); + outline-offset: 1px; +} +[data-theme="light"] .cp-input-clear { + background: rgba(15, 23, 42, 0.04); +} +[data-theme="light"] .cp-input-clear:hover { + background: rgba(15, 23, 42, 0.10); +} .cp-results-wrap { flex: 1; @@ -829,7 +1085,11 @@ const COMMAND_PALETTE_CSS = ` overflow-x: hidden; min-height: 0; scrollbar-width: thin; + transition: opacity 80ms ease-out; } +/* Previous results are still on screen but no longer reflect the input — + dim them while the debounce timer waits to fire the next fetch. */ +.cp-results-wrap[data-stale] { opacity: 0.55; } .cp-results-meta { padding: 14px 20px 8px; display: flex; @@ -926,9 +1186,42 @@ const COMMAND_PALETTE_CSS = ` line-height: 1.35; letter-spacing: -0.012em; color: color-mix(in srgb, var(--cf-fg) 96%, white); + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} +.cp-result-title-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-width: 0; +} +.cp-result-role { + display: inline-flex; + align-items: center; + height: 16px; + padding: 0 6px; + border-radius: 3px; + font-family: var(--font-mono); + font-weight: 500; + font-size: 9.5px; + line-height: 1; + letter-spacing: 0.04em; + text-transform: uppercase; + color: color-mix(in srgb, var(--cf-muted) 25%, var(--cf-fg)); + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + flex-shrink: 0; +} +[data-theme="light"] .cp-result-role { + background: rgba(15, 23, 42, 0.04); + border-color: rgba(15, 23, 42, 0.10); +} +.cp-result-role[data-role="team"] { + color: color-mix(in srgb, var(--cf-primary) 35%, var(--cf-fg)); + background: color-mix(in srgb, var(--cf-primary) 10%, transparent); + border-color: color-mix(in srgb, var(--cf-primary) 22%, transparent); } .cp-result[data-selected] .cp-result-title { color: #ffffff; } [data-theme="light"] .cp-result[data-selected] .cp-result-title { color: var(--cf-fg); } @@ -950,9 +1243,14 @@ const COMMAND_PALETTE_CSS = ` line-height: 1.45; color: color-mix(in srgb, var(--cf-fg) 68%, transparent); margin-top: 3px; - white-space: nowrap; + /* 2-line clamp keeps the FTS5 visible even when it lands mid-snippet. */ + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; overflow: hidden; text-overflow: ellipsis; + word-break: break-word; } .cp-result[data-selected] .cp-result-snippet { color: color-mix(in srgb, var(--cf-fg) 88%, white); @@ -1125,6 +1423,22 @@ const COMMAND_PALETTE_CSS = ` 50% { opacity: 0.9; } } +/* WCAG 2.3.3 — honor the OS-level motion preference. Replace flourish + animations with instant cuts and stop the loading shimmer pulse. */ +@media (prefers-reduced-motion: reduce) { + .cp-overlay, + .cp-surface, + .cp-overlay-sheet .cp-surface, + .cp-overlay-auto .cp-surface, + .cp-skeleton-title, + .cp-skeleton-path, + .cp-skeleton-snippet, + .cp-results-wrap { + animation: none !important; + transition: none !important; + } +} + .cp-state { padding: 2.25rem 1.25rem; text-align: center; @@ -1230,6 +1544,23 @@ const COMMAND_PALETTE_CSS = ` background: rgba(0, 0, 0, 0.02); border-color: rgba(15, 23, 42, 0.08); } +.cp-empty-chip-button { + cursor: pointer; + transition: background 100ms ease, border-color 100ms ease, color 100ms ease; +} +.cp-empty-chip-button:hover { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.14); + color: var(--cf-fg); +} +.cp-empty-chip-button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--cf-primary) 60%, transparent); + outline-offset: 2px; +} +[data-theme="light"] .cp-empty-chip-button:hover { + background: rgba(15, 23, 42, 0.06); + border-color: rgba(15, 23, 42, 0.16); +} .cp-chip-kbd { display: inline-flex; align-items: center; @@ -1313,8 +1644,11 @@ const COMMAND_PALETTE_CSS = ` font-size: 13px; cursor: pointer; transition: background 120ms ease, border-color 120ms ease, color 120ms ease; - min-width: 44px; - min-height: 44px; +} +/* Touch devices need a 44×44 hit target. Apply only on coarse pointers so + desktop mouse users keep the 32px-tall pill we visually designed for. */ +@media (pointer: coarse) { + .cp-trigger { min-width: 44px; min-height: 44px; padding: 0 12px; } } .cp-trigger:hover { background: rgba(255, 255, 255, 0.06); From c40009800ee52dcb110f37818b9fd5e38ebbf51a Mon Sep 17 00:00:00 2001 From: A-PachecoT Date: Fri, 22 May 2026 12:18:36 -0500 Subject: [PATCH 2/2] =?UTF-8?q?feat(command-palette):=20hook,=20telemetry?= =?UTF-8?q?=20callbacks,=20infra=20refactor=20=E2=86=92=200.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library prep for downstream consumers. 472 tests run, 471 pass — the only failure is the pre-existing ChatInput baseline (unrelated, documented). Public surface additions: - useCommandPaletteHotkeys({open, setOpen}) — Cmd+K / / / Esc binding - onSearch?(query, hits, took_ms?) telemetry - onSelect?(hit, idx, 'click' | 'enter') telemetry - trustSnippetHtml prop — opt-in raw HTML, default sanitize Infrastructure refactor (no API change): - Singleton CSS injection — one