@dominikcz/greg 0.9.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/README.md +397 -0
  2. package/bin/greg.js +241 -0
  3. package/bin/init.js +351 -0
  4. package/bin/templates/docs/getting-started.md +47 -0
  5. package/bin/templates/docs/index.md +11 -0
  6. package/bin/templates/greg.config.js +39 -0
  7. package/bin/templates/greg.config.ts +38 -0
  8. package/bin/templates/index.html +16 -0
  9. package/bin/templates/src/App.svelte +5 -0
  10. package/bin/templates/src/app.css +20 -0
  11. package/bin/templates/src/main.js +9 -0
  12. package/bin/templates/svelte.config.js +1 -0
  13. package/bin/templates/tsconfig.json +21 -0
  14. package/bin/templates/vite.config.js +23 -0
  15. package/docs/__partials/markdown/examples/basic.md +4 -0
  16. package/docs/__partials/markdown/examples/diff.md +10 -0
  17. package/docs/__partials/markdown/examples/focus.md +5 -0
  18. package/docs/__partials/markdown/examples/language-title.md +3 -0
  19. package/docs/__partials/markdown/examples/line-highlighting.md +5 -0
  20. package/docs/__partials/markdown/examples/line-numbers.md +5 -0
  21. package/docs/__partials/note.md +4 -0
  22. package/docs/guide/__shared-warning.md +4 -0
  23. package/docs/guide/asset-handling.md +88 -0
  24. package/docs/guide/deploying.md +162 -0
  25. package/docs/guide/getting-started.md +334 -0
  26. package/docs/guide/index.md +23 -0
  27. package/docs/guide/localization.md +290 -0
  28. package/docs/guide/markdown/code.md +95 -0
  29. package/docs/guide/markdown/components-and-mermaid.md +43 -0
  30. package/docs/guide/markdown/containers.md +110 -0
  31. package/docs/guide/markdown/header-anchors.md +34 -0
  32. package/docs/guide/markdown/includes.md +84 -0
  33. package/docs/guide/markdown/index.md +20 -0
  34. package/docs/guide/markdown/inline-attributes.md +21 -0
  35. package/docs/guide/markdown/links-and-toc.md +64 -0
  36. package/docs/guide/markdown/math.md +54 -0
  37. package/docs/guide/markdown/syntax-highlighting.md +75 -0
  38. package/docs/guide/routing.md +150 -0
  39. package/docs/guide/using-svelte.md +88 -0
  40. package/docs/guide/versioning.md +281 -0
  41. package/docs/incompatibilities.md +48 -0
  42. package/docs/index.md +43 -0
  43. package/docs/reference/badge.md +100 -0
  44. package/docs/reference/carbon-ads.md +46 -0
  45. package/docs/reference/code-group.md +126 -0
  46. package/docs/reference/home-page.md +232 -0
  47. package/docs/reference/index.md +18 -0
  48. package/docs/reference/markdowndocs.md +275 -0
  49. package/docs/reference/outline.md +79 -0
  50. package/docs/reference/search.md +263 -0
  51. package/docs/reference/steps.md +200 -0
  52. package/docs/reference/team-page.md +189 -0
  53. package/docs/reference/theme.md +150 -0
  54. package/fakeDocsGenerator/generate_docs.js +310 -0
  55. package/package.json +92 -0
  56. package/scripts/build-versions.js +609 -0
  57. package/scripts/generate-static.js +79 -0
  58. package/scripts/render-markdown.js +420 -0
  59. package/src/lib/MarkdownDocs/AiChat.svelte +936 -0
  60. package/src/lib/MarkdownDocs/BackToTop.svelte +68 -0
  61. package/src/lib/MarkdownDocs/Breadcrumb.svelte +68 -0
  62. package/src/lib/MarkdownDocs/DocsNavigation.svelte +149 -0
  63. package/src/lib/MarkdownDocs/DocsSiteHeader.svelte +758 -0
  64. package/src/lib/MarkdownDocs/DocsVersionSwitcher.svelte +103 -0
  65. package/src/lib/MarkdownDocs/MarkdownDocs.svelte +2115 -0
  66. package/src/lib/MarkdownDocs/MarkdownRenderer.svelte +487 -0
  67. package/src/lib/MarkdownDocs/Outline.svelte +238 -0
  68. package/src/lib/MarkdownDocs/PrevNext.svelte +115 -0
  69. package/src/lib/MarkdownDocs/SearchModal.svelte +1241 -0
  70. package/src/lib/MarkdownDocs/TreeView.svelte +32 -0
  71. package/src/lib/MarkdownDocs/TreeViewItem.svelte +219 -0
  72. package/src/lib/MarkdownDocs/VersionOutdatedNotice.svelte +72 -0
  73. package/src/lib/MarkdownDocs/__tests__/codeDirectives.test.js +54 -0
  74. package/src/lib/MarkdownDocs/__tests__/common.test.js +41 -0
  75. package/src/lib/MarkdownDocs/__tests__/docsExamplesLint.test.js +77 -0
  76. package/src/lib/MarkdownDocs/__tests__/fixtures/docs/markdown/__partial-basic.md +3 -0
  77. package/src/lib/MarkdownDocs/__tests__/fixtures/docs/markdown/snippet.js +9 -0
  78. package/src/lib/MarkdownDocs/__tests__/fixtures/includes/part.md +11 -0
  79. package/src/lib/MarkdownDocs/__tests__/fixtures/includes/wrapper.md +5 -0
  80. package/src/lib/MarkdownDocs/__tests__/fixtures/snippets/sample.js +8 -0
  81. package/src/lib/MarkdownDocs/__tests__/fixtures/snippets/sample.md +5 -0
  82. package/src/lib/MarkdownDocs/__tests__/helpers.js +67 -0
  83. package/src/lib/MarkdownDocs/__tests__/localeUtils.test.js +204 -0
  84. package/src/lib/MarkdownDocs/__tests__/markdown.test.js +704 -0
  85. package/src/lib/MarkdownDocs/__tests__/markdownRendererRuntime.test.js +65 -0
  86. package/src/lib/MarkdownDocs/__tests__/searchIndexBuilder.test.js +117 -0
  87. package/src/lib/MarkdownDocs/__tests__/sqliteStore.test.js +202 -0
  88. package/src/lib/MarkdownDocs/__tests__/useRouter.test.js +16 -0
  89. package/src/lib/MarkdownDocs/ai/adapters/customAdapter.js +14 -0
  90. package/src/lib/MarkdownDocs/ai/adapters/customAdapter.ts +43 -0
  91. package/src/lib/MarkdownDocs/ai/adapters/ollamaAdapter.js +81 -0
  92. package/src/lib/MarkdownDocs/ai/adapters/ollamaAdapter.ts +116 -0
  93. package/src/lib/MarkdownDocs/ai/adapters/openaiAdapter.js +92 -0
  94. package/src/lib/MarkdownDocs/ai/adapters/openaiAdapter.ts +137 -0
  95. package/src/lib/MarkdownDocs/ai/aiProvider.ts +31 -0
  96. package/src/lib/MarkdownDocs/ai/characters.js +52 -0
  97. package/src/lib/MarkdownDocs/ai/characters.ts +69 -0
  98. package/src/lib/MarkdownDocs/ai/chunkStore.ts +25 -0
  99. package/src/lib/MarkdownDocs/ai/chunker.js +85 -0
  100. package/src/lib/MarkdownDocs/ai/chunker.ts +135 -0
  101. package/src/lib/MarkdownDocs/ai/docLinker.js +26 -0
  102. package/src/lib/MarkdownDocs/ai/docLinker.ts +36 -0
  103. package/src/lib/MarkdownDocs/ai/promptBuilder.js +33 -0
  104. package/src/lib/MarkdownDocs/ai/promptBuilder.ts +53 -0
  105. package/src/lib/MarkdownDocs/ai/ragPipeline.js +54 -0
  106. package/src/lib/MarkdownDocs/ai/ragPipeline.ts +106 -0
  107. package/src/lib/MarkdownDocs/ai/stores/memoryStore.js +88 -0
  108. package/src/lib/MarkdownDocs/ai/stores/memoryStore.ts +112 -0
  109. package/src/lib/MarkdownDocs/ai/stores/sqliteStore.ts +372 -0
  110. package/src/lib/MarkdownDocs/ai/types.ts +71 -0
  111. package/src/lib/MarkdownDocs/aiServer.js +288 -0
  112. package/src/lib/MarkdownDocs/codeDirectives.js +191 -0
  113. package/src/lib/MarkdownDocs/codeFenceInfo.js +45 -0
  114. package/src/lib/MarkdownDocs/codeGroup.ts +46 -0
  115. package/src/lib/MarkdownDocs/common.ts +47 -0
  116. package/src/lib/MarkdownDocs/docsUtils.js +281 -0
  117. package/src/lib/MarkdownDocs/index.plugins.js +22 -0
  118. package/src/lib/MarkdownDocs/layouts/LayoutDoc.svelte +8 -0
  119. package/src/lib/MarkdownDocs/layouts/LayoutHome.svelte +58 -0
  120. package/src/lib/MarkdownDocs/layouts/LayoutPage.svelte +9 -0
  121. package/src/lib/MarkdownDocs/loadGregConfig.js +82 -0
  122. package/src/lib/MarkdownDocs/localeUtils.ts +682 -0
  123. package/src/lib/MarkdownDocs/markdownRendererRuntime.ts +314 -0
  124. package/src/lib/MarkdownDocs/mermaidThemes.js +319 -0
  125. package/src/lib/MarkdownDocs/navigationUtils.js +22 -0
  126. package/src/lib/MarkdownDocs/rehypeCodeGroup.js +326 -0
  127. package/src/lib/MarkdownDocs/rehypeCodeTitle.js +96 -0
  128. package/src/lib/MarkdownDocs/rehypeToc.js +170 -0
  129. package/src/lib/MarkdownDocs/remarkCodeMeta.js +22 -0
  130. package/src/lib/MarkdownDocs/remarkContainers.js +329 -0
  131. package/src/lib/MarkdownDocs/remarkCustomAnchors.js +42 -0
  132. package/src/lib/MarkdownDocs/remarkEscapeSvelte.js +33 -0
  133. package/src/lib/MarkdownDocs/remarkGlobalComponents.js +65 -0
  134. package/src/lib/MarkdownDocs/remarkImports.js +461 -0
  135. package/src/lib/MarkdownDocs/remarkImportsBrowser.js +349 -0
  136. package/src/lib/MarkdownDocs/remarkInlineAttrs.js +95 -0
  137. package/src/lib/MarkdownDocs/remarkMathToHtml.js +138 -0
  138. package/src/lib/MarkdownDocs/searchIndexBuilder.js +497 -0
  139. package/src/lib/MarkdownDocs/searchServer.js +263 -0
  140. package/src/lib/MarkdownDocs/treeViewTypes.ts +11 -0
  141. package/src/lib/MarkdownDocs/useRouter.svelte.ts +114 -0
  142. package/src/lib/MarkdownDocs/useSplitter.svelte.ts +33 -0
  143. package/src/lib/MarkdownDocs/versioningDefaults.js +20 -0
  144. package/src/lib/MarkdownDocs/vitePluginAiServer.js +204 -0
  145. package/src/lib/MarkdownDocs/vitePluginCopyDocs.js +153 -0
  146. package/src/lib/MarkdownDocs/vitePluginFrontmatter.js +109 -0
  147. package/src/lib/MarkdownDocs/vitePluginGregConfig.js +108 -0
  148. package/src/lib/MarkdownDocs/vitePluginSearchIndex.js +57 -0
  149. package/src/lib/MarkdownDocs/vitePluginSearchServer.js +190 -0
  150. package/src/lib/components/Badge.svelte +59 -0
  151. package/src/lib/components/Button.svelte +138 -0
  152. package/src/lib/components/CarbonAds.svelte +99 -0
  153. package/src/lib/components/CodeGroup.svelte +102 -0
  154. package/src/lib/components/Feature.svelte +209 -0
  155. package/src/lib/components/Features.svelte +123 -0
  156. package/src/lib/components/Hero.svelte +399 -0
  157. package/src/lib/components/Image.svelte +128 -0
  158. package/src/lib/components/Link.svelte +105 -0
  159. package/src/lib/components/SocialLink.svelte +84 -0
  160. package/src/lib/components/SocialLinks.svelte +33 -0
  161. package/src/lib/components/Steps.svelte +143 -0
  162. package/src/lib/components/TeamMember.svelte +273 -0
  163. package/src/lib/components/TeamMembers.svelte +81 -0
  164. package/src/lib/components/TeamPage.svelte +65 -0
  165. package/src/lib/components/TeamPageSection.svelte +108 -0
  166. package/src/lib/components/TeamPageTitle.svelte +89 -0
  167. package/src/lib/components/index.js +24 -0
  168. package/src/lib/portal/context.js +12 -0
  169. package/src/lib/portal/index.js +3 -0
  170. package/src/lib/portal/portal.svelte +14 -0
  171. package/src/lib/portal/slot.svelte +8 -0
  172. package/src/lib/scss/__code.scss +128 -0
  173. package/src/lib/scss/__containers.scss +99 -0
  174. package/src/lib/scss/__markdown.scss +447 -0
  175. package/src/lib/scss/__scrollbar.scss +60 -0
  176. package/src/lib/scss/__steps.scss +100 -0
  177. package/src/lib/scss/__theme.scss +238 -0
  178. package/src/lib/scss/__toc.scss +55 -0
  179. package/src/lib/scss/__utilities.scss +7 -0
  180. package/src/lib/scss/greg.scss +9 -0
  181. package/src/lib/spinner/spinner.svelte +42 -0
  182. package/svelte.config.js +146 -0
  183. package/types/index.d.ts +456 -0
@@ -0,0 +1,1241 @@
1
+ <script lang="ts">
2
+ import { onMount, tick } from "svelte";
3
+ import Fuse from "fuse.js";
4
+ import gregConfig from "virtual:greg-config";
5
+ import { withBase } from "./common";
6
+ import AiChat from "./AiChat.svelte";
7
+
8
+ // ── Types ──────────────────────────────────────────────────────────────────
9
+
10
+ type SearchSection = {
11
+ heading: string;
12
+ anchor: string;
13
+ content: string;
14
+ };
15
+
16
+ type SearchEntry = {
17
+ id: string;
18
+ title: string;
19
+ sections: SearchSection[];
20
+ };
21
+
22
+ type SearchResult = {
23
+ id: string;
24
+ title: string;
25
+ titleHtml: string;
26
+ sectionTitle: string;
27
+ sectionTitleHtml?: string;
28
+ sectionAnchor: string;
29
+ excerptHtml: string;
30
+ score: number;
31
+ };
32
+
33
+ /**
34
+ * Custom search provider function.
35
+ * `(query, limit?) => Promise<SearchResult[]>`
36
+ * When provided, takes priority over greg.config.js ”ş search.provider.
37
+ */
38
+ export type SearchProviderFn = (
39
+ query: string,
40
+ limit?: number,
41
+ ) => Promise<SearchResult[]>;
42
+
43
+ type Props = {
44
+ open: boolean;
45
+ onClose: () => void;
46
+ onNavigate: (path: string, anchor?: string) => void;
47
+ localeSrcDir?: string;
48
+ allLocaleSrcDirs?: string[];
49
+ baseSrcDir?: string;
50
+ searchProvider?: SearchProviderFn;
51
+ searchModalLabel?: string;
52
+ searchPlaceholder?: string;
53
+ searchLoadingText?: string;
54
+ searchErrorText?: string;
55
+ searchSearchingText?: string;
56
+ searchNoResultsText?: string;
57
+ searchStartText?: string;
58
+ searchResultsAriaLabel?: string;
59
+ searchNavigateText?: string;
60
+ searchSelectText?: string;
61
+ searchCloseText?: string;
62
+ aiTabLabel?: string;
63
+ aiPlaceholder?: string;
64
+ aiLoadingText?: string;
65
+ aiErrorText?: string;
66
+ aiStartText?: string;
67
+ aiSourcesLabel?: string;
68
+ aiClearChatLabel?: string;
69
+ aiSendLabel?: string;
70
+ };
71
+
72
+ let {
73
+ open = $bindable(false),
74
+ onClose,
75
+ onNavigate,
76
+ localeSrcDir = "/docs",
77
+ allLocaleSrcDirs = [],
78
+ baseSrcDir = "/docs",
79
+ searchProvider,
80
+ searchModalLabel = "Search",
81
+ searchPlaceholder = "Search docs...",
82
+ searchLoadingText = "Loading index...",
83
+ searchErrorText = "Failed to load search index.",
84
+ searchSearchingText = "Searching...",
85
+ searchNoResultsText = "No results for",
86
+ searchStartText = "Start typing to search across all documentation.",
87
+ searchResultsAriaLabel = "Search results",
88
+ searchNavigateText = "navigate",
89
+ searchSelectText = "open",
90
+ searchCloseText = "close",
91
+ aiTabLabel = "Ask AI",
92
+ aiPlaceholder = "Ask a question about the docs\u2026",
93
+ aiLoadingText = "Thinking\u2026",
94
+ aiErrorText = "Something went wrong. Please try again.",
95
+ aiStartText = "Ask me anything about this documentation. My answers are based exclusively on the docs.",
96
+ aiSourcesLabel = "Sources",
97
+ aiClearChatLabel = "Clear chat",
98
+ aiSendLabel = "Send",
99
+ }: Props = $props();
100
+
101
+ function normalizePath(path: string): string {
102
+ const value = String(path || "").trim();
103
+ if (!value || value === "/") return "/";
104
+ return "/" + value.replace(/^\/+|\/+$/g, "");
105
+ }
106
+
107
+ function isPathInActiveLocale(path: string): boolean {
108
+ const id = normalizePath(path);
109
+ const currentRoot = normalizePath(localeSrcDir);
110
+ const baseRoot = normalizePath(baseSrcDir);
111
+ const roots = (allLocaleSrcDirs ?? []).map(normalizePath);
112
+
113
+ const inCurrentRoot = currentRoot === "/"
114
+ ? id.startsWith("/")
115
+ : (id === currentRoot || id.startsWith(currentRoot + "/"));
116
+
117
+ if (!inCurrentRoot) {
118
+ return false;
119
+ }
120
+
121
+ // Root locale should not include localized subtrees.
122
+ if (currentRoot === baseRoot) {
123
+ const otherRoots = roots.filter((rp) => rp !== currentRoot);
124
+ if (
125
+ otherRoots.some(
126
+ (rp) => id === rp || id.startsWith(rp + "/"),
127
+ )
128
+ ) {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ return true;
134
+ }
135
+
136
+ // ── Config ─────────────────────────────────────────────────────────────────
137
+
138
+ const cfgSearch = (gregConfig as any)?.search ?? {};
139
+ /** Effective mode. Reactive so the prop can change at runtime. */
140
+ const mode = $derived<"local" | "server" | "custom" | "none">(
141
+ searchProvider ? "custom" : (cfgSearch.provider ?? "server"),
142
+ );
143
+ /** Whether the AI assistant is enabled in config. */
144
+ const aiEnabled = $derived(!!(cfgSearch?.ai?.enabled));
145
+ /** Which tab is active in the modal: search or AI chat. */
146
+ let tabMode = $state<"search" | "ai">("search");
147
+ /** Exposed handle from AiChat — gives access to clear() and hasMessages. */
148
+ let aiChatHandle = $state<{ clear: () => void; hasMessages: boolean } | undefined>(undefined);
149
+ const serverUrl: string = cfgSearch.serverUrl ?? "/api/search";
150
+ const fuzzyCfg = cfgSearch.fuzzy ?? {};
151
+ const localThreshold: number = Number.isFinite(Number(fuzzyCfg.threshold))
152
+ ? Number(fuzzyCfg.threshold)
153
+ : 0.35;
154
+ const localMinMatchCharLength: number = Number.isFinite(
155
+ Number(fuzzyCfg.minMatchCharLength),
156
+ )
157
+ ? Math.max(1, Number(fuzzyCfg.minMatchCharLength))
158
+ : 3;
159
+ const localIgnoreLocation: boolean = fuzzyCfg.ignoreLocation !== false;
160
+
161
+ // ── State ──────────────────────────────────────────────────────────────────
162
+
163
+ // ── Recent searches (localStorage) ───────────────────────────────────
164
+
165
+ const RECENT_KEY = 'greg-search-recent';
166
+ const MAX_RECENT = 8;
167
+
168
+ function loadRecentSearches(): string[] {
169
+ try {
170
+ const raw = localStorage.getItem(RECENT_KEY);
171
+ if (raw) {
172
+ const parsed = JSON.parse(raw);
173
+ if (Array.isArray(parsed)) return parsed as string[];
174
+ }
175
+ } catch { /* ignore */ }
176
+ return [];
177
+ }
178
+
179
+ function saveRecentSearches(list: string[]) {
180
+ try { localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch { /* ignore */ }
181
+ }
182
+
183
+ let recentSearches = $state<string[]>(loadRecentSearches());
184
+
185
+ function addRecentSearch(phrase: string) {
186
+ const trimmed = phrase.trim();
187
+ if (!trimmed) return;
188
+ const filtered = recentSearches.filter(r => r !== trimmed);
189
+ recentSearches = [trimmed, ...filtered].slice(0, MAX_RECENT);
190
+ saveRecentSearches(recentSearches);
191
+ }
192
+
193
+ function removeRecentSearch(phrase: string) {
194
+ recentSearches = recentSearches.filter(r => r !== phrase);
195
+ saveRecentSearches(recentSearches);
196
+ }
197
+
198
+ function clearRecentSearches() {
199
+ recentSearches = [];
200
+ try { localStorage.removeItem(RECENT_KEY); } catch { /* ignore */ }
201
+ }
202
+
203
+ let query = $state("");
204
+ let results = $state<SearchResult[]>([]);
205
+ let selectedIndex = $state(-1);
206
+ let inputEl = $state<HTMLInputElement | undefined>(undefined);
207
+ let listEl = $state<HTMLUListElement | undefined>(undefined);
208
+ /** True when selection was last moved by keyboard — suppresses mouseenter updates. */
209
+ let usingKeyboard = false;
210
+
211
+ /** True while a server / custom request is in flight. */
212
+ let isSearching = $state(false);
213
+ /** True when the last server/custom search ended with a network/HTTP error. */
214
+ let searchFailed = $state(false);
215
+ /** For local mode: true once the full JSON index has been downloaded. */
216
+ let indexReady = $state(false);
217
+ let indexError = $state(false);
218
+
219
+ let fuse: Fuse<SearchEntry> | null = null;
220
+
221
+ // ── Local mode: pre-load index into browser ─────────────────────────────
222
+
223
+ onMount(async () => {
224
+ if (mode !== "local") {
225
+ indexReady = true; // server / custom: ready immediately
226
+ return;
227
+ }
228
+ try {
229
+ const res = await fetch(withBase("/search-index.json"));
230
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
231
+ const data: SearchEntry[] = await res.json();
232
+ fuse = new Fuse(data, {
233
+ includeScore: true,
234
+ includeMatches: true,
235
+ threshold: localThreshold,
236
+ ignoreLocation: localIgnoreLocation,
237
+ minMatchCharLength: localMinMatchCharLength,
238
+ keys: [
239
+ { name: "title", weight: 3 },
240
+ { name: "sections.heading", weight: 2 },
241
+ { name: "sections.content", weight: 1 },
242
+ ],
243
+ });
244
+ indexReady = true;
245
+ } catch (e) {
246
+ console.error("[Search] Failed to load index:", e);
247
+ indexError = true;
248
+ }
249
+ });
250
+
251
+ // Focus input whenever modal opens; reset when it closes
252
+ $effect(() => {
253
+ if (open) {
254
+ tick().then(() => inputEl?.focus());
255
+ selectedIndex = -1;
256
+ } else {
257
+ query = "";
258
+ results = [];
259
+ tabMode = "search";
260
+ }
261
+ });
262
+
263
+ // Scroll selected item into view
264
+ $effect(() => {
265
+ tick().then(() => {
266
+ const item = listEl?.children[selectedIndex] as
267
+ | HTMLElement
268
+ | undefined;
269
+ item?.scrollIntoView({ block: "nearest" });
270
+ });
271
+ });
272
+
273
+ // ── Debounced search ───────────────────────────────────────────────────────
274
+
275
+ let searchTimer: ReturnType<typeof setTimeout>;
276
+ let abortCtrl: AbortController | null = null;
277
+ let searchGeneration = 0;
278
+
279
+ function handleInput() {
280
+ clearTimeout(searchTimer);
281
+ const q = query.trim();
282
+ searchGeneration += 1;
283
+ selectedIndex = -1;
284
+ if (!q) {
285
+ abortCtrl?.abort();
286
+ isSearching = false;
287
+ searchFailed = false;
288
+ results = [];
289
+ return;
290
+ }
291
+ if (mode !== "local") isSearching = true;
292
+ const generation = searchGeneration;
293
+ searchTimer = setTimeout(() => void runSearch(generation), 200);
294
+ }
295
+
296
+ async function runSearch(generation = searchGeneration) {
297
+ if (generation !== searchGeneration) return;
298
+ const q = query.trim();
299
+ if (!q) {
300
+ results = [];
301
+ searchFailed = false;
302
+ isSearching = false;
303
+ return;
304
+ }
305
+
306
+ const displayLimit = 10;
307
+ const fetchLimit = 50;
308
+
309
+ if (mode === "local") {
310
+ if (!fuse) return;
311
+ const localResults = fuse
312
+ .search(q, { limit: fetchLimit })
313
+ .filter((res) => isPathInActiveLocale(res.item.id))
314
+ .slice(0, displayLimit)
315
+ .map(buildLocalResult);
316
+ if (generation !== searchGeneration) return;
317
+ results = localResults;
318
+ selectedIndex = -1;
319
+ return;
320
+ }
321
+
322
+ // server / custom — cancel previous in-flight request
323
+ abortCtrl?.abort();
324
+ abortCtrl = new AbortController();
325
+ isSearching = true;
326
+ searchFailed = false;
327
+ try {
328
+ let raw: SearchResult[];
329
+ if (mode === "custom" && searchProvider) {
330
+ raw = await searchProvider(q, fetchLimit);
331
+ } else {
332
+ const localeRoot = normalizePath(localeSrcDir);
333
+ const baseRoot = normalizePath(baseSrcDir);
334
+ const localeRoots = (allLocaleSrcDirs ?? [])
335
+ .map(normalizePath)
336
+ .join(",");
337
+ const url =
338
+ `${withBase(serverUrl)}?q=${encodeURIComponent(q)}`+
339
+ `&limit=${fetchLimit}` +
340
+ `&localeRoot=${encodeURIComponent(localeRoot)}` +
341
+ `&baseRoot=${encodeURIComponent(baseRoot)}` +
342
+ `&localeRoots=${encodeURIComponent(localeRoots)}`;
343
+ const res = await fetch(url, { signal: abortCtrl.signal });
344
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
345
+ const data = await res.json();
346
+ raw = data.results ?? [];
347
+ }
348
+ if (generation !== searchGeneration) return;
349
+ results = raw.slice(0, displayLimit);
350
+ selectedIndex = -1;
351
+ } catch (e: any) {
352
+ if (e?.name === "AbortError") return; // superseded by newer query — ignore
353
+ if (generation !== searchGeneration) return;
354
+ console.error("[Search]", e);
355
+ searchFailed = true;
356
+ results = [];
357
+ } finally {
358
+ if (generation === searchGeneration) {
359
+ isSearching = false;
360
+ }
361
+ }
362
+ }
363
+
364
+ // ── Local mode helpers: Fuse.js result → SearchResult ─────────────────────
365
+
366
+ function escapeHtml(str: string): string {
367
+ return str
368
+ .replace(/&/g, "&amp;")
369
+ .replace(/</g, "&lt;")
370
+ .replace(/>/g, "&gt;")
371
+ .replace(/"/g, "&quot;");
372
+ }
373
+
374
+ /**
375
+ * Build highlighted HTML from a string and fuse.js index pairs.
376
+ */
377
+ function highlightText(text: string, indices: [number, number][]): string {
378
+ if (!indices?.length) return escapeHtml(text);
379
+ let html = "";
380
+ let last = 0;
381
+ for (const [s, e] of indices) {
382
+ if (s >= text.length) break;
383
+ if (s > last) html += escapeHtml(text.slice(last, s));
384
+ html += `<mark>${escapeHtml(text.slice(s, e + 1))}</mark>`;
385
+ last = e + 1;
386
+ }
387
+ html += escapeHtml(text.slice(last));
388
+ return html;
389
+ }
390
+
391
+ /**
392
+ * Extract a ~contextLen char excerpt centred on the first match,
393
+ * with matched spans wrapped in <mark>.
394
+ */
395
+ function getExcerptHtml(
396
+ text: string,
397
+ indices: [number, number][],
398
+ contextLen = 150,
399
+ ): string {
400
+ if (!text) return "";
401
+
402
+ if (!indices?.length) {
403
+ return (
404
+ escapeHtml(text.slice(0, contextLen)) +
405
+ (text.length > contextLen ? "…" : "")
406
+ );
407
+ }
408
+
409
+ const firstMatchStart = indices[0][0];
410
+ const from = Math.max(0, firstMatchStart - 50);
411
+ const to = Math.min(text.length, from + contextLen);
412
+ const sliced = text.slice(from, to);
413
+
414
+ // Adjust indices to the sliced window
415
+ const adjusted: [number, number][] = indices
416
+ .map(([s, e]) => [s - from, e - from] as [number, number])
417
+ .filter(([s, e]) => e >= 0 && s < sliced.length)
418
+ .map(
419
+ ([s, e]) =>
420
+ [Math.max(0, s), Math.min(sliced.length - 1, e)] as [
421
+ number,
422
+ number,
423
+ ],
424
+ );
425
+
426
+ const prefix = from > 0 ? "…" : "";
427
+ const suffix = to < text.length ? "…" : "";
428
+
429
+ let html = prefix;
430
+ let last = 0;
431
+ for (const [s, e] of adjusted) {
432
+ if (s > last) html += escapeHtml(sliced.slice(last, s));
433
+ html += `<mark>${escapeHtml(sliced.slice(s, e + 1))}</mark>`;
434
+ last = e + 1;
435
+ }
436
+ html += escapeHtml(sliced.slice(last)) + suffix;
437
+ return html;
438
+ }
439
+
440
+ function buildLocalResult(fuseResult: any): SearchResult {
441
+ const { item, matches = [], score = 1 } = fuseResult;
442
+
443
+ // Sort matches by total matched span length (longest = most relevant)
444
+ const sorted: any[] = [...(matches ?? [])].sort(
445
+ (a, b) =>
446
+ b.indices.reduce(
447
+ (s: number, [x, y]: number[]) => s + (y - x),
448
+ 0,
449
+ ) -
450
+ a.indices.reduce(
451
+ (s: number, [x, y]: number[]) => s + (y - x),
452
+ 0,
453
+ ),
454
+ );
455
+
456
+ const titleMatch = sorted.find((m) => m.key === "title");
457
+ const sectionContent = sorted.find((m) => m.key === "sections.content");
458
+ const sectionHeading = sorted.find((m) => m.key === "sections.heading");
459
+
460
+ let excerptHtml = "";
461
+ let sectionTitle = "";
462
+ let sectionAnchor = "";
463
+
464
+ if (sectionContent) {
465
+ const sec = item.sections[sectionContent.refIndex];
466
+ sectionTitle = sec?.heading ?? "";
467
+ sectionAnchor = sec?.anchor ?? "";
468
+ excerptHtml = getExcerptHtml(
469
+ sectionContent.value,
470
+ sectionContent.indices,
471
+ );
472
+ } else if (sectionHeading) {
473
+ const sec = item.sections[sectionHeading.refIndex];
474
+ sectionTitle = sec?.heading ?? "";
475
+ sectionAnchor = sec?.anchor ?? "";
476
+ excerptHtml = escapeHtml((sec?.content ?? "").slice(0, 150));
477
+ } else {
478
+ // Fallback: beginning of first section
479
+ excerptHtml = escapeHtml(
480
+ (item.sections[0]?.content ?? "").slice(0, 150),
481
+ );
482
+ }
483
+
484
+ const titleHtml = titleMatch
485
+ ? highlightText(item.title, titleMatch.indices)
486
+ : escapeHtml(item.title);
487
+ const sectionTitleHtml = sectionHeading
488
+ ? highlightText(sectionTitle, sectionHeading.indices)
489
+ : escapeHtml(sectionTitle);
490
+
491
+ return {
492
+ id: item.id,
493
+ title: item.title,
494
+ titleHtml,
495
+ sectionTitle,
496
+ sectionTitleHtml,
497
+ sectionAnchor,
498
+ excerptHtml,
499
+ score,
500
+ };
501
+ }
502
+
503
+ // ── Keyboard navigation ────────────────────────────────────────────────────
504
+
505
+ function handleKeydown(e: KeyboardEvent) {
506
+ e.stopPropagation();
507
+ // When query is empty, navigate/activate recent searches
508
+ const inRecent = !query.trim() && recentSearches.length > 0;
509
+ const listSize = inRecent ? recentSearches.length : results.length;
510
+ switch (e.key) {
511
+ case "Escape":
512
+ onClose();
513
+ break;
514
+ case "ArrowDown":
515
+ e.preventDefault();
516
+ usingKeyboard = true;
517
+ selectedIndex = Math.min(selectedIndex + 1, listSize - 1);
518
+ break;
519
+ case "ArrowUp":
520
+ e.preventDefault();
521
+ usingKeyboard = true;
522
+ selectedIndex = Math.max(selectedIndex - 1, 0);
523
+ break;
524
+ case "Enter":
525
+ if (inRecent && recentSearches[selectedIndex]) {
526
+ query = recentSearches[selectedIndex];
527
+ void runSearch();
528
+ } else if (results.length > 0) {
529
+ goTo(results[selectedIndex]);
530
+ }
531
+ break;
532
+ }
533
+ }
534
+
535
+ function goTo(result: SearchResult) {
536
+ const phrase = query.trim();
537
+ if (phrase) {
538
+ addRecentSearch(phrase);
539
+ try {
540
+ sessionStorage.setItem(
541
+ "greg-search-highlight",
542
+ JSON.stringify({
543
+ query: phrase,
544
+ path: result.id,
545
+ timestamp: Date.now(),
546
+ }),
547
+ );
548
+ } catch {
549
+ // Ignore storage errors and continue navigation.
550
+ }
551
+ }
552
+ onNavigate(result.id, result.sectionAnchor || undefined);
553
+ window.setTimeout(() => {
554
+ window.dispatchEvent(
555
+ new CustomEvent("greg-search-highlight-request"),
556
+ );
557
+ }, 0);
558
+ onClose();
559
+ }
560
+
561
+ function handleBackdropClick(e: MouseEvent) {
562
+ if (e.target === e.currentTarget) onClose();
563
+ }
564
+ </script>
565
+
566
+ {#if open}
567
+ <div
568
+ class="search-backdrop"
569
+ onclick={handleBackdropClick}
570
+ onkeydown={handleKeydown}
571
+ role="dialog"
572
+ aria-modal="true"
573
+ aria-label={searchModalLabel}
574
+ tabindex="-1"
575
+ >
576
+ <div class="search-modal" class:ai-mode={aiEnabled && tabMode === "ai"}>
577
+
578
+ <!-- ── Tab switcher (only when AI is enabled) ── -->
579
+ {#if aiEnabled}
580
+ <div class="modal-tabs">
581
+ <button
582
+ class="modal-tab"
583
+ class:active={tabMode === "search"}
584
+ onclick={() => { tabMode = "search"; tick().then(() => inputEl?.focus()); }}
585
+ type="button"
586
+ >
587
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="modal-tab-icon">
588
+ <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
589
+ </svg>
590
+ {searchModalLabel}
591
+ </button>
592
+ <button
593
+ class="modal-tab"
594
+ class:active={tabMode === "ai"}
595
+ onclick={() => (tabMode = "ai")}
596
+ type="button"
597
+ >
598
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="modal-tab-icon">
599
+ <circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/>
600
+ </svg>
601
+ {aiTabLabel}
602
+ </button>
603
+ <div class="modal-tabs-end">
604
+ {#if tabMode === "ai" && aiChatHandle?.hasMessages}
605
+ <button class="ai-clear-btn" type="button" onclick={() => aiChatHandle?.clear()} title={aiClearChatLabel}>
606
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
607
+ <polyline points="3 6 5 6 21 6"/>
608
+ <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
609
+ <path d="M10 11v6M14 11v6"/>
610
+ <path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
611
+ </svg>
612
+ {aiClearChatLabel}
613
+ </button>
614
+ {/if}
615
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
616
+ <kbd
617
+ class="search-esc-hint"
618
+ onclick={onClose}
619
+ role="button"
620
+ tabindex="-1">Esc</kbd
621
+ >
622
+ </div>
623
+ </div>
624
+ {/if}
625
+
626
+ {#if !aiEnabled || tabMode === "search"}
627
+ <!-- ── Search tab (existing) ── -->
628
+
629
+ <!-- Input row -->
630
+ <div class="search-field">
631
+ <svg
632
+ class="search-icon"
633
+ xmlns="http://www.w3.org/2000/svg"
634
+ viewBox="0 0 24 24"
635
+ fill="none"
636
+ stroke="currentColor"
637
+ stroke-width="2"
638
+ >
639
+ <circle cx="11" cy="11" r="8" /><line
640
+ x1="21"
641
+ y1="21"
642
+ x2="16.65"
643
+ y2="16.65"
644
+ />
645
+ </svg>
646
+ <input
647
+ bind:this={inputEl}
648
+ bind:value={query}
649
+ oninput={handleInput}
650
+ onkeydown={handleKeydown}
651
+ type="search"
652
+ class="search-field-input"
653
+ placeholder={searchPlaceholder}
654
+ autocomplete="off"
655
+ spellcheck="false"
656
+ />
657
+ {#if !aiEnabled}
658
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
659
+ <kbd
660
+ class="search-esc-hint"
661
+ onclick={onClose}
662
+ role="button"
663
+ tabindex="-1">Esc</kbd
664
+ >
665
+ {/if}
666
+ </div>
667
+
668
+ <!-- Body -->
669
+ {#if mode === "local" && !indexReady && !indexError}
670
+ <div class="search-status">{searchLoadingText}</div>
671
+ {:else if mode === "local" && indexError}
672
+ <div class="search-status search-error">
673
+ {searchErrorText}
674
+ </div>
675
+ {:else if isSearching}
676
+ <div class="search-status">
677
+ <span class="search-spinner" aria-hidden="true"></span>
678
+ {searchSearchingText}
679
+ </div>
680
+ {:else if searchFailed && query.trim()}
681
+ <div class="search-status search-error">
682
+ {searchErrorText}
683
+ </div>
684
+ {:else if query.trim() && results.length === 0}
685
+ <div class="search-status">
686
+ {searchNoResultsText} <strong>"{query}"</strong>
687
+ </div>
688
+ {:else if results.length > 0}
689
+ <ul
690
+ bind:this={listEl}
691
+ class="search-results"
692
+ role="listbox"
693
+ aria-label={searchResultsAriaLabel}
694
+ onpointermove={(e) => { if (e.movementX || e.movementY) usingKeyboard = false; }}
695
+ >
696
+ {#each results as result, i}
697
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
698
+ <li
699
+ class="search-result-item"
700
+ class:selected={i === selectedIndex}
701
+ role="option"
702
+ aria-selected={i === selectedIndex}
703
+ onclick={() => goTo(result)}
704
+ onmouseenter={() => { if (!usingKeyboard) selectedIndex = i; }}
705
+ >
706
+ <div class="result-header">
707
+ <svg
708
+ class="result-page-icon"
709
+ xmlns="http://www.w3.org/2000/svg"
710
+ viewBox="0 0 24 24"
711
+ fill="none"
712
+ stroke="currentColor"
713
+ stroke-width="2"
714
+ >
715
+ <path
716
+ d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
717
+ />
718
+ <polyline points="14 2 14 8 20 8" />
719
+ </svg>
720
+ <span class="result-title"
721
+ >{@html result.titleHtml}</span
722
+ >
723
+ {#if result.sectionTitle}
724
+ <svg
725
+ class="result-chevron"
726
+ xmlns="http://www.w3.org/2000/svg"
727
+ viewBox="0 0 24 24"
728
+ fill="none"
729
+ stroke="currentColor"
730
+ stroke-width="2.5"
731
+ >
732
+ <polyline points="9 18 15 12 9 6" />
733
+ </svg>
734
+ <span class="result-section"
735
+ >{@html result.sectionTitleHtml ??
736
+ escapeHtml(result.sectionTitle)}</span
737
+ >
738
+ {/if}
739
+ </div>
740
+ {#if result.excerptHtml}
741
+ <p class="result-excerpt">
742
+ {@html result.excerptHtml}
743
+ </p>
744
+ {/if}
745
+ </li>
746
+ {/each}
747
+ </ul>
748
+ <div class="search-footer">
749
+ <span><kbd>↑</kbd><kbd>↓</kbd> {searchNavigateText}</span>
750
+ <span><kbd>↵</kbd> {searchSelectText}</span>
751
+ <span><kbd>Esc</kbd> {searchCloseText}</span>
752
+ </div>
753
+ {:else if !query.trim()}
754
+ {#if recentSearches.length > 0}
755
+ <div class="search-recent">
756
+ <div class="search-recent-header">
757
+ <span class="search-recent-label">Recent searches</span>
758
+ <button class="search-recent-clear" type="button" onclick={clearRecentSearches}>Clear all</button>
759
+ </div>
760
+ <ul class="search-results" role="listbox" aria-label="Recent searches" onpointermove={(e) => { if (e.movementX || e.movementY) usingKeyboard = false; }}>
761
+ {#each recentSearches as phrase, i}
762
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
763
+ <li
764
+ class="search-result-item search-recent-item"
765
+ class:selected={i === selectedIndex}
766
+ role="option"
767
+ aria-selected={i === selectedIndex}
768
+ onclick={() => { query = phrase; void runSearch(); }}
769
+ onmouseenter={() => { if (!usingKeyboard) selectedIndex = i; }}
770
+ >
771
+ <div class="result-header">
772
+ <svg class="search-recent-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
773
+ <circle cx="12" cy="12" r="10"/>
774
+ <polyline points="12 6 12 12 16 14"/>
775
+ </svg>
776
+ <span class="result-title">{phrase}</span>
777
+ </div>
778
+ <button
779
+ class="search-recent-remove"
780
+ type="button"
781
+ aria-label="Remove from history"
782
+ onclick={(e) => { e.stopPropagation(); removeRecentSearch(phrase); }}
783
+ >×</button>
784
+ </li>
785
+ {/each}
786
+ </ul>
787
+ </div>
788
+ {:else}
789
+ <div class="search-status search-hint">
790
+ {searchStartText}
791
+ </div>
792
+ {/if}
793
+ {/if}
794
+
795
+ {:else}
796
+ <!-- ── AI Chat tab ── -->
797
+ <AiChat
798
+ {onNavigate}
799
+ {onClose}
800
+ localeSrcDir={localeSrcDir}
801
+ placeholder={aiPlaceholder}
802
+ loadingText={aiLoadingText}
803
+ errorText={aiErrorText}
804
+ startText={aiStartText}
805
+ sourcesLabel={aiSourcesLabel}
806
+ clearChatLabel={aiClearChatLabel}
807
+ sendLabel={aiSendLabel}
808
+ bind:chatHandle={aiChatHandle}
809
+ />
810
+ {/if}
811
+
812
+ </div>
813
+ </div>
814
+ {/if}
815
+
816
+ <style lang="scss">
817
+ .search-backdrop {
818
+ position: fixed;
819
+ inset: 0;
820
+ z-index: 1000;
821
+ background: rgba(0, 0, 0, 0.45);
822
+ backdrop-filter: blur(3px);
823
+ display: flex;
824
+ align-items: flex-start;
825
+ justify-content: center;
826
+ padding-top: 5rem;
827
+ animation: fade-in 0.12s ease;
828
+ }
829
+
830
+ @keyframes fade-in {
831
+ from {
832
+ opacity: 0;
833
+ }
834
+ to {
835
+ opacity: 1;
836
+ }
837
+ }
838
+
839
+ .search-modal {
840
+ background: var(--greg-menu-background);
841
+ border: 1px solid var(--greg-border-color);
842
+ border-radius: 12px;
843
+ width: 100%;
844
+ max-width: 700px;
845
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
846
+ overflow: hidden;
847
+ display: flex;
848
+ flex-direction: column;
849
+ max-height: calc(100vh - 8rem);
850
+ animation: slide-in 0.15s ease;
851
+
852
+ &.ai-mode {
853
+ max-height: calc(100vh - 5rem);
854
+ min-height: min(540px, calc(100vh - 5rem));
855
+ }
856
+ }
857
+
858
+ /* ── Mode tabs ─────────────────────────────────────────────── */
859
+ .modal-tabs {
860
+ display: flex;
861
+ align-items: center;
862
+ gap: 0.25rem;
863
+ padding: 0.5rem 0.75rem;
864
+ border-bottom: 1px solid var(--greg-border-color);
865
+ background: var(--greg-header-background);
866
+ flex-shrink: 0;
867
+ }
868
+
869
+ .modal-tab {
870
+ display: inline-flex;
871
+ align-items: center;
872
+ gap: 0.35rem;
873
+ padding: 0.3rem 0.75rem;
874
+ border-radius: 6px;
875
+ border: 1px solid transparent;
876
+ background: transparent;
877
+ color: var(--greg-menu-section-color);
878
+ cursor: pointer;
879
+ font-size: 0.8rem;
880
+ font-family: inherit;
881
+ font-weight: 500;
882
+ white-space: nowrap;
883
+ transition: all 0.15s;
884
+
885
+ &:hover {
886
+ color: var(--greg-color);
887
+ background: var(--greg-menu-hover-background);
888
+ }
889
+
890
+ &.active {
891
+ color: var(--greg-accent);
892
+ background: color-mix(in srgb, var(--greg-accent) 10%, transparent);
893
+ border-color: color-mix(in srgb, var(--greg-accent) 30%, transparent);
894
+ }
895
+ }
896
+
897
+ .modal-tab-icon {
898
+ width: 13px;
899
+ height: 13px;
900
+ flex-shrink: 0;
901
+ }
902
+
903
+ /* ── Right-side group (clear + Esc) ────────────────────── */
904
+
905
+ .modal-tabs-end {
906
+ display: flex;
907
+ align-items: center;
908
+ gap: 0.25rem;
909
+ margin-left: auto;
910
+ flex-shrink: 0;
911
+ }
912
+
913
+ /* ── Clear chat button (in modal-tabs) ──────────────────── */
914
+
915
+ .ai-clear-btn {
916
+ display: inline-flex;
917
+ align-items: center;
918
+ gap: 0.3rem;
919
+ background: none;
920
+ border: none;
921
+ color: var(--greg-menu-section-color);
922
+ font-size: 0.7rem;
923
+ font-family: inherit;
924
+ cursor: pointer;
925
+ padding: 0.15rem 0.3rem;
926
+ border-radius: 4px;
927
+ opacity: 0.6;
928
+ transition: opacity 0.15s, color 0.15s;
929
+ flex-shrink: 0;
930
+
931
+ svg {
932
+ width: 11px;
933
+ height: 11px;
934
+ }
935
+
936
+ &:hover {
937
+ opacity: 1;
938
+ color: var(--greg-danger-text, #e53e3e);
939
+ }
940
+ }
941
+
942
+ @keyframes slide-in {
943
+ from {
944
+ transform: translateY(-12px);
945
+ opacity: 0;
946
+ }
947
+ to {
948
+ transform: translateY(0);
949
+ opacity: 1;
950
+ }
951
+ }
952
+
953
+ /* ── Input row ─────────────────────────────────────────────── */
954
+ .search-field {
955
+ display: flex;
956
+ align-items: center;
957
+ gap: 0.75rem;
958
+ padding: 0.875rem 1rem;
959
+ border-bottom: 1px solid var(--greg-border-color);
960
+ background: var(--greg-header-background);
961
+ flex-shrink: 0;
962
+ }
963
+
964
+ .search-icon {
965
+ width: 18px;
966
+ height: 18px;
967
+ color: var(--greg-menu-section-color);
968
+ flex-shrink: 0;
969
+ }
970
+
971
+ .search-field-input {
972
+ flex: 1;
973
+ background: transparent;
974
+ border: none;
975
+ outline: none;
976
+ color: var(--greg-color);
977
+ font-size: 1rem;
978
+ appearance: none;
979
+ min-width: 0;
980
+
981
+ &::-webkit-search-cancel-button {
982
+ display: none;
983
+ }
984
+ &::placeholder {
985
+ color: var(--greg-menu-section-color);
986
+ }
987
+ }
988
+
989
+ .search-esc-hint {
990
+ cursor: pointer;
991
+ font-size: 0.7rem;
992
+ padding: 0.2rem 0.45rem;
993
+ border: 1px solid var(--greg-border-color);
994
+ border-radius: 4px;
995
+ color: var(--greg-menu-section-color);
996
+ background: var(--greg-menu-background);
997
+ flex-shrink: 0;
998
+ user-select: none;
999
+
1000
+ &:hover {
1001
+ border-color: var(--greg-accent);
1002
+ color: var(--greg-accent);
1003
+ }
1004
+ }
1005
+
1006
+ /* ── Status / empty states ─────────────────────────────────── */
1007
+ .search-status {
1008
+ padding: 2.5rem 1.25rem;
1009
+ text-align: center;
1010
+ color: var(--greg-menu-section-color);
1011
+ font-size: 0.875rem;
1012
+ display: flex;
1013
+ align-items: center;
1014
+ justify-content: center;
1015
+ gap: 0.6rem;
1016
+
1017
+ &.search-error {
1018
+ color: var(--greg-danger-text);
1019
+ }
1020
+ &.search-hint {
1021
+ font-size: 0.8rem;
1022
+ }
1023
+
1024
+ strong {
1025
+ color: var(--greg-color);
1026
+ }
1027
+ }
1028
+
1029
+ .search-spinner {
1030
+ display: inline-block;
1031
+ width: 1em;
1032
+ height: 1em;
1033
+ border: 2px solid var(--greg-border-color);
1034
+ border-top-color: var(--greg-accent);
1035
+ border-radius: 50%;
1036
+ animation: spin 0.6s linear infinite;
1037
+ }
1038
+
1039
+ @keyframes spin {
1040
+ to {
1041
+ transform: rotate(360deg);
1042
+ }
1043
+ }
1044
+
1045
+ /* ── Results list ──────────────────────────────────────────── */
1046
+ .search-results {
1047
+ list-style: none;
1048
+ margin: 0;
1049
+ padding: 0.5rem;
1050
+ overflow-y: auto;
1051
+ flex: 1;
1052
+ }
1053
+
1054
+ .search-result-item {
1055
+ padding: 0.7rem 0.875rem;
1056
+ border-radius: 8px;
1057
+ cursor: pointer;
1058
+ transition: background 0.1s;
1059
+
1060
+ & + & {
1061
+ margin-top: 2px;
1062
+ }
1063
+
1064
+ &.selected {
1065
+ background: var(--greg-menu-hover-background);
1066
+ }
1067
+ }
1068
+
1069
+ .result-header {
1070
+ display: flex;
1071
+ align-items: center;
1072
+ gap: 0.35rem;
1073
+ flex-wrap: wrap;
1074
+ margin-bottom: 0.25rem;
1075
+ }
1076
+
1077
+ .result-page-icon {
1078
+ width: 13px;
1079
+ height: 13px;
1080
+ color: var(--greg-accent);
1081
+ flex-shrink: 0;
1082
+ }
1083
+
1084
+ .result-title {
1085
+ font-size: 0.875rem;
1086
+ font-weight: 600;
1087
+ color: var(--greg-color);
1088
+ line-height: 1.3;
1089
+
1090
+ :global(mark) {
1091
+ background: var(--greg-accent);
1092
+ color: var(--greg-menu-active-color);
1093
+ border-radius: 2px;
1094
+ padding: 0 2px;
1095
+ }
1096
+ }
1097
+
1098
+ .result-chevron {
1099
+ width: 11px;
1100
+ height: 11px;
1101
+ color: var(--greg-menu-section-color);
1102
+ flex-shrink: 0;
1103
+ }
1104
+
1105
+ .result-section {
1106
+ font-size: 0.78rem;
1107
+ color: var(--greg-menu-section-color);
1108
+ font-weight: 500;
1109
+ }
1110
+
1111
+ .result-excerpt {
1112
+ margin: 0;
1113
+ font-size: 0.78rem;
1114
+ color: var(--greg-menu-section-color);
1115
+ line-height: 1.55;
1116
+ padding-left: 1.25rem; /* align under title */
1117
+ display: -webkit-box;
1118
+ -webkit-line-clamp: 2;
1119
+ line-clamp: 2;
1120
+ -webkit-box-orient: vertical;
1121
+ overflow: hidden;
1122
+
1123
+ :global(mark) {
1124
+ background: var(--greg-accent-light);
1125
+ color: var(--greg-accent);
1126
+ border-radius: 2px;
1127
+ padding: 0 1px;
1128
+ font-weight: 600;
1129
+ }
1130
+ }
1131
+
1132
+ /* ── Footer keyboard hints ─────────────────────────────────── */
1133
+ .search-footer {
1134
+ display: flex;
1135
+ gap: 1.25rem;
1136
+ padding: 0.55rem 1rem;
1137
+ border-top: 1px solid var(--greg-border-color);
1138
+ font-size: 0.7rem;
1139
+ color: var(--greg-menu-section-color);
1140
+ background: var(--greg-header-background);
1141
+ flex-shrink: 0;
1142
+
1143
+ span {
1144
+ display: flex;
1145
+ align-items: center;
1146
+ gap: 0.2rem;
1147
+ }
1148
+
1149
+ kbd {
1150
+ background: var(--greg-menu-background);
1151
+ border: 1px solid var(--greg-border-color);
1152
+ border-radius: 3px;
1153
+ padding: 0.1rem 0.3rem;
1154
+ font-size: 0.68rem;
1155
+ font-family: inherit;
1156
+ line-height: 1.4;
1157
+ }
1158
+ }
1159
+
1160
+ /* ── Recent searches ───────────────────────────────────────── */
1161
+
1162
+ .search-recent {
1163
+ display: flex;
1164
+ flex-direction: column;
1165
+ flex: 1;
1166
+ min-height: 0;
1167
+ }
1168
+
1169
+ .search-recent-header {
1170
+ display: flex;
1171
+ align-items: center;
1172
+ justify-content: space-between;
1173
+ padding: 0.5rem 1rem 0.25rem;
1174
+ flex-shrink: 0;
1175
+ }
1176
+
1177
+ .search-recent-label {
1178
+ font-size: 0.7rem;
1179
+ font-weight: 700;
1180
+ text-transform: uppercase;
1181
+ letter-spacing: 0.06em;
1182
+ color: var(--greg-menu-section-color);
1183
+ }
1184
+
1185
+ .search-recent-clear {
1186
+ background: none;
1187
+ border: none;
1188
+ font-size: 0.7rem;
1189
+ font-family: inherit;
1190
+ color: var(--greg-menu-section-color);
1191
+ cursor: pointer;
1192
+ padding: 0.1rem 0.3rem;
1193
+ border-radius: 3px;
1194
+ opacity: 0.7;
1195
+ transition: opacity 0.15s, color 0.15s;
1196
+
1197
+ &:hover {
1198
+ opacity: 1;
1199
+ color: var(--greg-danger-text, #e53e3e);
1200
+ }
1201
+ }
1202
+
1203
+ .search-recent-item {
1204
+ position: relative;
1205
+ padding-right: 2rem;
1206
+ }
1207
+
1208
+ .search-recent-icon {
1209
+ width: 13px;
1210
+ height: 13px;
1211
+ color: var(--greg-menu-section-color);
1212
+ flex-shrink: 0;
1213
+ }
1214
+
1215
+ .search-recent-remove {
1216
+ position: absolute;
1217
+ right: 0.6rem;
1218
+ top: 50%;
1219
+ transform: translateY(-50%);
1220
+ background: none;
1221
+ border: none;
1222
+ font-size: 1rem;
1223
+ line-height: 1;
1224
+ color: var(--greg-menu-section-color);
1225
+ cursor: pointer;
1226
+ padding: 0.2rem 0.3rem;
1227
+ border-radius: 3px;
1228
+ opacity: 0;
1229
+ transition: opacity 0.15s, color 0.15s;
1230
+
1231
+ .search-recent-item:hover &,
1232
+ .search-recent-item.selected & {
1233
+ opacity: 0.6;
1234
+ }
1235
+
1236
+ &:hover {
1237
+ opacity: 1 !important;
1238
+ color: var(--greg-danger-text, #e53e3e);
1239
+ }
1240
+ }
1241
+ </style>