@veluai/velu 0.2.0 → 0.2.2
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.
- package/dist/cli.js +29 -29
- package/package.json +2 -1
- package/runtime/velu-ui/components/Chatbot.jsx +214 -50
- package/runtime/velu-ui/components/ContextMenu.jsx +1 -0
- package/runtime/velu-ui/components/Search.jsx +66 -17
- package/runtime/velu-ui/components/Sidebar.jsx +55 -0
- package/runtime/velu-ui/components/context-menu.css +4 -1
- package/runtime/velu-ui/components/docs-layout.css +34 -14
- package/runtime/velu-ui/components/page-header.css +1 -0
- package/runtime/velu-ui/components/sidebar.css +64 -3
- package/runtime/velu-ui/components/theme-toggle.css +1 -0
- package/runtime/velu-ui/index.js +2 -0
- package/runtime/velu-ui/lib/brand-icons.jsx +9 -8
- package/runtime/velu-ui/lib/docs-assistant.js +163 -0
- package/runtime/velu-ui/lib/pagefind.js +113 -0
- package/schema/velu.schema.json +16 -0
- package/src/runtime/App.jsx +45 -3
|
@@ -13,15 +13,24 @@ import Cluster from '../primitives/Cluster.jsx';
|
|
|
13
13
|
* open={chatOpen}
|
|
14
14
|
* seedQuestion={question} // first message — sent on open
|
|
15
15
|
* onClose={() => setChatOpen(false)}
|
|
16
|
+
* ask={askDocs} // OPTIONAL — real backend (see below)
|
|
17
|
+
* onFeedback={(id, v) => …} // OPTIONAL — 👍/👎 for a message
|
|
16
18
|
* />
|
|
17
19
|
*
|
|
18
20
|
* Slide: the panel is fixed to the inline-end edge; `open` toggles a
|
|
19
21
|
* `translateX` between fully off-screen and 0. Width is the
|
|
20
22
|
* `--vchat-width` variable (default 24rem ≈ the design's 384px).
|
|
21
23
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
24
|
+
* Backend seam (mirrors Search.jsx's injectable `search` prop):
|
|
25
|
+
* ask(prompt, { conversationId, signal }) → AsyncIterable<AskEvent>
|
|
26
|
+
* AskEvent = {
|
|
27
|
+
* delta?: string, // text chunk to append (streamed)
|
|
28
|
+
* citations?: Source[], // [{ num, title, path, url }] — replaces sources
|
|
29
|
+
* messageId?: string, // assistant message id (for feedback)
|
|
30
|
+
* conversationId?: string, // threaded across turns
|
|
31
|
+
* }
|
|
32
|
+
* When `ask` is omitted the panel falls back to a canned, fake-streamed
|
|
33
|
+
* demo answer (so dev preview / offline still shows the experience).
|
|
25
34
|
*/
|
|
26
35
|
|
|
27
36
|
/* ── Brand mark — the Velu double-hook logo ─────────────────────────── */
|
|
@@ -111,6 +120,34 @@ function buildAnswer() {
|
|
|
111
120
|
};
|
|
112
121
|
}
|
|
113
122
|
|
|
123
|
+
/* Real-backend path: turn the accumulated answer TEXT into the same token
|
|
124
|
+
shape `renderStream` consumes. The assistant streams plain text with inline
|
|
125
|
+
`code` spans and bracketed citation markers ([1], [2]); split those out so
|
|
126
|
+
they render as <code> and citation chips, exactly like the canned path. */
|
|
127
|
+
function textToTokens(text) {
|
|
128
|
+
const tokens = [];
|
|
129
|
+
const re = /(`[^`]*`|\[\d+\])/g;
|
|
130
|
+
let last = 0;
|
|
131
|
+
let m;
|
|
132
|
+
while ((m = re.exec(text)) !== null) {
|
|
133
|
+
if (m.index > last) tokens.push({ kind: 't', v: text.slice(last, m.index) });
|
|
134
|
+
const tok = m[0];
|
|
135
|
+
if (tok[0] === '`') tokens.push({ kind: 'code', v: tok.slice(1, -1) });
|
|
136
|
+
else tokens.push({ kind: 'cite', n: Number(tok.slice(1, -1)) });
|
|
137
|
+
last = re.lastIndex;
|
|
138
|
+
}
|
|
139
|
+
if (last < text.length) tokens.push({ kind: 't', v: text.slice(last) });
|
|
140
|
+
return tokens;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* Plain-text of an answer (for the Copy action). */
|
|
144
|
+
function tokensToPlain(tokens) {
|
|
145
|
+
return tokens
|
|
146
|
+
.map((tk) => (tk.kind === 'cite' ? `[${tk.n}]` : tk.v))
|
|
147
|
+
.join('')
|
|
148
|
+
.trim();
|
|
149
|
+
}
|
|
150
|
+
|
|
114
151
|
/* Flatten an answer into a stream of small tokens for incremental render. */
|
|
115
152
|
function tokenizeAnswer(answer) {
|
|
116
153
|
const tokens = [];
|
|
@@ -286,8 +323,37 @@ function UserMsg({ text }) {
|
|
|
286
323
|
);
|
|
287
324
|
}
|
|
288
325
|
|
|
289
|
-
function AiMsg({
|
|
326
|
+
function AiMsg({
|
|
327
|
+
tokens,
|
|
328
|
+
sources,
|
|
329
|
+
followups,
|
|
330
|
+
streaming,
|
|
331
|
+
onFollowup,
|
|
332
|
+
messageId,
|
|
333
|
+
onFeedback,
|
|
334
|
+
}) {
|
|
290
335
|
const thinking = tokens.length === 0;
|
|
336
|
+
const [vote, setVote] = useState(null); // 'up' | 'down' | null
|
|
337
|
+
const [copied, setCopied] = useState(false);
|
|
338
|
+
|
|
339
|
+
const onAction = (ic) => {
|
|
340
|
+
if (ic === 'copy') {
|
|
341
|
+
try {
|
|
342
|
+
navigator.clipboard?.writeText(tokensToPlain(tokens));
|
|
343
|
+
setCopied(true);
|
|
344
|
+
setTimeout(() => setCopied(false), 1400);
|
|
345
|
+
} catch {
|
|
346
|
+
/* clipboard blocked — no-op */
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (ic === 'thumbs-up' || ic === 'thumbs-down') {
|
|
351
|
+
const v = ic === 'thumbs-up' ? 'up' : 'down';
|
|
352
|
+
const next = vote === v ? null : v; // toggle off if re-clicked
|
|
353
|
+
setVote(next);
|
|
354
|
+
onFeedback?.(messageId, next); // null = retract
|
|
355
|
+
}
|
|
356
|
+
};
|
|
291
357
|
return (
|
|
292
358
|
<div className="velu-chatbot__msg velu-chatbot__msg--ai">
|
|
293
359
|
<Cluster
|
|
@@ -345,16 +411,24 @@ function AiMsg({ tokens, sources, followups, streaming, onFollowup }) {
|
|
|
345
411
|
|
|
346
412
|
{!streaming && !thinking && (
|
|
347
413
|
<Cluster space="0" className="velu-chatbot__msg-actions">
|
|
348
|
-
{['copy', '
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
414
|
+
{['copy', 'thumbs-up', 'thumbs-down'].map((ic) => {
|
|
415
|
+
const active =
|
|
416
|
+
(ic === 'thumbs-up' && vote === 'up') ||
|
|
417
|
+
(ic === 'thumbs-down' && vote === 'down');
|
|
418
|
+
const label = ic === 'copy' && copied ? 'check' : ic;
|
|
419
|
+
return (
|
|
420
|
+
<button
|
|
421
|
+
key={ic}
|
|
422
|
+
type="button"
|
|
423
|
+
className={`velu-chatbot__msg-action${active ? ' is-active' : ''}`}
|
|
424
|
+
aria-label={ic}
|
|
425
|
+
aria-pressed={ic === 'copy' ? undefined : active}
|
|
426
|
+
onClick={() => onAction(ic)}
|
|
427
|
+
>
|
|
428
|
+
{resolveIcon(label, { size: '1em' })}
|
|
429
|
+
</button>
|
|
430
|
+
);
|
|
431
|
+
})}
|
|
358
432
|
</Cluster>
|
|
359
433
|
)}
|
|
360
434
|
</div>
|
|
@@ -413,6 +487,8 @@ export default function Chatbot({
|
|
|
413
487
|
open = false,
|
|
414
488
|
seedQuestion,
|
|
415
489
|
onClose,
|
|
490
|
+
ask,
|
|
491
|
+
onFeedback,
|
|
416
492
|
className = '',
|
|
417
493
|
...rest
|
|
418
494
|
}) {
|
|
@@ -422,48 +498,133 @@ export default function Chatbot({
|
|
|
422
498
|
const bodyRef = useRef(null);
|
|
423
499
|
const streamingRef = useRef(false);
|
|
424
500
|
const lastSeedRef = useRef(undefined);
|
|
501
|
+
// Real-backend session: conversation id threads turns; the controller lets
|
|
502
|
+
// a new-chat / close / unmount abort an in-flight stream.
|
|
503
|
+
const convoRef = useRef(undefined);
|
|
504
|
+
const abortRef = useRef(null);
|
|
425
505
|
|
|
426
506
|
useEffect(() => {
|
|
427
507
|
const el = bodyRef.current;
|
|
428
508
|
if (el) el.scrollTop = el.scrollHeight;
|
|
429
509
|
}, [messages]);
|
|
430
510
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
511
|
+
// Merge a patch into the last (assistant) message.
|
|
512
|
+
const patchLast = useCallback((patch) => {
|
|
513
|
+
setMessages((prev) => {
|
|
514
|
+
if (!prev.length) return prev;
|
|
515
|
+
const out = prev.slice();
|
|
516
|
+
out[out.length - 1] = { ...out[out.length - 1], ...patch };
|
|
517
|
+
return out;
|
|
518
|
+
});
|
|
519
|
+
}, []);
|
|
520
|
+
|
|
521
|
+
// Stop whatever is in flight — a canned setTimeout chain ({ timer }) or a
|
|
522
|
+
// real fetch stream (an AbortController). Safe to call any time.
|
|
523
|
+
const cancelInFlight = useCallback(() => {
|
|
524
|
+
const a = abortRef.current;
|
|
525
|
+
if (a) {
|
|
526
|
+
if (a.timer) clearTimeout(a.timer);
|
|
527
|
+
if (typeof a.abort === 'function') a.abort();
|
|
528
|
+
}
|
|
529
|
+
abortRef.current = null;
|
|
530
|
+
streamingRef.current = false;
|
|
531
|
+
}, []);
|
|
532
|
+
|
|
533
|
+
const send = useCallback(
|
|
534
|
+
async (text) => {
|
|
535
|
+
const prompt = String(text ?? '').trim();
|
|
536
|
+
if (!prompt || streamingRef.current) return;
|
|
537
|
+
setInput('');
|
|
538
|
+
|
|
539
|
+
// ── No backend wired → canned, fake-streamed demo answer ──────────
|
|
540
|
+
if (!ask) {
|
|
541
|
+
const answer = buildAnswer();
|
|
542
|
+
const fullTokens = tokenizeAnswer(answer);
|
|
543
|
+
setMessages((prev) => [
|
|
544
|
+
...prev,
|
|
545
|
+
{ role: 'user', text: prompt },
|
|
546
|
+
{
|
|
547
|
+
role: 'ai',
|
|
548
|
+
tokens: [],
|
|
549
|
+
sources: answer.sources,
|
|
550
|
+
followups: answer.followups,
|
|
551
|
+
streaming: true,
|
|
552
|
+
},
|
|
553
|
+
]);
|
|
554
|
+
streamingRef.current = true;
|
|
555
|
+
let i = 0;
|
|
556
|
+
const step = () => {
|
|
557
|
+
i = Math.min(fullTokens.length, i + 1);
|
|
558
|
+
patchLast({
|
|
559
|
+
tokens: fullTokens.slice(0, i),
|
|
560
|
+
streaming: i < fullTokens.length,
|
|
561
|
+
});
|
|
562
|
+
if (i < fullTokens.length) {
|
|
563
|
+
abortRef.current = { timer: setTimeout(step, 28) };
|
|
564
|
+
} else {
|
|
565
|
+
streamingRef.current = false;
|
|
566
|
+
abortRef.current = null;
|
|
567
|
+
}
|
|
456
568
|
};
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
569
|
+
abortRef.current = { timer: setTimeout(step, 1400) }; // "thinking" pause
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── Real backend → stream deltas + citations from `ask` ───────────
|
|
574
|
+
setMessages((prev) => [
|
|
575
|
+
...prev,
|
|
576
|
+
{ role: 'user', text: prompt },
|
|
577
|
+
{ role: 'ai', tokens: [], sources: [], followups: [], streaming: true },
|
|
578
|
+
]);
|
|
579
|
+
streamingRef.current = true;
|
|
580
|
+
const controller =
|
|
581
|
+
typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
582
|
+
abortRef.current = controller;
|
|
583
|
+
|
|
584
|
+
let acc = '';
|
|
585
|
+
let sources = [];
|
|
586
|
+
let messageId;
|
|
587
|
+
try {
|
|
588
|
+
for await (const ev of ask(prompt, {
|
|
589
|
+
conversationId: convoRef.current,
|
|
590
|
+
signal: controller?.signal,
|
|
591
|
+
})) {
|
|
592
|
+
if (ev.conversationId) convoRef.current = ev.conversationId;
|
|
593
|
+
if (ev.messageId) messageId = ev.messageId;
|
|
594
|
+
if (ev.delta) acc += ev.delta;
|
|
595
|
+
if (ev.citations) {
|
|
596
|
+
sources = ev.citations.map((c, idx) => ({
|
|
597
|
+
num: c.num ?? idx + 1,
|
|
598
|
+
title: c.title,
|
|
599
|
+
path: c.path ?? c.route_path,
|
|
600
|
+
url: c.url,
|
|
601
|
+
}));
|
|
602
|
+
}
|
|
603
|
+
patchLast({ tokens: textToTokens(acc), sources, streaming: true, messageId });
|
|
604
|
+
}
|
|
605
|
+
patchLast({ tokens: textToTokens(acc), sources, streaming: false, messageId });
|
|
606
|
+
} catch (err) {
|
|
607
|
+
if (controller?.signal?.aborted) {
|
|
608
|
+
patchLast({ streaming: false });
|
|
609
|
+
} else {
|
|
610
|
+
patchLast({
|
|
611
|
+
tokens: textToTokens(
|
|
612
|
+
acc || 'Sorry — I couldn’t reach the assistant. Please try again.'
|
|
613
|
+
),
|
|
614
|
+
streaming: false,
|
|
615
|
+
error: true,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
} finally {
|
|
462
619
|
streamingRef.current = false;
|
|
620
|
+
abortRef.current = null;
|
|
463
621
|
}
|
|
464
|
-
},
|
|
465
|
-
|
|
466
|
-
|
|
622
|
+
},
|
|
623
|
+
[ask, patchLast]
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
// Abort any in-flight stream when the panel unmounts.
|
|
627
|
+
useEffect(() => () => cancelInFlight(), [cancelInFlight]);
|
|
467
628
|
|
|
468
629
|
// A new seedQuestion (from the page's AskBar) sends the first message.
|
|
469
630
|
useEffect(() => {
|
|
@@ -474,7 +635,8 @@ export default function Chatbot({
|
|
|
474
635
|
}, [open, seedQuestion, send]);
|
|
475
636
|
|
|
476
637
|
const newChat = () => {
|
|
477
|
-
|
|
638
|
+
cancelInFlight();
|
|
639
|
+
convoRef.current = undefined; // fresh conversation thread
|
|
478
640
|
setMessages([]);
|
|
479
641
|
setInput('');
|
|
480
642
|
setHistoryOpen(false);
|
|
@@ -574,9 +736,11 @@ export default function Chatbot({
|
|
|
574
736
|
<AiMsg
|
|
575
737
|
key={i}
|
|
576
738
|
tokens={m.tokens}
|
|
577
|
-
sources={m.
|
|
578
|
-
followups={m.
|
|
739
|
+
sources={m.sources}
|
|
740
|
+
followups={m.followups}
|
|
579
741
|
streaming={m.streaming}
|
|
742
|
+
messageId={m.messageId}
|
|
743
|
+
onFeedback={onFeedback}
|
|
580
744
|
onFollowup={(f) => send(f)}
|
|
581
745
|
/>
|
|
582
746
|
),
|
|
@@ -162,10 +162,14 @@ function PaletteRow({ item, selected, navMode, onHover, onSelect }) {
|
|
|
162
162
|
);
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
/* The revealed palette — scrim + centered panel.
|
|
166
|
-
|
|
165
|
+
/* The revealed palette — scrim + centered panel. When `search` (an async
|
|
166
|
+
query → results function, e.g. the Pagefind client) is provided, results
|
|
167
|
+
come from it; otherwise the static `results` list is filtered in-memory. */
|
|
168
|
+
function SearchPalette({ results, search, placeholder, onSelect, onClose }) {
|
|
167
169
|
const [query, setQuery] = useState('');
|
|
168
170
|
const [selected, setSelected] = useState(0);
|
|
171
|
+
const [asyncResults, setAsyncResults] = useState([]);
|
|
172
|
+
const [searching, setSearching] = useState(false);
|
|
169
173
|
const inputRef = useRef(null);
|
|
170
174
|
// 'keyboard' | 'mouse' — which input last moved the selection; gates
|
|
171
175
|
// the rows' scrollIntoView so mouse hover doesn't jitter the list.
|
|
@@ -175,7 +179,32 @@ function SearchPalette({ results, placeholder, onSelect, onClose }) {
|
|
|
175
179
|
inputRef.current?.focus();
|
|
176
180
|
}, []);
|
|
177
181
|
|
|
182
|
+
// Async source (Pagefind): debounce the query, fetch results.
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
if (!search) return undefined;
|
|
185
|
+
const q = query.trim();
|
|
186
|
+
if (!q) {
|
|
187
|
+
setAsyncResults([]);
|
|
188
|
+
setSearching(false);
|
|
189
|
+
return undefined;
|
|
190
|
+
}
|
|
191
|
+
let cancelled = false;
|
|
192
|
+
setSearching(true);
|
|
193
|
+
const t = setTimeout(async () => {
|
|
194
|
+
const r = await search(q).catch(() => []);
|
|
195
|
+
if (!cancelled) {
|
|
196
|
+
setAsyncResults(Array.isArray(r) ? r : []);
|
|
197
|
+
setSearching(false);
|
|
198
|
+
}
|
|
199
|
+
}, 150);
|
|
200
|
+
return () => {
|
|
201
|
+
cancelled = true;
|
|
202
|
+
clearTimeout(t);
|
|
203
|
+
};
|
|
204
|
+
}, [query, search]);
|
|
205
|
+
|
|
178
206
|
const filtered = useMemo(() => {
|
|
207
|
+
if (search) return asyncResults;
|
|
179
208
|
const q = query.trim().toLowerCase();
|
|
180
209
|
if (!q) {
|
|
181
210
|
// Empty query → just the recents.
|
|
@@ -185,15 +214,18 @@ function SearchPalette({ results, placeholder, onSelect, onClose }) {
|
|
|
185
214
|
const hay = `${r.title} ${r.breadcrumb.join(' ')} ${r.desc}`.toLowerCase();
|
|
186
215
|
return hay.includes(q);
|
|
187
216
|
});
|
|
188
|
-
}, [query, results]);
|
|
217
|
+
}, [search, asyncResults, query, results]);
|
|
189
218
|
|
|
190
219
|
const grouped = useMemo(() => {
|
|
191
220
|
const map = new Map();
|
|
192
221
|
filtered.forEach((r) => {
|
|
193
|
-
|
|
194
|
-
map.
|
|
222
|
+
const g = r.group || 'Pages';
|
|
223
|
+
if (!map.has(g)) map.set(g, []);
|
|
224
|
+
map.get(g).push(r);
|
|
195
225
|
});
|
|
196
|
-
|
|
226
|
+
const known = GROUP_ORDER.filter((g) => map.has(g));
|
|
227
|
+
const extra = [...map.keys()].filter((g) => !GROUP_ORDER.includes(g));
|
|
228
|
+
return [...known, ...extra].map((g) => [g, map.get(g)]);
|
|
197
229
|
}, [filtered]);
|
|
198
230
|
|
|
199
231
|
const flat = useMemo(
|
|
@@ -266,19 +298,34 @@ function SearchPalette({ results, placeholder, onSelect, onClose }) {
|
|
|
266
298
|
</div>
|
|
267
299
|
|
|
268
300
|
<div className="velu-search__list" role="listbox">
|
|
269
|
-
{grouped.length === 0 &&
|
|
270
|
-
|
|
271
|
-
<
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
301
|
+
{grouped.length === 0 &&
|
|
302
|
+
(search && !query.trim() ? (
|
|
303
|
+
<div className="velu-search__empty">
|
|
304
|
+
<span className="velu-search__empty-icon" aria-hidden="true">
|
|
305
|
+
{resolveIcon('search', { size: '1em' })}
|
|
306
|
+
</span>
|
|
307
|
+
<div className="velu-search__empty-title">Search the docs</div>
|
|
308
|
+
<div className="velu-search__empty-sub">
|
|
309
|
+
Type to find pages and sections.
|
|
310
|
+
</div>
|
|
276
311
|
</div>
|
|
277
|
-
|
|
278
|
-
|
|
312
|
+
) : searching ? (
|
|
313
|
+
<div className="velu-search__empty">
|
|
314
|
+
<div className="velu-search__empty-sub">Searching…</div>
|
|
279
315
|
</div>
|
|
280
|
-
|
|
281
|
-
|
|
316
|
+
) : (
|
|
317
|
+
<div className="velu-search__empty">
|
|
318
|
+
<span className="velu-search__empty-icon" aria-hidden="true">
|
|
319
|
+
{resolveIcon('search-x', { size: '1em' })}
|
|
320
|
+
</span>
|
|
321
|
+
<div className="velu-search__empty-title">
|
|
322
|
+
No results for “{query}”
|
|
323
|
+
</div>
|
|
324
|
+
<div className="velu-search__empty-sub">
|
|
325
|
+
Try a different keyword or browse the sidebar.
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
))}
|
|
282
329
|
{grouped.map(([group, items]) => (
|
|
283
330
|
<div key={group}>
|
|
284
331
|
<div className="velu-search__group">{group}</div>
|
|
@@ -346,6 +393,7 @@ function SearchUnavailable({ message, onClose }) {
|
|
|
346
393
|
|
|
347
394
|
export default function Search({
|
|
348
395
|
results = DEFAULT_RESULTS,
|
|
396
|
+
search,
|
|
349
397
|
placeholder = 'Search documentation',
|
|
350
398
|
onSelect,
|
|
351
399
|
unavailable = false,
|
|
@@ -401,6 +449,7 @@ export default function Search({
|
|
|
401
449
|
) : (
|
|
402
450
|
<SearchPalette
|
|
403
451
|
results={results}
|
|
452
|
+
search={search}
|
|
404
453
|
placeholder={placeholder}
|
|
405
454
|
onSelect={onSelect}
|
|
406
455
|
onClose={() => setOpen(false)}
|
|
@@ -41,6 +41,11 @@ import scrollIntoNearestView from '../lib/scrollIntoNearestView.js';
|
|
|
41
41
|
|
|
42
42
|
const SidebarCtx = React.createContext({ activeHref: undefined, Link: 'a' });
|
|
43
43
|
|
|
44
|
+
// Positioning must happen before paint (no flash); fall back to useEffect on
|
|
45
|
+
// the server so React doesn't warn about useLayoutEffect during SSR.
|
|
46
|
+
const useIsoLayoutEffect =
|
|
47
|
+
typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
|
|
48
|
+
|
|
44
49
|
function isActive(item, activeHref) {
|
|
45
50
|
return Boolean(
|
|
46
51
|
item.active || (item.href != null && item.href === activeHref)
|
|
@@ -166,6 +171,47 @@ export default function Sidebar({
|
|
|
166
171
|
scrollIntoNearestView(el);
|
|
167
172
|
}, [activeHref]);
|
|
168
173
|
|
|
174
|
+
// The single gliding active indicator (crimson tint + flush-left bar). One
|
|
175
|
+
// element animates its position/height to the active item, so selection feels
|
|
176
|
+
// continuous rather than each item popping its own bar. It lives in the scroll
|
|
177
|
+
// content, so its offset is scroll-stable (no re-measure on scroll) and a
|
|
178
|
+
// pinned sticky heading's opaque background covers it when an item tucks under.
|
|
179
|
+
const [ind, setInd] = React.useState({ top: 0, h: 0, show: false });
|
|
180
|
+
const measure = React.useCallback(() => {
|
|
181
|
+
const nav = rootRef.current;
|
|
182
|
+
if (!nav) return;
|
|
183
|
+
const el = nav.querySelector('[aria-current="page"]');
|
|
184
|
+
if (!el) {
|
|
185
|
+
setInd((s) => (s.show ? { ...s, show: false } : s));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const top = el.getBoundingClientRect().top - nav.getBoundingClientRect().top;
|
|
189
|
+
const h = el.offsetHeight;
|
|
190
|
+
// Bail when nothing moved → no needless re-render (and no animation jitter).
|
|
191
|
+
setInd((s) =>
|
|
192
|
+
s.show && Math.abs(s.top - top) < 0.5 && Math.abs(s.h - h) < 0.5
|
|
193
|
+
? s
|
|
194
|
+
: { top, h, show: true },
|
|
195
|
+
);
|
|
196
|
+
}, []);
|
|
197
|
+
// Re-measure on selection / tab change…
|
|
198
|
+
useIsoLayoutEffect(() => {
|
|
199
|
+
measure();
|
|
200
|
+
}, [activeHref, sections, measure]);
|
|
201
|
+
// …and whenever the nav's layout changes (a group expands/collapses → the
|
|
202
|
+
// active item moves), plus on viewport resize.
|
|
203
|
+
React.useEffect(() => {
|
|
204
|
+
const nav = rootRef.current;
|
|
205
|
+
if (!nav || typeof ResizeObserver === 'undefined') return undefined;
|
|
206
|
+
const ro = new ResizeObserver(() => measure());
|
|
207
|
+
ro.observe(nav);
|
|
208
|
+
window.addEventListener('resize', measure);
|
|
209
|
+
return () => {
|
|
210
|
+
ro.disconnect();
|
|
211
|
+
window.removeEventListener('resize', measure);
|
|
212
|
+
};
|
|
213
|
+
}, [measure]);
|
|
214
|
+
|
|
169
215
|
return (
|
|
170
216
|
<SidebarCtx.Provider value={ctx}>
|
|
171
217
|
<Stack
|
|
@@ -176,6 +222,15 @@ export default function Sidebar({
|
|
|
176
222
|
aria-label="Documentation"
|
|
177
223
|
{...rest}
|
|
178
224
|
>
|
|
225
|
+
<span
|
|
226
|
+
className="velu-sidebar__indicator"
|
|
227
|
+
aria-hidden="true"
|
|
228
|
+
style={{
|
|
229
|
+
transform: `translateY(${ind.top}px)`,
|
|
230
|
+
height: `${ind.h}px`,
|
|
231
|
+
opacity: ind.show ? 1 : 0,
|
|
232
|
+
}}
|
|
233
|
+
/>
|
|
179
234
|
{/* Each section is its own Stack so the heading sits TIGHT to
|
|
180
235
|
its list (small inner gap), while the nav's larger gap
|
|
181
236
|
separates one section from the next — compact but still
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
tokens, light/dark via [data-theme]. */
|
|
4
4
|
|
|
5
5
|
.velu-context-bar {
|
|
6
|
-
|
|
6
|
+
/* Tight to the page title below — the eyebrow reads as a kicker on the
|
|
7
|
+
title, not a detached row. Negative pull cancels the h1's line-height
|
|
8
|
+
leading so the cap-height of the title sits right under the eyebrow. */
|
|
9
|
+
margin-block-end: calc(-1 * var(--s-5));
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
/* Section/group label, top-left — small, uppercase, accent (matches the
|
|
@@ -102,6 +102,16 @@
|
|
|
102
102
|
/* Region wrapping the scroll area + the overlay arrows. Fills the
|
|
103
103
|
remaining aside height; relative so the arrows position to its
|
|
104
104
|
top/bottom edges. */
|
|
105
|
+
/* Hairline between the top anchor links and the nav sections (the sidebar
|
|
106
|
+
design's anchor↔sidebar separator). Tokenized; sits flush in the aside flow. */
|
|
107
|
+
.velu-docs-context-divider {
|
|
108
|
+
flex: none;
|
|
109
|
+
block-size: var(--border-width);
|
|
110
|
+
background: var(--border-color);
|
|
111
|
+
margin-block: var(--s-2);
|
|
112
|
+
margin-inline: var(--s-2);
|
|
113
|
+
}
|
|
114
|
+
|
|
105
115
|
.velu-docs-nav-region {
|
|
106
116
|
position: relative;
|
|
107
117
|
flex: 1 1 auto;
|
|
@@ -131,7 +141,12 @@
|
|
|
131
141
|
padding: 0;
|
|
132
142
|
border: var(--border-width) solid var(--border-color);
|
|
133
143
|
border-radius: 999px;
|
|
134
|
-
|
|
144
|
+
/* Frosted glass — same recipe as the scrolled header / toc-bar: 75%
|
|
145
|
+
--page-bg + a saturate/blur backdrop, so the nav content shows through
|
|
146
|
+
faintly behind the button. */
|
|
147
|
+
background: color-mix(in srgb, var(--page-bg) 75%, transparent);
|
|
148
|
+
backdrop-filter: saturate(140%) blur(12px);
|
|
149
|
+
-webkit-backdrop-filter: saturate(140%) blur(12px);
|
|
135
150
|
color: var(--muted-color);
|
|
136
151
|
cursor: pointer;
|
|
137
152
|
z-index: 3;
|
|
@@ -151,19 +166,21 @@
|
|
|
151
166
|
block-size: 1.1em;
|
|
152
167
|
}
|
|
153
168
|
.velu-docs-nav-arrow--up {
|
|
154
|
-
|
|
169
|
+
/* Float ABOVE the scroll region — up in the context-divider gap — instead
|
|
170
|
+
of over the first section heading. The lift is the arrow's own height
|
|
171
|
+
(2rem, matching block-size) plus a small gap, so its bottom clears the
|
|
172
|
+
scroll content and it never overlaps nav text. */
|
|
173
|
+
inset-block-start: calc(-2rem - var(--s-4));
|
|
155
174
|
}
|
|
156
175
|
.velu-docs-nav-arrow--down {
|
|
157
176
|
inset-block-end: var(--s-3);
|
|
158
177
|
}
|
|
159
|
-
/* Reveal
|
|
160
|
-
|
|
161
|
-
.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
.velu-docs-
|
|
165
|
-
.velu-docs-nav-scroll[data-fade-bottom='true']
|
|
166
|
-
~ .velu-docs-nav-arrow--down {
|
|
178
|
+
/* Reveal whenever there's content beyond that edge — always on (not just on
|
|
179
|
+
hover), so the scroll affordance is visible as long as there's more to
|
|
180
|
+
scroll. The data-fade-* attrs (set by the runtime from scroll position)
|
|
181
|
+
already gate each arrow to the direction that actually has overflow. */
|
|
182
|
+
.velu-docs-nav-scroll[data-fade-top='true'] ~ .velu-docs-nav-arrow--up,
|
|
183
|
+
.velu-docs-nav-scroll[data-fade-bottom='true'] ~ .velu-docs-nav-arrow--down {
|
|
167
184
|
opacity: 1;
|
|
168
185
|
pointer-events: auto;
|
|
169
186
|
transform: translateX(-50%) scale(1);
|
|
@@ -200,10 +217,13 @@
|
|
|
200
217
|
.velu-docs-layout__aside--right[data-fade-top='true'] {
|
|
201
218
|
--vf-top: var(--s2);
|
|
202
219
|
}
|
|
203
|
-
/* The left nav's under-heading fade (the ::after below
|
|
204
|
-
|
|
205
|
-
section
|
|
206
|
-
|
|
220
|
+
/* The left nav's under-heading fade (the ::after below a sticky section
|
|
221
|
+
heading) is shown only while the nav is scrolled up AND only under the
|
|
222
|
+
currently-PINNED section ([data-stuck], set by the runtime). Other sections'
|
|
223
|
+
headings scroll normally, so their first item must not be dimmed — only the
|
|
224
|
+
item sliding under the top-pinned heading fades. */
|
|
225
|
+
.velu-docs-nav-scroll[data-fade-top='true']
|
|
226
|
+
.velu-sidebar__section[data-stuck='true']::after {
|
|
207
227
|
opacity: 1;
|
|
208
228
|
}
|
|
209
229
|
.velu-docs-nav-scroll[data-fade-bottom='true'],
|