@veluai/velu 0.1.13 → 0.1.15

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 (98) hide show
  1. package/README.md +80 -80
  2. package/dist/cli.js +21 -21
  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/Logo.jsx +31 -0
  19. package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
  20. package/runtime/velu-ui/components/NavSelect.jsx +108 -108
  21. package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
  22. package/runtime/velu-ui/components/PageFooter.jsx +145 -213
  23. package/runtime/velu-ui/components/PageHeader.jsx +422 -414
  24. package/runtime/velu-ui/components/PageNav.jsx +77 -77
  25. package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
  26. package/runtime/velu-ui/components/Prompt.jsx +115 -115
  27. package/runtime/velu-ui/components/Search.jsx +411 -366
  28. package/runtime/velu-ui/components/Sidebar.jsx +191 -191
  29. package/runtime/velu-ui/components/SocialLinks.jsx +90 -0
  30. package/runtime/velu-ui/components/Steps.jsx +65 -65
  31. package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
  32. package/runtime/velu-ui/components/Toc.jsx +537 -537
  33. package/runtime/velu-ui/components/TocBar.jsx +195 -195
  34. package/runtime/velu-ui/components/Tree.jsx +87 -87
  35. package/runtime/velu-ui/components/TryItBar.jsx +90 -90
  36. package/runtime/velu-ui/components/accordion.css +92 -92
  37. package/runtime/velu-ui/components/api.css +479 -479
  38. package/runtime/velu-ui/components/ask-bar.css +94 -94
  39. package/runtime/velu-ui/components/card.css +105 -105
  40. package/runtime/velu-ui/components/chatbot.css +617 -617
  41. package/runtime/velu-ui/components/code-block.css +263 -263
  42. package/runtime/velu-ui/components/docs-layout.css +784 -775
  43. package/runtime/velu-ui/components/field.css +82 -82
  44. package/runtime/velu-ui/components/image.css +237 -237
  45. package/runtime/velu-ui/components/nav-select.css +157 -157
  46. package/runtime/velu-ui/components/page-feedback.css +241 -241
  47. package/runtime/velu-ui/components/page-footer.css +130 -130
  48. package/runtime/velu-ui/components/page-header.css +557 -520
  49. package/runtime/velu-ui/components/page-nav.css +50 -50
  50. package/runtime/velu-ui/components/powered-by.css +86 -66
  51. package/runtime/velu-ui/components/prompt.css +99 -99
  52. package/runtime/velu-ui/components/search.css +307 -307
  53. package/runtime/velu-ui/components/sidebar.css +144 -144
  54. package/runtime/velu-ui/components/steps.css +77 -77
  55. package/runtime/velu-ui/components/theme-toggle.css +101 -101
  56. package/runtime/velu-ui/components/toc-bar.css +234 -234
  57. package/runtime/velu-ui/components/tree.css +49 -49
  58. package/runtime/velu-ui/index.js +48 -46
  59. package/runtime/velu-ui/lib/component-schemas.js +100 -100
  60. package/runtime/velu-ui/lib/copyText.js +64 -64
  61. package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
  62. package/runtime/velu-ui/lib/prism-langs.js +957 -957
  63. package/runtime/velu-ui/lib/prism-loader.js +74 -74
  64. package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
  65. package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
  66. package/runtime/velu-ui/mdx-components.jsx +105 -105
  67. package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
  68. package/runtime/velu-ui/primitives/Stack.jsx +63 -63
  69. package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
  70. package/runtime/velu-ui/primitives/stack.css +3 -3
  71. package/runtime/velu-ui/primitives/switcher.css +25 -25
  72. package/runtime/velu-ui/styles.css +43 -43
  73. package/runtime/velu-ui/tokens.css +4 -4
  74. package/schema/velu.schema.json +281 -167
  75. package/src/lib/extract-mdx-error.js +170 -170
  76. package/src/lib/issues.js +159 -159
  77. package/src/lib/known-components.js +34 -34
  78. package/src/navigation.js +434 -434
  79. package/src/runtime/App.jsx +1506 -1476
  80. package/src/runtime/ErrorBoundary.jsx +54 -54
  81. package/src/runtime/client-entry.jsx +22 -22
  82. package/src/runtime/server-entry.jsx +16 -16
  83. package/src/template.html +48 -48
  84. package/templates/starter/ai-tools/claude-code.mdx +26 -26
  85. package/templates/starter/ai-tools/cursor.mdx +17 -17
  86. package/templates/starter/api-reference/endpoint/create.mdx +24 -24
  87. package/templates/starter/api-reference/endpoint/get.mdx +27 -27
  88. package/templates/starter/api-reference/introduction.mdx +28 -28
  89. package/templates/starter/development.mdx +19 -19
  90. package/templates/starter/essentials/code.mdx +29 -29
  91. package/templates/starter/essentials/images.mdx +29 -29
  92. package/templates/starter/essentials/markdown.mdx +25 -25
  93. package/templates/starter/essentials/navigation.mdx +39 -39
  94. package/templates/starter/essentials/settings.mdx +30 -30
  95. package/templates/starter/favicon.svg +6 -6
  96. package/templates/starter/index.mdx +31 -31
  97. package/templates/starter/quickstart.mdx +31 -31
  98. 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
+ }