export declare const searchDocsTemplate = "\"use client\";\nimport React, {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport { useRouter } from \"next/navigation\";\nimport dynamic from \"next/dynamic\";\nimport styled from \"styled-components\";\nimport { useChat } from \"cherry-styled-components\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { interactiveStyles } from \"@/components/layout/SharedStyled\";\nimport { ChatContext } from \"@/components/Chat\";\nimport type { PageItem, MergedResult } from \"@/components/SearchModalContent\";\n\nconst SearchModalContent = dynamic(\n () =>\n import(\"@/components/SearchModalContent\").then(\n (mod) => mod.SearchModalContent,\n ),\n { ssr: false },\n);\n\ninterface SectionItem {\n label: string;\n slug: string;\n}\n\n// Stable empty array so the derived contentResults keeps the same identity\n// across renders when there are no hits (memo deps stay stable).\nconst EMPTY_RESULTS: ContentHit[] = [];\n\ninterface ContentHit {\n slug: string;\n snippet: string;\n}\n\ninterface SearchContextValue {\n openSearch: () => void;\n}\n\nconst SearchContext = createContext({\n openSearch: () => {},\n});\n\nconst StyledKbd = styled.kbd<{ theme: Theme }>`\n font-size: 11px;\n font-family: inherit;\n background: ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.grayDark};\n padding: 2px 6px;\n border-radius: 4px;\n margin-left: auto;\n font-weight: 600;\n display: none;\n\n ${mq(\"lg\")} {\n display: initial;\n }\n`;\n\nconst StyledSearchButton = styled.button<{ theme: Theme }>`\n ${interactiveStyles};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n display: flex;\n align-items: center;\n gap: 6px;\n /* Pinned to the 30px compact header-control tier, matching Cherry's\n ChatLauncher, so the height cannot drift with platform font metrics\n and the header controls always align exactly. */\n box-sizing: border-box;\n height: 30px;\n background: ${({ theme }) => theme.colors.light};\n color: ${({ theme }) => theme.colors.primary};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 0 8px;\n font-family: inherit;\n cursor: pointer;\n\n & svg.lucide {\n color: inherit;\n min-width: 14px;\n }\n\n ${mq(\"lg\")} {\n padding: 0 4px 0 8px;\n }\n`;\n\nfunction SearchProvider({\n pages,\n sections,\n children,\n}: {\n pages: PageItem[];\n sections?: SectionItem[];\n children: React.ReactNode;\n}) {\n const [isVisible, setIsVisible] = useState(false);\n const [isClosing, setIsClosing] = useState(false);\n const [query, setQuery] = useState(\"\");\n const [activeIndex, setActiveIndex] = useState(0);\n const [returnFocusTo, setReturnFocusTo] = useState(null);\n // Latest completed content search, keyed by the query that produced it.\n // contentResults and isSearching are derived from it at render time, so\n // the debounced-search effect never sets state synchronously (which would\n // trigger cascading renders \u2014 react-hooks/set-state-in-effect).\n const [fetched, setFetched] = useState<{\n q: string;\n results: ContentHit[];\n } | null>(null);\n const resultsRef = useRef(null);\n const closingRef = useRef(false);\n const isVisibleRef = useRef(false);\n const searchOpenerRef = useRef(null);\n const restoreSearchFocusRef = useRef(true);\n const abortRef = useRef(null);\n const debounceRef = useRef>(null);\n const router = useRouter();\n const { isChatActive } = useContext(ChatContext);\n const { ask, close: closeChat, isOpen: isChatOpen } = useChat();\n\n const sectionLabels = useMemo(() => {\n const map: Record = {};\n sections?.forEach((s) => {\n map[s.slug] = s.label;\n });\n return map;\n }, [sections]);\n\n const openSearch = useCallback(() => {\n const previousOverlayOpener = closeChat(false);\n closingRef.current = false;\n restoreSearchFocusRef.current = true;\n const activeElement = document.activeElement;\n const opener =\n previousOverlayOpener?.isConnected === true\n ? previousOverlayOpener\n : activeElement instanceof HTMLElement &&\n activeElement !== document.body\n ? activeElement\n : null;\n searchOpenerRef.current = opener;\n setReturnFocusTo(opener);\n isVisibleRef.current = true;\n setIsClosing(false);\n setIsVisible(true);\n }, [closeChat]);\n\n const closeSearch = useCallback(() => {\n closingRef.current = true;\n setIsClosing(true);\n if (abortRef.current) abortRef.current.abort();\n if (debounceRef.current) clearTimeout(debounceRef.current);\n }, []);\n\n const shouldRestoreSearchFocus = useCallback(\n () => restoreSearchFocusRef.current,\n [],\n );\n\n const handleCloseAnimationEnd = useCallback(() => {\n if (!closingRef.current) return;\n closingRef.current = false;\n isVisibleRef.current = false;\n setIsVisible(false);\n setIsClosing(false);\n setQuery(\"\");\n setActiveIndex(0);\n setFetched(null);\n searchOpenerRef.current = null;\n setReturnFocusTo(null);\n }, []);\n\n const closeSearchImmediately = useCallback((restoreFocus = true) => {\n if (!isVisibleRef.current) return null;\n restoreSearchFocusRef.current = restoreFocus;\n const opener = searchOpenerRef.current;\n searchOpenerRef.current = null;\n setReturnFocusTo(null);\n closingRef.current = false;\n isVisibleRef.current = false;\n if (abortRef.current) abortRef.current.abort();\n if (debounceRef.current) clearTimeout(debounceRef.current);\n const activeElement = document.activeElement;\n if (\n activeElement instanceof HTMLElement &&\n activeElement.closest(\"[data-search-dialog]\")\n ) {\n activeElement.blur();\n }\n setIsVisible(false);\n setIsClosing(false);\n setQuery(\"\");\n setActiveIndex(0);\n setFetched(null);\n return opener;\n }, []);\n\n // Only one overlay at a time: the chat panel opening (launcher, Cmd+I, or\n // `ask`) dismisses the search modal without restoring focus, since focus\n // has already moved into the chat composer.\n useEffect(() => {\n if (isChatOpen) closeSearchImmediately(false);\n }, [isChatOpen, closeSearchImmediately]);\n\n const trimmedQuery = query.trim();\n const contentResults =\n trimmedQuery.length >= 2 && fetched?.q === trimmedQuery\n ? fetched.results\n : EMPTY_RESULTS;\n const isSearching = trimmedQuery.length >= 2 && fetched?.q !== trimmedQuery;\n\n // Instant title/description filtering\n const titleFiltered = useMemo(() => {\n if (!query.trim()) return pages;\n const q = query.toLowerCase();\n return pages.filter(\n (p) =>\n p.title.toLowerCase().includes(q) ||\n p.description?.toLowerCase().includes(q),\n );\n }, [pages, query]);\n\n // Merge title matches with content matches\n const merged = useMemo(() => {\n if (!query.trim()) {\n return pages.map((p) => ({ page: p }));\n }\n\n const titleMatchSlugs = new Set(titleFiltered.map((p) => p.slug));\n const titleMatches: MergedResult[] = titleFiltered.map((p) => {\n const hit = contentResults.find((cr) => cr.slug === p.slug);\n return { page: p, snippet: hit?.snippet };\n });\n\n const pageMap = new Map(pages.map((p) => [p.slug, p]));\n const contentOnly: MergedResult[] = [];\n for (const cr of contentResults) {\n if (!titleMatchSlugs.has(cr.slug)) {\n const page = pageMap.get(cr.slug);\n if (page) {\n contentOnly.push({ page, snippet: cr.snippet });\n }\n }\n }\n\n return [...titleMatches, ...contentOnly];\n }, [pages, query, titleFiltered, contentResults]);\n\n // Debounced content search. State updates happen only inside the timeout's\n // async callback; short queries need no reset because the derived values\n // above ignore results whose query no longer matches.\n useEffect(() => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (abortRef.current) abortRef.current.abort();\n\n const q = query.trim();\n if (q.length < 2) return;\n\n debounceRef.current = setTimeout(async () => {\n const controller = new AbortController();\n abortRef.current = controller;\n try {\n const res = await fetch(\n `/api/search?q=${encodeURIComponent(q)}&limit=15`,\n { signal: controller.signal },\n );\n if (!res.ok) throw new Error(\"Search failed\");\n const data = await res.json();\n setFetched({ q, results: data.results ?? [] });\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setFetched({ q, results: [] });\n }\n }, 300);\n\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n };\n }, [query]);\n\n const navigate = useCallback(\n (\n slug: string,\n modifiers?: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean },\n ) => {\n const url = `/${slug}`;\n\n // Shift+Enter / Shift+Click: open in a separate browser window. Window\n // features (dimensions) are what make the browser spawn a window instead\n // of a tab; noopener/noreferrer keep the new window from reaching back\n // into this one via window.opener.\n if (modifiers?.shiftKey) {\n const width = Math.min(1024, window.screen.availWidth);\n const height = Math.min(800, window.screen.availHeight);\n const left = Math.round((window.screen.availWidth - width) / 2);\n const top = Math.round((window.screen.availHeight - height) / 2);\n window.open(\n url,\n \"_blank\",\n `popup=yes,noopener,noreferrer,width=${width},height=${height},left=${left},top=${top}`,\n );\n return;\n }\n\n // Cmd+Enter / Ctrl+Enter / Cmd+Click: open in a new background tab. With\n // no dimension features the browser keeps this as a tab.\n if (modifiers?.metaKey || modifiers?.ctrlKey) {\n window.open(url, \"_blank\", \"noopener,noreferrer\");\n return;\n }\n\n // Plain Enter / Click: navigate in place within the app.\n closeSearch();\n router.push(url);\n },\n [closeSearch, router],\n );\n\n // Hand the current query to the AI assistant, then dismiss the search modal.\n // `ask` opens the chat and either submits the question or (if a response is\n // already streaming) pre-fills it, ready to send.\n const askAssistantWithQuery = useCallback(() => {\n const q = query.trim();\n if (!q) return;\n const opener = closeSearchImmediately(false);\n ask(q, opener);\n }, [query, ask, closeSearchImmediately]);\n\n // Global Cmd+K / Ctrl+K listener\n useEffect(() => {\n function handleKeyDown(e: KeyboardEvent) {\n if ((e.metaKey || e.ctrlKey) && e.key === \"k\") {\n e.preventDefault();\n if (isVisibleRef.current) {\n closeSearch();\n } else {\n openSearch();\n }\n }\n }\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => document.removeEventListener(\"keydown\", handleKeyDown);\n }, [closeSearch, openSearch]);\n\n // Active-row scrolling now lives in SearchModalContent's layout effect,\n // alongside the highlight measurement, so highlight + scroll update together\n // before paint (no flicker) and stay in sync on query changes.\n\n function handleKeyDown(e: React.KeyboardEvent) {\n if (e.key === \"ArrowDown\") {\n e.preventDefault();\n setActiveIndex((i) => (i < merged.length - 1 ? i + 1 : 0));\n } else if (e.key === \"ArrowUp\") {\n e.preventDefault();\n setActiveIndex((i) => (i > 0 ? i - 1 : merged.length - 1));\n } else if (e.key === \"Enter\") {\n e.preventDefault();\n // Option/Alt+Enter is reserved for handing the query to the AI assistant.\n if (e.altKey) {\n if (isChatActive) askAssistantWithQuery();\n return;\n }\n if (merged[activeIndex]) {\n navigate(merged[activeIndex].page.slug, e);\n }\n } else if (e.key === \"Escape\") {\n closeSearch();\n }\n }\n\n return (\n \n {children}\n {isVisible && (\n \n )}\n \n );\n}\n\nexport {\n SearchProvider,\n SearchContext,\n StyledKbd as SearchKbd,\n StyledSearchButton,\n};\n";