@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,537 @@
1
+ import React from 'react';
2
+ import scrollIntoNearestView from '../lib/scrollIntoNearestView.js';
3
+
4
+ const { useState, useRef, useEffect, useMemo } = React;
5
+
6
+ /**
7
+ * Toc — "Comet Trail" table of contents (Claude Design v3 port).
8
+ *
9
+ * A bent SVG rail snakes through the (indented) items; a crimson comet head +
10
+ * persistent trail glides to the active section, with an additive hover comet.
11
+ * Faithful to the design's geometry/animation, but:
12
+ * - tokenized: the design's hardcoded colors + its own light/dark THEMES
13
+ * object are dropped; we use velu-ui tokens, so [data-theme] themes it
14
+ * globally (DRY — no per-component theme object).
15
+ * - SSR-safe: getTotalLength/getPointAtLength run only in client effects;
16
+ * first render (server + client) is deterministic (no comet until pathLen
17
+ * is measured), so hydration matches.
18
+ * - presentational + router/scroll-agnostic: takes `items`, `activeId`,
19
+ * `onSelect(id)` — exactly like Sidebar. The consumer owns scroll-spy +
20
+ * smooth-scroll (a scroll container concern, not the component's).
21
+ *
22
+ * Note: this is intentionally NOT a Stack composition — rows are
23
+ * absolutely positioned on a coordinate grid so the SVG path anchors line up
24
+ * with row centers. That coordinate model IS the component; Stack (flow/gap)
25
+ * cannot express it. Legitimately not a primitive use.
26
+ *
27
+ * @typedef {Object} TocItem
28
+ * @property {string} id section id (matches the page element id)
29
+ * @property {string} label
30
+ * @property {TocItem[]} [children]
31
+ *
32
+ * @param {{ items: TocItem[], activeId?: string, onSelect?: (id:string)=>void,
33
+ * rowH?: number, padTop?: number, railLeft?: number, indent?: number,
34
+ * gap?: number, tail?: number, fontSize?: number }} props
35
+ */
36
+
37
+ const ACCENT = 'var(--accent-color)';
38
+ // Faint guide rail (the design uses a lighter gray than structural borders);
39
+ // derived from the border token so it still themes — no new token needed.
40
+ const RAIL_BASE = 'color-mix(in srgb, var(--border-color) 45%, transparent)';
41
+ const TEXT = 'var(--text-color)';
42
+ const HALO = 'color-mix(in srgb, var(--accent-color) 22%, transparent)';
43
+ const GLOW = 'color-mix(in srgb, var(--accent-color) 90%, transparent)';
44
+
45
+ // Geometry is token-driven, never hardcoded. The SVG/comet math needs px
46
+ // numbers, so the modular-scale tokens are resolved to px at runtime via a
47
+ // probe element. Fallback = the default scale (s0=16, ratio=1.25) so SSR and
48
+ // the first client render match (no hydration mismatch); the client effect
49
+ // then re-reads the real tokens and auto-adapts if --s0/--ratio are themed.
50
+ const S0 = 16;
51
+ const RATIO = 1.25;
52
+ const scaleStep = (n) => S0 * Math.pow(RATIO, n);
53
+ const DIM_TOKENS = {
54
+ rowH: '--s3',
55
+ padTop: '--s-1',
56
+ railLeft: '--s-1',
57
+ indent: '--s-1',
58
+ fontPx: '--f-h6', // resolved type size; the comet artwork scales off this
59
+ };
60
+ const DIM_FALLBACK = {
61
+ rowH: scaleStep(3),
62
+ padTop: scaleStep(-1),
63
+ railLeft: scaleStep(-1),
64
+ indent: scaleStep(-1),
65
+ fontPx: 13, // --f-h6 clamp resolves ~12–14; 13 = the design's value
66
+ };
67
+
68
+ function useScaleDims() {
69
+ const [dims, setDims] = useState(DIM_FALLBACK);
70
+ useEffect(() => {
71
+ const probe = document.createElement('div');
72
+ probe.style.cssText =
73
+ 'position:absolute;visibility:hidden;height:0;pointer-events:none';
74
+ document.body.appendChild(probe);
75
+ const px = (expr) => {
76
+ probe.style.width = expr;
77
+ return parseFloat(getComputedStyle(probe).width);
78
+ };
79
+ const next = {};
80
+ let ok = true;
81
+ for (const k in DIM_TOKENS) {
82
+ const v = px(`var(${DIM_TOKENS[k]})`);
83
+ if (!Number.isFinite(v) || v <= 0) {
84
+ ok = false;
85
+ break;
86
+ }
87
+ next[k] = v;
88
+ }
89
+ document.body.removeChild(probe);
90
+ if (ok) setDims(next);
91
+ }, []);
92
+ return dims;
93
+ }
94
+
95
+ function flatten(nodes, depth = 0, out = []) {
96
+ for (const n of nodes) {
97
+ out.push({
98
+ id: n.id,
99
+ label: n.label,
100
+ depth,
101
+ hasChildren: !!(n.children && n.children.length),
102
+ });
103
+ if (n.children) flatten(n.children, depth + 1, out);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ // Always-smooth path: each horizontal change is one cubic bezier whose control
109
+ // points sit at the vertical midpoint — rounded elbow regardless of row gap.
110
+ function smoothPath(anchors) {
111
+ if (anchors.length === 0) return '';
112
+ let d = `M ${anchors[0].x} ${anchors[0].y}`;
113
+ for (let i = 1; i < anchors.length; i++) {
114
+ const p = anchors[i - 1];
115
+ const c = anchors[i];
116
+ if (c.x === p.x) d += ` L ${c.x} ${c.y}`;
117
+ else {
118
+ const m = (p.y + c.y) / 2;
119
+ d += ` C ${p.x} ${m}, ${c.x} ${m}, ${c.x} ${c.y}`;
120
+ }
121
+ }
122
+ return d;
123
+ }
124
+
125
+ const Row = React.forwardRef(function Row(
126
+ { item, idx, onEnter, onClick, hovered, active, rowH, indent, textStart, fontSize },
127
+ ref
128
+ ) {
129
+ return (
130
+ <div
131
+ ref={ref}
132
+ data-toc-active={active ? 'true' : undefined}
133
+ onMouseEnter={() => onEnter(idx)}
134
+ onClick={() => onClick(item.id)}
135
+ style={{
136
+ /* Natural document flow — rows stack normally so a wrapped row
137
+ PUSHES the rows below it down rather than overlapping them.
138
+ `minHeight: rowH` keeps single-line rows at the canonical
139
+ rhythm; multi-line rows take whatever vertical space they
140
+ need. The SVG rail's anchor y for each row is measured AFTER
141
+ layout (see useEffect in the parent) so the path stays
142
+ aligned with the (possibly grown) row centres. */
143
+ position: 'relative',
144
+ minHeight: rowH,
145
+ display: 'flex',
146
+ alignItems: 'flex-start',
147
+ paddingLeft: textStart + item.depth * indent,
148
+ fontSize,
149
+ color: hovered || active ? ACCENT : TEXT,
150
+ cursor: 'pointer',
151
+ transition: 'color 200ms ease',
152
+ }}
153
+ >
154
+ <span
155
+ style={{
156
+ /* Weight is depth-based ONLY — active state is distinguished
157
+ by colour (ACCENT) above, not by a 400→600 weight bump.
158
+ Reason: the bold version is wider, so a label sitting on
159
+ the edge of one line would flip its wrap state every time
160
+ it became active. That changed the measured row height,
161
+ which dragged the SVG anchors around, which made the
162
+ comet appear to wander mid-scroll. Stable weight → stable
163
+ heights → stable rail. */
164
+ fontWeight: item.depth === 0 ? 500 : 400,
165
+ }}
166
+ >
167
+ {item.label}
168
+ </span>
169
+ </div>
170
+ );
171
+ });
172
+
173
+ export default function Toc({
174
+ items = [],
175
+ activeId,
176
+ onSelect = () => {},
177
+ rowH: rowHProp,
178
+ padTop: padTopProp,
179
+ railLeft: railLeftProp,
180
+ indent: indentProp,
181
+ gap = 0,
182
+ tail: tailProp,
183
+ fontSize: fontSizeProp,
184
+ ...rest
185
+ }) {
186
+ // Defaults derive from the modular-scale tokens (no hardcoded sizes);
187
+ // callers may still override per-prop.
188
+ const dims = useScaleDims();
189
+ const rowH = rowHProp ?? dims.rowH;
190
+ const padTop = padTopProp ?? dims.padTop;
191
+ const railLeft = railLeftProp ?? dims.railLeft;
192
+ const indent = indentProp ?? dims.indent;
193
+ const tail = tailProp ?? rowH * 6;
194
+ const fontSize = fontSizeProp ?? 'var(--f-h6)';
195
+
196
+ // Comet artwork scales with the type token (dims.fontPx ← --f-h6). These
197
+ // multipliers are proportions of that size, calibrated to reproduce the
198
+ // design exactly at the default (≈13px) — so no raw px literals, and the
199
+ // comet rescales if the type token is themed.
200
+ const fpx = dims.fontPx;
201
+ const cometCoreR = fpx * 0.17;
202
+ const cometHaloR = fpx * 0.42;
203
+ const cometGlow = fpx * 0.31; // px used in blur()/drop-shadow
204
+ const SEL_W = fpx * 0.123;
205
+ const SEL_TAPER = fpx * 0.108;
206
+ const HOV_W = fpx * 0.031;
207
+ const HOV_TAPER = fpx * 0.2;
208
+ const HOV_GLOW_W = fpx * 0.231;
209
+
210
+ const FLAT = useMemo(() => flatten(items), [items]);
211
+ const MAX_DEPTH = useMemo(
212
+ () => FLAT.reduce((m, i) => Math.max(m, i.depth), 0),
213
+ [FLAT]
214
+ );
215
+ const activeIdx = useMemo(() => {
216
+ const i = FLAT.findIndex((f) => f.id === activeId);
217
+ return i === -1 ? null : i;
218
+ }, [FLAT, activeId]);
219
+
220
+ const [hoverIdx, setHoverIdx] = useState(null);
221
+
222
+ // Root container ref — declared up here because the measurement
223
+ // effect below needs it, and a later effect (auto-scroll the active
224
+ // row into the parent scrollport) reuses the same ref.
225
+ const rootRef = useRef(null);
226
+
227
+ // Precomputed anchors — what we use at SSR + before client measurement
228
+ // happens. Once the rows render and we know their actual offsetTop /
229
+ // height (some may have wrapped), we overwrite `anchors` with the
230
+ // measured values so the SVG rail tracks the real row centres.
231
+ const initialAnchors = useMemo(
232
+ () =>
233
+ FLAT.map((item, idx) => ({
234
+ x: railLeft + item.depth * indent,
235
+ y: padTop + idx * rowH + rowH / 2,
236
+ })),
237
+ [FLAT, rowH, padTop, railLeft, indent]
238
+ );
239
+ const [anchors, setAnchors] = useState(initialAnchors);
240
+ const textStart = railLeft + MAX_DEPTH * indent + gap;
241
+ const fullPath = useMemo(() => smoothPath(anchors), [anchors]);
242
+
243
+ // Row refs (collected per render) + remeasure on resize + on relevant
244
+ // dep changes. Using a ResizeObserver on the root catches width
245
+ // changes (which can flip wrap state on borderline labels) and any
246
+ // content-driven height changes (e.g. weight 400 → 600 on active).
247
+ const rowRefs = useRef([]);
248
+ const setRowRef = (idx) => (el) => {
249
+ rowRefs.current[idx] = el;
250
+ };
251
+ useEffect(() => {
252
+ if (typeof window === 'undefined' || !rootRef.current) return;
253
+ const measure = () => {
254
+ const next = FLAT.map((item, idx) => {
255
+ const el = rowRefs.current[idx];
256
+ if (!el) return initialAnchors[idx];
257
+ const top = el.offsetTop;
258
+ const h = el.offsetHeight;
259
+ return {
260
+ x: railLeft + item.depth * indent,
261
+ y: top + h / 2,
262
+ };
263
+ });
264
+ // Short-circuit when nothing changed — same values, same array
265
+ // reference. Prevents the anchorDist useMemo downstream from
266
+ // recomputing and tearing down the comet animation mid-ease.
267
+ setAnchors((prev) => {
268
+ if (
269
+ prev.length === next.length &&
270
+ next.every(
271
+ (a, i) => prev[i] && prev[i].x === a.x && prev[i].y === a.y,
272
+ )
273
+ ) {
274
+ return prev;
275
+ }
276
+ return next;
277
+ });
278
+ };
279
+ measure();
280
+ const ro = new ResizeObserver(measure);
281
+ ro.observe(rootRef.current);
282
+ rowRefs.current.forEach((el) => el && ro.observe(el));
283
+ return () => ro.disconnect();
284
+ // NOTE: deliberately NO `activeId` / `activeIdx` here — those are
285
+ // selection state, not layout. Re-running the measurement on every
286
+ // scroll-spy update would create a new anchors-array reference even
287
+ // when nothing physical changed, which would restart the comet
288
+ // animation every frame (visible as comet jitter).
289
+ }, [FLAT, railLeft, indent, padTop, rowH, initialAnchors]);
290
+
291
+ const pathRef = useRef(null);
292
+ const [pathLen, setPathLen] = useState(0);
293
+
294
+ // Client-only: measure the path once it's in the DOM. (useEffect, not
295
+ // useLayoutEffect, to avoid the SSR warning; brief first-frame settle is ok.)
296
+ useEffect(() => {
297
+ if (pathRef.current) setPathLen(pathRef.current.getTotalLength());
298
+ }, [fullPath]);
299
+
300
+ const anchorDist = useMemo(() => {
301
+ if (!pathRef.current || !pathLen) return [];
302
+ const samples = 800;
303
+ const pts = [];
304
+ for (let i = 0; i <= samples; i++)
305
+ pts.push(pathRef.current.getPointAtLength((i / samples) * pathLen));
306
+ return anchors.map((a) => {
307
+ let bestI = 0;
308
+ let bestD = Infinity;
309
+ for (let i = 0; i < pts.length; i++) {
310
+ const dx = pts[i].x - a.x;
311
+ const dy = pts[i].y - a.y;
312
+ const d = dx * dx + dy * dy;
313
+ if (d < bestD) {
314
+ bestD = d;
315
+ bestI = i;
316
+ }
317
+ }
318
+ return (bestI / samples) * pathLen;
319
+ });
320
+ }, [anchors, pathLen]);
321
+
322
+ const [selHead, setSelHead] = useState(0);
323
+ const [selPt, setSelPt] = useState({ x: 0, y: 0 });
324
+ const [hovHead, setHovHead] = useState(0);
325
+ const [hovPt, setHovPt] = useState({ x: 0, y: 0 });
326
+
327
+ // Scroll-driven selection head glides to the active section.
328
+ useEffect(() => {
329
+ if (activeIdx == null || !anchorDist.length || !pathRef.current) return;
330
+ const dest = anchorDist[activeIdx];
331
+ let raf;
332
+ const start = selHead;
333
+ const t0 = performance.now();
334
+ const ease = (t) => 1 - Math.pow(1 - t, 3);
335
+ const tick = (now) => {
336
+ const t = Math.min(1, (now - t0) / 500);
337
+ const v = start + (dest - start) * ease(t);
338
+ setSelHead(v);
339
+ const pt = pathRef.current.getPointAtLength(v);
340
+ setSelPt({ x: pt.x, y: pt.y });
341
+ if (t < 1) raf = requestAnimationFrame(tick);
342
+ };
343
+ raf = requestAnimationFrame(tick);
344
+ return () => cancelAnimationFrame(raf);
345
+ }, [activeIdx, anchorDist]);
346
+
347
+ // Hover head animates independently; selection trail is never cleared.
348
+ useEffect(() => {
349
+ if (hoverIdx == null || !anchorDist.length || !pathRef.current) return;
350
+ const dest = anchorDist[hoverIdx];
351
+ let raf;
352
+ const start = hovHead || dest;
353
+ const t0 = performance.now();
354
+ const ease = (t) => 1 - Math.pow(1 - t, 3);
355
+ const tick = (now) => {
356
+ const t = Math.min(1, (now - t0) / 280);
357
+ const v = start + (dest - start) * ease(t);
358
+ setHovHead(v);
359
+ const pt = pathRef.current.getPointAtLength(v);
360
+ setHovPt({ x: pt.x, y: pt.y });
361
+ if (t < 1) raf = requestAnimationFrame(tick);
362
+ };
363
+ raf = requestAnimationFrame(tick);
364
+ return () => cancelAnimationFrame(raf);
365
+ }, [hoverIdx, anchorDist]);
366
+
367
+ const hovTailStart = Math.max(0, hovHead - tail);
368
+ const hovVisible = hovHead - hovTailStart;
369
+
370
+ const trail = (headVisible, tailStart, baseW, taper, baseA, aTaper, keyP) => {
371
+ const SEGMENTS = 28;
372
+ const OVERLAP = 0.5;
373
+ const segs = [];
374
+ for (let i = 0; i < SEGMENTS; i++) {
375
+ const t0 = i / SEGMENTS;
376
+ const t1 = (i + 1) / SEGMENTS;
377
+ const segStart = tailStart + headVisible * t0 - (i > 0 ? OVERLAP : 0);
378
+ const segEnd = tailStart + headVisible * t1;
379
+ const dashLen = Math.max(0, segEnd - segStart);
380
+ if (dashLen <= 0) continue;
381
+ const f = t1;
382
+ segs.push(
383
+ <path
384
+ key={`${keyP}${i}`}
385
+ d={fullPath}
386
+ fill="none"
387
+ strokeLinecap="round"
388
+ strokeDasharray={`${dashLen} ${pathLen}`}
389
+ strokeDashoffset={-segStart}
390
+ style={{
391
+ stroke: ACCENT,
392
+ strokeWidth: baseW + Math.pow(f, 1.6) * taper,
393
+ opacity: baseA + Math.pow(f, aTaper) * (0.95 - baseA),
394
+ }}
395
+ />
396
+ );
397
+ }
398
+ return <g>{segs}</g>;
399
+ };
400
+
401
+ // Keep the active row inside the parent scroll container's visible
402
+ // region — pulling it above the footer-eclipsed band, plus a small
403
+ // safety gap so the active row never sits flush against the footer.
404
+ //
405
+ // The bottom-of-visible-region is computed from a LIVE measurement of
406
+ // the footer's getBoundingClientRect (looked up via `[data-velu-footer]`)
407
+ // rather than from the parent's CSS scroll-padding-bottom. The CSS
408
+ // version is driven by React state that lags one commit behind the
409
+ // user's actual scroll position — at high scroll velocities the active
410
+ // row would otherwise creep under the footer between frames. Reading
411
+ // the footer rect synchronously in the scroll handler is always
412
+ // current with the user's input.
413
+ useEffect(() => {
414
+ if (activeIdx == null) return;
415
+ const BOTTOM_GAP = 32; // px clear between active row and footer top
416
+ const update = () => {
417
+ const el = rootRef.current?.querySelector('[data-toc-active="true"]');
418
+ if (!el) return;
419
+ const footer = document.querySelector('[data-velu-footer]');
420
+ let padBottom;
421
+ if (footer) {
422
+ const rect = footer.getBoundingClientRect();
423
+ const overlap = Math.max(0, window.innerHeight - rect.top);
424
+ padBottom = overlap + BOTTOM_GAP;
425
+ }
426
+ scrollIntoNearestView(
427
+ el,
428
+ padBottom !== undefined ? { padBottom } : undefined,
429
+ );
430
+ };
431
+ update();
432
+ window.addEventListener('scroll', update, { passive: true });
433
+ return () => window.removeEventListener('scroll', update);
434
+ }, [activeIdx]);
435
+
436
+ return (
437
+ <div
438
+ ref={rootRef}
439
+ onMouseLeave={() => setHoverIdx(null)}
440
+ style={{
441
+ /* Container is naturally sized by the row stack inside —
442
+ wrapped rows extend it. `position: relative` makes the
443
+ absolutely-positioned SVG layer size to this container, and
444
+ gives the rows a stable offsetParent for the measurement
445
+ effect above. padBlock = padTop on both ends matches the
446
+ previous coordinate model's gutter. */
447
+ position: 'relative',
448
+ paddingBlockStart: padTop,
449
+ paddingBlockEnd: padTop,
450
+ }}
451
+ {...rest}
452
+ >
453
+ <svg
454
+ width="100%"
455
+ height="100%"
456
+ style={{
457
+ position: 'absolute',
458
+ inset: 0,
459
+ pointerEvents: 'none',
460
+ overflow: 'visible',
461
+ }}
462
+ >
463
+ <path
464
+ ref={pathRef}
465
+ d={fullPath}
466
+ fill="none"
467
+ style={{ stroke: RAIL_BASE, strokeWidth: 'var(--border-width)' }}
468
+ />
469
+
470
+ {pathLen > 0 &&
471
+ activeIdx != null &&
472
+ trail(selHead, 0, SEL_W, SEL_TAPER, 0.95, 1, 's')}
473
+ {pathLen > 0 && activeIdx != null && (
474
+ <g>
475
+ <circle cx={selPt.x} cy={selPt.y} r={cometHaloR} style={{ fill: HALO }} />
476
+ <circle
477
+ cx={selPt.x}
478
+ cy={selPt.y}
479
+ r={cometCoreR}
480
+ style={{
481
+ fill: ACCENT,
482
+ filter: `drop-shadow(0 0 ${cometGlow}px ${GLOW})`,
483
+ }}
484
+ />
485
+ </g>
486
+ )}
487
+
488
+ {pathLen > 0 &&
489
+ hoverIdx != null &&
490
+ trail(hovVisible, hovTailStart, HOV_W, HOV_TAPER, 0.05, 1.3, 'h')}
491
+ {pathLen > 0 && hoverIdx != null && (
492
+ <path
493
+ d={fullPath}
494
+ fill="none"
495
+ strokeLinecap="round"
496
+ strokeDasharray={`${Math.min(hovVisible, tail * 0.5)} ${pathLen}`}
497
+ strokeDashoffset={-(hovHead - Math.min(hovVisible, tail * 0.5))}
498
+ style={{
499
+ stroke: ACCENT,
500
+ strokeWidth: HOV_GLOW_W,
501
+ filter: `blur(${cometGlow}px)`,
502
+ opacity: 0.45,
503
+ }}
504
+ />
505
+ )}
506
+ {pathLen > 0 && hoverIdx != null && (
507
+ <g>
508
+ <circle cx={hovPt.x} cy={hovPt.y} r={cometHaloR} style={{ fill: HALO }} />
509
+ <circle
510
+ cx={hovPt.x}
511
+ cy={hovPt.y}
512
+ r={cometCoreR}
513
+ style={{ fill: ACCENT, filter: `drop-shadow(0 0 ${cometGlow}px ${GLOW})` }}
514
+ />
515
+ </g>
516
+ )}
517
+ </svg>
518
+
519
+ {FLAT.map((item, idx) => (
520
+ <Row
521
+ key={item.id}
522
+ ref={setRowRef(idx)}
523
+ item={item}
524
+ idx={idx}
525
+ onEnter={setHoverIdx}
526
+ onClick={onSelect}
527
+ hovered={hoverIdx === idx}
528
+ active={activeIdx === idx}
529
+ rowH={rowH}
530
+ indent={indent}
531
+ textStart={textStart}
532
+ fontSize={fontSize}
533
+ />
534
+ ))}
535
+ </div>
536
+ );
537
+ }