@veluai/velu 0.1.13 → 0.1.14

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 (96) hide show
  1. package/README.md +80 -80
  2. package/dist/cli.js +18 -18
  3. package/package.json +3 -2
  4. package/runtime/velu-ui/components/Accordion.jsx +64 -64
  5. package/runtime/velu-ui/components/ApiClient.jsx +121 -121
  6. package/runtime/velu-ui/components/ApiField.jsx +87 -87
  7. package/runtime/velu-ui/components/ApiPath.jsx +63 -63
  8. package/runtime/velu-ui/components/ApiSidebar.jsx +122 -122
  9. package/runtime/velu-ui/components/AskBar.jsx +71 -71
  10. package/runtime/velu-ui/components/Callout.jsx +114 -114
  11. package/runtime/velu-ui/components/Card.jsx +131 -131
  12. package/runtime/velu-ui/components/Chatbot.jsx +596 -596
  13. package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
  14. package/runtime/velu-ui/components/Columns.jsx +56 -56
  15. package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
  16. package/runtime/velu-ui/components/Field.jsx +81 -81
  17. package/runtime/velu-ui/components/Image.jsx +163 -163
  18. package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
  19. package/runtime/velu-ui/components/NavSelect.jsx +108 -108
  20. package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
  21. package/runtime/velu-ui/components/PageFooter.jsx +213 -213
  22. package/runtime/velu-ui/components/PageHeader.jsx +414 -414
  23. package/runtime/velu-ui/components/PageNav.jsx +77 -77
  24. package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
  25. package/runtime/velu-ui/components/Prompt.jsx +115 -115
  26. package/runtime/velu-ui/components/Search.jsx +411 -366
  27. package/runtime/velu-ui/components/Sidebar.jsx +191 -191
  28. package/runtime/velu-ui/components/Steps.jsx +65 -65
  29. package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
  30. package/runtime/velu-ui/components/Toc.jsx +537 -537
  31. package/runtime/velu-ui/components/TocBar.jsx +195 -195
  32. package/runtime/velu-ui/components/Tree.jsx +87 -87
  33. package/runtime/velu-ui/components/TryItBar.jsx +90 -90
  34. package/runtime/velu-ui/components/accordion.css +92 -92
  35. package/runtime/velu-ui/components/api.css +479 -479
  36. package/runtime/velu-ui/components/ask-bar.css +94 -94
  37. package/runtime/velu-ui/components/card.css +105 -105
  38. package/runtime/velu-ui/components/chatbot.css +617 -617
  39. package/runtime/velu-ui/components/code-block.css +263 -263
  40. package/runtime/velu-ui/components/docs-layout.css +784 -775
  41. package/runtime/velu-ui/components/field.css +82 -82
  42. package/runtime/velu-ui/components/image.css +237 -237
  43. package/runtime/velu-ui/components/nav-select.css +157 -157
  44. package/runtime/velu-ui/components/page-feedback.css +241 -241
  45. package/runtime/velu-ui/components/page-footer.css +130 -130
  46. package/runtime/velu-ui/components/page-header.css +520 -520
  47. package/runtime/velu-ui/components/page-nav.css +50 -50
  48. package/runtime/velu-ui/components/powered-by.css +66 -66
  49. package/runtime/velu-ui/components/prompt.css +99 -99
  50. package/runtime/velu-ui/components/search.css +307 -307
  51. package/runtime/velu-ui/components/sidebar.css +144 -144
  52. package/runtime/velu-ui/components/steps.css +77 -77
  53. package/runtime/velu-ui/components/theme-toggle.css +101 -101
  54. package/runtime/velu-ui/components/toc-bar.css +234 -234
  55. package/runtime/velu-ui/components/tree.css +49 -49
  56. package/runtime/velu-ui/index.js +46 -46
  57. package/runtime/velu-ui/lib/component-schemas.js +100 -100
  58. package/runtime/velu-ui/lib/copyText.js +64 -64
  59. package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
  60. package/runtime/velu-ui/lib/prism-langs.js +957 -957
  61. package/runtime/velu-ui/lib/prism-loader.js +74 -74
  62. package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
  63. package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
  64. package/runtime/velu-ui/mdx-components.jsx +105 -105
  65. package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
  66. package/runtime/velu-ui/primitives/Stack.jsx +63 -63
  67. package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
  68. package/runtime/velu-ui/primitives/stack.css +3 -3
  69. package/runtime/velu-ui/primitives/switcher.css +25 -25
  70. package/runtime/velu-ui/styles.css +43 -43
  71. package/runtime/velu-ui/tokens.css +4 -4
  72. package/schema/velu.schema.json +167 -167
  73. package/src/lib/extract-mdx-error.js +170 -170
  74. package/src/lib/issues.js +159 -159
  75. package/src/lib/known-components.js +34 -34
  76. package/src/navigation.js +434 -434
  77. package/src/runtime/App.jsx +1490 -1476
  78. package/src/runtime/ErrorBoundary.jsx +54 -54
  79. package/src/runtime/client-entry.jsx +22 -22
  80. package/src/runtime/server-entry.jsx +16 -16
  81. package/src/template.html +48 -48
  82. package/templates/starter/ai-tools/claude-code.mdx +26 -26
  83. package/templates/starter/ai-tools/cursor.mdx +17 -17
  84. package/templates/starter/api-reference/endpoint/create.mdx +24 -24
  85. package/templates/starter/api-reference/endpoint/get.mdx +27 -27
  86. package/templates/starter/api-reference/introduction.mdx +28 -28
  87. package/templates/starter/development.mdx +19 -19
  88. package/templates/starter/essentials/code.mdx +29 -29
  89. package/templates/starter/essentials/images.mdx +29 -29
  90. package/templates/starter/essentials/markdown.mdx +25 -25
  91. package/templates/starter/essentials/navigation.mdx +39 -39
  92. package/templates/starter/essentials/settings.mdx +30 -30
  93. package/templates/starter/favicon.svg +6 -6
  94. package/templates/starter/index.mdx +31 -31
  95. package/templates/starter/quickstart.mdx +31 -31
  96. package/templates/starter/velu.json +33 -33
@@ -1,366 +1,411 @@
1
- import React, {
2
- useState,
3
- useRef,
4
- useEffect,
5
- useMemo,
6
- useCallback,
7
- } from 'react';
8
- import resolveIcon from '../lib/resolveIcon.jsx';
9
-
10
- /**
11
- * Search — a Raycast-style command palette for the docs site. Ported
12
- * from the Claude-Design "Velu Search" handoff and retokenized onto
13
- * velu-ui's scale (themes for free via [data-theme]).
14
- *
15
- * <Search results={[…]} onSelect={(item) => …} />
16
- *
17
- * Renders a trigger box (drop it at the top of the page). Clicking it —
18
- * or pressing ⌘K / Ctrl+K anywhere — reveals the palette: a scrim plus
19
- * a centered panel with a search input and grouped, keyboard-navigable
20
- * results (↑/↓ to move, Enter to pick, Esc / scrim-click to close).
21
- *
22
- * @typedef {Object} SearchResult
23
- * @property {string} id
24
- * @property {string} group 'Recent Searches' | 'Suggested' | 'Pages'
25
- * @property {string[]} breadcrumb
26
- * @property {string} title
27
- * @property {'page'|'anchor'|'ext'} kind
28
- * @property {string} desc
29
- * @property {string} [href]
30
- *
31
- * @param {{ results?: SearchResult[], placeholder?: string,
32
- * onSelect?: (item: SearchResult) => void,
33
- * className?: string }} props
34
- */
35
-
36
- /* Sample result set — the demo data from the design. Override via the
37
- `results` prop with real indexed content. */
38
- const DEFAULT_RESULTS = [
39
- {
40
- id: 'auth-pw',
41
- group: 'Recent Searches',
42
- breadcrumb: ['Authentication setup', 'Password'],
43
- title: 'Password',
44
- kind: 'anchor',
45
- desc: 'Password authentication provides access control through credentials.',
46
- },
47
- {
48
- id: 'gh-sync',
49
- group: 'Recent Searches',
50
- breadcrumb: ['Get Started', 'GitHub & GitLab Sync'],
51
- title: 'Enabling GitHub sync',
52
- kind: 'page',
53
- desc: 'Connect a GitHub repository to keep your docs in sync on every push.',
54
- },
55
- {
56
- id: 'ai-search',
57
- group: 'Recent Searches',
58
- breadcrumb: ['Your Docs Site'],
59
- title: 'AI Search',
60
- kind: 'page',
61
- desc: 'Configure semantic search for your documentation site.',
62
- },
63
- {
64
- id: 'quickstart',
65
- group: 'Suggested',
66
- breadcrumb: ['Get Started'],
67
- title: 'Quickstart',
68
- kind: 'page',
69
- desc: 'Set up Velu and publish your first docs site in under 5 minutes.',
70
- },
71
- {
72
- id: 'overview',
73
- group: 'Suggested',
74
- breadcrumb: ['Get Started'],
75
- title: 'Overview',
76
- kind: 'page',
77
- desc: 'Velu is an AI-native documentation platform built for humans and AI agents.',
78
- },
79
- {
80
- id: 'site-settings',
81
- group: 'Suggested',
82
- breadcrumb: ['Your Docs Site'],
83
- title: 'Site Settings',
84
- kind: 'page',
85
- desc: 'Configure domain, theme, navigation and SEO for your docs site.',
86
- },
87
- {
88
- id: 'oauth',
89
- group: 'Pages',
90
- breadcrumb: ['Authentication setup', 'OAuth'],
91
- title: 'OAuth',
92
- kind: 'anchor',
93
- desc: 'Configure OAuth providers for sign-in.',
94
- },
95
- {
96
- id: 'saml',
97
- group: 'Pages',
98
- breadcrumb: ['Authentication setup', 'SAML'],
99
- title: 'SAML',
100
- kind: 'anchor',
101
- desc: 'Enterprise single sign-on via SAML 2.0.',
102
- },
103
- ];
104
-
105
- const GROUP_ORDER = ['Recent Searches', 'Suggested', 'Pages'];
106
-
107
- /* Left-hand glyph: '#' for an anchor, an icon for page / external. */
108
- function ResultGlyph({ kind }) {
109
- if (kind === 'anchor') {
110
- return <span className="velu-search__glyph-hash">#</span>;
111
- }
112
- const icon = kind === 'ext' ? 'external-link' : 'file-text';
113
- return (
114
- <span className="velu-search__glyph-icon">
115
- {resolveIcon(icon, { size: '1em' })}
116
- </span>
117
- );
118
- }
119
-
120
- function Breadcrumb({ items }) {
121
- return (
122
- <div className="velu-search__crumbs">
123
- {items.map((c, i) => (
124
- <React.Fragment key={i}>
125
- {i > 0 && <span className="velu-search__crumb-sep">›</span>}
126
- <span>{c}</span>
127
- </React.Fragment>
128
- ))}
129
- </div>
130
- );
131
- }
132
-
133
- function PaletteRow({ item, selected, navMode, onHover, onSelect }) {
134
- const ref = useRef(null);
135
- useEffect(() => {
136
- // Only auto-scroll for keyboard nav — on mouse hover the pointer is
137
- // already on the row, and scrolling there nudges the list (a 1px
138
- // jitter that flickers a line at the list edge).
139
- if (selected && navMode.current === 'keyboard' && ref.current) {
140
- ref.current.scrollIntoView({ block: 'nearest' });
141
- }
142
- }, [selected, navMode]);
143
- return (
144
- <div
145
- ref={ref}
146
- className={`velu-search__row${selected ? ' velu-search__row--selected' : ''}`}
147
- onMouseEnter={onHover}
148
- onClick={onSelect}
149
- role="option"
150
- aria-selected={selected}
151
- >
152
- <div className="velu-search__row-glyph">
153
- <ResultGlyph kind={item.kind} />
154
- </div>
155
- <Breadcrumb items={item.breadcrumb} />
156
- <div className="velu-search__row-title">{item.title}</div>
157
- <div className="velu-search__row-desc">{item.desc}</div>
158
- <div className="velu-search__row-meta">
159
- <span className="velu-search__kbd">↵</span>
160
- </div>
161
- </div>
162
- );
163
- }
164
-
165
- /* The revealed palette — scrim + centered panel. */
166
- function SearchPalette({ results, placeholder, onSelect, onClose }) {
167
- const [query, setQuery] = useState('');
168
- const [selected, setSelected] = useState(0);
169
- const inputRef = useRef(null);
170
- // 'keyboard' | 'mouse' — which input last moved the selection; gates
171
- // the rows' scrollIntoView so mouse hover doesn't jitter the list.
172
- const navMode = useRef('keyboard');
173
-
174
- useEffect(() => {
175
- inputRef.current?.focus();
176
- }, []);
177
-
178
- const filtered = useMemo(() => {
179
- const q = query.trim().toLowerCase();
180
- if (!q) {
181
- // Empty query → just the recents.
182
- return results.filter((r) => r.group === 'Recent Searches');
183
- }
184
- return results.filter((r) => {
185
- const hay = `${r.title} ${r.breadcrumb.join(' ')} ${r.desc}`.toLowerCase();
186
- return hay.includes(q);
187
- });
188
- }, [query, results]);
189
-
190
- const grouped = useMemo(() => {
191
- const map = new Map();
192
- filtered.forEach((r) => {
193
- if (!map.has(r.group)) map.set(r.group, []);
194
- map.get(r.group).push(r);
195
- });
196
- return GROUP_ORDER.filter((g) => map.has(g)).map((g) => [g, map.get(g)]);
197
- }, [filtered]);
198
-
199
- const flat = useMemo(
200
- () => grouped.flatMap(([, items]) => items),
201
- [grouped],
202
- );
203
-
204
- useEffect(() => setSelected(0), [query]);
205
-
206
- const pick = useCallback(
207
- (item) => {
208
- onSelect?.(item);
209
- onClose();
210
- },
211
- [onSelect, onClose],
212
- );
213
-
214
- useEffect(() => {
215
- const onKey = (e) => {
216
- if (e.key === 'Escape') {
217
- e.preventDefault();
218
- onClose();
219
- } else if (e.key === 'ArrowDown') {
220
- e.preventDefault();
221
- navMode.current = 'keyboard';
222
- setSelected((s) => Math.min(s + 1, flat.length - 1));
223
- } else if (e.key === 'ArrowUp') {
224
- e.preventDefault();
225
- navMode.current = 'keyboard';
226
- setSelected((s) => Math.max(s - 1, 0));
227
- } else if (e.key === 'Enter') {
228
- e.preventDefault();
229
- if (flat[selected]) pick(flat[selected]);
230
- else onClose();
231
- }
232
- };
233
- window.addEventListener('keydown', onKey);
234
- return () => window.removeEventListener('keydown', onKey);
235
- }, [flat, selected, pick, onClose]);
236
-
237
- let rowIndex = -1;
238
-
239
- return (
240
- <div
241
- className="velu-search__scrim"
242
- onClick={onClose}
243
- role="presentation"
244
- >
245
- <div
246
- className="velu-search__palette"
247
- role="dialog"
248
- aria-modal="true"
249
- aria-label="Search"
250
- onClick={(e) => e.stopPropagation()}
251
- >
252
- <div className="velu-search__input-wrap">
253
- <span className="velu-search__input-icon" aria-hidden="true">
254
- {resolveIcon('search', { size: '1em' })}
255
- </span>
256
- <input
257
- ref={inputRef}
258
- className="velu-search__input"
259
- placeholder={placeholder}
260
- value={query}
261
- onChange={(e) => setQuery(e.target.value)}
262
- spellCheck={false}
263
- aria-label="Search query"
264
- />
265
- <span className="velu-search__kbd">esc</span>
266
- </div>
267
-
268
- <div className="velu-search__list" role="listbox">
269
- {grouped.length === 0 && (
270
- <div className="velu-search__empty">
271
- <span className="velu-search__empty-icon" aria-hidden="true">
272
- {resolveIcon('search-x', { size: '1em' })}
273
- </span>
274
- <div className="velu-search__empty-title">
275
- No results for &ldquo;{query}&rdquo;
276
- </div>
277
- <div className="velu-search__empty-sub">
278
- Try a different keyword or browse the sidebar.
279
- </div>
280
- </div>
281
- )}
282
- {grouped.map(([group, items]) => (
283
- <div key={group}>
284
- <div className="velu-search__group">{group}</div>
285
- {items.map((item) => {
286
- rowIndex += 1;
287
- const idx = rowIndex;
288
- return (
289
- <PaletteRow
290
- key={item.id}
291
- item={item}
292
- selected={idx === selected}
293
- navMode={navMode}
294
- onHover={() => {
295
- navMode.current = 'mouse';
296
- setSelected(idx);
297
- }}
298
- onSelect={() => pick(item)}
299
- />
300
- );
301
- })}
302
- </div>
303
- ))}
304
- </div>
305
- </div>
306
- </div>
307
- );
308
- }
309
-
310
- export default function Search({
311
- results = DEFAULT_RESULTS,
312
- placeholder = 'Search documentation',
313
- onSelect,
314
- className = '',
315
- ...rest
316
- }) {
317
- const [open, setOpen] = useState(false);
318
- // Show ⌘ K on Mac, Ctrl K elsewhere. SSR-safe: default to the
319
- // non-Mac label (so the server render matches the first client
320
- // render), then switch on the client if it's actually Mac.
321
- const [isMac, setIsMac] = useState(false);
322
- useEffect(() => {
323
- if (typeof navigator === 'undefined') return;
324
- const ua = navigator.userAgent || '';
325
- const plat = navigator.platform || '';
326
- setIsMac(/Mac|iPhone|iPad|iPod/i.test(plat) || /Mac/i.test(ua));
327
- }, []);
328
-
329
- // Global ⌘K (Mac) / Ctrl+K (Win/Linux) opens the palette.
330
- useEffect(() => {
331
- const onKey = (e) => {
332
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
333
- e.preventDefault();
334
- setOpen(true);
335
- }
336
- };
337
- window.addEventListener('keydown', onKey);
338
- return () => window.removeEventListener('keydown', onKey);
339
- }, []);
340
-
341
- return (
342
- <>
343
- <button
344
- type="button"
345
- className={`velu-search__trigger ${className}`.trim()}
346
- onClick={() => setOpen(true)}
347
- {...rest}
348
- >
349
- <span className="velu-search__trigger-icon" aria-hidden="true">
350
- {resolveIcon('search', { size: '1.5em' })}
351
- </span>
352
- <span className="velu-search__trigger-label">Search…</span>
353
- <span className="velu-search__kbd">{isMac ? '⌘ K' : 'Ctrl K'}</span>
354
- </button>
355
-
356
- {open && (
357
- <SearchPalette
358
- results={results}
359
- placeholder={placeholder}
360
- onSelect={onSelect}
361
- onClose={() => setOpen(false)}
362
- />
363
- )}
364
- </>
365
- );
366
- }
1
+ import React, {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ useMemo,
6
+ useCallback,
7
+ } from 'react';
8
+ import resolveIcon from '../lib/resolveIcon.jsx';
9
+
10
+ /**
11
+ * Search — a Raycast-style command palette for the docs site. Ported
12
+ * from the Claude-Design "Velu Search" handoff and retokenized onto
13
+ * velu-ui's scale (themes for free via [data-theme]).
14
+ *
15
+ * <Search results={[…]} onSelect={(item) => …} />
16
+ *
17
+ * Renders a trigger box (drop it at the top of the page). Clicking it —
18
+ * or pressing ⌘K / Ctrl+K anywhere — reveals the palette: a scrim plus
19
+ * a centered panel with a search input and grouped, keyboard-navigable
20
+ * results (↑/↓ to move, Enter to pick, Esc / scrim-click to close).
21
+ *
22
+ * @typedef {Object} SearchResult
23
+ * @property {string} id
24
+ * @property {string} group 'Recent Searches' | 'Suggested' | 'Pages'
25
+ * @property {string[]} breadcrumb
26
+ * @property {string} title
27
+ * @property {'page'|'anchor'|'ext'} kind
28
+ * @property {string} desc
29
+ * @property {string} [href]
30
+ *
31
+ * @param {{ results?: SearchResult[], placeholder?: string,
32
+ * onSelect?: (item: SearchResult) => void,
33
+ * className?: string }} props
34
+ */
35
+
36
+ /* Sample result set — the demo data from the design. Override via the
37
+ `results` prop with real indexed content. */
38
+ const DEFAULT_RESULTS = [
39
+ {
40
+ id: 'auth-pw',
41
+ group: 'Recent Searches',
42
+ breadcrumb: ['Authentication setup', 'Password'],
43
+ title: 'Password',
44
+ kind: 'anchor',
45
+ desc: 'Password authentication provides access control through credentials.',
46
+ },
47
+ {
48
+ id: 'gh-sync',
49
+ group: 'Recent Searches',
50
+ breadcrumb: ['Get Started', 'GitHub & GitLab Sync'],
51
+ title: 'Enabling GitHub sync',
52
+ kind: 'page',
53
+ desc: 'Connect a GitHub repository to keep your docs in sync on every push.',
54
+ },
55
+ {
56
+ id: 'ai-search',
57
+ group: 'Recent Searches',
58
+ breadcrumb: ['Your Docs Site'],
59
+ title: 'AI Search',
60
+ kind: 'page',
61
+ desc: 'Configure semantic search for your documentation site.',
62
+ },
63
+ {
64
+ id: 'quickstart',
65
+ group: 'Suggested',
66
+ breadcrumb: ['Get Started'],
67
+ title: 'Quickstart',
68
+ kind: 'page',
69
+ desc: 'Set up Velu and publish your first docs site in under 5 minutes.',
70
+ },
71
+ {
72
+ id: 'overview',
73
+ group: 'Suggested',
74
+ breadcrumb: ['Get Started'],
75
+ title: 'Overview',
76
+ kind: 'page',
77
+ desc: 'Velu is an AI-native documentation platform built for humans and AI agents.',
78
+ },
79
+ {
80
+ id: 'site-settings',
81
+ group: 'Suggested',
82
+ breadcrumb: ['Your Docs Site'],
83
+ title: 'Site Settings',
84
+ kind: 'page',
85
+ desc: 'Configure domain, theme, navigation and SEO for your docs site.',
86
+ },
87
+ {
88
+ id: 'oauth',
89
+ group: 'Pages',
90
+ breadcrumb: ['Authentication setup', 'OAuth'],
91
+ title: 'OAuth',
92
+ kind: 'anchor',
93
+ desc: 'Configure OAuth providers for sign-in.',
94
+ },
95
+ {
96
+ id: 'saml',
97
+ group: 'Pages',
98
+ breadcrumb: ['Authentication setup', 'SAML'],
99
+ title: 'SAML',
100
+ kind: 'anchor',
101
+ desc: 'Enterprise single sign-on via SAML 2.0.',
102
+ },
103
+ ];
104
+
105
+ const GROUP_ORDER = ['Recent Searches', 'Suggested', 'Pages'];
106
+
107
+ /* Left-hand glyph: '#' for an anchor, an icon for page / external. */
108
+ function ResultGlyph({ kind }) {
109
+ if (kind === 'anchor') {
110
+ return <span className="velu-search__glyph-hash">#</span>;
111
+ }
112
+ const icon = kind === 'ext' ? 'external-link' : 'file-text';
113
+ return (
114
+ <span className="velu-search__glyph-icon">
115
+ {resolveIcon(icon, { size: '1em' })}
116
+ </span>
117
+ );
118
+ }
119
+
120
+ function Breadcrumb({ items }) {
121
+ return (
122
+ <div className="velu-search__crumbs">
123
+ {items.map((c, i) => (
124
+ <React.Fragment key={i}>
125
+ {i > 0 && <span className="velu-search__crumb-sep">›</span>}
126
+ <span>{c}</span>
127
+ </React.Fragment>
128
+ ))}
129
+ </div>
130
+ );
131
+ }
132
+
133
+ function PaletteRow({ item, selected, navMode, onHover, onSelect }) {
134
+ const ref = useRef(null);
135
+ useEffect(() => {
136
+ // Only auto-scroll for keyboard nav — on mouse hover the pointer is
137
+ // already on the row, and scrolling there nudges the list (a 1px
138
+ // jitter that flickers a line at the list edge).
139
+ if (selected && navMode.current === 'keyboard' && ref.current) {
140
+ ref.current.scrollIntoView({ block: 'nearest' });
141
+ }
142
+ }, [selected, navMode]);
143
+ return (
144
+ <div
145
+ ref={ref}
146
+ className={`velu-search__row${selected ? ' velu-search__row--selected' : ''}`}
147
+ onMouseEnter={onHover}
148
+ onClick={onSelect}
149
+ role="option"
150
+ aria-selected={selected}
151
+ >
152
+ <div className="velu-search__row-glyph">
153
+ <ResultGlyph kind={item.kind} />
154
+ </div>
155
+ <Breadcrumb items={item.breadcrumb} />
156
+ <div className="velu-search__row-title">{item.title}</div>
157
+ <div className="velu-search__row-desc">{item.desc}</div>
158
+ <div className="velu-search__row-meta">
159
+ <span className="velu-search__kbd">↵</span>
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ /* The revealed palette — scrim + centered panel. */
166
+ function SearchPalette({ results, placeholder, onSelect, onClose }) {
167
+ const [query, setQuery] = useState('');
168
+ const [selected, setSelected] = useState(0);
169
+ const inputRef = useRef(null);
170
+ // 'keyboard' | 'mouse' — which input last moved the selection; gates
171
+ // the rows' scrollIntoView so mouse hover doesn't jitter the list.
172
+ const navMode = useRef('keyboard');
173
+
174
+ useEffect(() => {
175
+ inputRef.current?.focus();
176
+ }, []);
177
+
178
+ const filtered = useMemo(() => {
179
+ const q = query.trim().toLowerCase();
180
+ if (!q) {
181
+ // Empty query → just the recents.
182
+ return results.filter((r) => r.group === 'Recent Searches');
183
+ }
184
+ return results.filter((r) => {
185
+ const hay = `${r.title} ${r.breadcrumb.join(' ')} ${r.desc}`.toLowerCase();
186
+ return hay.includes(q);
187
+ });
188
+ }, [query, results]);
189
+
190
+ const grouped = useMemo(() => {
191
+ const map = new Map();
192
+ filtered.forEach((r) => {
193
+ if (!map.has(r.group)) map.set(r.group, []);
194
+ map.get(r.group).push(r);
195
+ });
196
+ return GROUP_ORDER.filter((g) => map.has(g)).map((g) => [g, map.get(g)]);
197
+ }, [filtered]);
198
+
199
+ const flat = useMemo(
200
+ () => grouped.flatMap(([, items]) => items),
201
+ [grouped],
202
+ );
203
+
204
+ useEffect(() => setSelected(0), [query]);
205
+
206
+ const pick = useCallback(
207
+ (item) => {
208
+ onSelect?.(item);
209
+ onClose();
210
+ },
211
+ [onSelect, onClose],
212
+ );
213
+
214
+ useEffect(() => {
215
+ const onKey = (e) => {
216
+ if (e.key === 'Escape') {
217
+ e.preventDefault();
218
+ onClose();
219
+ } else if (e.key === 'ArrowDown') {
220
+ e.preventDefault();
221
+ navMode.current = 'keyboard';
222
+ setSelected((s) => Math.min(s + 1, flat.length - 1));
223
+ } else if (e.key === 'ArrowUp') {
224
+ e.preventDefault();
225
+ navMode.current = 'keyboard';
226
+ setSelected((s) => Math.max(s - 1, 0));
227
+ } else if (e.key === 'Enter') {
228
+ e.preventDefault();
229
+ if (flat[selected]) pick(flat[selected]);
230
+ else onClose();
231
+ }
232
+ };
233
+ window.addEventListener('keydown', onKey);
234
+ return () => window.removeEventListener('keydown', onKey);
235
+ }, [flat, selected, pick, onClose]);
236
+
237
+ let rowIndex = -1;
238
+
239
+ return (
240
+ <div
241
+ className="velu-search__scrim"
242
+ onClick={onClose}
243
+ role="presentation"
244
+ >
245
+ <div
246
+ className="velu-search__palette"
247
+ role="dialog"
248
+ aria-modal="true"
249
+ aria-label="Search"
250
+ onClick={(e) => e.stopPropagation()}
251
+ >
252
+ <div className="velu-search__input-wrap">
253
+ <span className="velu-search__input-icon" aria-hidden="true">
254
+ {resolveIcon('search', { size: '1em' })}
255
+ </span>
256
+ <input
257
+ ref={inputRef}
258
+ className="velu-search__input"
259
+ placeholder={placeholder}
260
+ value={query}
261
+ onChange={(e) => setQuery(e.target.value)}
262
+ spellCheck={false}
263
+ aria-label="Search query"
264
+ />
265
+ <span className="velu-search__kbd">esc</span>
266
+ </div>
267
+
268
+ <div className="velu-search__list" role="listbox">
269
+ {grouped.length === 0 && (
270
+ <div className="velu-search__empty">
271
+ <span className="velu-search__empty-icon" aria-hidden="true">
272
+ {resolveIcon('search-x', { size: '1em' })}
273
+ </span>
274
+ <div className="velu-search__empty-title">
275
+ No results for &ldquo;{query}&rdquo;
276
+ </div>
277
+ <div className="velu-search__empty-sub">
278
+ Try a different keyword or browse the sidebar.
279
+ </div>
280
+ </div>
281
+ )}
282
+ {grouped.map(([group, items]) => (
283
+ <div key={group}>
284
+ <div className="velu-search__group">{group}</div>
285
+ {items.map((item) => {
286
+ rowIndex += 1;
287
+ const idx = rowIndex;
288
+ return (
289
+ <PaletteRow
290
+ key={item.id}
291
+ item={item}
292
+ selected={idx === selected}
293
+ navMode={navMode}
294
+ onHover={() => {
295
+ navMode.current = 'mouse';
296
+ setSelected(idx);
297
+ }}
298
+ onSelect={() => pick(item)}
299
+ />
300
+ );
301
+ })}
302
+ </div>
303
+ ))}
304
+ </div>
305
+ </div>
306
+ </div>
307
+ );
308
+ }
309
+
310
+ /* Shown instead of the palette when search is unavailable (e.g. the local
311
+ dev preview has no search index — that's generated when the site deploys).
312
+ Reuses the palette's empty-state styling so it looks at home. */
313
+ function SearchUnavailable({ message, onClose }) {
314
+ useEffect(() => {
315
+ const onKey = (e) => {
316
+ if (e.key === 'Escape') {
317
+ e.preventDefault();
318
+ onClose();
319
+ }
320
+ };
321
+ window.addEventListener('keydown', onKey);
322
+ return () => window.removeEventListener('keydown', onKey);
323
+ }, [onClose]);
324
+ return (
325
+ <div className="velu-search__scrim" onClick={onClose} role="presentation">
326
+ <div
327
+ className="velu-search__palette"
328
+ role="dialog"
329
+ aria-modal="true"
330
+ aria-label="Search unavailable"
331
+ onClick={(e) => e.stopPropagation()}
332
+ >
333
+ <div className="velu-search__empty">
334
+ <span className="velu-search__empty-icon" aria-hidden="true">
335
+ {resolveIcon('search-x', { size: '1em' })}
336
+ </span>
337
+ <div className="velu-search__empty-title">
338
+ Search isn’t available in the dev preview
339
+ </div>
340
+ <div className="velu-search__empty-sub">{message}</div>
341
+ </div>
342
+ </div>
343
+ </div>
344
+ );
345
+ }
346
+
347
+ export default function Search({
348
+ results = DEFAULT_RESULTS,
349
+ placeholder = 'Search documentation',
350
+ onSelect,
351
+ unavailable = false,
352
+ unavailableMessage = 'Full-text search is generated when your site is deployed — it isn’t available in the local preview.',
353
+ className = '',
354
+ ...rest
355
+ }) {
356
+ const [open, setOpen] = useState(false);
357
+ // Show ⌘ K on Mac, Ctrl K elsewhere. SSR-safe: default to the
358
+ // non-Mac label (so the server render matches the first client
359
+ // render), then switch on the client if it's actually Mac.
360
+ const [isMac, setIsMac] = useState(false);
361
+ useEffect(() => {
362
+ if (typeof navigator === 'undefined') return;
363
+ const ua = navigator.userAgent || '';
364
+ const plat = navigator.platform || '';
365
+ setIsMac(/Mac|iPhone|iPad|iPod/i.test(plat) || /Mac/i.test(ua));
366
+ }, []);
367
+
368
+ // Global ⌘K (Mac) / Ctrl+K (Win/Linux) opens the palette.
369
+ useEffect(() => {
370
+ const onKey = (e) => {
371
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
372
+ e.preventDefault();
373
+ setOpen(true);
374
+ }
375
+ };
376
+ window.addEventListener('keydown', onKey);
377
+ return () => window.removeEventListener('keydown', onKey);
378
+ }, []);
379
+
380
+ return (
381
+ <>
382
+ <button
383
+ type="button"
384
+ className={`velu-search__trigger ${className}`.trim()}
385
+ onClick={() => setOpen(true)}
386
+ {...rest}
387
+ >
388
+ <span className="velu-search__trigger-icon" aria-hidden="true">
389
+ {resolveIcon('search', { size: '1.5em' })}
390
+ </span>
391
+ <span className="velu-search__trigger-label">Search…</span>
392
+ <span className="velu-search__kbd">{isMac ? '⌘ K' : 'Ctrl K'}</span>
393
+ </button>
394
+
395
+ {open &&
396
+ (unavailable ? (
397
+ <SearchUnavailable
398
+ message={unavailableMessage}
399
+ onClose={() => setOpen(false)}
400
+ />
401
+ ) : (
402
+ <SearchPalette
403
+ results={results}
404
+ placeholder={placeholder}
405
+ onSelect={onSelect}
406
+ onClose={() => setOpen(false)}
407
+ />
408
+ ))}
409
+ </>
410
+ );
411
+ }