@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.
@@ -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
- * 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.
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({ tokens, sources, followups, streaming, onFollowup }) {
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', '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
- ))}
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
- 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,
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
- return out;
458
- });
459
- if (i < fullTokens.length) {
460
- timer = setTimeout(step, stepDelay);
461
- } else {
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
- }, startDelay);
465
- return () => clearTimeout(timer);
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
- if (streamingRef.current) return;
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.answer?.sources}
578
- followups={m.answer?.followups}
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
  ),
@@ -203,6 +203,7 @@ export default function ContextMenu({
203
203
  justify="space-between"
204
204
  align="flex-end"
205
205
  className="velu-context-bar"
206
+ data-pagefind-ignore=""
206
207
  >
207
208
  {eyebrow ? <span className="velu-context-bar__eyebrow">{eyebrow}</span> : <span />}
208
209
 
@@ -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
- function SearchPalette({ results, placeholder, onSelect, onClose }) {
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
- if (!map.has(r.group)) map.set(r.group, []);
194
- map.get(r.group).push(r);
222
+ const g = r.group || 'Pages';
223
+ if (!map.has(g)) map.set(g, []);
224
+ map.get(g).push(r);
195
225
  });
196
- return GROUP_ORDER.filter((g) => map.has(g)).map((g) => [g, map.get(g)]);
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
- <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;
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
- <div className="velu-search__empty-sub">
278
- Try a different keyword or browse the sidebar.
312
+ ) : searching ? (
313
+ <div className="velu-search__empty">
314
+ <div className="velu-search__empty-sub">Searching…</div>
279
315
  </div>
280
- </div>
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 &ldquo;{query}&rdquo;
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
- margin-block-end: var(--s0);
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
- background: var(--page-bg);
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
- inset-block-start: var(--s-3);
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 only while hovering the sidebar AND there's content beyond
160
- that edge. */
161
- .velu-docs-layout__aside--left:hover
162
- .velu-docs-nav-scroll[data-fade-top='true']
163
- ~ .velu-docs-nav-arrow--up,
164
- .velu-docs-layout__aside--left:hover
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 each sticky
204
- section heading) is shown only while the nav is scrolled up, so a
205
- section's first item isn't dimmed at rest. */
206
- .velu-docs-nav-scroll[data-fade-top='true'] .velu-sidebar__section::after {
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'],
@@ -147,6 +147,7 @@
147
147
  color: var(--text-color);
148
148
  }
149
149
  .velu-header__action--outlined:hover {
150
+ background: var(--surface-color);
150
151
  border-color: var(--accent-color);
151
152
  color: var(--accent-color);
152
153
  }