{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "jev-search",
  "title": "jev-search",
  "author": "Kyle McLaren",
  "description": "Command-palette site search. Keyword hits on the first keystroke, re-ranked by TypeSafe's Jev model a few hundred milliseconds later. Ships the component, the hook, the lexical index, the server handler and an API route.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "kbd"
  ],
  "files": [
    {
      "path": "src/components/jev-search.tsx",
      "content": "\"use client\"\n\n/**\n * jev-search — a command-palette style site search that shows lexical hits\n * on the first keystroke and lets TypeSafe's Jev model re-rank them by intent\n * a couple of hundred milliseconds later.\n *\n *   <JevSearch endpoint=\"/api/jev-search\" placeholder=\"Search docs…\" />\n *\n * Styling uses shadcn/ui tokens. Override the accent with `--jev-accent`.\n */\nimport * as React from \"react\"\nimport { createPortal } from \"react-dom\"\nimport { ArrowDown, ArrowUp, Clock, CornerDownLeft, FileText, Hash, Search, Sparkles, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { highlightSegments, type SearchHit } from \"@/lib/jev-search-core\"\nimport { useJevSearch, type JevSearchState } from \"@/hooks/use-jev-search\"\nimport { Kbd, KbdGroup } from \"@/components/ui/kbd\"\n\n/* ------------------------------------------------------------------ */\n/* Public API                                                          */\n/* ------------------------------------------------------------------ */\n\nexport interface JevSearchProps {\n  /** Route that serves createJevSearchHandler. Default \"/api/jev-search\". */\n  endpoint?: string\n  placeholder?: string\n  /** Key combined with ⌘ / Ctrl that opens the palette. Default \"k\". */\n  hotkey?: string\n  /** Called instead of navigating when a hit is chosen. */\n  onSelect?: (hit: SearchHit) => void\n  /** Example queries shown while the box is empty. */\n  suggestions?: string[]\n  /** Label used in the footer status. Default \"jev\". */\n  brand?: string\n  /** Remember the last few searches in localStorage. Default true. */\n  recent?: boolean\n  className?: string\n  /** A custom trigger. Defaults to <JevSearchTrigger />. */\n  children?: React.ReactNode\n  /** Controlled open state. */\n  open?: boolean\n  onOpenChange?: (open: boolean) => void\n  /** Query to start with when the palette opens. */\n  initialQuery?: string\n}\n\nexport function JevSearch({ children, className, open: openProp, onOpenChange, ...dialog }: JevSearchProps) {\n  const [openState, setOpenState] = React.useState(false)\n  const open = openProp ?? openState\n  const setOpen = React.useCallback(\n    (next: boolean | ((o: boolean) => boolean)) => {\n      const value = typeof next === \"function\" ? next(open) : next\n      setOpenState(value)\n      onOpenChange?.(value)\n    },\n    [open, onOpenChange],\n  )\n  const triggerRef = React.useRef<HTMLElement | null>(null)\n  const hotkey = dialog.hotkey ?? \"k\"\n\n  React.useEffect(() => {\n    const onKey = (e: KeyboardEvent) => {\n      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === hotkey) {\n        e.preventDefault()\n        setOpen((o) => !o)\n      }\n    }\n    window.addEventListener(\"keydown\", onKey)\n    return () => window.removeEventListener(\"keydown\", onKey)\n  }, [hotkey, setOpen])\n\n  return (\n    <>\n      <span\n        ref={(el) => {\n          triggerRef.current = el\n        }}\n        onClick={() => setOpen(true)}\n        className=\"contents\"\n      >\n        {children ?? <JevSearchTrigger className={className} placeholder={dialog.placeholder} hotkey={hotkey} />}\n      </span>\n      <JevSearchDialog\n        {...dialog}\n        open={open}\n        onOpenChange={(o) => {\n          setOpen(o)\n          if (!o) {\n            const el = triggerRef.current?.querySelector<HTMLElement>(\"button, a, [tabindex]\")\n            el?.focus()\n          }\n        }}\n      />\n    </>\n  )\n}\n\nexport interface JevSearchTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  placeholder?: string\n  hotkey?: string\n}\n\nexport function JevSearchTrigger({ placeholder = \"Search…\", hotkey = \"k\", className, ...props }: JevSearchTriggerProps) {\n  const mod = useModifierKey()\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"jev-search-trigger\"\n      aria-label=\"Open search\"\n      className={cn(\n        \"inline-flex h-9 w-full max-w-72 items-center gap-2 rounded-lg border border-input bg-background px-3 text-sm text-muted-foreground shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50\",\n        className,\n      )}\n      {...props}\n    >\n      <Search className=\"size-4 shrink-0 opacity-70\" aria-hidden />\n      <span className=\"flex-1 truncate text-left\">{placeholder}</span>\n      <KbdGroup>\n        <Kbd>{mod}</Kbd>\n        <Kbd>{hotkey.toUpperCase()}</Kbd>\n      </KbdGroup>\n    </button>\n  )\n}\n\nexport interface JevSearchDialogProps extends Omit<JevSearchProps, \"children\" | \"className\" | \"open\" | \"onOpenChange\"> {\n  open: boolean\n  onOpenChange: (open: boolean) => void\n}\n\nexport function JevSearchDialog({\n  open,\n  onOpenChange,\n  endpoint,\n  placeholder = \"Search…\",\n  onSelect,\n  suggestions = [],\n  brand = \"jev\",\n  recent = true,\n  initialQuery,\n}: JevSearchDialogProps) {\n  const search = useJevSearch({ endpoint })\n  const [activeRaw, setActive] = React.useState(0)\n  const mounted = useMounted()\n  const [recents, setRecents] = useRecentSearches(recent)\n  const listRef = React.useRef<HTMLDivElement>(null)\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const panelRef = React.useRef<HTMLDivElement>(null)\n\n  // Reset on open/close, lock scroll, focus the input.\n  React.useEffect(() => {\n    if (!open) return\n    const prev = document.body.style.overflow\n    document.body.style.overflow = \"hidden\"\n    if (initialQuery) search.setQuery(initialQuery)\n    const t = setTimeout(() => inputRef.current?.focus(), 10)\n    return () => {\n      document.body.style.overflow = prev\n      clearTimeout(t)\n      search.reset()\n      setActive(0)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open])\n\n  // Keep the active row within range and in view.\n  const hits = search.hits\n  const active = Math.min(activeRaw, Math.max(hits.length - 1, 0))\n  React.useEffect(() => {\n    const el = listRef.current?.querySelector<HTMLElement>(`[data-index=\"${active}\"]`)\n    el?.scrollIntoView({ block: \"nearest\" })\n  }, [active, hits])\n\n  const choose = React.useCallback(\n    (hit: SearchHit) => {\n      if (search.query.trim()) setRecents(search.query.trim())\n      onOpenChange(false)\n      if (onSelect) onSelect(hit)\n      else window.location.assign(hit.url)\n    },\n    [onSelect, onOpenChange, search.query, setRecents],\n  )\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    switch (e.key) {\n      case \"ArrowDown\":\n        e.preventDefault()\n        setActive((a) => (hits.length ? (a + 1) % hits.length : 0))\n        break\n      case \"ArrowUp\":\n        e.preventDefault()\n        setActive((a) => (hits.length ? (a - 1 + hits.length) % hits.length : 0))\n        break\n      case \"Home\":\n        if (hits.length) {\n          e.preventDefault()\n          setActive(0)\n        }\n        break\n      case \"End\":\n        if (hits.length) {\n          e.preventDefault()\n          setActive(hits.length - 1)\n        }\n        break\n      case \"Enter\":\n        if (hits[active]) {\n          e.preventDefault()\n          choose(hits[active])\n        }\n        break\n      case \"Escape\":\n        e.preventDefault()\n        onOpenChange(false)\n        break\n      case \"Tab\": {\n        // Keep focus inside the panel.\n        const focusables = panelRef.current?.querySelectorAll<HTMLElement>(\"input, button, a[href]\")\n        if (!focusables?.length) break\n        const list = Array.from(focusables)\n        const i = list.indexOf(document.activeElement as HTMLElement)\n        const next = e.shiftKey ? (i - 1 + list.length) % list.length : (i + 1) % list.length\n        e.preventDefault()\n        list[next]?.focus()\n        break\n      }\n    }\n  }\n\n  if (!mounted || !open) return null\n\n  const query = search.query.trim()\n  const showEmptyState = query.length === 0\n  const listboxId = \"jev-search-listbox\"\n\n  return createPortal(\n    <div\n      data-slot=\"jev-search-overlay\"\n      className=\"fixed inset-0 z-50 flex items-start justify-center p-4 pt-[12vh] sm:pt-[15vh]\"\n      style={\n        {\n          // Defaults to the theme's primary colour, so the palette looks native\n          // in any shadcn project. Override --jev-accent (and optionally\n          // --jev-accent-foreground) to brand it.\n          \"--_jev-accent\": \"var(--jev-accent, var(--primary))\",\n          \"--_jev-accent-fg\": \"var(--jev-accent-foreground, var(--primary-foreground))\",\n        } as React.CSSProperties\n      }\n    >\n      <style>{KEYFRAMES}</style>\n      <div\n        data-slot=\"jev-search-backdrop\"\n        className=\"absolute inset-0 bg-black/40 backdrop-blur-[2px] supports-[backdrop-filter]:bg-black/30 dark:bg-black/60\"\n        style={{ animation: \"jev-fade 120ms ease-out\" }}\n        onClick={() => onOpenChange(false)}\n        aria-hidden\n      />\n      <div\n        ref={panelRef}\n        data-slot=\"jev-search-panel\"\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-label=\"Site search\"\n        onKeyDown={onKeyDown}\n        className=\"relative flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-2xl ring-1 ring-black/5 dark:ring-white/10\"\n        style={{ animation: \"jev-pop 160ms cubic-bezier(.2,.9,.3,1.2)\" }}\n      >\n        {/* Input row */}\n        <div data-slot=\"jev-search-input\" className=\"flex items-center gap-3 border-b border-border px-4\">\n          <Search className=\"size-5 shrink-0 text-muted-foreground\" aria-hidden />\n          <input\n            ref={inputRef}\n            role=\"combobox\"\n            aria-expanded={hits.length > 0}\n            aria-controls={listboxId}\n            aria-activedescendant={hits[active] ? `jev-hit-${hits[active].id}` : undefined}\n            aria-autocomplete=\"list\"\n            autoComplete=\"off\"\n            autoCorrect=\"off\"\n            spellCheck={false}\n            value={search.query}\n            onChange={(e) => {\n              search.setQuery(e.target.value)\n              setActive(0)\n            }}\n            placeholder={placeholder}\n            className=\"h-14 w-full flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground sm:text-[15px]\"\n          />\n          {search.query ? (\n            <button\n              type=\"button\"\n              aria-label=\"Clear\"\n              onClick={() => {\n                search.setQuery(\"\")\n                inputRef.current?.focus()\n              }}\n              className=\"rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground\"\n            >\n              <X className=\"size-4\" />\n            </button>\n          ) : null}\n          <button type=\"button\" onClick={() => onOpenChange(false)} aria-label=\"Close search\" className=\"cursor-pointer\">\n            <Kbd>esc</Kbd>\n          </button>\n        </div>\n\n        {/* Indeterminate bar while Jev judges. Restyle it via the data-slot. */}\n        <div\n          data-slot=\"jev-search-progress\"\n          data-state={search.phase === \"judging\" ? \"on\" : \"off\"}\n          aria-hidden\n          className=\"relative h-0.5 overflow-hidden bg-muted opacity-0 transition-opacity duration-150 data-[state=on]:opacity-100\"\n        >\n          <i\n            className={cn(\n              \"absolute inset-y-0 left-0 block w-1/5 bg-[var(--_jev-accent)]\",\n              search.phase === \"judging\" && \"animate-[jev-indeterminate_1.1s_ease-in-out_infinite] motion-reduce:w-full motion-reduce:animate-none\",\n            )}\n          />\n        </div>\n\n        {/* Body */}\n        <div ref={listRef} data-slot=\"jev-search-list\" id={listboxId} role=\"listbox\" className=\"max-h-[min(60vh,32rem)] overflow-y-auto overscroll-contain p-2\">\n          {showEmptyState ? (\n            <EmptyState\n              suggestions={suggestions}\n              recents={recents}\n              onPick={(q) => {\n                search.setQuery(q)\n                setActive(0)\n              }}\n              brand={brand}\n            />\n          ) : hits.length === 0 && search.phase !== \"lexical\" && search.phase !== \"idle\" ? (\n            <NoResults query={query} search={search} brand={brand} />\n          ) : (\n            <Results\n              hits={hits}\n              demoted={search.demoted}\n              active={active}\n              setActive={setActive}\n              choose={choose}\n              judged={search.judged}\n              judging={search.phase === \"judging\"}\n            />\n          )}\n        </div>\n\n        {/* Footer */}\n        <div data-slot=\"jev-search-footer\" className=\"flex items-center gap-3 border-t border-border bg-muted/40 px-3 py-2 text-[11px] text-muted-foreground\">\n          <span className=\"hidden items-center gap-1.5 sm:inline-flex\">\n            <KbdGroup>\n              <Kbd>\n                <ArrowUp />\n              </Kbd>\n              <Kbd>\n                <ArrowDown />\n              </Kbd>\n            </KbdGroup>\n            navigate\n          </span>\n          <span className=\"hidden items-center gap-1.5 sm:inline-flex\">\n            <Kbd>\n              <CornerDownLeft />\n            </Kbd>\n            open\n          </span>\n          <Status search={search} brand={brand} className=\"ml-auto\" />\n        </div>\n      </div>\n    </div>,\n    document.body,\n  )\n}\n\n/* ------------------------------------------------------------------ */\n/* Pieces                                                              */\n/* ------------------------------------------------------------------ */\n\nfunction Results({\n  hits,\n  demoted,\n  active,\n  setActive,\n  choose,\n  judged,\n  judging,\n}: {\n  hits: SearchHit[]\n  demoted: SearchHit[]\n  active: number\n  setActive: (i: number) => void\n  choose: (h: SearchHit) => void\n  judged: boolean\n  judging: boolean\n}) {\n  useFlip(hits)\n  // Group by section, preserving rank order within and across groups.\n  const groups: { section: string; items: { hit: SearchHit; index: number }[] }[] = []\n  hits.forEach((hit, index) => {\n    const section = hit.section ?? \"\"\n    let g = groups.find((x) => x.section === section)\n    if (!g) {\n      g = { section, items: [] }\n      groups.push(g)\n    }\n    g.items.push({ hit, index })\n  })\n\n  return (\n    <>\n      {groups.map((g) => (\n        <div key={g.section || \"_\"} role=\"group\" aria-label={g.section || undefined}>\n          {g.section ? (\n            <div\n              data-slot=\"jev-search-group\"\n              className=\"px-2 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground\"\n            >\n              {g.section}\n            </div>\n          ) : null}\n          {g.items.map(({ hit, index }) => (\n            <Row\n              key={hit.id}\n              hit={hit}\n              index={index}\n              active={index === active}\n              onHover={() => setActive(index)}\n              onChoose={() => choose(hit)}\n              judged={judged}\n              judging={judging}\n            />\n          ))}\n        </div>\n      ))}\n      {demoted.length > 0 ? (\n        <div role=\"group\" aria-label=\"Below the relevance threshold\">\n          <div\n            data-slot=\"jev-search-group\"\n            data-variant=\"demoted\"\n            className=\"px-2 pb-1 pt-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground\"\n          >\n            Jev ruled these out\n          </div>\n          {demoted.map((hit, i) => (\n            <Row\n              key={hit.id}\n              hit={hit}\n              index={hits.length + i}\n              active={false}\n              onHover={() => {}}\n              onChoose={() => choose(hit)}\n              judged={judged}\n              judging={judging}\n              demoted\n            />\n          ))}\n        </div>\n      ) : null}\n    </>\n  )\n}\n\nfunction Row({\n  hit,\n  index,\n  active,\n  onHover,\n  onChoose,\n  judged,\n  judging,\n  demoted,\n}: {\n  hit: SearchHit\n  index: number\n  active: boolean\n  onHover: () => void\n  onChoose: () => void\n  judged: boolean\n  judging: boolean\n  demoted?: boolean\n}) {\n  const isAnchor = hit.url.includes(\"#\")\n  const Icon = isAnchor ? Hash : FileText\n  return (\n    <a\n      id={`jev-hit-${hit.id}`}\n      data-slot=\"jev-search-item\"\n      data-demoted={demoted ? \"\" : undefined}\n      href={hit.url}\n      role=\"option\"\n      aria-selected={active}\n      data-index={index}\n      data-flip={hit.id}\n      onMouseMove={onHover}\n      onClick={(e) => {\n        if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return\n        e.preventDefault()\n        onChoose()\n      }}\n      className={cn(\n        \"group flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm outline-none transition-colors\",\n        active ? \"bg-accent text-accent-foreground\" : \"text-foreground\",\n        demoted && \"opacity-55\",\n      )}\n    >\n      <span\n        data-slot=\"jev-search-item-icon\"\n        className={cn(\n          \"flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-background text-muted-foreground\",\n          active && \"border-transparent bg-[var(--_jev-accent)] text-[var(--_jev-accent-fg)]\",\n        )}\n      >\n        <Icon className=\"size-4\" aria-hidden />\n      </span>\n      <span className=\"min-w-0 flex-1\">\n        <span data-slot=\"jev-search-item-title\" className=\"block truncate font-medium leading-5\">\n          <Highlight text={hit.title} terms={hit.terms} />\n        </span>\n        {hit.description ? (\n          <span data-slot=\"jev-search-item-description\" className=\"block truncate text-xs text-muted-foreground\">\n            <Highlight text={hit.description} terms={hit.terms} />\n          </span>\n        ) : null}\n      </span>\n      <Meter value={hit.relevance} judged={judged} judging={judging} />\n    </a>\n  )\n}\n\n/** A tiny relevance meter. Shimmers while Jev thinks, then fills. */\nfunction Meter({ value, judged, judging }: { value?: number; judged: boolean; judging: boolean }) {\n  if (!judged && !judging) return null\n  if (!judged || value === undefined) {\n    return (\n      <span\n        data-slot=\"jev-search-meter\"\n        data-state=\"pending\"\n        className=\"h-1.5 w-12 shrink-0 rounded-full bg-muted\"\n        style={{\n          backgroundImage: \"linear-gradient(90deg, transparent 0%, color-mix(in oklch, var(--_jev-accent) 60%, transparent) 50%, transparent 100%)\",\n          backgroundSize: \"200% 100%\",\n          animation: \"jev-shimmer 1.1s linear infinite\",\n        }}\n        aria-hidden\n      />\n    )\n  }\n  const pct = Math.round(value * 100)\n  return (\n    <span data-slot=\"jev-search-meter\" data-state=\"done\" className=\"flex shrink-0 items-center gap-2\" title={`jev relevance ${pct}%`}>\n      <span data-slot=\"jev-search-meter-track\" className=\"h-1.5 w-12 overflow-hidden rounded-full bg-muted\">\n        <span\n          data-slot=\"jev-search-meter-fill\"\n          className=\"block h-full rounded-full\"\n          style={{\n            width: `${pct}%`,\n            background: `color-mix(in oklch, var(--_jev-accent) ${40 + pct * 0.6}%, var(--muted-foreground))`,\n            animation: \"jev-grow 400ms cubic-bezier(.2,.8,.2,1)\",\n            transformOrigin: \"left\",\n          }}\n        />\n      </span>\n      <span data-slot=\"jev-search-meter-value\" className=\"w-8 text-right font-mono text-[11px] tabular-nums text-muted-foreground\">\n        {pct}%\n      </span>\n    </span>\n  )\n}\n\nfunction Status({ search, brand, className }: { search: JevSearchState; brand: string; className?: string }) {\n  const { phase, jevMs, judgedCount, cached, error, answerable } = search\n  const q = search.query.trim()\n  return (\n    <span data-slot=\"jev-search-status\" className={cn(\"inline-flex min-w-0 items-center gap-1.5 truncate\", className)}>\n      {phase === \"judging\" ? (\n        <>\n          <Sparkles className=\"size-3 shrink-0\" style={{ color: \"var(--_jev-accent)\" }} aria-hidden />\n          <span className=\"jev-shimmer-text\">{brand} is reading the top matches…</span>\n        </>\n      ) : phase === \"done\" ? (\n        <>\n          <Sparkles className=\"size-3 shrink-0\" style={{ color: \"var(--_jev-accent)\" }} aria-hidden />\n          <span>\n            {brand} ranked {judgedCount} {judgedCount === 1 ? \"page\" : \"pages\"}\n            {cached ? \" · cached\" : ` in ${jevMs} ms`}\n            {answerable !== undefined && answerable < 0.35 ? \" · low confidence\" : \"\"}\n          </span>\n        </>\n      ) : phase === \"error\" ? (\n        <span className=\"text-destructive\" title={error}>\n          keyword ranking only\n        </span>\n      ) : q.length === 0 ? (\n        <span>Ask in plain English</span>\n      ) : null}\n    </span>\n  )\n}\n\nfunction EmptyState({\n  suggestions,\n  recents,\n  onPick,\n  brand,\n}: {\n  suggestions: string[]\n  recents: string[]\n  onPick: (q: string) => void\n  brand: string\n}) {\n  if (suggestions.length === 0 && recents.length === 0) {\n    return (\n      <p className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\n        Type to search. Plain questions work — {brand} reads the pages, not just the words.\n      </p>\n    )\n  }\n  return (\n    <div className=\"space-y-3 p-1\">\n      {recents.length > 0 ? (\n        <div>\n          <div data-slot=\"jev-search-group\" className=\"px-2 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground\">\n            Recent\n          </div>\n          {recents.map((r) => (\n            <button\n              key={r}\n              type=\"button\"\n              onClick={() => onPick(r)}\n              className=\"flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left text-sm hover:bg-accent\"\n            >\n              <Clock className=\"size-4 text-muted-foreground\" aria-hidden />\n              <span className=\"truncate\">{r}</span>\n            </button>\n          ))}\n        </div>\n      ) : null}\n      {suggestions.length > 0 ? (\n        <div>\n          <div data-slot=\"jev-search-group\" className=\"px-2 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground\">\n            Try asking\n          </div>\n          <div className=\"flex flex-wrap gap-1.5 px-2 pb-2\">\n            {suggestions.map((s) => (\n              <button\n                key={s}\n                type=\"button\"\n                onClick={() => onPick(s)}\n                data-slot=\"jev-search-suggestion\"\n                className=\"rounded-full border border-border bg-background px-3 py-1 text-xs text-foreground/80 transition-colors hover:border-[var(--_jev-accent)] hover:bg-accent\"\n              >\n                {s}\n              </button>\n            ))}\n          </div>\n        </div>\n      ) : null}\n    </div>\n  )\n}\n\nfunction NoResults({ query, search, brand }: { query: string; search: JevSearchState; brand: string }) {\n  return (\n    <div data-slot=\"jev-search-empty\" className=\"px-3 py-10 text-center text-sm text-muted-foreground\">\n      <p>\n        Nothing matches <span className=\"font-medium text-foreground\">“{query}”</span>.\n      </p>\n      {search.judged && (search.answerable ?? 1) < 0.35 ? (\n        <p className=\"mt-1 text-xs\">{brand} doesn’t think this site covers that yet.</p>\n      ) : (\n        <p className=\"mt-1 text-xs\">Try different words, or ask it as a question.</p>\n      )}\n    </div>\n  )\n}\n\nfunction Highlight({ text, terms }: { text: string; terms: string[] }) {\n  const segs = highlightSegments(text, terms)\n  return (\n    <>\n      {segs.map((s, i) =>\n        s.match ? (\n          <mark\n            key={i}\n            className=\"bg-transparent font-semibold text-inherit underline decoration-[var(--_jev-accent)] decoration-2 underline-offset-2\"\n          >\n            {s.text}\n          </mark>\n        ) : (\n          <React.Fragment key={i}>{s.text}</React.Fragment>\n        ),\n      )}\n    </>\n  )\n}\n\n/* ------------------------------------------------------------------ */\n/* Hooks                                                               */\n/* ------------------------------------------------------------------ */\n\nconst noop = () => () => {}\n\nfunction useMounted() {\n  return React.useSyncExternalStore(noop, () => true, () => false)\n}\n\nfunction useModifierKey() {\n  return React.useSyncExternalStore(\n    noop,\n    () => {\n      const nav = navigator as Navigator & { userAgentData?: { platform?: string } }\n      const platform = nav.userAgentData?.platform ?? navigator.platform ?? \"\"\n      return /mac|iphone|ipad/i.test(platform) ? \"⌘\" : \"Ctrl\"\n    },\n    () => \"⌘\",\n  )\n}\n\nconst RECENT_KEY = \"jev-search:recent\"\n\nfunction useRecentSearches(enabled: boolean): [string[], (q: string) => void] {\n  const [recents, setRecents] = React.useState<string[]>(() => {\n    if (!enabled || typeof window === \"undefined\") return []\n    try {\n      const raw = localStorage.getItem(RECENT_KEY)\n      return raw ? (JSON.parse(raw) as string[]) : []\n    } catch {\n      return []\n    }\n  })\n  const add = React.useCallback(\n    (q: string) => {\n      if (!enabled) return\n      setRecents((prev) => {\n        const next = [q, ...prev.filter((p) => p.toLowerCase() !== q.toLowerCase())].slice(0, 5)\n        try {\n          localStorage.setItem(RECENT_KEY, JSON.stringify(next))\n        } catch {\n          /* ignore */\n        }\n        return next\n      })\n    },\n    [enabled],\n  )\n  return [recents, add]\n}\n\n/**\n * FLIP animation: when Jev reorders the list, rows glide from their previous\n * position to the new one instead of jumping.\n */\nfunction useFlip(hits: SearchHit[]) {\n  const positions = React.useRef(new Map<string, number>())\n  React.useLayoutEffect(() => {\n    const reduce = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches\n    const els = document.querySelectorAll<HTMLElement>(\"[data-flip]\")\n    const next = new Map<string, number>()\n    els.forEach((el) => {\n      const id = el.dataset.flip!\n      const top = el.getBoundingClientRect().top\n      next.set(id, top)\n      const prev = positions.current.get(id)\n      if (!reduce && prev !== undefined && prev !== top) {\n        const dy = prev - top\n        el.style.transition = \"none\"\n        el.style.transform = `translateY(${dy}px)`\n        requestAnimationFrame(() => {\n          el.style.transition = \"transform 260ms cubic-bezier(.2,.8,.2,1)\"\n          el.style.transform = \"\"\n        })\n      }\n    })\n    positions.current = next\n  }, [hits])\n}\n\nconst KEYFRAMES = `\n@keyframes jev-indeterminate {\n  0% { transform: translateX(-10%) }\n  50% { transform: translateX(410%) }\n  100% { transform: translateX(-10%) }\n}\n@keyframes jev-fade { from { opacity: 0 } to { opacity: 1 } }\n@keyframes jev-pop { from { opacity: 0; transform: translateY(-6px) scale(.985) } to { opacity: 1; transform: none } }\n@keyframes jev-shimmer { from { background-position: 200% 0 } to { background-position: -200% 0 } }\n@keyframes jev-grow { from { transform: scaleX(0) } to { transform: scaleX(1) } }\n.jev-shimmer-text {\n  background: linear-gradient(90deg, currentColor 0%, currentColor 40%, var(--_jev-accent) 50%, currentColor 60%, currentColor 100%);\n  background-size: 200% 100%;\n  -webkit-background-clip: text; background-clip: text; color: transparent;\n  animation: jev-shimmer 1.4s linear infinite;\n}\n@media (prefers-reduced-motion: reduce) {\n  .jev-shimmer-text { animation: none; color: inherit; background: none; }\n}\n`\n",
      "type": "registry:component"
    },
    {
      "path": "src/hooks/use-jev-search.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport type { SearchEvent, SearchHit } from \"@/lib/jev-search-core\"\n\nexport type SearchPhase = \"idle\" | \"lexical\" | \"judging\" | \"done\" | \"error\"\n\nexport interface UseJevSearchOptions {\n  /** Route that serves createJevSearchHandler. Default \"/api/jev-search\". */\n  endpoint?: string\n  /** Debounce in ms before hitting the endpoint. Lexical results are cached so 0 feels fine. Default 60. */\n  debounceMs?: number\n  minLength?: number\n  /** Keep the keyword list on screen at least this long before swapping in\n   *  Jev's ranking, so a fast or cached answer does not flash past. Default 200. */\n  minDwellMs?: number\n}\n\nexport interface JevSearchState {\n  query: string\n  setQuery: (q: string) => void\n  hits: SearchHit[]\n  /** The keyword pass on its own, before Jev spoke. Handy for before/after views. */\n  lexicalHits: SearchHit[]\n  /** The re-ranked list, or undefined until Jev answers. */\n  jevHits?: SearchHit[]\n  /** Judged but below threshold, ranked. Render these dimmed so the list\n   *  does not collapse when the refined answer lands. */\n  demoted: SearchHit[]\n  phase: SearchPhase\n  /** True once the visible hits are Jev-ranked (not just lexical). */\n  judged: boolean\n  lexicalMs?: number\n  jevMs?: number\n  judgedCount?: number\n  /** Input tokens billed for this query. */\n  inputTokens?: number\n  answerable?: number\n  model?: string\n  cached?: boolean\n  error?: string\n  reset: () => void\n}\n\ninterface Entry {\n  lexical?: Extract<SearchEvent, { type: \"lexical\" }>\n  jev?: Extract<SearchEvent, { type: \"jev\" }>\n  error?: string\n}\n\nexport function useJevSearch(options: UseJevSearchOptions = {}): JevSearchState {\n  const { endpoint = \"/api/jev-search\", debounceMs = 60, minLength = 1, minDwellMs = 200 } = options\n  const [query, setQuery] = React.useState(\"\")\n  const [entry, setEntry] = React.useState<Entry>({})\n  const [phase, setPhase] = React.useState<SearchPhase>(\"idle\")\n  const cache = React.useRef(new Map<string, Entry>())\n  const controller = React.useRef<AbortController | null>(null)\n\n  React.useEffect(() => {\n    const q = query.trim()\n    controller.current?.abort()\n    if (q.length < minLength) {\n      setEntry({})\n      setPhase(\"idle\")\n      return\n    }\n    const key = q.toLowerCase()\n    const hit = cache.current.get(key)\n    if (hit?.jev) {\n      setEntry(hit)\n      setPhase(\"done\")\n      return\n    }\n    if (hit?.lexical) {\n      setEntry(hit)\n      setPhase(\"judging\")\n    }\n\n    const ac = new AbortController()\n    controller.current = ac\n    const timer = setTimeout(async () => {\n      const url = `${endpoint}?q=${encodeURIComponent(q)}`\n      try {\n        const res = await fetch(url, { signal: ac.signal })\n        if (!res.ok || !res.body) throw new Error(`search endpoint responded ${res.status}`)\n        const reader = res.body.getReader()\n        const decoder = new TextDecoder()\n        let buffer = \"\"\n        const current: Entry = { ...(cache.current.get(key) ?? {}) }\n        let shownAt = 0\n        const apply = async (line: string) => {\n          if (!line.trim()) return\n          const ev = JSON.parse(line) as SearchEvent\n          if (ev.type === \"lexical\") {\n            current.lexical = ev\n            shownAt = performance.now()\n            setPhase(\"judging\")\n          } else if (ev.type === \"jev\") {\n            const held = minDwellMs - (performance.now() - shownAt)\n            if (held > 0) await new Promise((r) => setTimeout(r, held))\n            if (ac.signal.aborted) return\n            current.jev = ev\n            setPhase(\"done\")\n          } else {\n            current.error = ev.message\n            setPhase(\"error\")\n          }\n          cache.current.set(key, { ...current })\n          if (!ac.signal.aborted) setEntry({ ...current })\n        }\n        while (true) {\n          const { value, done } = await reader.read()\n          if (done) break\n          buffer += decoder.decode(value, { stream: true })\n          let nl: number\n          while ((nl = buffer.indexOf(\"\\n\")) >= 0) {\n            await apply(buffer.slice(0, nl))\n            buffer = buffer.slice(nl + 1)\n          }\n        }\n        if (buffer.trim()) await apply(buffer)\n      } catch (err) {\n        if ((err as Error).name === \"AbortError\") return\n        setEntry((e) => ({ ...e, error: (err as Error).message }))\n        setPhase(\"error\")\n      }\n    }, debounceMs)\n    return () => {\n      clearTimeout(timer)\n      ac.abort()\n    }\n  }, [query, endpoint, debounceMs, minLength, minDwellMs])\n\n  const hits = entry.jev?.hits ?? entry.lexical?.hits ?? []\n  return {\n    query,\n    setQuery,\n    hits,\n    lexicalHits: entry.lexical?.hits ?? [],\n    jevHits: entry.jev?.hits,\n    demoted: entry.jev?.demoted ?? [],\n    phase,\n    judged: Boolean(entry.jev),\n    lexicalMs: entry.lexical?.tookMs,\n    jevMs: entry.jev?.tookMs,\n    judgedCount: entry.jev?.judged,\n    inputTokens: entry.jev?.inputTokens,\n    answerable: entry.jev?.answerable,\n    model: entry.jev?.model,\n    cached: entry.jev?.cached,\n    error: entry.error,\n    reset: () => {\n      setQuery(\"\")\n      setEntry({})\n      setPhase(\"idle\")\n    },\n  }\n}\n",
      "type": "registry:hook"
    },
    {
      "path": "src/lib/jev-search-core.ts",
      "content": "/**\n * jev-search — shared types and the lexical first pass.\n *\n * This file is used on both the client (highlighting) and the server\n * (candidate selection). It has no dependencies.\n */\n\nexport interface SearchDocument {\n  /** Stable id, used as the key in Jev questions. Keep it short. */\n  id: string\n  title: string\n  /** Where the result links to. */\n  url: string\n  description?: string\n  /** Body text. Only the first `excerptLength` chars are sent to Jev. */\n  content?: string\n  /** Group label shown in the results list, e.g. \"Docs\", \"Blog\", \"API\". */\n  section?: string\n  keywords?: string[]\n}\n\n/** What the server sends back for each hit. Content never leaves the server. */\nexport interface SearchHit {\n  id: string\n  title: string\n  url: string\n  description?: string\n  section?: string\n  /** Lexical score (relative, unbounded). */\n  score: number\n  /** Jev's calibrated 0–1 answer to \"is this what the user wants?\". */\n  relevance?: number\n  /** Jev's share of \"best single answer\" probability across the candidates. */\n  probability?: number\n  /** Query terms that matched, for highlighting. */\n  terms: string[]\n}\n\nexport type SearchEvent =\n  | { type: \"lexical\"; query: string; hits: SearchHit[]; tookMs: number }\n  | {\n      type: \"jev\"\n      query: string\n      hits: SearchHit[]\n      /** Judged but below threshold, still ranked. */\n      demoted: SearchHit[]\n      tookMs: number\n      model: string\n      judged: number\n      /** Input tokens billed for this query. */\n      inputTokens: number\n      /** Jev's belief that at least one candidate answers the query. */\n      answerable: number\n      cached: boolean\n    }\n  | { type: \"error\"; query: string; message: string }\n\nconst STOP = new Set([\n  \"a\", \"an\", \"and\", \"are\", \"as\", \"at\", \"be\", \"by\", \"can\", \"do\", \"does\", \"for\",\n  \"from\", \"how\", \"i\", \"in\", \"is\", \"it\", \"my\", \"of\", \"on\", \"or\", \"the\", \"to\",\n  \"what\", \"with\", \"you\", \"your\",\n])\n\n/** A very small stemmer: enough for \"tickets\", \"connecting\" and \"judged\" to meet in the middle. */\nexport function stem(token: string): string {\n  if (token.length <= 4) return token\n  return token.replace(/(ing|ed|es|ly|s)$/, (m) => (token.length - m.length >= 4 ? \"\" : m))\n}\n\nexport function tokenize(text: string): string[] {\n  return text\n    .toLowerCase()\n    .normalize(\"NFKD\")\n    .replace(/[̀-ͯ]/g, \"\")\n    .split(/[^a-z0-9_]+/)\n    .filter((t) => t.length > 0)\n    .map(stem)\n}\n\n/** Query tokens: stop words dropped unless the query is nothing but stop words. */\nexport function queryTerms(query: string): string[] {\n  const all = tokenize(query)\n  const kept = all.filter((t) => !STOP.has(t))\n  return kept.length > 0 ? kept : all\n}\n\ninterface IndexedDocument {\n  doc: SearchDocument\n  title: string[]\n  keywords: string[]\n  description: string[]\n  content: string[]\n  titleText: string\n  titleJoined: string\n  bodyText: string\n}\n\nexport interface LexicalIndex {\n  docs: IndexedDocument[]\n}\n\nexport function buildIndex(documents: SearchDocument[]): LexicalIndex {\n  return {\n    docs: documents.map((doc) => ({\n      doc,\n      title: tokenize(doc.title),\n      keywords: tokenize((doc.keywords ?? []).join(\" \")),\n      description: tokenize(doc.description ?? \"\"),\n      content: tokenize(doc.content ?? \"\"),\n      titleText: doc.title.toLowerCase(),\n      titleJoined: tokenize(doc.title).join(\"\"),\n      bodyText: `${doc.description ?? \"\"}\\n${doc.content ?? \"\"}`.toLowerCase(),\n    })),\n  }\n}\n\n/** Damerau–Levenshtein distance capped at 1: true when a and b differ by one edit. */\nfunction withinOneEdit(a: string, b: string): boolean {\n  if (a === b) return true\n  const la = a.length\n  const lb = b.length\n  if (Math.abs(la - lb) > 1) return false\n  let i = 0\n  while (i < la && i < lb && a[i] === b[i]) i++\n  if (la === lb) {\n    // substitution or transposition\n    if (a.slice(i + 1) === b.slice(i + 1)) return true\n    return a[i] === b[i + 1] && a[i + 1] === b[i] && a.slice(i + 2) === b.slice(i + 2)\n  }\n  // insertion / deletion\n  return la > lb ? a.slice(i + 1) === b.slice(i) : a.slice(i) === b.slice(i + 1)\n}\n\nconst WEIGHTS = {\n  titleExact: 10,\n  titlePrefix: 6,\n  titleFuzzy: 4,\n  keywordExact: 7,\n  keywordPrefix: 4,\n  descriptionExact: 3,\n  descriptionPrefix: 2,\n  contentExact: 1,\n  contentPrefix: 0.4,\n  titlePhrase: 12,\n  bodyPhrase: 4,\n} as const\n\nfunction fieldScore(tokens: string[], term: string, exact: number, prefix: number, fuzzy = 0): number {\n  let best = 0\n  let hits = 0\n  for (const t of tokens) {\n    if (t === term) {\n      best = Math.max(best, exact)\n      hits++\n    } else if (t.startsWith(term)) {\n      best = Math.max(best, prefix)\n      hits++\n    } else if (fuzzy > 0 && term.length >= 5 && withinOneEdit(t, term)) {\n      best = Math.max(best, fuzzy)\n      hits++\n    }\n  }\n  if (best === 0) return 0\n  // A little extra for repeated hits, with quickly diminishing returns.\n  return best + Math.min(hits - 1, 3) * best * 0.1\n}\n\nexport interface LexicalOptions {\n  limit?: number\n}\n\n/**\n * Rank documents for a query with weighted field matching, prefix matching and\n * one-edit typo tolerance on titles. Fast enough to run on every keystroke for\n * a few thousand documents.\n */\nexport function lexicalSearch(index: LexicalIndex, query: string, options: LexicalOptions = {}): SearchHit[] {\n  const limit = options.limit ?? 20\n  const terms = queryTerms(query)\n  if (terms.length === 0) return []\n  const phrase = query.trim().toLowerCase()\n\n  const scored: { hit: SearchHit; matched: number }[] = []\n  for (const d of index.docs) {\n    let score = 0\n    let matched = 0\n    const matchedTerms: string[] = []\n    for (const term of terms) {\n      let s =\n        fieldScore(d.title, term, WEIGHTS.titleExact, WEIGHTS.titlePrefix, WEIGHTS.titleFuzzy) +\n        fieldScore(d.keywords, term, WEIGHTS.keywordExact, WEIGHTS.keywordPrefix, WEIGHTS.titleFuzzy) +\n        fieldScore(d.description, term, WEIGHTS.descriptionExact, WEIGHTS.descriptionPrefix, WEIGHTS.descriptionPrefix) +\n        fieldScore(d.content, term, WEIGHTS.contentExact, WEIGHTS.contentPrefix)\n      // \"quickstrat\" → \"quick start\": one edit away from the title with its spaces removed.\n      if (s === 0 && term.length >= 6 && withinOneEdit(term, d.titleJoined)) s = WEIGHTS.titleFuzzy\n      if (s > 0) {\n        matched++\n        matchedTerms.push(term)\n        score += s\n      }\n    }\n    if (matched === 0) continue\n    // Reward documents that match more of the query.\n    score *= matched / terms.length\n    if (phrase.length >= 3 && terms.length > 1) {\n      if (d.titleText.includes(phrase)) score += WEIGHTS.titlePhrase\n      else if (d.bodyText.includes(phrase)) score += WEIGHTS.bodyPhrase\n    }\n    // Mild length normalisation so long pages do not win on volume alone.\n    score /= 1 + Math.log1p(d.content.length) / 12\n    scored.push({\n      matched,\n      hit: {\n        id: d.doc.id,\n        title: d.doc.title,\n        url: d.doc.url,\n        description: d.doc.description,\n        section: d.doc.section,\n        score,\n        terms: matchedTerms,\n      },\n    })\n  }\n  scored.sort((a, b) => b.hit.score - a.hit.score || a.hit.title.localeCompare(b.hit.title))\n  return scored.slice(0, limit).map((s) => s.hit)\n}\n\n/** Split text into [plain, match, plain, match…] segments for highlighting. */\nexport function highlightSegments(text: string, terms: string[]): { text: string; match: boolean }[] {\n  if (!text || terms.length === 0) return [{ text, match: false }]\n  const escaped = terms\n    .filter((t) => t.length > 0)\n    .sort((a, b) => b.length - a.length)\n    .map((t) => t.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\"))\n  if (escaped.length === 0) return [{ text, match: false }]\n  const re = new RegExp(`(${escaped.join(\"|\")})`, \"gi\")\n  const out: { text: string; match: boolean }[] = []\n  let last = 0\n  for (const m of text.matchAll(re)) {\n    const i = m.index ?? 0\n    if (i > last) out.push({ text: text.slice(last, i), match: false })\n    out.push({ text: m[0], match: true })\n    last = i + m[0].length\n  }\n  if (last < text.length) out.push({ text: text.slice(last), match: false })\n  return out\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "src/lib/jev-search-server.ts",
      "content": "/**\n * jev-search — server side.\n *\n * Runs the lexical first pass over your index, then asks TypeSafe's Jev model\n * to judge the top candidates against the query. Jev returns a calibrated\n * relevance for every candidate in one request, so a query costs one round\n * trip and a few hundred input tokens.\n *\n * Works anywhere the Fetch API exists: Next.js route handlers, Remix, Hono,\n * Bun, Deno, Cloudflare Workers.\n */\nimport { buildIndex, lexicalSearch, type LexicalIndex, type SearchDocument, type SearchEvent, type SearchHit } from \"./jev-search-core\"\n\nexport interface JevSearchOptions {\n  documents: SearchDocument[]\n  /** Defaults to process.env.TYPESAFE_API_KEY. */\n  apiKey?: string\n  /** Defaults to process.env.TYPESAFE_API_URL or https://api.typesafe.ai/v1/systemone. */\n  apiUrl?: string\n  /** Defaults to \"jev-latest\". */\n  model?: string\n  /** How many lexical hits Jev judges. Accuracy plateaus around 20. Default 20. */\n  candidates?: number\n  /** Hits below this relevance are dropped once Jev has spoken. Default 0.15. */\n  threshold?: number\n  /** Characters of body text sent to Jev per candidate. Default 320. */\n  excerptLength?: number\n  /** Number of queries kept in the in-memory cache. Default 1000. */\n  cacheSize?: number\n  /** Abort the Jev call after this long and fall back to lexical order. Default 4000. */\n  timeoutMs?: number\n  /** Blend between Jev's per-page relevance and its \"best answer\" share. Default 0.75 (mostly relevance). */\n  relevanceWeight?: number\n}\n\nexport interface JevRanking {\n  hits: SearchHit[]\n  /** Judged candidates that fell below `threshold`, still ranked. Rendering\n   *  these dimmed keeps a result list from collapsing when Jev answers. */\n  demoted: SearchHit[]\n  model: string\n  judged: number\n  /** Input tokens billed for this query. Output tokens are free. */\n  inputTokens: number\n  answerable: number\n  tookMs: number\n  cached: boolean\n}\n\ninterface TypeSafeAnswer {\n  type: string\n  noul?: number\n  choice?: string\n  probabilities?: Record<string, number>\n  confidence?: number\n}\n\ninterface TypeSafeResponse {\n  model: string\n  answers: Record<string, TypeSafeAnswer>\n  usage?: { input_tokens: number; output_tokens: number }\n}\n\nclass LRU<V> {\n  private map = new Map<string, V>()\n  constructor(private max: number) {}\n  get(key: string): V | undefined {\n    const v = this.map.get(key)\n    if (v !== undefined) {\n      this.map.delete(key)\n      this.map.set(key, v)\n    }\n    return v\n  }\n  set(key: string, value: V) {\n    this.map.delete(key)\n    this.map.set(key, value)\n    if (this.map.size > this.max) {\n      const oldest = this.map.keys().next().value\n      if (oldest !== undefined) this.map.delete(oldest)\n    }\n  }\n}\n\nexport function createJevSearch(options: JevSearchOptions) {\n  const {\n    documents,\n    model = \"jev-latest\",\n    candidates: candidateCount = 20,\n    threshold = 0.15,\n    excerptLength = 320,\n    cacheSize = 1000,\n    timeoutMs = 4000,\n    relevanceWeight = 0.75,\n  } = options\n  const apiKey = options.apiKey ?? process.env.TYPESAFE_API_KEY\n  const apiUrl = options.apiUrl ?? process.env.TYPESAFE_API_URL ?? \"https://api.typesafe.ai/v1/systemone\"\n\n  const index: LexicalIndex = buildIndex(documents)\n  const byId = new Map(documents.map((d) => [d.id, d]))\n  const cache = new LRU<Omit<JevRanking, \"cached\">>(cacheSize)\n\n  function normalize(query: string): string {\n    return query.trim().replace(/\\s+/g, \" \").toLowerCase()\n  }\n\n  function lexical(query: string, limit = 20): SearchHit[] {\n    return lexicalSearch(index, query, { limit })\n  }\n\n  async function judge(query: string, hits: SearchHit[]): Promise<JevRanking> {\n    const key = `${model}\\u0000${normalize(query)}`\n    const cached = cache.get(key)\n    if (cached) return { ...cached, cached: true }\n    if (!apiKey) throw new Error(\"jev-search: TYPESAFE_API_KEY is not set\")\n\n    const started = performance.now()\n    const cands = hits.slice(0, candidateCount)\n    if (cands.length === 0) {\n      return { hits: [], demoted: [], model, judged: 0, inputTokens: 0, answerable: 0, tookMs: 0, cached: false }\n    }\n\n    const state = {\n      query,\n      candidates: cands.map((h, i) => {\n        const doc = byId.get(h.id)\n        return {\n          id: `c${i + 1}`,\n          section: h.section,\n          title: h.title,\n          description: h.description,\n          excerpt: doc?.content?.slice(0, excerptLength),\n        }\n      }),\n    }\n    const criteria: Record<string, string> = {}\n    const questions: Record<string, unknown> = {}\n    cands.forEach((h, i) => {\n      const id = `c${i + 1}`\n      criteria[id] = `${h.title}${h.description ? ` — ${h.description}` : \"\"}`\n      questions[id] = {\n        type: \"noul\",\n        instructions: `The user typed the search query above into a site search box. Is candidate ${id} (\"${h.title}\") a page they would be glad to land on for that query?`,\n        criteria: {\n          true: \"The page answers, covers or is clearly about what the query is asking for\",\n          false: \"The page is off-topic, or only shares a word or two with the query\",\n        },\n      }\n    })\n    questions.best = {\n      type: \"choice\",\n      instructions: \"Which candidate page best answers the user's search query?\",\n      criteria,\n    }\n    questions.answerable = {\n      type: \"noul\",\n      instructions: \"Does at least one candidate page answer the user's search query?\",\n    }\n\n    const payload = JSON.stringify({ state, model, questions })\n    let body: TypeSafeResponse | undefined\n    let lastError: Error | undefined\n    // 401 and 422 are our fault; anything else gets two quick retries.\n    for (let attempt = 0; attempt < 3 && !body; attempt++) {\n      if (attempt > 0) await new Promise((r) => setTimeout(r, 150 * attempt * attempt))\n      const controller = new AbortController()\n      const timer = setTimeout(() => controller.abort(), timeoutMs)\n      try {\n        const res = await fetch(apiUrl, {\n          method: \"POST\",\n          headers: { Authorization: `Bearer ${apiKey}`, \"Content-Type\": \"application/json\" },\n          body: payload,\n          signal: controller.signal,\n        })\n        if (res.ok) {\n          body = (await res.json()) as TypeSafeResponse\n        } else {\n          const text = await res.text().catch(() => \"\")\n          lastError = new Error(`jev-search: TypeSafe responded ${res.status} ${text.slice(0, 200)}`)\n          if (res.status === 401 || res.status === 422) break\n        }\n      } catch (err) {\n        lastError = err as Error\n      } finally {\n        clearTimeout(timer)\n      }\n    }\n    if (!body) throw lastError ?? new Error(\"jev-search: TypeSafe call failed\")\n    const best = body.answers.best?.probabilities ?? {}\n\n    const ranked: SearchHit[] = cands.map((h, i) => {\n      const id = `c${i + 1}`\n      const relevance = body.answers[id]?.noul ?? 0\n      const probability = best[id] ?? 0\n      return { ...h, relevance, probability }\n    })\n    const blended = (h: SearchHit) => relevanceWeight * (h.relevance ?? 0) + (1 - relevanceWeight) * (h.probability ?? 0)\n    ranked.sort((a, b) => blended(b) - blended(a) || b.score - a.score)\n    let kept = ranked.filter((h) => (h.relevance ?? 0) >= threshold)\n    if (kept.length === 0) kept = ranked.slice(0, 3)\n    const keptIds = new Set(kept.map((h) => h.id))\n\n    const result = {\n      hits: kept,\n      demoted: ranked.filter((h) => !keptIds.has(h.id)),\n      model: body.model ?? model,\n      judged: cands.length,\n      inputTokens: body.usage?.input_tokens ?? 0,\n      answerable: body.answers.answerable?.noul ?? 0,\n      tookMs: Math.round(performance.now() - started),\n    }\n    cache.set(key, result)\n    return { ...result, cached: false }\n  }\n\n  /** Run both passes and return everything. Useful for tests and benchmarks. */\n  async function search(query: string) {\n    const t0 = performance.now()\n    const lex = lexical(query)\n    const lexicalMs = performance.now() - t0\n    const jev = await judge(query, lex)\n    return { lexical: lex, lexicalMs, jev }\n  }\n\n  /**\n   * Fetch-API handler. GET /?q=term streams NDJSON: a `lexical` event as soon\n   * as the first pass is done, then a `jev` event. Add `stream=0` to receive a\n   * single JSON object with the final ranking instead.\n   */\n  async function handler(request: Request): Promise<Response> {\n    const url = new URL(request.url)\n    const query = (url.searchParams.get(\"q\") ?? \"\").slice(0, 200)\n    const wantsStream = url.searchParams.get(\"stream\") !== \"0\"\n    const headers = { \"Cache-Control\": \"no-store\" }\n\n    if (query.trim().length === 0) {\n      return Response.json({ type: \"lexical\", query, hits: [], tookMs: 0 } satisfies SearchEvent, { headers })\n    }\n\n    if (!wantsStream) {\n      try {\n        const r = await search(query)\n        const event: SearchEvent = { type: \"jev\", query, ...r.jev }\n        return Response.json(event, { headers })\n      } catch (err) {\n        return Response.json({ type: \"error\", query, message: (err as Error).message } satisfies SearchEvent, { status: 502, headers })\n      }\n    }\n\n    const encoder = new TextEncoder()\n    const stream = new ReadableStream<Uint8Array>({\n      async start(controller) {\n        const send = (e: SearchEvent) => controller.enqueue(encoder.encode(JSON.stringify(e) + \"\\n\"))\n        const t0 = performance.now()\n        const lex = lexical(query)\n        send({ type: \"lexical\", query, hits: lex, tookMs: Math.round((performance.now() - t0) * 100) / 100 })\n        try {\n          const jev = await judge(query, lex)\n          send({ type: \"jev\", query, ...jev })\n        } catch (err) {\n          send({ type: \"error\", query, message: (err as Error).message })\n        }\n        controller.close()\n      },\n    })\n    return new Response(stream, {\n      headers: { ...headers, \"Content-Type\": \"application/x-ndjson; charset=utf-8\", \"X-Accel-Buffering\": \"no\" },\n    })\n  }\n\n  return { search, lexical, judge, handler, documents }\n}\n\n/** Convenience: just the request handler. */\nexport function createJevSearchHandler(options: JevSearchOptions) {\n  return createJevSearch(options).handler\n}\n",
      "type": "registry:lib"
    },
    {
      "path": "registry/next/route.ts",
      "content": "import { createJevSearchHandler } from \"@/lib/jev-search-server\"\nimport documents from \"@/lib/jev-search-index.json\"\n\n// Build lib/jev-search-index.json with `bunx tsx scripts/jev-search-index.ts`\n// or hand it any SearchDocument[] you like. TYPESAFE_API_KEY must be set.\nconst handler = createJevSearchHandler({ documents })\n\nexport const GET = handler\nexport const POST = handler\n",
      "type": "registry:page",
      "target": "app/api/jev-search/route.ts"
    },
    {
      "path": "registry/lib/jev-search-index.json",
      "content": "[\n  {\n    \"id\": \"getting-started\",\n    \"title\": \"Getting started\",\n    \"url\": \"/docs/getting-started\",\n    \"description\": \"Install the package and run your first query.\",\n    \"section\": \"Docs\",\n    \"content\": \"Install with your package manager, set the API key, and call the client. The first request warms the cache.\",\n    \"keywords\": [\"install\", \"setup\", \"quickstart\"]\n  },\n  {\n    \"id\": \"configuration\",\n    \"title\": \"Configuration\",\n    \"url\": \"/docs/configuration\",\n    \"description\": \"Environment variables, config files and precedence.\",\n    \"section\": \"Docs\",\n    \"content\": \"Settings come from flags, then environment variables, then the config file. Secrets are never written to disk.\"\n  },\n  {\n    \"id\": \"pricing\",\n    \"title\": \"Pricing\",\n    \"url\": \"/pricing\",\n    \"description\": \"What it costs and how to keep the bill small.\",\n    \"section\": \"Company\",\n    \"content\": \"Usage is billed per request. Set a monthly budget in the dashboard to cap spend.\"\n  }\n]\n",
      "type": "registry:lib"
    }
  ],
  "envVars": {
    "TYPESAFE_API_KEY": ""
  },
  "docs": "Set TYPESAFE_API_KEY, replace lib/jev-search-index.json with your own documents (or run the jev-search-indexer), then render <JevSearch /> anywhere.",
  "categories": [
    "search",
    "command",
    "navigation"
  ],
  "type": "registry:block"
}