@veluai/velu 0.1.0

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 (90) hide show
  1. package/dist/cli.js +11 -0
  2. package/package.json +52 -0
  3. package/runtime/velu-ui/base.css +311 -0
  4. package/runtime/velu-ui/components/Accordion.jsx +64 -0
  5. package/runtime/velu-ui/components/ApiClient.jsx +121 -0
  6. package/runtime/velu-ui/components/ApiField.jsx +87 -0
  7. package/runtime/velu-ui/components/ApiPath.jsx +63 -0
  8. package/runtime/velu-ui/components/ApiSidebar.jsx +122 -0
  9. package/runtime/velu-ui/components/AskBar.jsx +71 -0
  10. package/runtime/velu-ui/components/Callout.jsx +114 -0
  11. package/runtime/velu-ui/components/Card.jsx +131 -0
  12. package/runtime/velu-ui/components/Chatbot.jsx +596 -0
  13. package/runtime/velu-ui/components/CodeBlock.jsx +375 -0
  14. package/runtime/velu-ui/components/Columns.jsx +56 -0
  15. package/runtime/velu-ui/components/Field.jsx +81 -0
  16. package/runtime/velu-ui/components/Image.jsx +163 -0
  17. package/runtime/velu-ui/components/MethodBadge.jsx +31 -0
  18. package/runtime/velu-ui/components/NavSelect.jsx +108 -0
  19. package/runtime/velu-ui/components/PageFeedback.jsx +219 -0
  20. package/runtime/velu-ui/components/PageFooter.jsx +213 -0
  21. package/runtime/velu-ui/components/PageHeader.jsx +414 -0
  22. package/runtime/velu-ui/components/PageNav.jsx +77 -0
  23. package/runtime/velu-ui/components/PoweredBy.jsx +51 -0
  24. package/runtime/velu-ui/components/Prompt.jsx +115 -0
  25. package/runtime/velu-ui/components/Search.jsx +366 -0
  26. package/runtime/velu-ui/components/Sidebar.jsx +191 -0
  27. package/runtime/velu-ui/components/Steps.jsx +65 -0
  28. package/runtime/velu-ui/components/ThemeToggle.jsx +48 -0
  29. package/runtime/velu-ui/components/Toc.jsx +537 -0
  30. package/runtime/velu-ui/components/TocBar.jsx +195 -0
  31. package/runtime/velu-ui/components/Tree.jsx +87 -0
  32. package/runtime/velu-ui/components/TryItBar.jsx +90 -0
  33. package/runtime/velu-ui/components/accordion.css +92 -0
  34. package/runtime/velu-ui/components/api.css +479 -0
  35. package/runtime/velu-ui/components/ask-bar.css +94 -0
  36. package/runtime/velu-ui/components/card.css +105 -0
  37. package/runtime/velu-ui/components/chatbot.css +617 -0
  38. package/runtime/velu-ui/components/code-block.css +263 -0
  39. package/runtime/velu-ui/components/docs-layout.css +775 -0
  40. package/runtime/velu-ui/components/field.css +82 -0
  41. package/runtime/velu-ui/components/image.css +237 -0
  42. package/runtime/velu-ui/components/nav-select.css +157 -0
  43. package/runtime/velu-ui/components/page-feedback.css +241 -0
  44. package/runtime/velu-ui/components/page-footer.css +130 -0
  45. package/runtime/velu-ui/components/page-header.css +520 -0
  46. package/runtime/velu-ui/components/page-nav.css +50 -0
  47. package/runtime/velu-ui/components/powered-by.css +66 -0
  48. package/runtime/velu-ui/components/prompt.css +99 -0
  49. package/runtime/velu-ui/components/search.css +307 -0
  50. package/runtime/velu-ui/components/sidebar.css +144 -0
  51. package/runtime/velu-ui/components/steps.css +77 -0
  52. package/runtime/velu-ui/components/theme-toggle.css +70 -0
  53. package/runtime/velu-ui/components/toc-bar.css +234 -0
  54. package/runtime/velu-ui/components/tree.css +49 -0
  55. package/runtime/velu-ui/index.js +45 -0
  56. package/runtime/velu-ui/lib/copyText.js +64 -0
  57. package/runtime/velu-ui/lib/lang-icons.jsx +156 -0
  58. package/runtime/velu-ui/lib/prism-langs.js +957 -0
  59. package/runtime/velu-ui/lib/prism-loader.js +74 -0
  60. package/runtime/velu-ui/lib/resolveIcon.jsx +29 -0
  61. package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -0
  62. package/runtime/velu-ui/mdx-components.jsx +85 -0
  63. package/runtime/velu-ui/primitives/Cluster.jsx +49 -0
  64. package/runtime/velu-ui/primitives/Stack.jsx +63 -0
  65. package/runtime/velu-ui/primitives/Switcher.jsx +57 -0
  66. package/runtime/velu-ui/primitives/stack.css +3 -0
  67. package/runtime/velu-ui/primitives/switcher.css +25 -0
  68. package/runtime/velu-ui/styles.css +43 -0
  69. package/runtime/velu-ui/tokens.css +4 -0
  70. package/schema/velu.schema.json +167 -0
  71. package/src/navigation.js +434 -0
  72. package/src/runtime/App.jsx +1473 -0
  73. package/src/runtime/client-entry.jsx +22 -0
  74. package/src/runtime/server-entry.jsx +16 -0
  75. package/src/template.html +48 -0
  76. package/templates/starter/ai-tools/claude-code.mdx +26 -0
  77. package/templates/starter/ai-tools/cursor.mdx +17 -0
  78. package/templates/starter/api-reference/endpoint/create.mdx +24 -0
  79. package/templates/starter/api-reference/endpoint/get.mdx +27 -0
  80. package/templates/starter/api-reference/introduction.mdx +28 -0
  81. package/templates/starter/development.mdx +19 -0
  82. package/templates/starter/essentials/code.mdx +28 -0
  83. package/templates/starter/essentials/images.mdx +29 -0
  84. package/templates/starter/essentials/markdown.mdx +25 -0
  85. package/templates/starter/essentials/navigation.mdx +39 -0
  86. package/templates/starter/essentials/settings.mdx +30 -0
  87. package/templates/starter/favicon.svg +6 -0
  88. package/templates/starter/index.mdx +31 -0
  89. package/templates/starter/quickstart.mdx +31 -0
  90. package/templates/starter/velu.json +33 -0
@@ -0,0 +1,596 @@
1
+ import React, { useState, useRef, useEffect, useCallback } from 'react';
2
+ import resolveIcon from '../lib/resolveIcon.jsx';
3
+ import Stack from '../primitives/Stack.jsx';
4
+ import Cluster from '../primitives/Cluster.jsx';
5
+
6
+ /**
7
+ * Chatbot — the Velu "Ask AI" panel. A right-side slide-out: header +
8
+ * scrolling message body + composer. Ported from the Claude-Design
9
+ * "Velu Chatbot" handoff (rich variant), retokenized onto velu-ui's
10
+ * scale so it themes for free via [data-theme].
11
+ *
12
+ * <Chatbot
13
+ * open={chatOpen}
14
+ * seedQuestion={question} // first message — sent on open
15
+ * onClose={() => setChatOpen(false)}
16
+ * />
17
+ *
18
+ * Slide: the panel is fixed to the inline-end edge; `open` toggles a
19
+ * `translateX` between fully off-screen and 0. Width is the
20
+ * `--vchat-width` variable (default 24rem ≈ the design's 384px).
21
+ *
22
+ * The AI reply is a canned, fake-streamed answer (token-by-token) with
23
+ * inline citation chips, a Sources list, and follow-up suggestions —
24
+ * a dummy stand-in until a real RAG backend is wired up.
25
+ */
26
+
27
+ /* ── Brand mark — the Velu double-hook logo ─────────────────────────── */
28
+ // `size` is the glyph height; width derives from the 32:24 viewBox.
29
+ function VeluMark({ size = 'var(--icon-size-lg)' }) {
30
+ return (
31
+ <svg
32
+ className="velu-chatbot__mark"
33
+ viewBox="0 0 32 24"
34
+ fill="currentColor"
35
+ aria-hidden="true"
36
+ style={{ width: `calc(${size} * (32 / 24))`, height: size }}
37
+ >
38
+ <path d="M 29.656 14.153 C 29.574 14.562 29.351 14.929 29.025 15.193 C 28.699 15.457 28.291 15.601 27.869 15.601 L 21.37 15.601 C 20.534 15.601 19.805 16.163 19.603 16.964 L 18.167 22.637 C 18.068 23.026 17.841 23.372 17.521 23.619 C 17.2 23.866 16.805 24 16.399 24 L 10.438 24 C 10.161 24 9.888 23.938 9.639 23.818 C 9.39 23.698 9.172 23.524 9.001 23.308 C 8.831 23.092 8.712 22.841 8.655 22.574 C 8.598 22.306 8.603 22.029 8.67 21.764 L 10.189 15.764 C 10.287 15.374 10.515 15.029 10.835 14.782 C 11.155 14.535 11.55 14.401 11.956 14.4 L 18.393 14.4 C 19.262 14.4 20.01 13.795 20.18 12.953 L 22.388 2.048 C 22.471 1.639 22.694 1.272 23.019 1.008 C 23.345 0.744 23.754 0.6 24.175 0.6 L 30.177 0.6 C 30.447 0.6 30.713 0.659 30.957 0.773 C 31.201 0.886 31.416 1.052 31.587 1.258 C 31.758 1.464 31.881 1.705 31.946 1.964 C 32.011 2.222 32.017 2.492 31.964 2.753 L 29.656 14.153 Z M 9.611 13.554 C 9.528 13.962 9.305 14.329 8.979 14.593 C 8.653 14.857 8.245 15.001 7.824 15.001 L 1.823 15.001 C 1.553 15.001 1.287 14.942 1.043 14.828 C 0.799 14.714 0.584 14.548 0.413 14.342 C 0.242 14.136 0.119 13.895 0.054 13.637 C -0.011 13.378 -0.017 13.109 0.036 12.848 L 2.344 1.447 C 2.426 1.039 2.649 0.672 2.975 0.408 C 3.301 0.144 3.709 0 4.131 0 L 10.132 0 C 10.402 0 10.668 0.059 10.912 0.173 C 11.155 0.287 11.371 0.453 11.541 0.659 C 11.712 0.865 11.835 1.106 11.9 1.364 C 11.965 1.623 11.972 1.892 11.919 2.153 L 9.611 13.553 L 9.611 13.554 Z" />
39
+ </svg>
40
+ );
41
+ }
42
+
43
+ /* ── "Thinking" loader — 3x3 grid of squares, opacity wave sweeps the
44
+ anti-diagonal so it ripples top-left → bottom-right. ─────────────── */
45
+ function BlocksWave() {
46
+ const cells = [
47
+ [1, 1, 0], [8.33, 1, 1], [15.66, 1, 2],
48
+ [1, 8.33, 1], [8.33, 8.33, 2], [15.66, 8.33, 3],
49
+ [1, 15.66, 2], [8.33, 15.66, 3], [15.66, 15.66, 4],
50
+ ];
51
+ return (
52
+ <svg className="velu-chatbot__wave" viewBox="0 0 24 24" aria-hidden="true">
53
+ {cells.map(([x, y, cell], i) => (
54
+ <rect key={i} x={x} y={y} width="7.33" height="7.33" data-cell={cell} />
55
+ ))}
56
+ </svg>
57
+ );
58
+ }
59
+
60
+ /* ── Demo data ──────────────────────────────────────────────────────── */
61
+ const SUGGESTIONS = [
62
+ { label: 'How do I set up GitHub sync?', icon: 'github' },
63
+ { label: 'Customize the look of my docs site', icon: 'book' },
64
+ { label: 'Configure AI search ranking', icon: 'sparkles' },
65
+ { label: 'Quickstart in under five minutes', icon: 'rocket' },
66
+ ];
67
+
68
+ const HISTORY = [
69
+ { id: 'h1', title: 'Setting up GitHub sync', when: 'Today, 10:24' },
70
+ { id: 'h2', title: 'Custom theme tokens & dark mode', when: 'Today, 09:11' },
71
+ { id: 'h3', title: 'Configure AI search ranking', when: 'Yesterday' },
72
+ { id: 'h4', title: 'Embed a Mermaid diagram in MDX', when: 'Yesterday' },
73
+ { id: 'h5', title: 'Why are my callouts missing icons?', when: 'Apr 28' },
74
+ { id: 'h6', title: 'Quickstart — minimum site config', when: 'Apr 27' },
75
+ ];
76
+
77
+ const SOURCES = [
78
+ { num: 1, title: 'GitHub & GitLab Sync', path: 'docs/integrations/github-sync.md' },
79
+ { num: 2, title: 'Site Settings — Repositories', path: 'docs/your-docs-site/site-settings.md' },
80
+ { num: 3, title: 'AI-native Documentation', path: 'docs/get-started/ai-native.md' },
81
+ ];
82
+
83
+ /* Canned answer — segments interleave text, inline `code`, and citation
84
+ markers. The same answer is returned for any prompt (demo). */
85
+ function buildAnswer() {
86
+ return {
87
+ segments: [
88
+ { t: 'Velu syncs documentation directly with GitHub and GitLab — every push to a connected repository updates the live docs site within seconds.' },
89
+ { cite: 1 },
90
+ { t: ' To set it up:\n\n1. Open ' },
91
+ { code: 'Site Settings → Repositories' },
92
+ { t: ' and click ' },
93
+ { code: 'Connect repository' },
94
+ { t: '.\n2. Authorize the Velu app on the org that owns the repo.\n3. Pick a branch (default ' },
95
+ { code: 'main' },
96
+ { t: ') and a docs root path.' },
97
+ { cite: 2 },
98
+ { t: '\n\nVelu watches changes to ' },
99
+ { code: '.md' },
100
+ { t: ' and ' },
101
+ { code: '.mdx' },
102
+ { t: ' files and re-indexes the site automatically.' },
103
+ { cite: 3 },
104
+ ],
105
+ sources: SOURCES,
106
+ followups: [
107
+ 'How do I preview pull-request changes?',
108
+ 'Can I sync from a monorepo subdirectory?',
109
+ 'What about private repositories?',
110
+ ],
111
+ };
112
+ }
113
+
114
+ /* Flatten an answer into a stream of small tokens for incremental render. */
115
+ function tokenizeAnswer(answer) {
116
+ const tokens = [];
117
+ for (const seg of answer.segments) {
118
+ if (seg.cite != null) { tokens.push({ kind: 'cite', n: seg.cite }); continue; }
119
+ if (seg.code != null) { tokens.push({ kind: 'code', v: seg.code }); continue; }
120
+ const parts = seg.t.match(/\S+\s*|\s+/g) || [seg.t];
121
+ for (const p of parts) tokens.push({ kind: 't', v: p });
122
+ }
123
+ return tokens;
124
+ }
125
+
126
+ /* Render partial tokens as paragraphs (split on \n\n, single \n → <br>). */
127
+ function renderStream(tokens, sources) {
128
+ const paragraphs = [[]];
129
+ for (const tk of tokens) {
130
+ if (tk.kind === 't' && tk.v.includes('\n\n')) {
131
+ const split = tk.v.split('\n\n');
132
+ paragraphs[paragraphs.length - 1].push({ kind: 't', v: split[0] });
133
+ for (let i = 1; i < split.length; i++) {
134
+ paragraphs.push([]);
135
+ if (split[i]) paragraphs[paragraphs.length - 1].push({ kind: 't', v: split[i] });
136
+ }
137
+ } else {
138
+ paragraphs[paragraphs.length - 1].push(tk);
139
+ }
140
+ }
141
+ return paragraphs.map((para, pi) => (
142
+ <p key={pi}>
143
+ {para.map((tk, i) => {
144
+ if (tk.kind === 'cite') {
145
+ return (
146
+ <a
147
+ key={i}
148
+ className="velu-chatbot__cite"
149
+ href="#"
150
+ onClick={(e) => e.preventDefault()}
151
+ title={sources?.find((s) => s.num === tk.n)?.title}
152
+ >
153
+ {tk.n}
154
+ </a>
155
+ );
156
+ }
157
+ if (tk.kind === 'code') return <code key={i}>{tk.v}</code>;
158
+ const lines = tk.v.split('\n');
159
+ return lines.map((line, li) => (
160
+ <React.Fragment key={`${i}-${li}`}>
161
+ {li > 0 && <br />}
162
+ {line}
163
+ </React.Fragment>
164
+ ));
165
+ })}
166
+ </p>
167
+ ));
168
+ }
169
+
170
+ /* ── Header ─────────────────────────────────────────────────────────── */
171
+ function ChatHeader({ onClose, onNew, onHistory, historyOpen }) {
172
+ return (
173
+ <Cluster
174
+ space="var(--s-2)"
175
+ justify="space-between"
176
+ align="center"
177
+ className="velu-chatbot__header"
178
+ >
179
+ <Cluster space="var(--s-2)" align="center">
180
+ <VeluMark />
181
+ <span className="velu-chatbot__brand">
182
+ <span className="velu-chatbot__brand-name">Velu</span>
183
+ <span className="velu-chatbot__brand-sep">/</span>
184
+ <span className="velu-chatbot__brand-title">Ask AI</span>
185
+ </span>
186
+ </Cluster>
187
+ <Cluster space="var(--s-5)" align="center">
188
+ <button
189
+ type="button"
190
+ className={`velu-chatbot__iconbtn${historyOpen ? ' is-active' : ''}`}
191
+ aria-label="History"
192
+ onClick={onHistory}
193
+ >
194
+ {resolveIcon('history', { size: '1em' })}
195
+ </button>
196
+ <button
197
+ type="button"
198
+ className="velu-chatbot__iconbtn"
199
+ aria-label="New chat"
200
+ onClick={onNew}
201
+ >
202
+ {resolveIcon('plus', { size: '1em' })}
203
+ </button>
204
+ <button
205
+ type="button"
206
+ className="velu-chatbot__iconbtn"
207
+ aria-label="Close"
208
+ onClick={onClose}
209
+ >
210
+ {resolveIcon('x', { size: '1em' })}
211
+ </button>
212
+ </Cluster>
213
+ </Cluster>
214
+ );
215
+ }
216
+
217
+ /* ── History overlay ────────────────────────────────────────────────── */
218
+ function HistoryPanel({ onPick, onClose }) {
219
+ return (
220
+ <div className="velu-chatbot__history">
221
+ <Cluster
222
+ space="var(--s-2)"
223
+ justify="space-between"
224
+ align="center"
225
+ className="velu-chatbot__history-head"
226
+ >
227
+ <span className="velu-chatbot__eyebrow">Recent chats</span>
228
+ <button
229
+ type="button"
230
+ className="velu-chatbot__iconbtn"
231
+ aria-label="Close history"
232
+ onClick={onClose}
233
+ >
234
+ {resolveIcon('x', { size: '1em' })}
235
+ </button>
236
+ </Cluster>
237
+ <Stack as="div" space="var(--s-5)" className="velu-chatbot__history-list">
238
+ {HISTORY.map((h) => (
239
+ <button
240
+ key={h.id}
241
+ type="button"
242
+ className="velu-chatbot__history-item"
243
+ onClick={() => onPick(h)}
244
+ >
245
+ <span className="velu-chatbot__history-item-title">{h.title}</span>
246
+ <span className="velu-chatbot__history-item-when">{h.when}</span>
247
+ </button>
248
+ ))}
249
+ </Stack>
250
+ </div>
251
+ );
252
+ }
253
+
254
+ /* ── Welcome / empty state ──────────────────────────────────────────── */
255
+ function Welcome({ onPick }) {
256
+ return (
257
+ <Stack space="var(--s0)" className="velu-chatbot__welcome">
258
+ <p className="velu-chatbot__welcome-hi">Ask anything about the docs.</p>
259
+ <span className="velu-chatbot__eyebrow">Suggested</span>
260
+ <Stack space="var(--s-4)">
261
+ {SUGGESTIONS.map((s, i) => (
262
+ <button
263
+ key={i}
264
+ type="button"
265
+ className="velu-chatbot__suggest"
266
+ onClick={() => onPick(s.label)}
267
+ >
268
+ <Cluster space="var(--s-2)" align="center">
269
+ {resolveIcon(s.icon, { size: '1em' })}
270
+ <span>{s.label}</span>
271
+ </Cluster>
272
+ {resolveIcon('arrow-right', { size: '1em' })}
273
+ </button>
274
+ ))}
275
+ </Stack>
276
+ </Stack>
277
+ );
278
+ }
279
+
280
+ /* ── Messages ───────────────────────────────────────────────────────── */
281
+ function UserMsg({ text }) {
282
+ return (
283
+ <div className="velu-chatbot__msg velu-chatbot__msg--user">
284
+ <div className="velu-chatbot__bubble">{text}</div>
285
+ </div>
286
+ );
287
+ }
288
+
289
+ function AiMsg({ tokens, sources, followups, streaming, onFollowup }) {
290
+ const thinking = tokens.length === 0;
291
+ return (
292
+ <div className="velu-chatbot__msg velu-chatbot__msg--ai">
293
+ <Cluster
294
+ space="var(--s-3)"
295
+ align="center"
296
+ className={`velu-chatbot__role${thinking ? ' is-thinking' : ''}`}
297
+ >
298
+ {thinking ? <BlocksWave /> : resolveIcon('sparkles', { size: '1em' })}
299
+ <span>{thinking ? 'Velu is thinking' : 'Velu'}</span>
300
+ </Cluster>
301
+
302
+ {!thinking && (
303
+ <div className="velu-chatbot__answer">
304
+ {renderStream(tokens, sources)}
305
+ {streaming && <span className="velu-chatbot__caret" />}
306
+
307
+ {!streaming && sources?.length > 0 && (
308
+ <Stack space="var(--s-4)" className="velu-chatbot__sources">
309
+ <span className="velu-chatbot__eyebrow">Sources</span>
310
+ {sources.map((s) => (
311
+ <a
312
+ key={s.num}
313
+ className="velu-chatbot__source"
314
+ href="#"
315
+ onClick={(e) => e.preventDefault()}
316
+ >
317
+ <span className="velu-chatbot__source-num">{s.num}</span>
318
+ <span className="velu-chatbot__source-meta">
319
+ <span className="velu-chatbot__source-title">{s.title}</span>
320
+ <span className="velu-chatbot__source-path">{s.path}</span>
321
+ </span>
322
+ </a>
323
+ ))}
324
+ </Stack>
325
+ )}
326
+
327
+ {!streaming && followups?.length > 0 && (
328
+ <Stack space="var(--s-4)" className="velu-chatbot__followups">
329
+ <span className="velu-chatbot__eyebrow">Follow up</span>
330
+ {followups.map((f, i) => (
331
+ <button
332
+ key={i}
333
+ type="button"
334
+ className="velu-chatbot__followup"
335
+ onClick={() => onFollowup(f)}
336
+ >
337
+ <span>{f}</span>
338
+ {resolveIcon('arrow-right', { size: '1em' })}
339
+ </button>
340
+ ))}
341
+ </Stack>
342
+ )}
343
+ </div>
344
+ )}
345
+
346
+ {!streaming && !thinking && (
347
+ <Cluster space="0" className="velu-chatbot__msg-actions">
348
+ {['copy', 'refresh-cw', 'thumbs-up', 'thumbs-down'].map((ic) => (
349
+ <button
350
+ key={ic}
351
+ type="button"
352
+ className="velu-chatbot__msg-action"
353
+ aria-label={ic}
354
+ >
355
+ {resolveIcon(ic, { size: '1em' })}
356
+ </button>
357
+ ))}
358
+ </Cluster>
359
+ )}
360
+ </div>
361
+ );
362
+ }
363
+
364
+ /* ── Composer ───────────────────────────────────────────────────────── */
365
+ function Composer({ value, onChange, onSubmit, disabled }) {
366
+ const taRef = useRef(null);
367
+ useEffect(() => {
368
+ const ta = taRef.current;
369
+ if (!ta) return;
370
+ ta.style.height = 'auto';
371
+ ta.style.height = `${ta.scrollHeight}px`;
372
+ }, [value]);
373
+
374
+ const active = value.trim().length > 0 && !disabled;
375
+ const onKeyDown = (e) => {
376
+ if (e.key === 'Enter' && !e.shiftKey) {
377
+ e.preventDefault();
378
+ if (active) onSubmit();
379
+ }
380
+ };
381
+
382
+ return (
383
+ <div className="velu-chatbot__composer">
384
+ <div className="velu-chatbot__input-wrap">
385
+ <textarea
386
+ ref={taRef}
387
+ className="velu-chatbot__input"
388
+ placeholder="Ask anything about the docs…"
389
+ rows={1}
390
+ value={value}
391
+ onChange={(e) => onChange(e.target.value)}
392
+ onKeyDown={onKeyDown}
393
+ />
394
+ <button
395
+ type="button"
396
+ className={`velu-chatbot__send${active ? ' is-active' : ''}`}
397
+ onClick={onSubmit}
398
+ disabled={!active}
399
+ aria-label="Send"
400
+ >
401
+ {resolveIcon('arrow-up', { size: '1em' })}
402
+ </button>
403
+ </div>
404
+ <div className="velu-chatbot__foot">
405
+ <kbd>Enter</kbd> to send · <kbd>⇧ Enter</kbd> for newline
406
+ </div>
407
+ </div>
408
+ );
409
+ }
410
+
411
+ /* ── Panel ──────────────────────────────────────────────────────────── */
412
+ export default function Chatbot({
413
+ open = false,
414
+ seedQuestion,
415
+ onClose,
416
+ className = '',
417
+ ...rest
418
+ }) {
419
+ const [messages, setMessages] = useState([]);
420
+ const [input, setInput] = useState('');
421
+ const [historyOpen, setHistoryOpen] = useState(false);
422
+ const bodyRef = useRef(null);
423
+ const streamingRef = useRef(false);
424
+ const lastSeedRef = useRef(undefined);
425
+
426
+ useEffect(() => {
427
+ const el = bodyRef.current;
428
+ if (el) el.scrollTop = el.scrollHeight;
429
+ }, [messages]);
430
+
431
+ const send = useCallback((text) => {
432
+ const prompt = String(text ?? '').trim();
433
+ if (!prompt || streamingRef.current) return;
434
+ setInput('');
435
+ const answer = buildAnswer();
436
+ const fullTokens = tokenizeAnswer(answer);
437
+ setMessages((prev) => [
438
+ ...prev,
439
+ { role: 'user', text: prompt },
440
+ { role: 'ai', answer, tokens: [], streaming: true },
441
+ ]);
442
+ streamingRef.current = true;
443
+
444
+ let i = 0;
445
+ const startDelay = 1400; // "thinking" pause
446
+ const stepDelay = 28; // per token
447
+ let timer = setTimeout(function step() {
448
+ i = Math.min(fullTokens.length, i + 1);
449
+ setMessages((prev) => {
450
+ const out = prev.slice();
451
+ const last = out[out.length - 1];
452
+ out[out.length - 1] = {
453
+ ...last,
454
+ tokens: fullTokens.slice(0, i),
455
+ streaming: i < fullTokens.length,
456
+ };
457
+ return out;
458
+ });
459
+ if (i < fullTokens.length) {
460
+ timer = setTimeout(step, stepDelay);
461
+ } else {
462
+ streamingRef.current = false;
463
+ }
464
+ }, startDelay);
465
+ return () => clearTimeout(timer);
466
+ }, []);
467
+
468
+ // A new seedQuestion (from the page's AskBar) sends the first message.
469
+ useEffect(() => {
470
+ if (open && seedQuestion && seedQuestion !== lastSeedRef.current) {
471
+ lastSeedRef.current = seedQuestion;
472
+ send(seedQuestion);
473
+ }
474
+ }, [open, seedQuestion, send]);
475
+
476
+ const newChat = () => {
477
+ if (streamingRef.current) return;
478
+ setMessages([]);
479
+ setInput('');
480
+ setHistoryOpen(false);
481
+ };
482
+
483
+ // Drag-to-dismiss (mobile bottom-sheet only). Pointer events are
484
+ // attached to the drag-handle element below; the handle's pointer
485
+ // capture keeps the events flowing even if the finger slides off
486
+ // the handle into the body. While dragging we apply an inline
487
+ // `transform: translateY(...)` to the panel, overriding the CSS-
488
+ // controlled transform. On release, if the drag distance exceeded
489
+ // the threshold we call onClose() and the panel slides the rest of
490
+ // the way down via the CSS transition; otherwise we drop the
491
+ // inline transform and the panel snaps back via the same
492
+ // transition.
493
+ const dragRef = useRef({ active: false, startY: 0, currentY: 0 });
494
+ const [dragOffset, setDragOffset] = useState(0);
495
+ // Drag-to-dismiss is active wherever the chatbot is rendered as a
496
+ // bottom sheet — same threshold as the @container query in
497
+ // chatbot.css (< 1024px covers both narrow / tablet and mobile).
498
+ const isBottomSheet = () =>
499
+ typeof window !== 'undefined' &&
500
+ window.matchMedia('(max-width: 1024px)').matches;
501
+ const onDragStart = (e) => {
502
+ if (!isBottomSheet()) return;
503
+ dragRef.current = { active: true, startY: e.clientY, currentY: 0 };
504
+ e.currentTarget.setPointerCapture?.(e.pointerId);
505
+ };
506
+ const onDragMove = (e) => {
507
+ if (!dragRef.current.active) return;
508
+ const delta = Math.max(0, e.clientY - dragRef.current.startY);
509
+ dragRef.current.currentY = delta;
510
+ setDragOffset(delta);
511
+ };
512
+ const onDragEnd = () => {
513
+ if (!dragRef.current.active) return;
514
+ dragRef.current.active = false;
515
+ const shouldClose = dragRef.current.currentY > 80;
516
+ setDragOffset(0);
517
+ if (shouldClose) onClose?.();
518
+ };
519
+ // Reset any residual drag offset whenever the panel closes from
520
+ // outside (scrim click, X button, etc.) so a re-open starts clean.
521
+ useEffect(() => {
522
+ if (!open) setDragOffset(0);
523
+ }, [open]);
524
+
525
+ const cls = `velu-chatbot${open ? ' velu-chatbot--open' : ''} ${className}`.trim();
526
+ const dragStyle =
527
+ dragOffset > 0
528
+ ? { transform: `translateY(${dragOffset}px)`, transition: 'none' }
529
+ : undefined;
530
+
531
+ return (
532
+ <aside
533
+ className={cls}
534
+ aria-hidden={!open}
535
+ aria-label="Ask AI"
536
+ style={dragStyle}
537
+ {...rest}
538
+ >
539
+ {/* Drag handle — mobile only (CSS in chatbot.css). The pill
540
+ visual + the touch-capturing strip across the top of the
541
+ sheet. Pointer events here drive the drag-to-dismiss
542
+ gesture above. */}
543
+ <div
544
+ className="velu-chatbot__drag-handle"
545
+ onPointerDown={onDragStart}
546
+ onPointerMove={onDragMove}
547
+ onPointerUp={onDragEnd}
548
+ onPointerCancel={onDragEnd}
549
+ aria-hidden="true"
550
+ />
551
+ <ChatHeader
552
+ onClose={onClose}
553
+ onNew={newChat}
554
+ onHistory={() => setHistoryOpen((o) => !o)}
555
+ historyOpen={historyOpen}
556
+ />
557
+
558
+ {historyOpen && (
559
+ <HistoryPanel
560
+ onClose={() => setHistoryOpen(false)}
561
+ onPick={() => setHistoryOpen(false)}
562
+ />
563
+ )}
564
+
565
+ <div className="velu-chatbot__body" ref={bodyRef}>
566
+ {messages.length === 0 ? (
567
+ <Welcome onPick={(t) => send(t)} />
568
+ ) : (
569
+ <Stack space="var(--s1)">
570
+ {messages.map((m, i) =>
571
+ m.role === 'user' ? (
572
+ <UserMsg key={i} text={m.text} />
573
+ ) : (
574
+ <AiMsg
575
+ key={i}
576
+ tokens={m.tokens}
577
+ sources={m.answer?.sources}
578
+ followups={m.answer?.followups}
579
+ streaming={m.streaming}
580
+ onFollowup={(f) => send(f)}
581
+ />
582
+ ),
583
+ )}
584
+ </Stack>
585
+ )}
586
+ </div>
587
+
588
+ <Composer
589
+ value={input}
590
+ onChange={setInput}
591
+ onSubmit={() => send(input)}
592
+ disabled={streamingRef.current}
593
+ />
594
+ </aside>
595
+ );
596
+ }