@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,366 @@
1
+ import React, {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ useMemo,
6
+ useCallback,
7
+ } from 'react';
8
+ import resolveIcon from '../lib/resolveIcon.jsx';
9
+
10
+ /**
11
+ * Search — a Raycast-style command palette for the docs site. Ported
12
+ * from the Claude-Design "Velu Search" handoff and retokenized onto
13
+ * velu-ui's scale (themes for free via [data-theme]).
14
+ *
15
+ * <Search results={[…]} onSelect={(item) => …} />
16
+ *
17
+ * Renders a trigger box (drop it at the top of the page). Clicking it —
18
+ * or pressing ⌘K / Ctrl+K anywhere — reveals the palette: a scrim plus
19
+ * a centered panel with a search input and grouped, keyboard-navigable
20
+ * results (↑/↓ to move, Enter to pick, Esc / scrim-click to close).
21
+ *
22
+ * @typedef {Object} SearchResult
23
+ * @property {string} id
24
+ * @property {string} group 'Recent Searches' | 'Suggested' | 'Pages'
25
+ * @property {string[]} breadcrumb
26
+ * @property {string} title
27
+ * @property {'page'|'anchor'|'ext'} kind
28
+ * @property {string} desc
29
+ * @property {string} [href]
30
+ *
31
+ * @param {{ results?: SearchResult[], placeholder?: string,
32
+ * onSelect?: (item: SearchResult) => void,
33
+ * className?: string }} props
34
+ */
35
+
36
+ /* Sample result set — the demo data from the design. Override via the
37
+ `results` prop with real indexed content. */
38
+ const DEFAULT_RESULTS = [
39
+ {
40
+ id: 'auth-pw',
41
+ group: 'Recent Searches',
42
+ breadcrumb: ['Authentication setup', 'Password'],
43
+ title: 'Password',
44
+ kind: 'anchor',
45
+ desc: 'Password authentication provides access control through credentials.',
46
+ },
47
+ {
48
+ id: 'gh-sync',
49
+ group: 'Recent Searches',
50
+ breadcrumb: ['Get Started', 'GitHub & GitLab Sync'],
51
+ title: 'Enabling GitHub sync',
52
+ kind: 'page',
53
+ desc: 'Connect a GitHub repository to keep your docs in sync on every push.',
54
+ },
55
+ {
56
+ id: 'ai-search',
57
+ group: 'Recent Searches',
58
+ breadcrumb: ['Your Docs Site'],
59
+ title: 'AI Search',
60
+ kind: 'page',
61
+ desc: 'Configure semantic search for your documentation site.',
62
+ },
63
+ {
64
+ id: 'quickstart',
65
+ group: 'Suggested',
66
+ breadcrumb: ['Get Started'],
67
+ title: 'Quickstart',
68
+ kind: 'page',
69
+ desc: 'Set up Velu and publish your first docs site in under 5 minutes.',
70
+ },
71
+ {
72
+ id: 'overview',
73
+ group: 'Suggested',
74
+ breadcrumb: ['Get Started'],
75
+ title: 'Overview',
76
+ kind: 'page',
77
+ desc: 'Velu is an AI-native documentation platform built for humans and AI agents.',
78
+ },
79
+ {
80
+ id: 'site-settings',
81
+ group: 'Suggested',
82
+ breadcrumb: ['Your Docs Site'],
83
+ title: 'Site Settings',
84
+ kind: 'page',
85
+ desc: 'Configure domain, theme, navigation and SEO for your docs site.',
86
+ },
87
+ {
88
+ id: 'oauth',
89
+ group: 'Pages',
90
+ breadcrumb: ['Authentication setup', 'OAuth'],
91
+ title: 'OAuth',
92
+ kind: 'anchor',
93
+ desc: 'Configure OAuth providers for sign-in.',
94
+ },
95
+ {
96
+ id: 'saml',
97
+ group: 'Pages',
98
+ breadcrumb: ['Authentication setup', 'SAML'],
99
+ title: 'SAML',
100
+ kind: 'anchor',
101
+ desc: 'Enterprise single sign-on via SAML 2.0.',
102
+ },
103
+ ];
104
+
105
+ const GROUP_ORDER = ['Recent Searches', 'Suggested', 'Pages'];
106
+
107
+ /* Left-hand glyph: '#' for an anchor, an icon for page / external. */
108
+ function ResultGlyph({ kind }) {
109
+ if (kind === 'anchor') {
110
+ return <span className="velu-search__glyph-hash">#</span>;
111
+ }
112
+ const icon = kind === 'ext' ? 'external-link' : 'file-text';
113
+ return (
114
+ <span className="velu-search__glyph-icon">
115
+ {resolveIcon(icon, { size: '1em' })}
116
+ </span>
117
+ );
118
+ }
119
+
120
+ function Breadcrumb({ items }) {
121
+ return (
122
+ <div className="velu-search__crumbs">
123
+ {items.map((c, i) => (
124
+ <React.Fragment key={i}>
125
+ {i > 0 && <span className="velu-search__crumb-sep">›</span>}
126
+ <span>{c}</span>
127
+ </React.Fragment>
128
+ ))}
129
+ </div>
130
+ );
131
+ }
132
+
133
+ function PaletteRow({ item, selected, navMode, onHover, onSelect }) {
134
+ const ref = useRef(null);
135
+ useEffect(() => {
136
+ // Only auto-scroll for keyboard nav — on mouse hover the pointer is
137
+ // already on the row, and scrolling there nudges the list (a 1px
138
+ // jitter that flickers a line at the list edge).
139
+ if (selected && navMode.current === 'keyboard' && ref.current) {
140
+ ref.current.scrollIntoView({ block: 'nearest' });
141
+ }
142
+ }, [selected, navMode]);
143
+ return (
144
+ <div
145
+ ref={ref}
146
+ className={`velu-search__row${selected ? ' velu-search__row--selected' : ''}`}
147
+ onMouseEnter={onHover}
148
+ onClick={onSelect}
149
+ role="option"
150
+ aria-selected={selected}
151
+ >
152
+ <div className="velu-search__row-glyph">
153
+ <ResultGlyph kind={item.kind} />
154
+ </div>
155
+ <Breadcrumb items={item.breadcrumb} />
156
+ <div className="velu-search__row-title">{item.title}</div>
157
+ <div className="velu-search__row-desc">{item.desc}</div>
158
+ <div className="velu-search__row-meta">
159
+ <span className="velu-search__kbd">↵</span>
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ /* The revealed palette — scrim + centered panel. */
166
+ function SearchPalette({ results, placeholder, onSelect, onClose }) {
167
+ const [query, setQuery] = useState('');
168
+ const [selected, setSelected] = useState(0);
169
+ const inputRef = useRef(null);
170
+ // 'keyboard' | 'mouse' — which input last moved the selection; gates
171
+ // the rows' scrollIntoView so mouse hover doesn't jitter the list.
172
+ const navMode = useRef('keyboard');
173
+
174
+ useEffect(() => {
175
+ inputRef.current?.focus();
176
+ }, []);
177
+
178
+ const filtered = useMemo(() => {
179
+ const q = query.trim().toLowerCase();
180
+ if (!q) {
181
+ // Empty query → just the recents.
182
+ return results.filter((r) => r.group === 'Recent Searches');
183
+ }
184
+ return results.filter((r) => {
185
+ const hay = `${r.title} ${r.breadcrumb.join(' ')} ${r.desc}`.toLowerCase();
186
+ return hay.includes(q);
187
+ });
188
+ }, [query, results]);
189
+
190
+ const grouped = useMemo(() => {
191
+ const map = new Map();
192
+ filtered.forEach((r) => {
193
+ if (!map.has(r.group)) map.set(r.group, []);
194
+ map.get(r.group).push(r);
195
+ });
196
+ return GROUP_ORDER.filter((g) => map.has(g)).map((g) => [g, map.get(g)]);
197
+ }, [filtered]);
198
+
199
+ const flat = useMemo(
200
+ () => grouped.flatMap(([, items]) => items),
201
+ [grouped],
202
+ );
203
+
204
+ useEffect(() => setSelected(0), [query]);
205
+
206
+ const pick = useCallback(
207
+ (item) => {
208
+ onSelect?.(item);
209
+ onClose();
210
+ },
211
+ [onSelect, onClose],
212
+ );
213
+
214
+ useEffect(() => {
215
+ const onKey = (e) => {
216
+ if (e.key === 'Escape') {
217
+ e.preventDefault();
218
+ onClose();
219
+ } else if (e.key === 'ArrowDown') {
220
+ e.preventDefault();
221
+ navMode.current = 'keyboard';
222
+ setSelected((s) => Math.min(s + 1, flat.length - 1));
223
+ } else if (e.key === 'ArrowUp') {
224
+ e.preventDefault();
225
+ navMode.current = 'keyboard';
226
+ setSelected((s) => Math.max(s - 1, 0));
227
+ } else if (e.key === 'Enter') {
228
+ e.preventDefault();
229
+ if (flat[selected]) pick(flat[selected]);
230
+ else onClose();
231
+ }
232
+ };
233
+ window.addEventListener('keydown', onKey);
234
+ return () => window.removeEventListener('keydown', onKey);
235
+ }, [flat, selected, pick, onClose]);
236
+
237
+ let rowIndex = -1;
238
+
239
+ return (
240
+ <div
241
+ className="velu-search__scrim"
242
+ onClick={onClose}
243
+ role="presentation"
244
+ >
245
+ <div
246
+ className="velu-search__palette"
247
+ role="dialog"
248
+ aria-modal="true"
249
+ aria-label="Search"
250
+ onClick={(e) => e.stopPropagation()}
251
+ >
252
+ <div className="velu-search__input-wrap">
253
+ <span className="velu-search__input-icon" aria-hidden="true">
254
+ {resolveIcon('search', { size: '1em' })}
255
+ </span>
256
+ <input
257
+ ref={inputRef}
258
+ className="velu-search__input"
259
+ placeholder={placeholder}
260
+ value={query}
261
+ onChange={(e) => setQuery(e.target.value)}
262
+ spellCheck={false}
263
+ aria-label="Search query"
264
+ />
265
+ <span className="velu-search__kbd">esc</span>
266
+ </div>
267
+
268
+ <div className="velu-search__list" role="listbox">
269
+ {grouped.length === 0 && (
270
+ <div className="velu-search__empty">
271
+ <span className="velu-search__empty-icon" aria-hidden="true">
272
+ {resolveIcon('search-x', { size: '1em' })}
273
+ </span>
274
+ <div className="velu-search__empty-title">
275
+ No results for &ldquo;{query}&rdquo;
276
+ </div>
277
+ <div className="velu-search__empty-sub">
278
+ Try a different keyword or browse the sidebar.
279
+ </div>
280
+ </div>
281
+ )}
282
+ {grouped.map(([group, items]) => (
283
+ <div key={group}>
284
+ <div className="velu-search__group">{group}</div>
285
+ {items.map((item) => {
286
+ rowIndex += 1;
287
+ const idx = rowIndex;
288
+ return (
289
+ <PaletteRow
290
+ key={item.id}
291
+ item={item}
292
+ selected={idx === selected}
293
+ navMode={navMode}
294
+ onHover={() => {
295
+ navMode.current = 'mouse';
296
+ setSelected(idx);
297
+ }}
298
+ onSelect={() => pick(item)}
299
+ />
300
+ );
301
+ })}
302
+ </div>
303
+ ))}
304
+ </div>
305
+ </div>
306
+ </div>
307
+ );
308
+ }
309
+
310
+ export default function Search({
311
+ results = DEFAULT_RESULTS,
312
+ placeholder = 'Search documentation',
313
+ onSelect,
314
+ className = '',
315
+ ...rest
316
+ }) {
317
+ const [open, setOpen] = useState(false);
318
+ // Show ⌘ K on Mac, Ctrl K elsewhere. SSR-safe: default to the
319
+ // non-Mac label (so the server render matches the first client
320
+ // render), then switch on the client if it's actually Mac.
321
+ const [isMac, setIsMac] = useState(false);
322
+ useEffect(() => {
323
+ if (typeof navigator === 'undefined') return;
324
+ const ua = navigator.userAgent || '';
325
+ const plat = navigator.platform || '';
326
+ setIsMac(/Mac|iPhone|iPad|iPod/i.test(plat) || /Mac/i.test(ua));
327
+ }, []);
328
+
329
+ // Global ⌘K (Mac) / Ctrl+K (Win/Linux) opens the palette.
330
+ useEffect(() => {
331
+ const onKey = (e) => {
332
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
333
+ e.preventDefault();
334
+ setOpen(true);
335
+ }
336
+ };
337
+ window.addEventListener('keydown', onKey);
338
+ return () => window.removeEventListener('keydown', onKey);
339
+ }, []);
340
+
341
+ return (
342
+ <>
343
+ <button
344
+ type="button"
345
+ className={`velu-search__trigger ${className}`.trim()}
346
+ onClick={() => setOpen(true)}
347
+ {...rest}
348
+ >
349
+ <span className="velu-search__trigger-icon" aria-hidden="true">
350
+ {resolveIcon('search', { size: '1.5em' })}
351
+ </span>
352
+ <span className="velu-search__trigger-label">Search…</span>
353
+ <span className="velu-search__kbd">{isMac ? '⌘ K' : 'Ctrl K'}</span>
354
+ </button>
355
+
356
+ {open && (
357
+ <SearchPalette
358
+ results={results}
359
+ placeholder={placeholder}
360
+ onSelect={onSelect}
361
+ onClose={() => setOpen(false)}
362
+ />
363
+ )}
364
+ </>
365
+ );
366
+ }
@@ -0,0 +1,191 @@
1
+ import React from 'react';
2
+ import { ChevronRight, ExternalLink } from 'lucide-react';
3
+ import Stack from '../primitives/Stack.jsx';
4
+ import resolveIcon from '../lib/resolveIcon.jsx';
5
+ import scrollIntoNearestView from '../lib/scrollIntoNearestView.js';
6
+
7
+ /**
8
+ * Sidebar — docs navigation. Data-driven, recursive (arbitrary depth),
9
+ * tokenized, SSR-safe.
10
+ *
11
+ * Composition: ALL vertical rhythm is the Stack primitive (DRY) — faithful to
12
+ * the source design's flat structure:
13
+ * <Stack as="nav" space=--s1> ← headings + lists alternate here
14
+ * <h5 section/> (uniform --s1 between every child)
15
+ * <Stack as="ul" space=--s0> items </Stack>
16
+ * nested groups: <Stack as="ul" space="0"> (spacing via item padding)
17
+ * sidebar.css holds only what Stack cannot: rails, active state, item rows,
18
+ * chevron, summary, icon sizing.
19
+ *
20
+ * Icons (`section.icon` / `item.icon`) are **lucide icon id strings**
21
+ * (kebab-case), resolved by the shared resolveIcon util (a React node is
22
+ * also accepted). Size + stroke come from .velu-sidebar__icon CSS.
23
+ *
24
+ * Selection is ROUTE-DRIVEN (SSR-correct, reload-safe, URL-shareable): an
25
+ * item is active when its href === activeHref (or active:true). Ancestor
26
+ * groups of the active item auto-open. Router-agnostic via `linkComponent`
27
+ * (defaults to <a>); external items always render a real <a target=_blank>.
28
+ *
29
+ * @typedef {Object} SidebarItem
30
+ * @property {string} label
31
+ * @property {string} [href]
32
+ * @property {string|React.ReactNode} [icon] // lucide id or node
33
+ * @property {boolean} [active]
34
+ * @property {boolean} [external]
35
+ * @property {SidebarItem[]} [items]
36
+ *
37
+ * @param {{ sections: { title: string, icon?: string|React.ReactNode,
38
+ * items: SidebarItem[] }[], activeHref?: string,
39
+ * linkComponent?: React.ElementType, className?: string }} props
40
+ */
41
+
42
+ const SidebarCtx = React.createContext({ activeHref: undefined, Link: 'a' });
43
+
44
+ function isActive(item, activeHref) {
45
+ return Boolean(
46
+ item.active || (item.href != null && item.href === activeHref)
47
+ );
48
+ }
49
+
50
+ function hasActiveDescendant(item, activeHref) {
51
+ if (isActive(item, activeHref)) return true;
52
+ return (
53
+ Array.isArray(item.items) &&
54
+ item.items.some((c) => hasActiveDescendant(c, activeHref))
55
+ );
56
+ }
57
+
58
+ function Icon({ icon }) {
59
+ const node = resolveIcon(icon);
60
+ return node ? (
61
+ <span className="velu-sidebar__icon" aria-hidden="true">
62
+ {node}
63
+ </span>
64
+ ) : null;
65
+ }
66
+
67
+ function Chevron() {
68
+ return (
69
+ <ChevronRight
70
+ className="velu-sidebar__chevron"
71
+ aria-hidden="true"
72
+ focusable="false"
73
+ />
74
+ );
75
+ }
76
+
77
+ function ExternalIcon() {
78
+ return (
79
+ <ExternalLink
80
+ className="velu-sidebar__icon"
81
+ aria-hidden="true"
82
+ focusable="false"
83
+ />
84
+ );
85
+ }
86
+
87
+ /** Recursive node. depth 0 = top-level; depth >= 1 = nested (subitem style). */
88
+ function Node({ item, depth }) {
89
+ const { activeHref, Link } = React.useContext(SidebarCtx);
90
+ const { label, href = '#', icon, external, items } = item;
91
+ const base = depth === 0 ? 'velu-sidebar__item' : 'velu-sidebar__subitem';
92
+
93
+ if (items && items.length) {
94
+ return (
95
+ <li>
96
+ <details open={hasActiveDescendant(item, activeHref) || undefined}>
97
+ <summary className={`${base} velu-sidebar__summary`}>
98
+ <Icon icon={icon} />
99
+ <span className="velu-sidebar__label">{label}</span>
100
+ <Chevron />
101
+ </summary>
102
+ {/* nested list: Stack with no gap (spacing = item padding-block),
103
+ matching the source's stack-s */}
104
+ <Stack as="ul" space="0" className="velu-sidebar__sublist">
105
+ {items.map((child, i) => (
106
+ <Node key={i} item={child} depth={depth + 1} />
107
+ ))}
108
+ </Stack>
109
+ </details>
110
+ </li>
111
+ );
112
+ }
113
+
114
+ const active = isActive(item, activeHref);
115
+ const cls = base + (active ? ' velu-sidebar__item--active' : '');
116
+ const LinkTag = external ? 'a' : Link;
117
+ const linkProps = external
118
+ ? { href, target: '_blank', rel: 'noreferrer' }
119
+ : { href };
120
+
121
+ return (
122
+ <li>
123
+ <LinkTag
124
+ className={cls}
125
+ aria-current={active ? 'page' : undefined}
126
+ {...linkProps}
127
+ >
128
+ <Icon icon={icon} />
129
+ <span className="velu-sidebar__label">{label}</span>
130
+ {external ? <ExternalIcon /> : null}
131
+ </LinkTag>
132
+ </li>
133
+ );
134
+ }
135
+
136
+ export default function Sidebar({
137
+ sections = [],
138
+ activeHref,
139
+ linkComponent = 'a',
140
+ className = '',
141
+ ...rest
142
+ }) {
143
+ const ctx = React.useMemo(
144
+ () => ({ activeHref, Link: linkComponent }),
145
+ [activeHref, linkComponent]
146
+ );
147
+
148
+ // Bring the active item into view inside the parent scroll container
149
+ // whenever the active route changes — e.g. after navigation, the
150
+ // selected page is already visible without the user scrolling the
151
+ // sidebar manually. Uses the shared util (instead of native
152
+ // scrollIntoView) so `scroll-padding-*` on the parent — which the
153
+ // docs layout uses to mark the footer-eclipsed band as off-limits —
154
+ // is reliably honoured.
155
+ const rootRef = React.useRef(null);
156
+ React.useEffect(() => {
157
+ const el = rootRef.current?.querySelector('[aria-current="page"]');
158
+ scrollIntoNearestView(el);
159
+ }, [activeHref]);
160
+
161
+ return (
162
+ <SidebarCtx.Provider value={ctx}>
163
+ <Stack
164
+ ref={rootRef}
165
+ as="nav"
166
+ space="var(--s0)"
167
+ className={`velu-sidebar ${className}`.trim()}
168
+ aria-label="Documentation"
169
+ {...rest}
170
+ >
171
+ {/* Each section is its own Stack so the heading sits TIGHT to
172
+ its list (small inner gap), while the nav's larger gap
173
+ separates one section from the next — compact but still
174
+ visibly grouped. */}
175
+ {sections.map((section, i) => (
176
+ <Stack key={i} space="var(--s-4)">
177
+ <h5 className="velu-sidebar__section">
178
+ <Icon icon={section.icon} />
179
+ {section.title}
180
+ </h5>
181
+ <Stack as="ul" space="var(--s-4)" className="velu-sidebar__list">
182
+ {section.items.map((it, j) => (
183
+ <Node key={j} item={it} depth={0} />
184
+ ))}
185
+ </Stack>
186
+ </Stack>
187
+ ))}
188
+ </Stack>
189
+ </SidebarCtx.Provider>
190
+ );
191
+ }
@@ -0,0 +1,65 @@
1
+ import React, { Children, cloneElement, isValidElement } from 'react';
2
+
3
+ /**
4
+ * Steps + Step — numbered procedural steps with a continuous accent rail
5
+ * down the left edge.
6
+ *
7
+ * <Steps>
8
+ * <Step title="First Step">These are instructions…</Step>
9
+ * <Step title="Second Step">…</Step>
10
+ * <Step title="Third Step">…</Step>
11
+ * </Steps>
12
+ *
13
+ * Steps assigns 1-based `index` to each <Step> child via cloneElement —
14
+ * callers don't have to number them by hand. A <Step> rendered outside
15
+ * <Steps> falls back to whatever `index` it was given (or 1).
16
+ *
17
+ * Each Step is a 2-column grid:
18
+ *
19
+ * [circle + line] title
20
+ * body
21
+ *
22
+ * The marker column spans both rows. The vertical rail (line below the
23
+ * circle) is `flex: 1`, so it stretches to fill the Step's full height —
24
+ * meaning adjacent steps' rails meet end-to-end with no manual height
25
+ * math. The last step keeps its rail too (matches the design).
26
+ */
27
+
28
+ export function Step({
29
+ title,
30
+ index = 1,
31
+ children,
32
+ className = '',
33
+ ...rest
34
+ }) {
35
+ return (
36
+ <div className={`velu-step ${className}`.trim()} {...rest}>
37
+ <div className="velu-step__circle" aria-hidden="true">
38
+ {index}
39
+ </div>
40
+ <div className="velu-step__line" aria-hidden="true" />
41
+ {title != null && (
42
+ <div className="velu-step__title">{title}</div>
43
+ )}
44
+ {children != null && children !== false && (
45
+ <div className="velu-step__body">{children}</div>
46
+ )}
47
+ </div>
48
+ );
49
+ }
50
+ Step.displayName = 'Step';
51
+
52
+ export default function Steps({ children, className = '', ...rest }) {
53
+ let i = 0;
54
+ const numbered = Children.map(children, (child) => {
55
+ if (!isValidElement(child)) return child;
56
+ if (child.type !== Step && child.type?.displayName !== 'Step') return child;
57
+ i += 1;
58
+ return cloneElement(child, { index: child.props.index ?? i });
59
+ });
60
+ return (
61
+ <div className={`velu-steps ${className}`.trim()} {...rest}>
62
+ {numbered}
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,48 @@
1
+ import React from 'react';
2
+ import { Sun, Moon } from 'lucide-react';
3
+
4
+ /**
5
+ * ThemeToggle — pill-shaped two-state light/dark switch. The thumb
6
+ * sits at the inline-start in light mode and slides to the inline-end
7
+ * in dark mode. A sun glyph rides the thumb in light, a moon in dark.
8
+ *
9
+ * SSR-safe by design: markup is identical regardless of theme (the
10
+ * server doesn't know it), and the thumb position + which glyph shows
11
+ * are decided purely by CSS via the `[data-theme]` attribute — NOT by
12
+ * React state. So there is no hydration mismatch and no icon flash. JS
13
+ * only handles the click: it flips `data-theme` and persists the
14
+ * explicit choice (which then overrides the OS preference on future
15
+ * visits, per the anti-flash script in the template).
16
+ */
17
+ export default function ThemeToggle({ className = '', ...rest }) {
18
+ function toggle() {
19
+ const root = document.documentElement;
20
+ const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
21
+ root.dataset.theme = next;
22
+ try {
23
+ localStorage.setItem('velu-theme', next);
24
+ } catch {
25
+ /* private mode / storage disabled — toggle still works for the session */
26
+ }
27
+ }
28
+
29
+ return (
30
+ <button
31
+ type="button"
32
+ onClick={toggle}
33
+ aria-label="Toggle color theme"
34
+ title="Toggle color theme"
35
+ className={`velu-theme-toggle ${className}`.trim()}
36
+ {...rest}
37
+ >
38
+ <Sun
39
+ className="velu-theme-toggle__icon velu-theme-toggle__icon--sun"
40
+ aria-hidden="true"
41
+ />
42
+ <Moon
43
+ className="velu-theme-toggle__icon velu-theme-toggle__icon--moon"
44
+ aria-hidden="true"
45
+ />
46
+ </button>
47
+ );
48
+ }