@veluai/velu 0.1.13 → 0.1.15

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 (98) hide show
  1. package/README.md +80 -80
  2. package/dist/cli.js +21 -21
  3. package/package.json +3 -2
  4. package/runtime/velu-ui/components/Accordion.jsx +64 -64
  5. package/runtime/velu-ui/components/ApiClient.jsx +121 -121
  6. package/runtime/velu-ui/components/ApiField.jsx +87 -87
  7. package/runtime/velu-ui/components/ApiPath.jsx +63 -63
  8. package/runtime/velu-ui/components/ApiSidebar.jsx +122 -122
  9. package/runtime/velu-ui/components/AskBar.jsx +71 -71
  10. package/runtime/velu-ui/components/Callout.jsx +114 -114
  11. package/runtime/velu-ui/components/Card.jsx +131 -131
  12. package/runtime/velu-ui/components/Chatbot.jsx +596 -596
  13. package/runtime/velu-ui/components/CodeBlock.jsx +375 -375
  14. package/runtime/velu-ui/components/Columns.jsx +56 -56
  15. package/runtime/velu-ui/components/ErrorCard.jsx +138 -138
  16. package/runtime/velu-ui/components/Field.jsx +81 -81
  17. package/runtime/velu-ui/components/Image.jsx +163 -163
  18. package/runtime/velu-ui/components/Logo.jsx +31 -0
  19. package/runtime/velu-ui/components/MethodBadge.jsx +31 -31
  20. package/runtime/velu-ui/components/NavSelect.jsx +108 -108
  21. package/runtime/velu-ui/components/PageFeedback.jsx +219 -219
  22. package/runtime/velu-ui/components/PageFooter.jsx +145 -213
  23. package/runtime/velu-ui/components/PageHeader.jsx +422 -414
  24. package/runtime/velu-ui/components/PageNav.jsx +77 -77
  25. package/runtime/velu-ui/components/PoweredBy.jsx +51 -51
  26. package/runtime/velu-ui/components/Prompt.jsx +115 -115
  27. package/runtime/velu-ui/components/Search.jsx +411 -366
  28. package/runtime/velu-ui/components/Sidebar.jsx +191 -191
  29. package/runtime/velu-ui/components/SocialLinks.jsx +90 -0
  30. package/runtime/velu-ui/components/Steps.jsx +65 -65
  31. package/runtime/velu-ui/components/ThemeToggle.jsx +48 -48
  32. package/runtime/velu-ui/components/Toc.jsx +537 -537
  33. package/runtime/velu-ui/components/TocBar.jsx +195 -195
  34. package/runtime/velu-ui/components/Tree.jsx +87 -87
  35. package/runtime/velu-ui/components/TryItBar.jsx +90 -90
  36. package/runtime/velu-ui/components/accordion.css +92 -92
  37. package/runtime/velu-ui/components/api.css +479 -479
  38. package/runtime/velu-ui/components/ask-bar.css +94 -94
  39. package/runtime/velu-ui/components/card.css +105 -105
  40. package/runtime/velu-ui/components/chatbot.css +617 -617
  41. package/runtime/velu-ui/components/code-block.css +263 -263
  42. package/runtime/velu-ui/components/docs-layout.css +784 -775
  43. package/runtime/velu-ui/components/field.css +82 -82
  44. package/runtime/velu-ui/components/image.css +237 -237
  45. package/runtime/velu-ui/components/nav-select.css +157 -157
  46. package/runtime/velu-ui/components/page-feedback.css +241 -241
  47. package/runtime/velu-ui/components/page-footer.css +130 -130
  48. package/runtime/velu-ui/components/page-header.css +557 -520
  49. package/runtime/velu-ui/components/page-nav.css +50 -50
  50. package/runtime/velu-ui/components/powered-by.css +86 -66
  51. package/runtime/velu-ui/components/prompt.css +99 -99
  52. package/runtime/velu-ui/components/search.css +307 -307
  53. package/runtime/velu-ui/components/sidebar.css +144 -144
  54. package/runtime/velu-ui/components/steps.css +77 -77
  55. package/runtime/velu-ui/components/theme-toggle.css +101 -101
  56. package/runtime/velu-ui/components/toc-bar.css +234 -234
  57. package/runtime/velu-ui/components/tree.css +49 -49
  58. package/runtime/velu-ui/index.js +48 -46
  59. package/runtime/velu-ui/lib/component-schemas.js +100 -100
  60. package/runtime/velu-ui/lib/copyText.js +64 -64
  61. package/runtime/velu-ui/lib/lang-icons.jsx +147 -147
  62. package/runtime/velu-ui/lib/prism-langs.js +957 -957
  63. package/runtime/velu-ui/lib/prism-loader.js +74 -74
  64. package/runtime/velu-ui/lib/resolveIcon.jsx +29 -29
  65. package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -66
  66. package/runtime/velu-ui/mdx-components.jsx +105 -105
  67. package/runtime/velu-ui/primitives/Cluster.jsx +49 -49
  68. package/runtime/velu-ui/primitives/Stack.jsx +63 -63
  69. package/runtime/velu-ui/primitives/Switcher.jsx +57 -57
  70. package/runtime/velu-ui/primitives/stack.css +3 -3
  71. package/runtime/velu-ui/primitives/switcher.css +25 -25
  72. package/runtime/velu-ui/styles.css +43 -43
  73. package/runtime/velu-ui/tokens.css +4 -4
  74. package/schema/velu.schema.json +281 -167
  75. package/src/lib/extract-mdx-error.js +170 -170
  76. package/src/lib/issues.js +159 -159
  77. package/src/lib/known-components.js +34 -34
  78. package/src/navigation.js +434 -434
  79. package/src/runtime/App.jsx +1506 -1476
  80. package/src/runtime/ErrorBoundary.jsx +54 -54
  81. package/src/runtime/client-entry.jsx +22 -22
  82. package/src/runtime/server-entry.jsx +16 -16
  83. package/src/template.html +48 -48
  84. package/templates/starter/ai-tools/claude-code.mdx +26 -26
  85. package/templates/starter/ai-tools/cursor.mdx +17 -17
  86. package/templates/starter/api-reference/endpoint/create.mdx +24 -24
  87. package/templates/starter/api-reference/endpoint/get.mdx +27 -27
  88. package/templates/starter/api-reference/introduction.mdx +28 -28
  89. package/templates/starter/development.mdx +19 -19
  90. package/templates/starter/essentials/code.mdx +29 -29
  91. package/templates/starter/essentials/images.mdx +29 -29
  92. package/templates/starter/essentials/markdown.mdx +25 -25
  93. package/templates/starter/essentials/navigation.mdx +39 -39
  94. package/templates/starter/essentials/settings.mdx +30 -30
  95. package/templates/starter/favicon.svg +6 -6
  96. package/templates/starter/index.mdx +31 -31
  97. package/templates/starter/quickstart.mdx +31 -31
  98. package/templates/starter/velu.json +33 -33
@@ -1,1476 +1,1506 @@
1
- import React from 'react';
2
- import { Routes, Route, useLocation, Link } from 'react-router-dom';
3
- import { MDXProvider } from '@mdx-js/react';
4
- // The project's pages + navigation, generated from velu.json by
5
- // vite-plugin-velu-site (see src/vite-plugin-velu-site.js). `pages` is
6
- // url → { Component, frontmatter, toc } (or { missing:true }).
7
- import { pages, navigation } from 'virtual:velu-site';
8
- import { resolve, normalizeUrl } from '../navigation.js';
9
- import {
10
- Stack,
11
- Cluster,
12
- ThemeToggle,
13
- Sidebar,
14
- NavSelect,
15
- Toc,
16
- TocBar,
17
- Callout,
18
- Accordion,
19
- AccordionGroup,
20
- Card,
21
- CardGroup,
22
- Switcher,
23
- Image,
24
- CodeBlock,
25
- CodeGroup,
26
- Columns,
27
- Field,
28
- Prompt,
29
- Steps,
30
- Step,
31
- AskBar,
32
- Chatbot,
33
- PageFeedback,
34
- PageNav,
35
- PageFooter,
36
- PageHeader,
37
- PoweredBy,
38
- defaultMdxComponents,
39
- resolveIcon,
40
- Search,
41
- Tree,
42
- Folder,
43
- File,
44
- TryItBar,
45
- ApiClient,
46
- ApiField,
47
- ApiSidebar,
48
- VeluMark,
49
- } from 'velu-ui';
50
- import { X, ChevronDown, ChevronUp } from 'lucide-react';
51
- import ErrorBoundary from './ErrorBoundary.jsx';
52
-
53
- const CALLOUT_TYPES = [
54
- 'note',
55
- 'warning',
56
- 'info',
57
- 'tip',
58
- 'check',
59
- 'danger',
60
- 'callout',
61
- ];
62
-
63
- function CalloutGallery() {
64
- return (
65
- <Stack space="var(--s0)">
66
- {CALLOUT_TYPES.map((t) => (
67
- <Callout key={t} type={t}>
68
- This is a {t}. This is a {t}. This is a {t}. This is a {t}.
69
- </Callout>
70
- ))}
71
- <p style={{ color: 'var(--muted-color)' }}>
72
- Custom callouts can be built with a custom icon &amp; color:
73
- </p>
74
- <Callout
75
- icon="sparkles"
76
- stroke="var(--accent-color)"
77
- bg="color-mix(in srgb, var(--accent-color) 8%, transparent)"
78
- >
79
- A custom callout — lucide id <code>"sparkles"</code> with custom{' '}
80
- <code>stroke</code> &amp; <code>bg</code>.
81
- </Callout>
82
-
83
- <Callout type="info">
84
- This is a multi-line callout. It keeps going and going so the text
85
- wraps onto several lines — the icon sits on the first line, the text
86
- wraps beside it, and every line after flows back under the icon (no
87
- hanging indent). Resize the window to watch the wrapping reflow while
88
- the icon stays put on line one. This is a multi-line callout. It keeps
89
- going so the text wraps onto several lines.
90
- </Callout>
91
- </Stack>
92
- );
93
- }
94
-
95
- function AccordionDemo() {
96
- return (
97
- <Stack space="var(--s1)">
98
- <Accordion title="This is an accordion">
99
- This is content inside the accordion. Hover the header to see the
100
- surface tint; the chevron rotates when expanded.
101
- </Accordion>
102
- <Accordion title="This is an accordion (open by default)" defaultOpen>
103
- This one renders expanded — header takes the surface tint and a
104
- divider separates it from this content.
105
- </Accordion>
106
- <AccordionGroup>
107
- <Accordion title="This is an accordion">
108
- Grouped items share one bordered box with dividers between them.
109
- </Accordion>
110
- <Accordion title="This is an accordion" defaultOpen>
111
- This is content inside the accordion.
112
- </Accordion>
113
- <Accordion title="This is an accordion">
114
- No per-item border or radius — the group owns the chrome.
115
- </Accordion>
116
- </AccordionGroup>
117
- </Stack>
118
- );
119
- }
120
-
121
- function CardDemo() {
122
- return (
123
- <Stack space="var(--s2)">
124
- <Card icon="align-justify" title="Card Title">
125
- This is how you use a card with an icon and a link. (Cards are not
126
- clickable — only the CTA card's link is.)
127
- </Card>
128
-
129
- <Card icon="align-justify" title="Card Title" horizontal>
130
- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
131
- tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
132
- veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
133
- commodo consequat.
134
- </Card>
135
-
136
- <Card
137
- img="https://picsum.photos/seed/velu/1200/560"
138
- imgAlt="Yosemite valley"
139
- title="Card Title"
140
- >
141
- This is how you use a card with an icon. The image fills the container
142
- and is clipped to the card radius.
143
- </Card>
144
-
145
- <Card
146
- icon="align-justify"
147
- title="Card Title"
148
- cta={{ label: 'Click Here', href: '#cards' }}
149
- >
150
- This is how you use a card with an icon and a link. The "Click Here"
151
- link below is the only clickable element.
152
- </Card>
153
-
154
- <CardGroup>
155
- <Card icon="align-justify" title="Card Title">
156
- This is how you use a card with an icon and a link.
157
- </Card>
158
- <Card icon="align-justify" title="Card Title">
159
- This is how you use a card with an icon and a link.
160
- </Card>
161
- </CardGroup>
162
- </Stack>
163
- );
164
- }
165
-
166
- function SwitcherBox({ children }) {
167
- return (
168
- <div
169
- style={{
170
- border: 'var(--border-width) solid var(--border-color)',
171
- borderRadius: 'var(--radius-sm)',
172
- padding: 'var(--s0) var(--s3)',
173
- }}
174
- >
175
- {children}
176
- </div>
177
- );
178
- }
179
-
180
- function SwitcherDemo() {
181
- return (
182
- <Stack space="var(--s1)">
183
- <p style={{ color: 'var(--muted-color)' }}>
184
- Resize the window — these flip between a row and a column at the
185
- container threshold, with no media queries.
186
- </p>
187
- <Switcher space="var(--s0)" threshold="30rem">
188
- <SwitcherBox>One</SwitcherBox>
189
- <SwitcherBox>Two</SwitcherBox>
190
- <SwitcherBox>Three</SwitcherBox>
191
- </Switcher>
192
- <p style={{ color: 'var(--muted-color)' }}>
193
- With <code>limit={2}</code>: more than 2 items forces vertical
194
- regardless of width.
195
- </p>
196
- <Switcher space="var(--s0)" threshold="30rem" limit={2}>
197
- <SwitcherBox>One</SwitcherBox>
198
- <SwitcherBox>Two</SwitcherBox>
199
- <SwitcherBox>Three</SwitcherBox>
200
- </Switcher>
201
- </Stack>
202
- );
203
- }
204
-
205
- const DEMO_JS = `let greeting = function (name) {
206
- console.log(\`Hello, \${name}!\`);
207
- };`;
208
- const DEMO_RB = `def greeting(name)
209
- puts "Hello, #{name}!"
210
- end`;
211
- const DEMO_PY = `def greeting(name):
212
- print(f"Hello, {name}!")`;
213
-
214
- function CodeBlockDemo() {
215
- return (
216
- <Stack space="var(--s2)">
217
- <p style={{ color: 'var(--muted-color)' }}>Code Group</p>
218
- <CodeGroup>
219
- <CodeBlock title="Javascript" language="javascript">
220
- {DEMO_JS}
221
- </CodeBlock>
222
- <CodeBlock title="Ruby" language="ruby">
223
- {DEMO_RB}
224
- </CodeBlock>
225
- <CodeBlock title="Python" language="python">
226
- {DEMO_PY}
227
- </CodeBlock>
228
- </CodeGroup>
229
-
230
- <p style={{ color: 'var(--muted-color)' }}>Code (with file name)</p>
231
- <CodeBlock filename="index.js" language="javascript">
232
- {DEMO_JS}
233
- </CodeBlock>
234
-
235
- <p style={{ color: 'var(--muted-color)' }}>
236
- With line numbers (icon suppressed via <code>withIcon={'{false}'}</code>)
237
- </p>
238
- <CodeBlock
239
- filename="index.js"
240
- language="javascript"
241
- withIcon={false}
242
- lineNumbers
243
- >
244
- {DEMO_JS}
245
- </CodeBlock>
246
-
247
- <p style={{ color: 'var(--muted-color)' }}>
248
- Line highlight — single line (<code>highlight="2"</code>)
249
- </p>
250
- <CodeBlock
251
- filename="index.js"
252
- language="javascript"
253
- lineNumbers
254
- highlight="2"
255
- >
256
- {DEMO_JS}
257
- </CodeBlock>
258
-
259
- <p style={{ color: 'var(--muted-color)' }}>
260
- Line highlight — range + single (<code>highlight="1-2,4"</code>)
261
- </p>
262
- <CodeBlock
263
- filename="server.js"
264
- language="javascript"
265
- lineNumbers
266
- highlight="1-2,4"
267
- >
268
- {`const express = require('express');
269
- const app = express();
270
-
271
- app.get('/', (req, res) => res.send('Hello'));
272
-
273
- app.listen(3000);`}
274
- </CodeBlock>
275
- </Stack>
276
- );
277
- }
278
-
279
- function ApiDemo() {
280
- const path = '/project/preview/{projectId}';
281
- const curl = `curl --request POST \\
282
- --url https://api.mintlify.com/v1/project/update/{projectId} \\
283
- --header 'Authorization: Bearer <token>'`;
284
- return (
285
- <Stack space="var(--s2)">
286
- <p style={{ color: 'var(--muted-color)' }}>Try-it bar</p>
287
- <Stack space="var(--s-1)">
288
- <TryItBar method="POST" path={path} />
289
- <TryItBar method="GET" path={path} />
290
- <TryItBar method="PUT" path={path} />
291
- <TryItBar method="DELETE" path={path} />
292
- <TryItBar method="PATCH" path={path} />
293
- </Stack>
294
-
295
- <p style={{ color: 'var(--muted-color)' }}>API sidebar</p>
296
- <ApiSidebar
297
- activeHref="/api/status"
298
- sections={[
299
- {
300
- title: 'Admin',
301
- icon: 'rocket',
302
- endpoints: [
303
- { method: 'POST', label: 'Trigger', href: '/api/trigger' },
304
- {
305
- method: 'GET',
306
- label: 'Get deployment-status',
307
- href: '/api/status',
308
- },
309
- {
310
- method: 'DELETE',
311
- label: 'Trigger Preview deployment',
312
- href: '/api/preview',
313
- },
314
- ],
315
- },
316
- ]}
317
- />
318
-
319
- <p style={{ color: 'var(--muted-color)' }}>API Client</p>
320
- {/* Only the ApiClient breaks out of the 46rem prose column — the
321
- TryItBars above stay at the normal column width. clamp keeps
322
- it ≥ 100% (so it never collapses when the main-area calc goes
323
- negative on narrow screens) and ≤ 64rem. */}
324
- <div
325
- style={{
326
- width:
327
- 'clamp(100%, calc(100vw - 240px - 280px - var(--s4) * 2), 64rem)',
328
- }}
329
- >
330
- <ApiClient
331
- method="DELETE"
332
- label="Trigger Delete"
333
- path={path}
334
- onClose={() => {}}
335
- description="Trigger a documentation deployment programmatically to publish updates outside of Git workflows."
336
- aside={
337
- <Stack space="var(--s1)">
338
- <Callout type="danger">401 — Unauthorized</Callout>
339
- <CodeGroup>
340
- <CodeBlock title="Response" language="json">
341
- {`{\n "status": 401,\n "error": "Unauthorized"\n}`}
342
- </CodeBlock>
343
- <CodeBlock title="Headers" language="http">
344
- {`HTTP/1.1 401 Unauthorized
345
- date: Mon, 27 Apr 2026 12:00:55 GMT
346
- content-type: application/json; charset=utf-8
347
- content-length: 24
348
- connection: close
349
- x-powered-by: Express
350
- access-control-allow-origin: *`}
351
- </CodeBlock>
352
- </CodeGroup>
353
- <CodeGroup>
354
- <CodeBlock title="Curl" language="bash">
355
- {curl}
356
- </CodeBlock>
357
- <CodeBlock title="Ruby" language="ruby">
358
- {`require 'net/http'
359
- uri = URI('https://api.mintlify.com/v1/project/update/<projectId>')
360
- req = Net::HTTP::Post.new(uri)
361
- req['Authorization'] = 'Bearer <token>'
362
- Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }`}
363
- </CodeBlock>
364
- <CodeBlock title="Python" language="python">
365
- {`import requests
366
- requests.post(
367
- 'https://api.mintlify.com/v1/project/update/<projectId>',
368
- headers={'Authorization': 'Bearer <token>'},
369
- )`}
370
- </CodeBlock>
371
- </CodeGroup>
372
- <CodeGroup>
373
- <CodeBlock title="202" language="json">
374
- {`{\n "statusId": "<string>"\n}`}
375
- </CodeBlock>
376
- <CodeBlock title="200" language="json">
377
- {`{\n "statusId": "<string>",\n "completedAt": "2026-04-27T12:00:55Z"\n}`}
378
- </CodeBlock>
379
- </CodeGroup>
380
- </Stack>
381
- }
382
- >
383
- <AccordionGroup>
384
- <Accordion title="Authorization" defaultOpen>
385
- <Stack space="var(--s1)">
386
- <ApiField
387
- name="Authorization"
388
- type="string"
389
- required
390
- prefix="Bearer"
391
- >
392
- The Authorization header expects a Bearer token. Use an
393
- admin API key (prefixed with <code>mint_</code>).
394
- </ApiField>
395
- </Stack>
396
- </Accordion>
397
- <Accordion title="Path" defaultOpen>
398
- <Stack space="var(--s1)">
399
- <ApiField name="projectId" type="string" required>
400
- Your project ID. Copy it from the API keys page.
401
- </ApiField>
402
- <ApiField name="bodyId" type="string" required>
403
- Your body ID. Copy it from the API keys page.
404
- </ApiField>
405
- </Stack>
406
- </Accordion>
407
- <Accordion title="Query">
408
- <Stack space="var(--s1)">
409
- <ApiField name="branch" type="string">
410
- Git branch to deploy. Defaults to the production branch.
411
- </ApiField>
412
- <ApiField name="dryRun" type="boolean" default="false">
413
- When true, validates the deployment without publishing.
414
- </ApiField>
415
- </Stack>
416
- </Accordion>
417
- <Accordion title="Headers">
418
- <Stack space="var(--s1)">
419
- <ApiField name="X-Idempotency-Key" type="string">
420
- Optional unique key so retried requests don't trigger
421
- duplicate deployments.
422
- </ApiField>
423
- </Stack>
424
- </Accordion>
425
- <Accordion title="Body">
426
- <Stack space="var(--s1)">
427
- <ApiField name="message" type="string">
428
- Deployment message shown in the activity log.
429
- </ApiField>
430
- <ApiField name="notify" type="boolean" default="true">
431
- Whether to notify when the deployment completes.
432
- </ApiField>
433
- </Stack>
434
- </Accordion>
435
- </AccordionGroup>
436
- </ApiClient>
437
- </div>
438
- </Stack>
439
- );
440
- }
441
-
442
- function TreeDemo() {
443
- return (
444
- <Tree>
445
- <Folder name="app">
446
- <File name="layout.tsx" />
447
- <File name="page.tsx" />
448
- <Folder name="api" defaultOpen={false}>
449
- <File name="route.ts" />
450
- </Folder>
451
- </Folder>
452
- <File name="package.json" />
453
- </Tree>
454
- );
455
- }
456
-
457
- function StepsDemo() {
458
- return (
459
- <Steps>
460
- <Step title="Install the CLI">Run the install script.</Step>
461
- <Step title="Bootstrap a new project">
462
- Scaffold a fresh velu project with a single command, then drop in the
463
- starter MDX so you can begin editing right away. The CLI prompts for a
464
- project name and target directory, copies the template files, and
465
- installs dependencies in the background while you keep working.
466
- </Step>
467
- <Step title="Add your first page">
468
- Drop an MDX file into the <code>content/</code> directory.
469
- </Step>
470
- <Step title="Configure navigation">
471
- Edit <code>velu.config.js</code> and add your page paths under the
472
- sidebar tree. Nested groups are supported, every item can carry an
473
- icon, and external links are flagged automatically. Save the file and
474
- the dev server hot-reloads with the new structure.
475
- </Step>
476
- <Step title="Preview locally">
477
- Run <code>velu dev</code> — the local preview opens at
478
- <code> localhost:5173</code> with SSR + HMR. Edits to any MDX or React
479
- component reflect instantly. The right-rail TOC is generated from the
480
- page's headings; the comet trail follows scroll.
481
- </Step>
482
- <Step title="Push and ship">Open a PR. Done.</Step>
483
- </Steps>
484
- );
485
- }
486
-
487
- function PromptDemo() {
488
- return (
489
- <Prompt title="Generate clear, concise documentation." openInCursor>
490
- {`You are a **technical writing assistant**. Write documentation that is clear, accurate, and concise.
491
- - Use second-person voice
492
- - Avoid jargon
493
- - Lead with what the reader can do, not how it works internally
494
- - Code blocks use the project's language conventions
495
- - Every example must be runnable as-is`}
496
- </Prompt>
497
- );
498
- }
499
-
500
- function FieldDemo() {
501
- const desc =
502
- 'An example of a parameter field. An example of a parameter field. An example of a parameter field. An example of a parameter field. An example of a parameter field. An example of a parameter field';
503
- return (
504
- <div>
505
- <Field
506
- name="param"
507
- pre="pre"
508
- type="string"
509
- required
510
- default={3}
511
- post="post"
512
- >
513
- {desc}
514
- </Field>
515
- <Field
516
- name="param"
517
- pre="pre"
518
- type="string"
519
- required
520
- default={3}
521
- post="post"
522
- >
523
- {desc}
524
- </Field>
525
- <Field
526
- name="param"
527
- pre="pre"
528
- type="string"
529
- required
530
- default={3}
531
- post="post"
532
- >
533
- {desc}
534
- </Field>
535
- </div>
536
- );
537
- }
538
-
539
- function ColumnsDemo() {
540
- return (
541
- <Stack space="var(--s2)">
542
- <p style={{ color: 'var(--muted-color)' }}>
543
- Two equal columns (<code>cols=&#123;2&#125;</code>) — collapses to a
544
- single stack when items would fall below their per-item min width.
545
- </p>
546
- <Columns cols={2}>
547
- <Card
548
- icon="align-justify"
549
- title="Card Title"
550
- cta={{ label: 'Click Here', href: '#columns' }}
551
- >
552
- This is how you use a card with an icon and a link. Clicking on
553
- this card brings you to the Columns page.
554
- </Card>
555
- <Card
556
- icon="align-justify"
557
- title="Card Title"
558
- cta={{ label: 'Click Here', href: '#columns' }}
559
- >
560
- This is how you use a card with an icon and a link. Clicking on
561
- this card brings you to the Columns page.
562
- </Card>
563
- </Columns>
564
-
565
- <p style={{ color: 'var(--muted-color)' }}>
566
- Heterogeneous children a Card alongside a CodeBlock.
567
- </p>
568
- <Columns cols={2}>
569
- <Card icon="sparkles" title="With a card">
570
- Cards, callouts, code, images — anything composes in a column.
571
- </Card>
572
- <CodeBlock filename="hello.js" language="javascript">
573
- {DEMO_JS}
574
- </CodeBlock>
575
- </Columns>
576
-
577
- <p style={{ color: 'var(--muted-color)' }}>
578
- Three columns (<code>cols=&#123;3&#125;</code>) with a stacked column
579
- in the middle.
580
- </p>
581
- <Columns cols={3}>
582
- <Card icon="rocket" title="Left">
583
- Single card on the left.
584
- </Card>
585
- <Stack space="var(--s0)">
586
- <Card icon="book" title="Top">
587
- Two cards stacked via Stack inside one column.
588
- </Card>
589
- <Card icon="brain" title="Bottom">
590
- Stack lets a column carry heterogeneous, multi-item content.
591
- </Card>
592
- </Stack>
593
- <Card icon="zap" title="Right">
594
- Single card on the right.
595
- </Card>
596
- </Columns>
597
- </Stack>
598
- );
599
- }
600
-
601
- const DEMO_IMG = 'https://picsum.photos/seed/velu-yosemite/1200/600';
602
- const DEMO_SHOT = 'https://picsum.photos/seed/velu-shot/600/1000';
603
-
604
- function ImageDemo() {
605
- return (
606
- <Stack space="var(--s2)">
607
- <p style={{ color: 'var(--muted-color)' }}>Image</p>
608
- <Image src={DEMO_IMG} alt="" />
609
-
610
- <p style={{ color: 'var(--muted-color)' }}>
611
- Image with caption (caption can be markdown later)
612
- </p>
613
- <Image src={DEMO_IMG} alt="" caption="Caption for this image" />
614
-
615
- <p style={{ color: 'var(--muted-color)' }}>Image with window chrome</p>
616
- <Image src={DEMO_IMG} alt="" chrome="window" />
617
-
618
- <p style={{ color: 'var(--muted-color)' }}>Image with display frame</p>
619
- <Image src={DEMO_SHOT} alt="" chrome="frame" caption="Caption can also be added" />
620
- </Stack>
621
- );
622
- }
623
-
624
- const RouterLink = ({ href, ...rest }) => <Link to={href} {...rest} />;
625
-
626
- // Flatten a nested TOC tree to {id, label, depth} for scroll-spy.
627
- function flat(nodes, depth = 0, out = []) {
628
- for (const n of nodes) {
629
- out.push({ id: n.id, label: n.label, depth });
630
- if (n.children) flat(n.children, depth + 1, out);
631
- }
632
- return out;
633
- }
634
-
635
- // Scroll-spy: active = the section whose top last crossed the trigger line.
636
- /**
637
- * useScrollSpy tracks the section currently in view, with an
638
- * explicit "lock" override for click-driven scrolls.
639
- *
640
- * Without the lock, clicking a TOC item triggers a smooth-scroll
641
- * animation; during the ~500ms animation the scroll listener fires
642
- * many times with the page at intermediate positions, and the
643
- * "closest section" answer flickers across multiple sections
644
- * before settling — visible as the TocBar label briefly showing
645
- * the wrong heading. The lock blocks spy updates for a configurable
646
- * window so the click's intended `activeId` survives the animation.
647
- *
648
- * Returns `[activeId, setActive]` where `setActive(id)` writes the
649
- * active id directly AND locks the spy for ~800ms.
650
- */
651
- function useScrollSpy(ids) {
652
- const [activeId, setActiveId] = React.useState(ids[0]);
653
- const lockUntilRef = React.useRef(0);
654
- React.useEffect(() => {
655
- const onScroll = () => {
656
- if (Date.now() < lockUntilRef.current) return;
657
- // Trigger line reads from --velu-scroll-offset (published by
658
- // App.jsx as header + tocbar-toggle + 1rem). Same offset CSS
659
- // uses for scroll-margin, so a heading freshly scrolled into
660
- // view sits exactly at the trigger. Semantics: pick the
661
- // section whose top is CLOSEST to the trigger; tie-break
662
- // favours later sections in document order.
663
- const trigger =
664
- parseFloat(
665
- getComputedStyle(document.documentElement).getPropertyValue(
666
- '--velu-scroll-offset',
667
- ),
668
- ) || 0;
669
- const vh = window.innerHeight;
670
- let best = ids[0];
671
- let bestDist = Infinity;
672
- for (const id of ids) {
673
- const node = document.getElementById(id);
674
- if (!node) continue;
675
- const top = node.getBoundingClientRect().top;
676
- if (top >= vh) continue;
677
- const dist = Math.abs(top - trigger);
678
- if (dist <= bestDist) {
679
- bestDist = dist;
680
- best = id;
681
- }
682
- }
683
- setActiveId(best);
684
- };
685
- window.addEventListener('scroll', onScroll, { passive: true });
686
- onScroll();
687
- return () => window.removeEventListener('scroll', onScroll);
688
- }, [ids]);
689
-
690
- const setActive = React.useCallback((id) => {
691
- setActiveId(id);
692
- // ~800ms covers the default smooth-scroll animation duration.
693
- lockUntilRef.current = Date.now() + 800;
694
- }, []);
695
-
696
- return [activeId, setActive];
697
- }
698
-
699
- // Slugger used for the frontmatter-derived h1 id — same library as
700
- // extract-toc + rehype-slug, so the id we render in the DOM matches
701
- // what the TOC's `pageId` will compute. (One instance per page render
702
- // is fine; the slugger is stateful only across calls on the SAME
703
- // instance.)
704
- import GithubSlugger from 'github-slugger';
705
-
706
- function DocsPage() {
707
- // Resolve the current route page entry + navigation context. Both
708
- // SSR and client compute these from the same pathname + the same
709
- // shared `resolve()`, so the render is identical (hydration-safe).
710
- const location = useLocation();
711
- const pathname = normalizeUrl(location.pathname);
712
- const entry = pages[pathname];
713
- const nav = React.useMemo(
714
- () => resolve(pathname, navigation, pages),
715
- [pathname],
716
- );
717
-
718
- const frontmatter = entry?.frontmatter ?? {};
719
- const PageComponent = entry?.Component ?? null;
720
- const pageToc = entry?.toc ?? [];
721
-
722
- // Switcher option sets (only render a switcher when an axis has >1
723
- // option). Anchors are pinned sidebar links shown in the context zone.
724
- const productOptions = nav?.products ?? [];
725
- const versionOptions = nav?.versions ?? [];
726
- const languageOptions = nav?.languages ?? [];
727
- const anchors = nav?.anchors ?? [];
728
- const versionSwitcher = versionOptions.length > 1 && (
729
- <NavSelect
730
- size="sm"
731
- value={nav.activeVersion}
732
- options={versionOptions}
733
- linkComponent={RouterLink}
734
- ariaLabel="Version"
735
- />
736
- );
737
- const languageSwitcher = languageOptions.length > 1 && (
738
- <NavSelect
739
- bare
740
- icon="globe"
741
- value={nav.activeLanguage}
742
- valueCode={nav.activeLanguageCode}
743
- options={languageOptions}
744
- linkComponent={RouterLink}
745
- ariaLabel="Language"
746
- />
747
- );
748
- const productSwitcher = productOptions.length > 1 && (
749
- <NavSelect
750
- value={nav.activeProduct}
751
- options={productOptions}
752
- linkComponent={RouterLink}
753
- ariaLabel="Product"
754
- />
755
- );
756
-
757
- // Frontmatter title needs an id so scroll-spy + click-to-scroll work
758
- // against it like any other heading.
759
- const pageId = React.useMemo(() => {
760
- if (!frontmatter.title) return null;
761
- return new GithubSlugger().slug(frontmatter.title);
762
- }, [frontmatter.title]);
763
-
764
- // Combined TOC: the frontmatter title is the page's top-level entry,
765
- // and the MDX-derived headings (h2s) become its children. Without this,
766
- // the right rail starts from the first h2 — orphaning the page title
767
- // that sits visibly above the article.
768
- const toc = React.useMemo(() => {
769
- if (!frontmatter.title || !pageId) return pageToc;
770
- return [{ id: pageId, label: frontmatter.title, children: pageToc }];
771
- }, [pageId, frontmatter.title, pageToc]);
772
-
773
- // Flat list of section ids for scroll-spy sourced from the same
774
- // nested TOC tree we pass to the right-rail <Toc>, so spy + render
775
- // can't drift out of sync with the MDX content.
776
- const ids = React.useMemo(() => flat(toc).map((s) => s.id), [toc]);
777
- const [activeId, setActive] = useScrollSpy(ids);
778
-
779
- // Ask-AI chatbot: opens (slides in from the side) when a question is
780
- // submitted in the AskBar. While it's open the TOC is hidden.
781
- const [chatOpen, setChatOpen] = React.useState(false);
782
- const [chatQuestion, setChatQuestion] = React.useState('');
783
-
784
- // Left sidebar open/closed (narrow widths) chevron toggle. Defaults
785
- // open per design; safe to default true since the narrow-width
786
- // sidebar is in-flow and doesn't obscure content.
787
- const [sidebarOpen, setSidebarOpen] = React.useState(true);
788
- // Mobile drawer open/closed (< 640px) burger / breadcrumb / X /
789
- // scrim drive this. Defaults closed so refreshing the page at
790
- // mobile doesn't surface the drawer over the article. Independent
791
- // of sidebarOpen so neither breakpoint's default leaks into the
792
- // other.
793
- const [drawerOpen, setDrawerOpen] = React.useState(false);
794
-
795
- // Drawer's nav dropdown — list of section links (Home/Docs/Blog),
796
- // mirrors the desktop tabs. Click-outside + Escape close.
797
- const [navOpen, setNavOpen] = React.useState(false);
798
- const navRef = React.useRef(null);
799
- React.useEffect(() => {
800
- if (!navOpen) return;
801
- const onDocClick = (e) => {
802
- if (!navRef.current?.contains(e.target)) setNavOpen(false);
803
- };
804
- const onKey = (e) => {
805
- if (e.key === 'Escape') setNavOpen(false);
806
- };
807
- document.addEventListener('mousedown', onDocClick);
808
- document.addEventListener('keydown', onKey);
809
- return () => {
810
- document.removeEventListener('mousedown', onDocClick);
811
- document.removeEventListener('keydown', onKey);
812
- };
813
- }, [navOpen]);
814
- // Mobile drawer's section picker = the resolved tabs (top-level
815
- // navigation), so it mirrors the desktop tabs row instead of a
816
- // hardcoded list. Its button shows the active tab's label.
817
- const navItems = nav?.tabs ?? [];
818
- const activeNavLabel =
819
- navItems.find((t) => t.href === nav?.activeTab)?.label ??
820
- navItems[0]?.label ??
821
- '';
822
- const askAI = React.useCallback((q) => {
823
- setChatQuestion(q);
824
- setChatOpen(true);
825
- }, []);
826
-
827
- // Measure the live sticky chrome — header + (collapsed) TocBar
828
- // toggle — and publish three CSS variables on `:root`:
829
- //
830
- // --velu-header-height live header height
831
- // --velu-tocbar-height TocBar's TOGGLE row height (always-
832
- // visible bit; 0 when the bar isn't shown)
833
- // --velu-scroll-offset header + tocbar-toggle + 1rem
834
- //
835
- // Measuring just the toggle row (NOT the expanded list) is
836
- // important: the expanded dropdown's max-block-size is large, and
837
- // including it in the offset would inflate scroll-margin /
838
- // scroll-spy trigger as soon as the user expands the dropdown —
839
- // causing the page geometry to lurch and the spy / active label
840
- // to flicker.
841
- React.useEffect(() => {
842
- if (typeof window === 'undefined') return;
843
- const header = document.querySelector('.velu-header');
844
- if (!header) return;
845
- const root = document.documentElement;
846
- const remPx = parseFloat(getComputedStyle(root).fontSize) || 16;
847
- const apply = () => {
848
- const headerH = header.getBoundingClientRect().height;
849
- // Re-query each apply — the TocBar toggle button is mounted
850
- // after first paint and its parent may also remount on
851
- // theme/viewport changes.
852
- const tocBarToggle = document.querySelector('.velu-toc-bar__toggle');
853
- const tocBarH = tocBarToggle
854
- ? tocBarToggle.getBoundingClientRect().height
855
- : 0;
856
- if (headerH > 0)
857
- root.style.setProperty('--velu-header-height', `${headerH}px`);
858
- root.style.setProperty('--velu-tocbar-height', `${tocBarH}px`);
859
- // +1rem of breathing room between the chrome and the heading
860
- // it's anchoring. The rem is read live so it stays in tokens.
861
- root.style.setProperty(
862
- '--velu-scroll-offset',
863
- `${headerH + tocBarH + remPx}px`,
864
- );
865
- };
866
- apply();
867
- const ro = new ResizeObserver(apply);
868
- ro.observe(header);
869
- // Observe the toggle row only (not the expanded list) so the
870
- // offset stays put when the user opens the dropdown.
871
- const tocBarToggle = document.querySelector('.velu-toc-bar__toggle');
872
- if (tocBarToggle) ro.observe(tocBarToggle);
873
- return () => ro.disconnect();
874
- }, []);
875
-
876
- // The asides (left sidebar + right TOC) scroll independently of the
877
- // page. On scroll/resize we set data-fade-top/-bottom on each scroll
878
- // region so CSS can fade its edges (and reveal the nav arrows) only
879
- // when there's content beyond them. Written imperatively (no
880
- // re-render). Re-binds on chatOpen toggle (the right TOC
881
- // mounts/unmounts with it).
882
- const leftAsideRef = React.useRef(null);
883
- const rightAsideRef = React.useRef(null);
884
- React.useEffect(() => {
885
- const els = [leftAsideRef.current, rightAsideRef.current].filter(Boolean);
886
- if (!els.length) return;
887
- const update = () => {
888
- for (const el of els) {
889
- el.dataset.fadeTop = el.scrollTop > 0 ? 'true' : 'false';
890
- el.dataset.fadeBottom =
891
- el.scrollTop + el.clientHeight < el.scrollHeight - 1
892
- ? 'true'
893
- : 'false';
894
- }
895
- };
896
- update();
897
- // Recompute on scroll AND on any size change of the scroll region
898
- // or its content (ResizeObserver) + window resize — so the fade
899
- // reflects hidden content persistently, not just during a scroll
900
- // gesture (e.g. after a route change shifts the nav's height).
901
- const ro = new ResizeObserver(update);
902
- els.forEach((el) => {
903
- el.addEventListener('scroll', update, { passive: true });
904
- ro.observe(el);
905
- if (el.firstElementChild) ro.observe(el.firstElementChild);
906
- });
907
- window.addEventListener('resize', update);
908
- return () => {
909
- els.forEach((el) => el.removeEventListener('scroll', update));
910
- ro.disconnect();
911
- window.removeEventListener('resize', update);
912
- };
913
- }, [chatOpen, pathname]);
914
-
915
- // Flag the sidebar section heading currently pinned at the top of the
916
- // scroll region (CSS sticky gives no "is-stuck" hook). When the pinned
917
- // heading changes as you scroll, the new one gets data-stuck and
918
- // animates in (see sidebar.css). Re-binds on page/tab change since the
919
- // section set changes with it.
920
- React.useEffect(() => {
921
- const scroller = leftAsideRef.current;
922
- if (!scroller) return;
923
- let prev = null;
924
- const update = () => {
925
- const top = scroller.getBoundingClientRect().top;
926
- let stuck = null;
927
- for (const h of scroller.querySelectorAll('.velu-sidebar__section')) {
928
- if (h.getBoundingClientRect().top <= top + 1) stuck = h;
929
- }
930
- if (stuck !== prev) {
931
- prev?.removeAttribute('data-stuck');
932
- stuck?.setAttribute('data-stuck', 'true');
933
- prev = stuck;
934
- }
935
- };
936
- update();
937
- scroller.addEventListener('scroll', update, { passive: true });
938
- return () => scroller.removeEventListener('scroll', update);
939
- }, [pathname, chatOpen]);
940
-
941
- const footerRef = React.useRef(null);
942
- const [footerOverlap, setFooterOverlap] = React.useState(0);
943
- React.useEffect(() => {
944
- const node = footerRef.current;
945
- if (!node) return;
946
- const update = () => {
947
- const rect = node.getBoundingClientRect();
948
- setFooterOverlap(Math.max(0, window.innerHeight - rect.top));
949
- };
950
- update();
951
- window.addEventListener('scroll', update, { passive: true });
952
- window.addEventListener('resize', update);
953
- return () => {
954
- window.removeEventListener('scroll', update);
955
- window.removeEventListener('resize', update);
956
- };
957
- }, []);
958
-
959
- // Sticky AskBar fades out once the user scrolls near the PageFeedback
960
- // widget — so it doesn't sit on top of the page-foot widgets.
961
- const feedbackRef = React.useRef(null);
962
- const [askBarHidden, setAskBarHidden] = React.useState(false);
963
- React.useEffect(() => {
964
- const node = feedbackRef.current;
965
- if (!node) return;
966
- const FADE_BUFFER = 64; // matches the rootMargin below
967
- const apply = (rect) => {
968
- // Hide whenever the feedback's top has reached the (effective)
969
- // viewport-bottom line — covers both "feedback is in view" AND
970
- // "feedback is already scrolled past" without flickering back on.
971
- // Threshold matches the IO's rootMargin so the show/hide flip is
972
- // exactly at the same line in both scroll directions.
973
- setAskBarHidden(rect.top < window.innerHeight - FADE_BUFFER);
974
- };
975
- apply(node.getBoundingClientRect());
976
- const io = new IntersectionObserver(
977
- ([entry]) => apply(entry.boundingClientRect),
978
- // Trigger ~64px before the feedback enters the viewport, so the
979
- // AskBar fades just as the widget starts to peek up from below.
980
- { rootMargin: '0px 0px -64px 0px' },
981
- );
982
- io.observe(node);
983
- return () => io.disconnect();
984
- }, []);
985
-
986
- const scrollTo = React.useCallback(
987
- (id) => {
988
- const node = document.getElementById(id);
989
- if (!node) return;
990
- // 1) Set + lock the active id IMMEDIATELY so the spy doesn't
991
- // overwrite it during the smooth-scroll animation.
992
- setActive(id);
993
- // 2) Compute the target scroll position MANUALLY (instead of
994
- // scrollIntoView). The browser clamps to max-scroll
995
- // automatically; the target is baked in at click time and
996
- // doesn't drift if layout shifts mid-animation.
997
- const offset =
998
- parseFloat(
999
- getComputedStyle(document.documentElement).getPropertyValue(
1000
- '--velu-scroll-offset',
1001
- ),
1002
- ) || 0;
1003
- const top = node.getBoundingClientRect().top + window.scrollY - offset;
1004
- window.scrollTo({ top, behavior: 'smooth' });
1005
- },
1006
- [setActive],
1007
- );
1008
-
1009
- const kicker = {
1010
- fontFamily: 'var(--font-mono)',
1011
- fontSize: 11,
1012
- letterSpacing: '0.6px',
1013
- textTransform: 'uppercase',
1014
- color: 'var(--muted-color)',
1015
- };
1016
-
1017
- return (
1018
- <div
1019
- className="velu-docs-layout"
1020
- data-chat-open={chatOpen ? 'true' : 'false'}
1021
- data-sidebar-open={sidebarOpen ? 'true' : 'false'}
1022
- data-drawer-open={drawerOpen ? 'true' : 'false'}
1023
- >
1024
- {/* Scrim — visible at mobile while the drawer OR the chatbot
1025
- sheet is open. Sits between the article (z-0) and the
1026
- drawer/chatbot (z-35 / z-50) and blocks pointer events to
1027
- everything beneath; click closes whichever surface is up. */}
1028
- <div
1029
- className="velu-docs-layout__scrim"
1030
- aria-hidden="true"
1031
- onClick={() => {
1032
- setDrawerOpen(false);
1033
- setChatOpen(false);
1034
- }}
1035
- />
1036
- {/* Site header — brand, centered search, right-side actions,
1037
- tabs row. Configurable: pass any number of actions / tabs. */}
1038
- <PageHeader
1039
- linkComponent={RouterLink}
1040
- brand={{ label: 'Velu', href: '/' }}
1041
- brandTrailing={
1042
- versionSwitcher && (
1043
- <span className="velu-hide-on-mobile">{versionSwitcher}</span>
1044
- )
1045
- }
1046
- tabsTrailing={languageSwitcher || undefined}
1047
- center={
1048
- <Cluster space="var(--s-6)" align="center">
1049
- <Search style={{ inlineSize: '30ch' }} />
1050
- <button
1051
- type="button"
1052
- className="velu-header__action velu-header__action--outlined"
1053
- onClick={() => askAI('')}
1054
- >
1055
- <span className="velu-header__action-icon" aria-hidden="true">
1056
- {resolveIcon('sparkles', { size: '1.5em' })}
1057
- </span>
1058
- <span>Ask AI</span>
1059
- </button>
1060
- </Cluster>
1061
- }
1062
- actions={[
1063
- {
1064
- label: 'Book Demo',
1065
- kind: 'primary',
1066
- href: '#',
1067
- },
1068
- ]}
1069
- trailing={<ThemeToggle />}
1070
- onMenuClick={() => setDrawerOpen((v) => !v)}
1071
- breadcrumb={nav?.breadcrumb ?? []}
1072
- activeTab={nav?.activeTab}
1073
- tabs={nav?.tabs ?? []}
1074
- />
1075
-
1076
- {/* Fixed left sidebar — pinned to viewport-left below the header.
1077
- Does NOT scroll with the page; the footer rises over its bottom
1078
- edge thanks to the higher z-index on the footer below.
1079
- `top` + `bottom` give a robust height (some browsers don't
1080
- honour `inset-block-start` for fixed positioning the same way
1081
- as plain `top`). */}
1082
- <aside
1083
- className="velu-docs-layout__aside velu-docs-layout__aside--left"
1084
- style={{
1085
- /* Bottom edge stays a fixed gap above the viewport bottom,
1086
- AND lifts to keep that gap above the footer as it scrolls
1087
- into view (footerOverlap = how far the footer intrudes).
1088
- Set as a custom prop so the mobile drawer's
1089
- `inset-block-end: 0` override still wins. */
1090
- '--velu-aside-bottom': `calc(${footerOverlap}px + var(--s4))`,
1091
- }}
1092
- >
1093
- {/* Drawer body — Stack with 32px gap composes the slots
1094
- (topbar / tab-dropdown / context / nav). The aside is a flex
1095
- column (CSS): the pinned slots keep their height and only the
1096
- nav region scrolls (see velu-docs-nav-scroll below). At wide
1097
- widths the topbar + docselect are `display: none`. */}
1098
- <Stack space="var(--s0)" className="velu-docs-aside-stack">
1099
- <div className="velu-docs-layout__drawer-head">
1100
- <RouterLink
1101
- href="/"
1102
- className="velu-docs-layout__drawer-brand"
1103
- >
1104
- <VeluMark />
1105
- <span className="velu-header__wordmark">Velu</span>
1106
- </RouterLink>
1107
- <ThemeToggle />
1108
- <button
1109
- type="button"
1110
- className="velu-docs-layout__drawer-close"
1111
- aria-label="Close navigation"
1112
- onClick={() => setDrawerOpen(false)}
1113
- >
1114
- <X aria-hidden="true" focusable="false" />
1115
- </button>
1116
- </div>
1117
- {/* Nav dropdown custom button + menu, fills drawer width.
1118
- Items mirror the desktop tabs (Home / Docs / Blog).
1119
- Click-outside + Escape close (see navOpen useEffect
1120
- above). Chevron rotates 180° on open. */}
1121
- <div
1122
- ref={navRef}
1123
- className="velu-docs-layout__drawer-docselect"
1124
- data-open={navOpen ? 'true' : 'false'}
1125
- >
1126
- <button
1127
- type="button"
1128
- className="velu-docs-layout__drawer-docselect-btn"
1129
- onClick={() => setNavOpen((o) => !o)}
1130
- aria-haspopup="menu"
1131
- aria-expanded={navOpen}
1132
- >
1133
- <span className="velu-docs-layout__drawer-docselect-label">
1134
- {activeNavLabel}
1135
- </span>
1136
- <ChevronDown
1137
- className="velu-docs-layout__drawer-docselect-chev"
1138
- aria-hidden="true"
1139
- focusable="false"
1140
- />
1141
- </button>
1142
- <ul
1143
- className="velu-docs-layout__drawer-docselect-menu"
1144
- role="menu"
1145
- aria-hidden={!navOpen}
1146
- >
1147
- {navItems.map((it) => (
1148
- <li key={it.href} role="none">
1149
- <a
1150
- role="menuitem"
1151
- className="velu-docs-layout__drawer-docselect-item"
1152
- href={it.href}
1153
- tabIndex={navOpen ? 0 : -1}
1154
- onClick={() => setNavOpen(false)}
1155
- >
1156
- {it.label}
1157
- </a>
1158
- </li>
1159
- ))}
1160
- </ul>
1161
- </div>
1162
- {/* Context zoneproduct switcher + anchors at the top of
1163
- the sidebar (all breakpoints), plus version/language
1164
- switchers that only show on mobile (desktop has them in
1165
- the header). Rendered only when there's something to show
1166
- so simple projects keep a bare sidebar. */}
1167
- {(productSwitcher ||
1168
- versionSwitcher ||
1169
- languageSwitcher ||
1170
- anchors.length > 0) && (
1171
- <Stack space="var(--s-1)" className="velu-docs-context">
1172
- {productSwitcher}
1173
- {versionSwitcher && (
1174
- <span className="velu-show-on-mobile">{versionSwitcher}</span>
1175
- )}
1176
- {languageSwitcher && (
1177
- <span className="velu-show-on-mobile">{languageSwitcher}</span>
1178
- )}
1179
- {anchors.length > 0 && (
1180
- <ul className="velu-docs-anchors">
1181
- {anchors.map((a, i) => (
1182
- <li key={i}>
1183
- <a
1184
- className="velu-docs-anchors__link"
1185
- href={a.href}
1186
- target="_blank"
1187
- rel="noreferrer"
1188
- >
1189
- {a.icon && (
1190
- <span
1191
- className="velu-docs-anchors__icon"
1192
- aria-hidden="true"
1193
- >
1194
- {resolveIcon(a.icon, { size: '1em' })}
1195
- </span>
1196
- )}
1197
- <span>{a.label}</span>
1198
- </a>
1199
- </li>
1200
- ))}
1201
- </ul>
1202
- )}
1203
- </Stack>
1204
- )}
1205
- {/* Only this region scrolls — the context zone above stays
1206
- pinned. The up/down arrows overlay its top/bottom edges and
1207
- appear (via the data-fade-* attrs the scroll handler sets)
1208
- when there's content beyond that edge; clicking jumps the
1209
- nav fully to that end. */}
1210
- <div className="velu-docs-nav-region">
1211
- <div
1212
- ref={leftAsideRef}
1213
- className="velu-docs-nav-scroll velu-hide-scrollbar"
1214
- style={{
1215
- /* Just breathing room — the aside's bottom edge already
1216
- stays above the footer (see --velu-aside-bottom). */
1217
- paddingBlockEnd: 'var(--s1)',
1218
- scrollPaddingBlockEnd: 'var(--s1)',
1219
- }}
1220
- >
1221
- <Sidebar
1222
- sections={nav?.sidebarSections ?? []}
1223
- activeHref={pathname}
1224
- linkComponent={RouterLink}
1225
- />
1226
- </div>
1227
- <button
1228
- type="button"
1229
- className="velu-docs-nav-arrow velu-docs-nav-arrow--up"
1230
- aria-label="Scroll navigation to top"
1231
- onClick={() =>
1232
- leftAsideRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
1233
- }
1234
- >
1235
- <ChevronUp aria-hidden="true" focusable="false" />
1236
- </button>
1237
- <button
1238
- type="button"
1239
- className="velu-docs-nav-arrow velu-docs-nav-arrow--down"
1240
- aria-label="Scroll navigation to bottom"
1241
- onClick={() =>
1242
- leftAsideRef.current?.scrollTo({
1243
- top: leftAsideRef.current.scrollHeight,
1244
- behavior: 'smooth',
1245
- })
1246
- }
1247
- >
1248
- <ChevronDown aria-hidden="true" focusable="false" />
1249
- </button>
1250
- </div>
1251
- </Stack>
1252
- </aside>
1253
-
1254
- {/* No separate rail element — at narrow widths the sidebar's
1255
- aside ANIMATES its `inline-size` between 240px (open) and
1256
- --velu-rail-width (closed), and its inner Sidebar nav fades
1257
- out via opacity. The aside's border-inline-end therefore
1258
- becomes the rail's right border when collapsed. This makes
1259
- the collapse a smooth width+opacity transition instead of a
1260
- display:none snap. See docs-layout.css. */}
1261
-
1262
- {/* Fixed right TOC — pinned to viewport-right. Hidden while the
1263
- Ask-AI chatbot is open (the panel takes that edge). */}
1264
- {!chatOpen && (
1265
- <aside
1266
- ref={rightAsideRef}
1267
- className="velu-docs-layout__aside velu-docs-layout__aside--right velu-hide-scrollbar"
1268
- style={{
1269
- /* Dynamic footer-overlap padding only — static geometry
1270
- lives in docs-layout.css. */
1271
- paddingBlockEnd: `calc(var(--s3) + ${footerOverlap}px + 2rem)`,
1272
- scrollPaddingBlockEnd: `calc(${footerOverlap}px + 2rem)`,
1273
- }}
1274
- >
1275
- <Toc items={toc} activeId={activeId} onSelect={scrollTo} />
1276
- </aside>
1277
- )}
1278
-
1279
- {/* Center column. Margin to reserve aside space lives in
1280
- docs-layout.css (drops to 0 at narrow widths via @container,
1281
- and the right margin collapses to 0 while the chatbot is
1282
- open via [data-chat-open="true"]). */}
1283
- <div className="velu-docs-layout__center">
1284
- {/* Narrow-layout TOC bar — always in DOM, only visible at
1285
- < 1024px (toggled by @container in toc-bar.css). Shares
1286
- its `items` + `activeId` + `onSelect` API with the
1287
- right-rail <Toc> so both views are driven by the same
1288
- data; whichever one is visible at a given width responds
1289
- to the same scroll-spy state. */}
1290
- <TocBar items={toc} activeId={activeId} onSelect={scrollTo} />
1291
- <main
1292
- className="velu-docs-layout__main"
1293
- style={{
1294
- /* No padding-block-end — the article's last child (PoweredBy)
1295
- owns the gap to the footer via its own margin-bottom, so
1296
- padding-bottom here would double-count it. `position:
1297
- relative` makes <main> the offsetParent for the absolutely
1298
- positioned sidebar-toggle below — so its `top: 0` lands
1299
- at the top of the article area, BELOW the sticky TocBar
1300
- (which lives outside <main>). Padding-inline lives in CSS
1301
- so the @container query can collapse the start side when
1302
- the sidebar is closed (no "ghost column" on the left). */
1303
- position: 'relative',
1304
- color: 'var(--text-color)',
1305
- }}
1306
- >
1307
- {/* Sidebar togglenarrow-layout only (hidden by @container
1308
- at wide widths). Lives inside <main> so its absolute
1309
- position is relative to the article area, not the centre
1310
- column, and therefore sits BELOW the TocBar rather than
1311
- overlapping it. Chevron flips direction with the
1312
- `data-sidebar-open` data attribute on the layout root. */}
1313
- <button
1314
- type="button"
1315
- className="velu-docs-layout__sidebar-toggle"
1316
- onClick={() => setSidebarOpen((v) => !v)}
1317
- aria-label={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
1318
- aria-expanded={sidebarOpen}
1319
- >
1320
- <span aria-hidden="true">
1321
- {resolveIcon(sidebarOpen ? 'chevron-left' : 'chevron-right', {
1322
- size: '1em',
1323
- })}
1324
- </span>
1325
- </button>
1326
- <div className="velu-docs-layout__article">
1327
- {/* Page hero from MDX frontmatter. `.velu-hero` rules
1328
- (in base.css) keep the title and description tightly
1329
- grouped and create a clear break before the prose body. */}
1330
- {(frontmatter.title || frontmatter.description) && (
1331
- <div className="velu-hero">
1332
- {frontmatter.title && <h1 id={pageId}>{frontmatter.title}</h1>}
1333
- {frontmatter.description && (
1334
- <p
1335
- style={{
1336
- color: 'var(--muted-color)',
1337
- fontSize: 'var(--f-h4)',
1338
- lineHeight: 'var(--lh-h4)',
1339
- }}
1340
- >
1341
- {frontmatter.description}
1342
- </p>
1343
- )}
1344
- </div>
1345
- )}
1346
-
1347
- {/* Article body the current route's MDX page, rendered
1348
- through the velu-ui MDX component registry. `velu-prose`
1349
- gives a consistent vertical rhythm scaled by heading
1350
- level. Falls back to a not-found / missing-file notice
1351
- when the route has no page (or its file is absent). */}
1352
- <MDXProvider components={defaultMdxComponents}>
1353
- <div className="velu-prose">
1354
- {PageComponent ? (
1355
- <ErrorBoundary key={pathname} file={entry?.relPath}>
1356
- <PageComponent />
1357
- </ErrorBoundary>
1358
- ) : (
1359
- <p style={{ color: 'var(--muted-color)' }}>
1360
- {entry?.missing
1361
- ? 'This page is listed in navigation but its .mdx file was not found.'
1362
- : 'Page not found.'}
1363
- </p>
1364
- )}
1365
- </div>
1366
- </MDXProvider>
1367
- {/* Page-foot feedback widget — ref'd so the sticky AskBar
1368
- above can hide as the user scrolls near it. */}
1369
- <div ref={feedbackRef} style={{ marginTop: 'var(--s3)' }}>
1370
- <PageFeedback />
1371
- </div>
1372
- {/* Previous / next page navigation derived from the
1373
- sidebar reading order of the active section. */}
1374
- {(nav?.prev || nav?.next) && (
1375
- <PageNav
1376
- style={{ marginTop: 'var(--s2)' }}
1377
- prev={nav?.prev}
1378
- next={nav?.next}
1379
- linkComponent={RouterLink}
1380
- />
1381
- )}
1382
- {/* "Ask a question" bar — sticky to viewport-bottom while the
1383
- article column is in view; lives inside the prose-column
1384
- div so its width matches the article. Submitting opens
1385
- the Ask-AI chatbot; fades out as the PageFeedback widget
1386
- approaches (see IntersectionObserver above). */}
1387
- <AskBar
1388
- onSubmit={askAI}
1389
- style={{
1390
- position: 'sticky',
1391
- insetBlockEnd: 'var(--s1)',
1392
- /* Once the user has scrolled near the feedback widget the
1393
- AskBar fades and collapses to zero flow space — height,
1394
- margin, padding all drop to 0 so the next element
1395
- (PoweredBy) sits directly under PageNav at its own
1396
- 32px margin, with no phantom gap. */
1397
- marginTop: askBarHidden ? 0 : 'var(--s2)',
1398
- height: askBarHidden ? 0 : 'auto',
1399
- overflow: 'hidden',
1400
- opacity: askBarHidden ? 0 : 1,
1401
- transform: askBarHidden ? 'translateY(20%)' : 'translateY(0)',
1402
- pointerEvents: askBarHidden ? 'none' : 'auto',
1403
- transition: 'opacity 0.2s ease, transform 0.2s ease',
1404
- }}
1405
- />
1406
- {/* "Powered by Velu" attribution bottom-right of the
1407
- article column, just under the AskBar. Muted so it reads
1408
- as a footer-of-content note, not a brand statement. */}
1409
- <PoweredBy />
1410
- </div>
1411
- </main>
1412
- </div>
1413
-
1414
- {/* Site footer spans full width below the article. Its raised
1415
- z-index ensures it visually eclipses the bottoms of the fixed
1416
- left sidebar and right TOC as the page scrolls into it. The
1417
- ref is watched so the asides can pad their bottom by the same
1418
- overlap amount, keeping every item scrollable into view. */}
1419
- <div
1420
- ref={footerRef}
1421
- data-velu-footer
1422
- style={{ position: 'relative', zIndex: 20 }}
1423
- >
1424
- <PageFooter
1425
- brand={{ href: '#' }}
1426
- columns={[
1427
- {
1428
- title: 'Resources',
1429
- items: [
1430
- { label: 'Showcase', href: '#' },
1431
- { label: 'Enterprise', href: '#' },
1432
- { label: 'Status', href: '#' },
1433
- ],
1434
- },
1435
- {
1436
- title: 'Company',
1437
- items: [
1438
- { label: 'Careers', href: '#' },
1439
- { label: 'Blog', href: '#' },
1440
- { label: 'Community', href: '#' },
1441
- ],
1442
- },
1443
- {
1444
- title: 'Policies',
1445
- items: [
1446
- { label: 'Subprocessors', href: '#' },
1447
- { label: 'Terms of Service', href: '#' },
1448
- ],
1449
- },
1450
- ]}
1451
- socials={[
1452
- { kind: 'github', href: 'https://github.com/aravindc26/velu-cli' },
1453
- { kind: 'x', href: 'https://x.com/' },
1454
- { kind: 'youtube', href: 'https://youtube.com/' },
1455
- { kind: 'linkedin', href: 'https://linkedin.com/' },
1456
- ]}
1457
- />
1458
- </div>
1459
-
1460
- {/* Ask-AI chatbot — slides in from the inline-end edge. */}
1461
- <Chatbot
1462
- open={chatOpen}
1463
- seedQuestion={chatQuestion}
1464
- onClose={() => setChatOpen(false)}
1465
- />
1466
- </div>
1467
- );
1468
- }
1469
-
1470
- export default function App() {
1471
- return (
1472
- <Routes>
1473
- <Route path="*" element={<DocsPage />} />
1474
- </Routes>
1475
- );
1476
- }
1
+ import React from 'react';
2
+ import { Routes, Route, useLocation, Link } from 'react-router-dom';
3
+ import { MDXProvider } from '@mdx-js/react';
4
+ // The project's pages + navigation, generated from velu.json by
5
+ // vite-plugin-velu-site (see src/vite-plugin-velu-site.js). `pages` is
6
+ // url → { Component, frontmatter, toc } (or { missing:true }).
7
+ import { pages, navigation, site } from 'virtual:velu-site';
8
+ import { resolve, normalizeUrl } from '../navigation.js';
9
+ import {
10
+ Stack,
11
+ Cluster,
12
+ ThemeToggle,
13
+ Sidebar,
14
+ NavSelect,
15
+ Toc,
16
+ TocBar,
17
+ Callout,
18
+ Accordion,
19
+ AccordionGroup,
20
+ Card,
21
+ CardGroup,
22
+ Switcher,
23
+ Image,
24
+ CodeBlock,
25
+ CodeGroup,
26
+ Columns,
27
+ Field,
28
+ Prompt,
29
+ Steps,
30
+ Step,
31
+ AskBar,
32
+ Chatbot,
33
+ PageFeedback,
34
+ PageNav,
35
+ PageFooter,
36
+ PageHeader,
37
+ PoweredBy,
38
+ defaultMdxComponents,
39
+ resolveIcon,
40
+ Search,
41
+ Logo,
42
+ SocialLinks,
43
+ Tree,
44
+ Folder,
45
+ File,
46
+ TryItBar,
47
+ ApiClient,
48
+ ApiField,
49
+ ApiSidebar,
50
+ VeluMark,
51
+ } from 'velu-ui';
52
+ import { X, ChevronDown, ChevronUp } from 'lucide-react';
53
+ import ErrorBoundary from './ErrorBoundary.jsx';
54
+
55
+ // True under `velu dev`, false in the deployed production build (Vite injects
56
+ // this). AI features (Ask AI, the Ask-a-question bar) and full-text search run
57
+ // on the deployed site, so they're hidden / shown-as-unavailable in the local
58
+ // preview. Identical on SSR + client in each mode, so no hydration mismatch.
59
+ const IS_DEV_PREVIEW = import.meta.env.DEV;
60
+
61
+ const CALLOUT_TYPES = [
62
+ 'note',
63
+ 'warning',
64
+ 'info',
65
+ 'tip',
66
+ 'check',
67
+ 'danger',
68
+ 'callout',
69
+ ];
70
+
71
+ function CalloutGallery() {
72
+ return (
73
+ <Stack space="var(--s0)">
74
+ {CALLOUT_TYPES.map((t) => (
75
+ <Callout key={t} type={t}>
76
+ This is a {t}. This is a {t}. This is a {t}. This is a {t}.
77
+ </Callout>
78
+ ))}
79
+ <p style={{ color: 'var(--muted-color)' }}>
80
+ Custom callouts can be built with a custom icon &amp; color:
81
+ </p>
82
+ <Callout
83
+ icon="sparkles"
84
+ stroke="var(--accent-color)"
85
+ bg="color-mix(in srgb, var(--accent-color) 8%, transparent)"
86
+ >
87
+ A custom callout lucide id <code>"sparkles"</code> with custom{' '}
88
+ <code>stroke</code> &amp; <code>bg</code>.
89
+ </Callout>
90
+
91
+ <Callout type="info">
92
+ This is a multi-line callout. It keeps going and going so the text
93
+ wraps onto several lines — the icon sits on the first line, the text
94
+ wraps beside it, and every line after flows back under the icon (no
95
+ hanging indent). Resize the window to watch the wrapping reflow while
96
+ the icon stays put on line one. This is a multi-line callout. It keeps
97
+ going so the text wraps onto several lines.
98
+ </Callout>
99
+ </Stack>
100
+ );
101
+ }
102
+
103
+ function AccordionDemo() {
104
+ return (
105
+ <Stack space="var(--s1)">
106
+ <Accordion title="This is an accordion">
107
+ This is content inside the accordion. Hover the header to see the
108
+ surface tint; the chevron rotates when expanded.
109
+ </Accordion>
110
+ <Accordion title="This is an accordion (open by default)" defaultOpen>
111
+ This one renders expanded — header takes the surface tint and a
112
+ divider separates it from this content.
113
+ </Accordion>
114
+ <AccordionGroup>
115
+ <Accordion title="This is an accordion">
116
+ Grouped items share one bordered box with dividers between them.
117
+ </Accordion>
118
+ <Accordion title="This is an accordion" defaultOpen>
119
+ This is content inside the accordion.
120
+ </Accordion>
121
+ <Accordion title="This is an accordion">
122
+ No per-item border or radius — the group owns the chrome.
123
+ </Accordion>
124
+ </AccordionGroup>
125
+ </Stack>
126
+ );
127
+ }
128
+
129
+ function CardDemo() {
130
+ return (
131
+ <Stack space="var(--s2)">
132
+ <Card icon="align-justify" title="Card Title">
133
+ This is how you use a card with an icon and a link. (Cards are not
134
+ clickable — only the CTA card's link is.)
135
+ </Card>
136
+
137
+ <Card icon="align-justify" title="Card Title" horizontal>
138
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
139
+ tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
140
+ veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
141
+ commodo consequat.
142
+ </Card>
143
+
144
+ <Card
145
+ img="https://picsum.photos/seed/velu/1200/560"
146
+ imgAlt="Yosemite valley"
147
+ title="Card Title"
148
+ >
149
+ This is how you use a card with an icon. The image fills the container
150
+ and is clipped to the card radius.
151
+ </Card>
152
+
153
+ <Card
154
+ icon="align-justify"
155
+ title="Card Title"
156
+ cta={{ label: 'Click Here', href: '#cards' }}
157
+ >
158
+ This is how you use a card with an icon and a link. The "Click Here"
159
+ link below is the only clickable element.
160
+ </Card>
161
+
162
+ <CardGroup>
163
+ <Card icon="align-justify" title="Card Title">
164
+ This is how you use a card with an icon and a link.
165
+ </Card>
166
+ <Card icon="align-justify" title="Card Title">
167
+ This is how you use a card with an icon and a link.
168
+ </Card>
169
+ </CardGroup>
170
+ </Stack>
171
+ );
172
+ }
173
+
174
+ function SwitcherBox({ children }) {
175
+ return (
176
+ <div
177
+ style={{
178
+ border: 'var(--border-width) solid var(--border-color)',
179
+ borderRadius: 'var(--radius-sm)',
180
+ padding: 'var(--s0) var(--s3)',
181
+ }}
182
+ >
183
+ {children}
184
+ </div>
185
+ );
186
+ }
187
+
188
+ function SwitcherDemo() {
189
+ return (
190
+ <Stack space="var(--s1)">
191
+ <p style={{ color: 'var(--muted-color)' }}>
192
+ Resize the window these flip between a row and a column at the
193
+ container threshold, with no media queries.
194
+ </p>
195
+ <Switcher space="var(--s0)" threshold="30rem">
196
+ <SwitcherBox>One</SwitcherBox>
197
+ <SwitcherBox>Two</SwitcherBox>
198
+ <SwitcherBox>Three</SwitcherBox>
199
+ </Switcher>
200
+ <p style={{ color: 'var(--muted-color)' }}>
201
+ With <code>limit={2}</code>: more than 2 items forces vertical
202
+ regardless of width.
203
+ </p>
204
+ <Switcher space="var(--s0)" threshold="30rem" limit={2}>
205
+ <SwitcherBox>One</SwitcherBox>
206
+ <SwitcherBox>Two</SwitcherBox>
207
+ <SwitcherBox>Three</SwitcherBox>
208
+ </Switcher>
209
+ </Stack>
210
+ );
211
+ }
212
+
213
+ const DEMO_JS = `let greeting = function (name) {
214
+ console.log(\`Hello, \${name}!\`);
215
+ };`;
216
+ const DEMO_RB = `def greeting(name)
217
+ puts "Hello, #{name}!"
218
+ end`;
219
+ const DEMO_PY = `def greeting(name):
220
+ print(f"Hello, {name}!")`;
221
+
222
+ function CodeBlockDemo() {
223
+ return (
224
+ <Stack space="var(--s2)">
225
+ <p style={{ color: 'var(--muted-color)' }}>Code Group</p>
226
+ <CodeGroup>
227
+ <CodeBlock title="Javascript" language="javascript">
228
+ {DEMO_JS}
229
+ </CodeBlock>
230
+ <CodeBlock title="Ruby" language="ruby">
231
+ {DEMO_RB}
232
+ </CodeBlock>
233
+ <CodeBlock title="Python" language="python">
234
+ {DEMO_PY}
235
+ </CodeBlock>
236
+ </CodeGroup>
237
+
238
+ <p style={{ color: 'var(--muted-color)' }}>Code (with file name)</p>
239
+ <CodeBlock filename="index.js" language="javascript">
240
+ {DEMO_JS}
241
+ </CodeBlock>
242
+
243
+ <p style={{ color: 'var(--muted-color)' }}>
244
+ With line numbers (icon suppressed via <code>withIcon={'{false}'}</code>)
245
+ </p>
246
+ <CodeBlock
247
+ filename="index.js"
248
+ language="javascript"
249
+ withIcon={false}
250
+ lineNumbers
251
+ >
252
+ {DEMO_JS}
253
+ </CodeBlock>
254
+
255
+ <p style={{ color: 'var(--muted-color)' }}>
256
+ Line highlight — single line (<code>highlight="2"</code>)
257
+ </p>
258
+ <CodeBlock
259
+ filename="index.js"
260
+ language="javascript"
261
+ lineNumbers
262
+ highlight="2"
263
+ >
264
+ {DEMO_JS}
265
+ </CodeBlock>
266
+
267
+ <p style={{ color: 'var(--muted-color)' }}>
268
+ Line highlight range + single (<code>highlight="1-2,4"</code>)
269
+ </p>
270
+ <CodeBlock
271
+ filename="server.js"
272
+ language="javascript"
273
+ lineNumbers
274
+ highlight="1-2,4"
275
+ >
276
+ {`const express = require('express');
277
+ const app = express();
278
+
279
+ app.get('/', (req, res) => res.send('Hello'));
280
+
281
+ app.listen(3000);`}
282
+ </CodeBlock>
283
+ </Stack>
284
+ );
285
+ }
286
+
287
+ function ApiDemo() {
288
+ const path = '/project/preview/{projectId}';
289
+ const curl = `curl --request POST \\
290
+ --url https://api.mintlify.com/v1/project/update/{projectId} \\
291
+ --header 'Authorization: Bearer <token>'`;
292
+ return (
293
+ <Stack space="var(--s2)">
294
+ <p style={{ color: 'var(--muted-color)' }}>Try-it bar</p>
295
+ <Stack space="var(--s-1)">
296
+ <TryItBar method="POST" path={path} />
297
+ <TryItBar method="GET" path={path} />
298
+ <TryItBar method="PUT" path={path} />
299
+ <TryItBar method="DELETE" path={path} />
300
+ <TryItBar method="PATCH" path={path} />
301
+ </Stack>
302
+
303
+ <p style={{ color: 'var(--muted-color)' }}>API sidebar</p>
304
+ <ApiSidebar
305
+ activeHref="/api/status"
306
+ sections={[
307
+ {
308
+ title: 'Admin',
309
+ icon: 'rocket',
310
+ endpoints: [
311
+ { method: 'POST', label: 'Trigger', href: '/api/trigger' },
312
+ {
313
+ method: 'GET',
314
+ label: 'Get deployment-status',
315
+ href: '/api/status',
316
+ },
317
+ {
318
+ method: 'DELETE',
319
+ label: 'Trigger Preview deployment',
320
+ href: '/api/preview',
321
+ },
322
+ ],
323
+ },
324
+ ]}
325
+ />
326
+
327
+ <p style={{ color: 'var(--muted-color)' }}>API Client</p>
328
+ {/* Only the ApiClient breaks out of the 46rem prose column — the
329
+ TryItBars above stay at the normal column width. clamp keeps
330
+ it ≥ 100% (so it never collapses when the main-area calc goes
331
+ negative on narrow screens) and ≤ 64rem. */}
332
+ <div
333
+ style={{
334
+ width:
335
+ 'clamp(100%, calc(100vw - 240px - 280px - var(--s4) * 2), 64rem)',
336
+ }}
337
+ >
338
+ <ApiClient
339
+ method="DELETE"
340
+ label="Trigger Delete"
341
+ path={path}
342
+ onClose={() => {}}
343
+ description="Trigger a documentation deployment programmatically to publish updates outside of Git workflows."
344
+ aside={
345
+ <Stack space="var(--s1)">
346
+ <Callout type="danger">401 Unauthorized</Callout>
347
+ <CodeGroup>
348
+ <CodeBlock title="Response" language="json">
349
+ {`{\n "status": 401,\n "error": "Unauthorized"\n}`}
350
+ </CodeBlock>
351
+ <CodeBlock title="Headers" language="http">
352
+ {`HTTP/1.1 401 Unauthorized
353
+ date: Mon, 27 Apr 2026 12:00:55 GMT
354
+ content-type: application/json; charset=utf-8
355
+ content-length: 24
356
+ connection: close
357
+ x-powered-by: Express
358
+ access-control-allow-origin: *`}
359
+ </CodeBlock>
360
+ </CodeGroup>
361
+ <CodeGroup>
362
+ <CodeBlock title="Curl" language="bash">
363
+ {curl}
364
+ </CodeBlock>
365
+ <CodeBlock title="Ruby" language="ruby">
366
+ {`require 'net/http'
367
+ uri = URI('https://api.mintlify.com/v1/project/update/<projectId>')
368
+ req = Net::HTTP::Post.new(uri)
369
+ req['Authorization'] = 'Bearer <token>'
370
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }`}
371
+ </CodeBlock>
372
+ <CodeBlock title="Python" language="python">
373
+ {`import requests
374
+ requests.post(
375
+ 'https://api.mintlify.com/v1/project/update/<projectId>',
376
+ headers={'Authorization': 'Bearer <token>'},
377
+ )`}
378
+ </CodeBlock>
379
+ </CodeGroup>
380
+ <CodeGroup>
381
+ <CodeBlock title="202" language="json">
382
+ {`{\n "statusId": "<string>"\n}`}
383
+ </CodeBlock>
384
+ <CodeBlock title="200" language="json">
385
+ {`{\n "statusId": "<string>",\n "completedAt": "2026-04-27T12:00:55Z"\n}`}
386
+ </CodeBlock>
387
+ </CodeGroup>
388
+ </Stack>
389
+ }
390
+ >
391
+ <AccordionGroup>
392
+ <Accordion title="Authorization" defaultOpen>
393
+ <Stack space="var(--s1)">
394
+ <ApiField
395
+ name="Authorization"
396
+ type="string"
397
+ required
398
+ prefix="Bearer"
399
+ >
400
+ The Authorization header expects a Bearer token. Use an
401
+ admin API key (prefixed with <code>mint_</code>).
402
+ </ApiField>
403
+ </Stack>
404
+ </Accordion>
405
+ <Accordion title="Path" defaultOpen>
406
+ <Stack space="var(--s1)">
407
+ <ApiField name="projectId" type="string" required>
408
+ Your project ID. Copy it from the API keys page.
409
+ </ApiField>
410
+ <ApiField name="bodyId" type="string" required>
411
+ Your body ID. Copy it from the API keys page.
412
+ </ApiField>
413
+ </Stack>
414
+ </Accordion>
415
+ <Accordion title="Query">
416
+ <Stack space="var(--s1)">
417
+ <ApiField name="branch" type="string">
418
+ Git branch to deploy. Defaults to the production branch.
419
+ </ApiField>
420
+ <ApiField name="dryRun" type="boolean" default="false">
421
+ When true, validates the deployment without publishing.
422
+ </ApiField>
423
+ </Stack>
424
+ </Accordion>
425
+ <Accordion title="Headers">
426
+ <Stack space="var(--s1)">
427
+ <ApiField name="X-Idempotency-Key" type="string">
428
+ Optional unique key so retried requests don't trigger
429
+ duplicate deployments.
430
+ </ApiField>
431
+ </Stack>
432
+ </Accordion>
433
+ <Accordion title="Body">
434
+ <Stack space="var(--s1)">
435
+ <ApiField name="message" type="string">
436
+ Deployment message shown in the activity log.
437
+ </ApiField>
438
+ <ApiField name="notify" type="boolean" default="true">
439
+ Whether to notify when the deployment completes.
440
+ </ApiField>
441
+ </Stack>
442
+ </Accordion>
443
+ </AccordionGroup>
444
+ </ApiClient>
445
+ </div>
446
+ </Stack>
447
+ );
448
+ }
449
+
450
+ function TreeDemo() {
451
+ return (
452
+ <Tree>
453
+ <Folder name="app">
454
+ <File name="layout.tsx" />
455
+ <File name="page.tsx" />
456
+ <Folder name="api" defaultOpen={false}>
457
+ <File name="route.ts" />
458
+ </Folder>
459
+ </Folder>
460
+ <File name="package.json" />
461
+ </Tree>
462
+ );
463
+ }
464
+
465
+ function StepsDemo() {
466
+ return (
467
+ <Steps>
468
+ <Step title="Install the CLI">Run the install script.</Step>
469
+ <Step title="Bootstrap a new project">
470
+ Scaffold a fresh velu project with a single command, then drop in the
471
+ starter MDX so you can begin editing right away. The CLI prompts for a
472
+ project name and target directory, copies the template files, and
473
+ installs dependencies in the background while you keep working.
474
+ </Step>
475
+ <Step title="Add your first page">
476
+ Drop an MDX file into the <code>content/</code> directory.
477
+ </Step>
478
+ <Step title="Configure navigation">
479
+ Edit <code>velu.config.js</code> and add your page paths under the
480
+ sidebar tree. Nested groups are supported, every item can carry an
481
+ icon, and external links are flagged automatically. Save the file and
482
+ the dev server hot-reloads with the new structure.
483
+ </Step>
484
+ <Step title="Preview locally">
485
+ Run <code>velu dev</code> — the local preview opens at
486
+ <code> localhost:5173</code> with SSR + HMR. Edits to any MDX or React
487
+ component reflect instantly. The right-rail TOC is generated from the
488
+ page's headings; the comet trail follows scroll.
489
+ </Step>
490
+ <Step title="Push and ship">Open a PR. Done.</Step>
491
+ </Steps>
492
+ );
493
+ }
494
+
495
+ function PromptDemo() {
496
+ return (
497
+ <Prompt title="Generate clear, concise documentation." openInCursor>
498
+ {`You are a **technical writing assistant**. Write documentation that is clear, accurate, and concise.
499
+ - Use second-person voice
500
+ - Avoid jargon
501
+ - Lead with what the reader can do, not how it works internally
502
+ - Code blocks use the project's language conventions
503
+ - Every example must be runnable as-is`}
504
+ </Prompt>
505
+ );
506
+ }
507
+
508
+ function FieldDemo() {
509
+ const desc =
510
+ 'An example of a parameter field. An example of a parameter field. An example of a parameter field. An example of a parameter field. An example of a parameter field. An example of a parameter field';
511
+ return (
512
+ <div>
513
+ <Field
514
+ name="param"
515
+ pre="pre"
516
+ type="string"
517
+ required
518
+ default={3}
519
+ post="post"
520
+ >
521
+ {desc}
522
+ </Field>
523
+ <Field
524
+ name="param"
525
+ pre="pre"
526
+ type="string"
527
+ required
528
+ default={3}
529
+ post="post"
530
+ >
531
+ {desc}
532
+ </Field>
533
+ <Field
534
+ name="param"
535
+ pre="pre"
536
+ type="string"
537
+ required
538
+ default={3}
539
+ post="post"
540
+ >
541
+ {desc}
542
+ </Field>
543
+ </div>
544
+ );
545
+ }
546
+
547
+ function ColumnsDemo() {
548
+ return (
549
+ <Stack space="var(--s2)">
550
+ <p style={{ color: 'var(--muted-color)' }}>
551
+ Two equal columns (<code>cols=&#123;2&#125;</code>) — collapses to a
552
+ single stack when items would fall below their per-item min width.
553
+ </p>
554
+ <Columns cols={2}>
555
+ <Card
556
+ icon="align-justify"
557
+ title="Card Title"
558
+ cta={{ label: 'Click Here', href: '#columns' }}
559
+ >
560
+ This is how you use a card with an icon and a link. Clicking on
561
+ this card brings you to the Columns page.
562
+ </Card>
563
+ <Card
564
+ icon="align-justify"
565
+ title="Card Title"
566
+ cta={{ label: 'Click Here', href: '#columns' }}
567
+ >
568
+ This is how you use a card with an icon and a link. Clicking on
569
+ this card brings you to the Columns page.
570
+ </Card>
571
+ </Columns>
572
+
573
+ <p style={{ color: 'var(--muted-color)' }}>
574
+ Heterogeneous children — a Card alongside a CodeBlock.
575
+ </p>
576
+ <Columns cols={2}>
577
+ <Card icon="sparkles" title="With a card">
578
+ Cards, callouts, code, images — anything composes in a column.
579
+ </Card>
580
+ <CodeBlock filename="hello.js" language="javascript">
581
+ {DEMO_JS}
582
+ </CodeBlock>
583
+ </Columns>
584
+
585
+ <p style={{ color: 'var(--muted-color)' }}>
586
+ Three columns (<code>cols=&#123;3&#125;</code>) with a stacked column
587
+ in the middle.
588
+ </p>
589
+ <Columns cols={3}>
590
+ <Card icon="rocket" title="Left">
591
+ Single card on the left.
592
+ </Card>
593
+ <Stack space="var(--s0)">
594
+ <Card icon="book" title="Top">
595
+ Two cards stacked via Stack inside one column.
596
+ </Card>
597
+ <Card icon="brain" title="Bottom">
598
+ Stack lets a column carry heterogeneous, multi-item content.
599
+ </Card>
600
+ </Stack>
601
+ <Card icon="zap" title="Right">
602
+ Single card on the right.
603
+ </Card>
604
+ </Columns>
605
+ </Stack>
606
+ );
607
+ }
608
+
609
+ const DEMO_IMG = 'https://picsum.photos/seed/velu-yosemite/1200/600';
610
+ const DEMO_SHOT = 'https://picsum.photos/seed/velu-shot/600/1000';
611
+
612
+ function ImageDemo() {
613
+ return (
614
+ <Stack space="var(--s2)">
615
+ <p style={{ color: 'var(--muted-color)' }}>Image</p>
616
+ <Image src={DEMO_IMG} alt="" />
617
+
618
+ <p style={{ color: 'var(--muted-color)' }}>
619
+ Image with caption (caption can be markdown later)
620
+ </p>
621
+ <Image src={DEMO_IMG} alt="" caption="Caption for this image" />
622
+
623
+ <p style={{ color: 'var(--muted-color)' }}>Image with window chrome</p>
624
+ <Image src={DEMO_IMG} alt="" chrome="window" />
625
+
626
+ <p style={{ color: 'var(--muted-color)' }}>Image with display frame</p>
627
+ <Image src={DEMO_SHOT} alt="" chrome="frame" caption="Caption can also be added" />
628
+ </Stack>
629
+ );
630
+ }
631
+
632
+ const RouterLink = ({ href, ...rest }) => <Link to={href} {...rest} />;
633
+
634
+ // Flatten a nested TOC tree to {id, label, depth} for scroll-spy.
635
+ function flat(nodes, depth = 0, out = []) {
636
+ for (const n of nodes) {
637
+ out.push({ id: n.id, label: n.label, depth });
638
+ if (n.children) flat(n.children, depth + 1, out);
639
+ }
640
+ return out;
641
+ }
642
+
643
+ // Scroll-spy: active = the section whose top last crossed the trigger line.
644
+ /**
645
+ * useScrollSpy tracks the section currently in view, with an
646
+ * explicit "lock" override for click-driven scrolls.
647
+ *
648
+ * Without the lock, clicking a TOC item triggers a smooth-scroll
649
+ * animation; during the ~500ms animation the scroll listener fires
650
+ * many times with the page at intermediate positions, and the
651
+ * "closest section" answer flickers across multiple sections
652
+ * before settling — visible as the TocBar label briefly showing
653
+ * the wrong heading. The lock blocks spy updates for a configurable
654
+ * window so the click's intended `activeId` survives the animation.
655
+ *
656
+ * Returns `[activeId, setActive]` where `setActive(id)` writes the
657
+ * active id directly AND locks the spy for ~800ms.
658
+ */
659
+ function useScrollSpy(ids) {
660
+ const [activeId, setActiveId] = React.useState(ids[0]);
661
+ const lockUntilRef = React.useRef(0);
662
+ React.useEffect(() => {
663
+ const onScroll = () => {
664
+ if (Date.now() < lockUntilRef.current) return;
665
+ // Trigger line reads from --velu-scroll-offset (published by
666
+ // App.jsx as header + tocbar-toggle + 1rem). Same offset CSS
667
+ // uses for scroll-margin, so a heading freshly scrolled into
668
+ // view sits exactly at the trigger. Semantics: pick the
669
+ // section whose top is CLOSEST to the trigger; tie-break
670
+ // favours later sections in document order.
671
+ const trigger =
672
+ parseFloat(
673
+ getComputedStyle(document.documentElement).getPropertyValue(
674
+ '--velu-scroll-offset',
675
+ ),
676
+ ) || 0;
677
+ const vh = window.innerHeight;
678
+ let best = ids[0];
679
+ let bestDist = Infinity;
680
+ for (const id of ids) {
681
+ const node = document.getElementById(id);
682
+ if (!node) continue;
683
+ const top = node.getBoundingClientRect().top;
684
+ if (top >= vh) continue;
685
+ const dist = Math.abs(top - trigger);
686
+ if (dist <= bestDist) {
687
+ bestDist = dist;
688
+ best = id;
689
+ }
690
+ }
691
+ setActiveId(best);
692
+ };
693
+ window.addEventListener('scroll', onScroll, { passive: true });
694
+ onScroll();
695
+ return () => window.removeEventListener('scroll', onScroll);
696
+ }, [ids]);
697
+
698
+ const setActive = React.useCallback((id) => {
699
+ setActiveId(id);
700
+ // ~800ms covers the default smooth-scroll animation duration.
701
+ lockUntilRef.current = Date.now() + 800;
702
+ }, []);
703
+
704
+ return [activeId, setActive];
705
+ }
706
+
707
+ // Slugger used for the frontmatter-derived h1 id same library as
708
+ // extract-toc + rehype-slug, so the id we render in the DOM matches
709
+ // what the TOC's `pageId` will compute. (One instance per page render
710
+ // is fine; the slugger is stateful only across calls on the SAME
711
+ // instance.)
712
+ import GithubSlugger from 'github-slugger';
713
+
714
+ function DocsPage() {
715
+ // Resolve the current route → page entry + navigation context. Both
716
+ // SSR and client compute these from the same pathname + the same
717
+ // shared `resolve()`, so the render is identical (hydration-safe).
718
+ const location = useLocation();
719
+ const pathname = normalizeUrl(location.pathname);
720
+ const entry = pages[pathname];
721
+ const nav = React.useMemo(
722
+ () => resolve(pathname, navigation, pages),
723
+ [pathname],
724
+ );
725
+
726
+ const frontmatter = entry?.frontmatter ?? {};
727
+ const PageComponent = entry?.Component ?? null;
728
+ const pageToc = entry?.toc ?? [];
729
+
730
+ // Switcher option sets (only render a switcher when an axis has >1
731
+ // option). Anchors are pinned sidebar links shown in the context zone.
732
+ const productOptions = nav?.products ?? [];
733
+ const versionOptions = nav?.versions ?? [];
734
+ const languageOptions = nav?.languages ?? [];
735
+ const anchors = nav?.anchors ?? [];
736
+ const versionSwitcher = versionOptions.length > 1 && (
737
+ <NavSelect
738
+ size="sm"
739
+ value={nav.activeVersion}
740
+ options={versionOptions}
741
+ linkComponent={RouterLink}
742
+ ariaLabel="Version"
743
+ />
744
+ );
745
+ const languageSwitcher = languageOptions.length > 1 && (
746
+ <NavSelect
747
+ bare
748
+ icon="globe"
749
+ value={nav.activeLanguage}
750
+ valueCode={nav.activeLanguageCode}
751
+ options={languageOptions}
752
+ linkComponent={RouterLink}
753
+ ariaLabel="Language"
754
+ />
755
+ );
756
+ const productSwitcher = productOptions.length > 1 && (
757
+ <NavSelect
758
+ value={nav.activeProduct}
759
+ options={productOptions}
760
+ linkComponent={RouterLink}
761
+ ariaLabel="Product"
762
+ />
763
+ );
764
+
765
+ // Frontmatter title needs an id so scroll-spy + click-to-scroll work
766
+ // against it like any other heading.
767
+ const pageId = React.useMemo(() => {
768
+ if (!frontmatter.title) return null;
769
+ return new GithubSlugger().slug(frontmatter.title);
770
+ }, [frontmatter.title]);
771
+
772
+ // Combined TOC: the frontmatter title is the page's top-level entry,
773
+ // and the MDX-derived headings (h2s) become its children. Without this,
774
+ // the right rail starts from the first h2 orphaning the page title
775
+ // that sits visibly above the article.
776
+ const toc = React.useMemo(() => {
777
+ if (!frontmatter.title || !pageId) return pageToc;
778
+ return [{ id: pageId, label: frontmatter.title, children: pageToc }];
779
+ }, [pageId, frontmatter.title, pageToc]);
780
+
781
+ // Flat list of section ids for scroll-spy — sourced from the same
782
+ // nested TOC tree we pass to the right-rail <Toc>, so spy + render
783
+ // can't drift out of sync with the MDX content.
784
+ const ids = React.useMemo(() => flat(toc).map((s) => s.id), [toc]);
785
+ const [activeId, setActive] = useScrollSpy(ids);
786
+
787
+ // Ask-AI chatbot: opens (slides in from the side) when a question is
788
+ // submitted in the AskBar. While it's open the TOC is hidden.
789
+ const [chatOpen, setChatOpen] = React.useState(false);
790
+ const [chatQuestion, setChatQuestion] = React.useState('');
791
+
792
+ // Left sidebar open/closed (narrow widths) — chevron toggle. Defaults
793
+ // open per design; safe to default true since the narrow-width
794
+ // sidebar is in-flow and doesn't obscure content.
795
+ const [sidebarOpen, setSidebarOpen] = React.useState(true);
796
+ // Mobile drawer open/closed (< 640px) burger / breadcrumb / X /
797
+ // scrim drive this. Defaults closed so refreshing the page at
798
+ // mobile doesn't surface the drawer over the article. Independent
799
+ // of sidebarOpen so neither breakpoint's default leaks into the
800
+ // other.
801
+ const [drawerOpen, setDrawerOpen] = React.useState(false);
802
+
803
+ // Drawer's nav dropdown — list of section links (Home/Docs/Blog),
804
+ // mirrors the desktop tabs. Click-outside + Escape close.
805
+ const [navOpen, setNavOpen] = React.useState(false);
806
+ const navRef = React.useRef(null);
807
+ React.useEffect(() => {
808
+ if (!navOpen) return;
809
+ const onDocClick = (e) => {
810
+ if (!navRef.current?.contains(e.target)) setNavOpen(false);
811
+ };
812
+ const onKey = (e) => {
813
+ if (e.key === 'Escape') setNavOpen(false);
814
+ };
815
+ document.addEventListener('mousedown', onDocClick);
816
+ document.addEventListener('keydown', onKey);
817
+ return () => {
818
+ document.removeEventListener('mousedown', onDocClick);
819
+ document.removeEventListener('keydown', onKey);
820
+ };
821
+ }, [navOpen]);
822
+ // Mobile drawer's section picker = the resolved tabs (top-level
823
+ // navigation), so it mirrors the desktop tabs row instead of a
824
+ // hardcoded list. Its button shows the active tab's label.
825
+ const navItems = nav?.tabs ?? [];
826
+ const activeNavLabel =
827
+ navItems.find((t) => t.href === nav?.activeTab)?.label ??
828
+ navItems[0]?.label ??
829
+ '';
830
+ const askAI = React.useCallback((q) => {
831
+ setChatQuestion(q);
832
+ setChatOpen(true);
833
+ }, []);
834
+
835
+ // Measure the live sticky chrome header + (collapsed) TocBar
836
+ // toggle and publish three CSS variables on `:root`:
837
+ //
838
+ // --velu-header-height live header height
839
+ // --velu-tocbar-height TocBar's TOGGLE row height (always-
840
+ // visible bit; 0 when the bar isn't shown)
841
+ // --velu-scroll-offset header + tocbar-toggle + 1rem
842
+ //
843
+ // Measuring just the toggle row (NOT the expanded list) is
844
+ // important: the expanded dropdown's max-block-size is large, and
845
+ // including it in the offset would inflate scroll-margin /
846
+ // scroll-spy trigger as soon as the user expands the dropdown —
847
+ // causing the page geometry to lurch and the spy / active label
848
+ // to flicker.
849
+ React.useEffect(() => {
850
+ if (typeof window === 'undefined') return;
851
+ const header = document.querySelector('.velu-header');
852
+ if (!header) return;
853
+ const root = document.documentElement;
854
+ const remPx = parseFloat(getComputedStyle(root).fontSize) || 16;
855
+ const apply = () => {
856
+ const headerH = header.getBoundingClientRect().height;
857
+ // Re-query each apply — the TocBar toggle button is mounted
858
+ // after first paint and its parent may also remount on
859
+ // theme/viewport changes.
860
+ const tocBarToggle = document.querySelector('.velu-toc-bar__toggle');
861
+ const tocBarH = tocBarToggle
862
+ ? tocBarToggle.getBoundingClientRect().height
863
+ : 0;
864
+ if (headerH > 0)
865
+ root.style.setProperty('--velu-header-height', `${headerH}px`);
866
+ root.style.setProperty('--velu-tocbar-height', `${tocBarH}px`);
867
+ // +1rem of breathing room between the chrome and the heading
868
+ // it's anchoring. The rem is read live so it stays in tokens.
869
+ root.style.setProperty(
870
+ '--velu-scroll-offset',
871
+ `${headerH + tocBarH + remPx}px`,
872
+ );
873
+ };
874
+ apply();
875
+ const ro = new ResizeObserver(apply);
876
+ ro.observe(header);
877
+ // Observe the toggle row only (not the expanded list) so the
878
+ // offset stays put when the user opens the dropdown.
879
+ const tocBarToggle = document.querySelector('.velu-toc-bar__toggle');
880
+ if (tocBarToggle) ro.observe(tocBarToggle);
881
+ return () => ro.disconnect();
882
+ }, []);
883
+
884
+ // The asides (left sidebar + right TOC) scroll independently of the
885
+ // page. On scroll/resize we set data-fade-top/-bottom on each scroll
886
+ // region so CSS can fade its edges (and reveal the nav arrows) only
887
+ // when there's content beyond them. Written imperatively (no
888
+ // re-render). Re-binds on chatOpen toggle (the right TOC
889
+ // mounts/unmounts with it).
890
+ const leftAsideRef = React.useRef(null);
891
+ const rightAsideRef = React.useRef(null);
892
+ React.useEffect(() => {
893
+ const els = [leftAsideRef.current, rightAsideRef.current].filter(Boolean);
894
+ if (!els.length) return;
895
+ const update = () => {
896
+ for (const el of els) {
897
+ el.dataset.fadeTop = el.scrollTop > 0 ? 'true' : 'false';
898
+ el.dataset.fadeBottom =
899
+ el.scrollTop + el.clientHeight < el.scrollHeight - 1
900
+ ? 'true'
901
+ : 'false';
902
+ }
903
+ };
904
+ update();
905
+ // Recompute on scroll AND on any size change of the scroll region
906
+ // or its content (ResizeObserver) + window resize — so the fade
907
+ // reflects hidden content persistently, not just during a scroll
908
+ // gesture (e.g. after a route change shifts the nav's height).
909
+ const ro = new ResizeObserver(update);
910
+ els.forEach((el) => {
911
+ el.addEventListener('scroll', update, { passive: true });
912
+ ro.observe(el);
913
+ if (el.firstElementChild) ro.observe(el.firstElementChild);
914
+ });
915
+ window.addEventListener('resize', update);
916
+ return () => {
917
+ els.forEach((el) => el.removeEventListener('scroll', update));
918
+ ro.disconnect();
919
+ window.removeEventListener('resize', update);
920
+ };
921
+ }, [chatOpen, pathname]);
922
+
923
+ // Flag the sidebar section heading currently pinned at the top of the
924
+ // scroll region (CSS sticky gives no "is-stuck" hook). When the pinned
925
+ // heading changes as you scroll, the new one gets data-stuck and
926
+ // animates in (see sidebar.css). Re-binds on page/tab change since the
927
+ // section set changes with it.
928
+ React.useEffect(() => {
929
+ const scroller = leftAsideRef.current;
930
+ if (!scroller) return;
931
+ let prev = null;
932
+ const update = () => {
933
+ const top = scroller.getBoundingClientRect().top;
934
+ let stuck = null;
935
+ for (const h of scroller.querySelectorAll('.velu-sidebar__section')) {
936
+ if (h.getBoundingClientRect().top <= top + 1) stuck = h;
937
+ }
938
+ if (stuck !== prev) {
939
+ prev?.removeAttribute('data-stuck');
940
+ stuck?.setAttribute('data-stuck', 'true');
941
+ prev = stuck;
942
+ }
943
+ };
944
+ update();
945
+ scroller.addEventListener('scroll', update, { passive: true });
946
+ return () => scroller.removeEventListener('scroll', update);
947
+ }, [pathname, chatOpen]);
948
+
949
+ // Footer config (from velu.json via virtual:velu-site). Link columns →
950
+ // full footer section; socials with no columns → inline next to PoweredBy.
951
+ const footerSocials = site.footer?.socials ?? [];
952
+ const footerHasLinks = (site.footer?.links?.length ?? 0) > 0;
953
+
954
+ // Header top-right actions, built from velu.json's `navbar`: secondary
955
+ // `links` (plain text links) then the `primary` CTA (a filled button, or an
956
+ // outlined GitHub button). Empty when unconfigured — no placeholder buttons.
957
+ const navActions = [];
958
+ for (const l of site.navbar?.links ?? []) {
959
+ navActions.push({
960
+ label: l.label,
961
+ href: l.href,
962
+ icon: l.icon || undefined,
963
+ kind: 'link',
964
+ external: l.external,
965
+ });
966
+ }
967
+ if (site.navbar?.primary) {
968
+ const p = site.navbar.primary;
969
+ navActions.push({
970
+ label: p.label,
971
+ href: p.href,
972
+ kind: p.kind === 'github' ? 'outlined' : 'primary',
973
+ icon: p.kind === 'github' ? 'github' : undefined,
974
+ external: p.external,
975
+ });
976
+ }
977
+
978
+ const footerRef = React.useRef(null);
979
+ const [footerOverlap, setFooterOverlap] = React.useState(0);
980
+ React.useEffect(() => {
981
+ const node = footerRef.current;
982
+ if (!node) return;
983
+ const update = () => {
984
+ const rect = node.getBoundingClientRect();
985
+ setFooterOverlap(Math.max(0, window.innerHeight - rect.top));
986
+ };
987
+ update();
988
+ window.addEventListener('scroll', update, { passive: true });
989
+ window.addEventListener('resize', update);
990
+ return () => {
991
+ window.removeEventListener('scroll', update);
992
+ window.removeEventListener('resize', update);
993
+ };
994
+ }, []);
995
+
996
+ // Sticky AskBar fades out once the user scrolls near the PageFeedback
997
+ // widget — so it doesn't sit on top of the page-foot widgets.
998
+ const feedbackRef = React.useRef(null);
999
+ const [askBarHidden, setAskBarHidden] = React.useState(false);
1000
+ React.useEffect(() => {
1001
+ const node = feedbackRef.current;
1002
+ if (!node) return;
1003
+ const FADE_BUFFER = 64; // matches the rootMargin below
1004
+ const apply = (rect) => {
1005
+ // Hide whenever the feedback's top has reached the (effective)
1006
+ // viewport-bottom line — covers both "feedback is in view" AND
1007
+ // "feedback is already scrolled past" without flickering back on.
1008
+ // Threshold matches the IO's rootMargin so the show/hide flip is
1009
+ // exactly at the same line in both scroll directions.
1010
+ setAskBarHidden(rect.top < window.innerHeight - FADE_BUFFER);
1011
+ };
1012
+ apply(node.getBoundingClientRect());
1013
+ const io = new IntersectionObserver(
1014
+ ([entry]) => apply(entry.boundingClientRect),
1015
+ // Trigger ~64px before the feedback enters the viewport, so the
1016
+ // AskBar fades just as the widget starts to peek up from below.
1017
+ { rootMargin: '0px 0px -64px 0px' },
1018
+ );
1019
+ io.observe(node);
1020
+ return () => io.disconnect();
1021
+ }, []);
1022
+
1023
+ const scrollTo = React.useCallback(
1024
+ (id) => {
1025
+ const node = document.getElementById(id);
1026
+ if (!node) return;
1027
+ // 1) Set + lock the active id IMMEDIATELY so the spy doesn't
1028
+ // overwrite it during the smooth-scroll animation.
1029
+ setActive(id);
1030
+ // 2) Compute the target scroll position MANUALLY (instead of
1031
+ // scrollIntoView). The browser clamps to max-scroll
1032
+ // automatically; the target is baked in at click time and
1033
+ // doesn't drift if layout shifts mid-animation.
1034
+ const offset =
1035
+ parseFloat(
1036
+ getComputedStyle(document.documentElement).getPropertyValue(
1037
+ '--velu-scroll-offset',
1038
+ ),
1039
+ ) || 0;
1040
+ const top = node.getBoundingClientRect().top + window.scrollY - offset;
1041
+ window.scrollTo({ top, behavior: 'smooth' });
1042
+ },
1043
+ [setActive],
1044
+ );
1045
+
1046
+ const kicker = {
1047
+ fontFamily: 'var(--font-mono)',
1048
+ fontSize: 11,
1049
+ letterSpacing: '0.6px',
1050
+ textTransform: 'uppercase',
1051
+ color: 'var(--muted-color)',
1052
+ };
1053
+
1054
+ return (
1055
+ <div
1056
+ className="velu-docs-layout"
1057
+ data-chat-open={chatOpen ? 'true' : 'false'}
1058
+ data-sidebar-open={sidebarOpen ? 'true' : 'false'}
1059
+ data-drawer-open={drawerOpen ? 'true' : 'false'}
1060
+ >
1061
+ {/* Scrim — visible at mobile while the drawer OR the chatbot
1062
+ sheet is open. Sits between the article (z-0) and the
1063
+ drawer/chatbot (z-35 / z-50) and blocks pointer events to
1064
+ everything beneath; click closes whichever surface is up. */}
1065
+ <div
1066
+ className="velu-docs-layout__scrim"
1067
+ aria-hidden="true"
1068
+ onClick={() => {
1069
+ setDrawerOpen(false);
1070
+ setChatOpen(false);
1071
+ }}
1072
+ />
1073
+ {/* Site header — brand, centered search, right-side actions,
1074
+ tabs row. Configurable: pass any number of actions / tabs. */}
1075
+ <PageHeader
1076
+ linkComponent={RouterLink}
1077
+ brand={{
1078
+ label: site.name,
1079
+ href: site.logo?.href || '/',
1080
+ logo: site.logo,
1081
+ }}
1082
+ brandTrailing={
1083
+ versionSwitcher && (
1084
+ <span className="velu-hide-on-mobile">{versionSwitcher}</span>
1085
+ )
1086
+ }
1087
+ tabsTrailing={languageSwitcher || undefined}
1088
+ center={
1089
+ <Cluster space="var(--s-6)" align="center">
1090
+ <Search style={{ inlineSize: '30ch' }} unavailable={IS_DEV_PREVIEW} />
1091
+ {/* Ask AI talks to the deployed site's AI backend — hidden in
1092
+ the local dev preview where there's nothing to talk to. */}
1093
+ {!IS_DEV_PREVIEW && (
1094
+ <button
1095
+ type="button"
1096
+ className="velu-header__action velu-header__action--outlined"
1097
+ onClick={() => askAI('')}
1098
+ >
1099
+ <span className="velu-header__action-icon" aria-hidden="true">
1100
+ {resolveIcon('sparkles', { size: '1.5em' })}
1101
+ </span>
1102
+ <span>Ask AI</span>
1103
+ </button>
1104
+ )}
1105
+ </Cluster>
1106
+ }
1107
+ actions={navActions}
1108
+ trailing={<ThemeToggle />}
1109
+ onMenuClick={() => setDrawerOpen((v) => !v)}
1110
+ breadcrumb={nav?.breadcrumb ?? []}
1111
+ activeTab={nav?.activeTab}
1112
+ tabs={nav?.tabs ?? []}
1113
+ />
1114
+
1115
+ {/* Fixed left sidebar — pinned to viewport-left below the header.
1116
+ Does NOT scroll with the page; the footer rises over its bottom
1117
+ edge thanks to the higher z-index on the footer below.
1118
+ `top` + `bottom` give a robust height (some browsers don't
1119
+ honour `inset-block-start` for fixed positioning the same way
1120
+ as plain `top`). */}
1121
+ <aside
1122
+ className="velu-docs-layout__aside velu-docs-layout__aside--left"
1123
+ style={{
1124
+ /* Bottom edge stays a fixed gap above the viewport bottom,
1125
+ AND lifts to keep that gap above the footer as it scrolls
1126
+ into view (footerOverlap = how far the footer intrudes).
1127
+ Set as a custom prop so the mobile drawer's
1128
+ `inset-block-end: 0` override still wins. */
1129
+ '--velu-aside-bottom': `calc(${footerOverlap}px + var(--s4))`,
1130
+ }}
1131
+ >
1132
+ {/* Drawer body — Stack with 32px gap composes the slots
1133
+ (topbar / tab-dropdown / context / nav). The aside is a flex
1134
+ column (CSS): the pinned slots keep their height and only the
1135
+ nav region scrolls (see velu-docs-nav-scroll below). At wide
1136
+ widths the topbar + docselect are `display: none`. */}
1137
+ <Stack space="var(--s0)" className="velu-docs-aside-stack">
1138
+ <div className="velu-docs-layout__drawer-head">
1139
+ <RouterLink
1140
+ href="/"
1141
+ className="velu-docs-layout__drawer-brand"
1142
+ >
1143
+ {site.logo ? (
1144
+ <Logo logo={site.logo} alt={site.name} />
1145
+ ) : (
1146
+ <>
1147
+ <VeluMark />
1148
+ <span className="velu-header__wordmark">{site.name}</span>
1149
+ </>
1150
+ )}
1151
+ </RouterLink>
1152
+ <ThemeToggle />
1153
+ <button
1154
+ type="button"
1155
+ className="velu-docs-layout__drawer-close"
1156
+ aria-label="Close navigation"
1157
+ onClick={() => setDrawerOpen(false)}
1158
+ >
1159
+ <X aria-hidden="true" focusable="false" />
1160
+ </button>
1161
+ </div>
1162
+ {/* Nav dropdowncustom button + menu, fills drawer width.
1163
+ Items mirror the desktop tabs (Home / Docs / Blog).
1164
+ Click-outside + Escape close (see navOpen useEffect
1165
+ above). Chevron rotates 180° on open. */}
1166
+ <div
1167
+ ref={navRef}
1168
+ className="velu-docs-layout__drawer-docselect"
1169
+ data-open={navOpen ? 'true' : 'false'}
1170
+ >
1171
+ <button
1172
+ type="button"
1173
+ className="velu-docs-layout__drawer-docselect-btn"
1174
+ onClick={() => setNavOpen((o) => !o)}
1175
+ aria-haspopup="menu"
1176
+ aria-expanded={navOpen}
1177
+ >
1178
+ <span className="velu-docs-layout__drawer-docselect-label">
1179
+ {activeNavLabel}
1180
+ </span>
1181
+ <ChevronDown
1182
+ className="velu-docs-layout__drawer-docselect-chev"
1183
+ aria-hidden="true"
1184
+ focusable="false"
1185
+ />
1186
+ </button>
1187
+ <ul
1188
+ className="velu-docs-layout__drawer-docselect-menu"
1189
+ role="menu"
1190
+ aria-hidden={!navOpen}
1191
+ >
1192
+ {navItems.map((it) => (
1193
+ <li key={it.href} role="none">
1194
+ <a
1195
+ role="menuitem"
1196
+ className="velu-docs-layout__drawer-docselect-item"
1197
+ href={it.href}
1198
+ tabIndex={navOpen ? 0 : -1}
1199
+ onClick={() => setNavOpen(false)}
1200
+ >
1201
+ {it.label}
1202
+ </a>
1203
+ </li>
1204
+ ))}
1205
+ </ul>
1206
+ </div>
1207
+ {/* Context zone product switcher + anchors at the top of
1208
+ the sidebar (all breakpoints), plus version/language
1209
+ switchers that only show on mobile (desktop has them in
1210
+ the header). Rendered only when there's something to show
1211
+ so simple projects keep a bare sidebar. */}
1212
+ {(productSwitcher ||
1213
+ versionSwitcher ||
1214
+ languageSwitcher ||
1215
+ anchors.length > 0) && (
1216
+ <Stack space="var(--s-1)" className="velu-docs-context">
1217
+ {productSwitcher}
1218
+ {versionSwitcher && (
1219
+ <span className="velu-show-on-mobile">{versionSwitcher}</span>
1220
+ )}
1221
+ {languageSwitcher && (
1222
+ <span className="velu-show-on-mobile">{languageSwitcher}</span>
1223
+ )}
1224
+ {anchors.length > 0 && (
1225
+ <ul className="velu-docs-anchors">
1226
+ {anchors.map((a, i) => (
1227
+ <li key={i}>
1228
+ <a
1229
+ className="velu-docs-anchors__link"
1230
+ href={a.href}
1231
+ target="_blank"
1232
+ rel="noreferrer"
1233
+ >
1234
+ {a.icon && (
1235
+ <span
1236
+ className="velu-docs-anchors__icon"
1237
+ aria-hidden="true"
1238
+ >
1239
+ {resolveIcon(a.icon, { size: '1em' })}
1240
+ </span>
1241
+ )}
1242
+ <span>{a.label}</span>
1243
+ </a>
1244
+ </li>
1245
+ ))}
1246
+ </ul>
1247
+ )}
1248
+ </Stack>
1249
+ )}
1250
+ {/* Only this region scrolls — the context zone above stays
1251
+ pinned. The up/down arrows overlay its top/bottom edges and
1252
+ appear (via the data-fade-* attrs the scroll handler sets)
1253
+ when there's content beyond that edge; clicking jumps the
1254
+ nav fully to that end. */}
1255
+ <div className="velu-docs-nav-region">
1256
+ <div
1257
+ ref={leftAsideRef}
1258
+ className="velu-docs-nav-scroll velu-hide-scrollbar"
1259
+ style={{
1260
+ /* Just breathing room — the aside's bottom edge already
1261
+ stays above the footer (see --velu-aside-bottom). */
1262
+ paddingBlockEnd: 'var(--s1)',
1263
+ scrollPaddingBlockEnd: 'var(--s1)',
1264
+ }}
1265
+ >
1266
+ <Sidebar
1267
+ sections={nav?.sidebarSections ?? []}
1268
+ activeHref={pathname}
1269
+ linkComponent={RouterLink}
1270
+ />
1271
+ </div>
1272
+ <button
1273
+ type="button"
1274
+ className="velu-docs-nav-arrow velu-docs-nav-arrow--up"
1275
+ aria-label="Scroll navigation to top"
1276
+ onClick={() =>
1277
+ leftAsideRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
1278
+ }
1279
+ >
1280
+ <ChevronUp aria-hidden="true" focusable="false" />
1281
+ </button>
1282
+ <button
1283
+ type="button"
1284
+ className="velu-docs-nav-arrow velu-docs-nav-arrow--down"
1285
+ aria-label="Scroll navigation to bottom"
1286
+ onClick={() =>
1287
+ leftAsideRef.current?.scrollTo({
1288
+ top: leftAsideRef.current.scrollHeight,
1289
+ behavior: 'smooth',
1290
+ })
1291
+ }
1292
+ >
1293
+ <ChevronDown aria-hidden="true" focusable="false" />
1294
+ </button>
1295
+ </div>
1296
+ </Stack>
1297
+ </aside>
1298
+
1299
+ {/* No separate rail element at narrow widths the sidebar's
1300
+ aside ANIMATES its `inline-size` between 240px (open) and
1301
+ --velu-rail-width (closed), and its inner Sidebar nav fades
1302
+ out via opacity. The aside's border-inline-end therefore
1303
+ becomes the rail's right border when collapsed. This makes
1304
+ the collapse a smooth width+opacity transition instead of a
1305
+ display:none snap. See docs-layout.css. */}
1306
+
1307
+ {/* Fixed right TOC pinned to viewport-right. Hidden while the
1308
+ Ask-AI chatbot is open (the panel takes that edge). */}
1309
+ {!chatOpen && (
1310
+ <aside
1311
+ ref={rightAsideRef}
1312
+ className="velu-docs-layout__aside velu-docs-layout__aside--right velu-hide-scrollbar"
1313
+ style={{
1314
+ /* Dynamic footer-overlap padding only — static geometry
1315
+ lives in docs-layout.css. */
1316
+ paddingBlockEnd: `calc(var(--s3) + ${footerOverlap}px + 2rem)`,
1317
+ scrollPaddingBlockEnd: `calc(${footerOverlap}px + 2rem)`,
1318
+ }}
1319
+ >
1320
+ <Toc items={toc} activeId={activeId} onSelect={scrollTo} />
1321
+ </aside>
1322
+ )}
1323
+
1324
+ {/* Center column. Margin to reserve aside space lives in
1325
+ docs-layout.css (drops to 0 at narrow widths via @container,
1326
+ and the right margin collapses to 0 while the chatbot is
1327
+ open via [data-chat-open="true"]). */}
1328
+ <div className="velu-docs-layout__center">
1329
+ {/* Narrow-layout TOC bar always in DOM, only visible at
1330
+ < 1024px (toggled by @container in toc-bar.css). Shares
1331
+ its `items` + `activeId` + `onSelect` API with the
1332
+ right-rail <Toc> so both views are driven by the same
1333
+ data; whichever one is visible at a given width responds
1334
+ to the same scroll-spy state. */}
1335
+ <TocBar items={toc} activeId={activeId} onSelect={scrollTo} />
1336
+ <main
1337
+ className="velu-docs-layout__main"
1338
+ style={{
1339
+ /* No padding-block-end — the article's last child (PoweredBy)
1340
+ owns the gap to the footer via its own margin-bottom, so
1341
+ padding-bottom here would double-count it. `position:
1342
+ relative` makes <main> the offsetParent for the absolutely
1343
+ positioned sidebar-toggle below — so its `top: 0` lands
1344
+ at the top of the article area, BELOW the sticky TocBar
1345
+ (which lives outside <main>). Padding-inline lives in CSS
1346
+ so the @container query can collapse the start side when
1347
+ the sidebar is closed (no "ghost column" on the left). */
1348
+ position: 'relative',
1349
+ color: 'var(--text-color)',
1350
+ }}
1351
+ >
1352
+ {/* Sidebar toggle — narrow-layout only (hidden by @container
1353
+ at wide widths). Lives inside <main> so its absolute
1354
+ position is relative to the article area, not the centre
1355
+ column, and therefore sits BELOW the TocBar rather than
1356
+ overlapping it. Chevron flips direction with the
1357
+ `data-sidebar-open` data attribute on the layout root. */}
1358
+ <button
1359
+ type="button"
1360
+ className="velu-docs-layout__sidebar-toggle"
1361
+ onClick={() => setSidebarOpen((v) => !v)}
1362
+ aria-label={sidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'}
1363
+ aria-expanded={sidebarOpen}
1364
+ >
1365
+ <span aria-hidden="true">
1366
+ {resolveIcon(sidebarOpen ? 'chevron-left' : 'chevron-right', {
1367
+ size: '1em',
1368
+ })}
1369
+ </span>
1370
+ </button>
1371
+ <div className="velu-docs-layout__article">
1372
+ {/* Page hero from MDX frontmatter. `.velu-hero` rules
1373
+ (in base.css) keep the title and description tightly
1374
+ grouped and create a clear break before the prose body. */}
1375
+ {(frontmatter.title || frontmatter.description) && (
1376
+ <div className="velu-hero">
1377
+ {frontmatter.title && <h1 id={pageId}>{frontmatter.title}</h1>}
1378
+ {frontmatter.description && (
1379
+ <p
1380
+ style={{
1381
+ color: 'var(--muted-color)',
1382
+ fontSize: 'var(--f-h4)',
1383
+ lineHeight: 'var(--lh-h4)',
1384
+ }}
1385
+ >
1386
+ {frontmatter.description}
1387
+ </p>
1388
+ )}
1389
+ </div>
1390
+ )}
1391
+
1392
+ {/* Article body the current route's MDX page, rendered
1393
+ through the velu-ui MDX component registry. `velu-prose`
1394
+ gives a consistent vertical rhythm scaled by heading
1395
+ level. Falls back to a not-found / missing-file notice
1396
+ when the route has no page (or its file is absent). */}
1397
+ <MDXProvider components={defaultMdxComponents}>
1398
+ <div className="velu-prose">
1399
+ {PageComponent ? (
1400
+ <ErrorBoundary key={pathname} file={entry?.relPath}>
1401
+ <PageComponent />
1402
+ </ErrorBoundary>
1403
+ ) : (
1404
+ <p style={{ color: 'var(--muted-color)' }}>
1405
+ {entry?.missing
1406
+ ? 'This page is listed in navigation but its .mdx file was not found.'
1407
+ : 'Page not found.'}
1408
+ </p>
1409
+ )}
1410
+ </div>
1411
+ </MDXProvider>
1412
+ {/* Page-foot feedback widget — ref'd so the sticky AskBar
1413
+ above can hide as the user scrolls near it. */}
1414
+ <div ref={feedbackRef} style={{ marginTop: 'var(--s3)' }}>
1415
+ <PageFeedback />
1416
+ </div>
1417
+ {/* Previous / next page navigation derived from the
1418
+ sidebar reading order of the active section. */}
1419
+ {(nav?.prev || nav?.next) && (
1420
+ <PageNav
1421
+ style={{ marginTop: 'var(--s2)' }}
1422
+ prev={nav?.prev}
1423
+ next={nav?.next}
1424
+ linkComponent={RouterLink}
1425
+ />
1426
+ )}
1427
+ {/* "Ask a question" bar — sticky to viewport-bottom while the
1428
+ article column is in view; lives inside the prose-column
1429
+ div so its width matches the article. Submitting opens
1430
+ the Ask-AI chatbot; fades out as the PageFeedback widget
1431
+ approaches (see IntersectionObserver above). */}
1432
+ {/* Ask-a-question bar — opens the AI chatbot, which runs on the
1433
+ deployed site. Hidden in the local dev preview. */}
1434
+ {!IS_DEV_PREVIEW && (
1435
+ <AskBar
1436
+ onSubmit={askAI}
1437
+ style={{
1438
+ position: 'sticky',
1439
+ insetBlockEnd: 'var(--s1)',
1440
+ /* Once the user has scrolled near the feedback widget the
1441
+ AskBar fades and collapses to zero flow space — height,
1442
+ margin, padding all drop to 0 so the next element
1443
+ (PoweredBy) sits directly under PageNav at its own
1444
+ 32px margin, with no phantom gap. */
1445
+ marginTop: askBarHidden ? 0 : 'var(--s2)',
1446
+ height: askBarHidden ? 0 : 'auto',
1447
+ overflow: 'hidden',
1448
+ opacity: askBarHidden ? 0 : 1,
1449
+ transform: askBarHidden ? 'translateY(20%)' : 'translateY(0)',
1450
+ pointerEvents: askBarHidden ? 'none' : 'auto',
1451
+ transition: 'opacity 0.2s ease, transform 0.2s ease',
1452
+ }}
1453
+ />
1454
+ )}
1455
+ {/* Content foot "Powered by Velu" at the bottom-right of the
1456
+ article column. When the footer has no link columns, the
1457
+ configured social icons sit on the LEFT of this same row (no
1458
+ full footer section); with link columns they live in the full
1459
+ footer below instead. */}
1460
+ {footerHasLinks ? (
1461
+ <PoweredBy />
1462
+ ) : (
1463
+ <div className="velu-content-foot">
1464
+ <SocialLinks socials={footerSocials} />
1465
+ <PoweredBy />
1466
+ </div>
1467
+ )}
1468
+ </div>
1469
+ </main>
1470
+ </div>
1471
+
1472
+ {/* Full site footer — only when the config provides link columns. Spans
1473
+ full width below the article; its raised z-index eclipses the bottoms
1474
+ of the fixed sidebar and TOC as the page scrolls into it, and the ref
1475
+ is watched so the asides can pad their bottom by the overlap. */}
1476
+ {footerHasLinks && (
1477
+ <div
1478
+ ref={footerRef}
1479
+ data-velu-footer
1480
+ style={{ position: 'relative', zIndex: 20 }}
1481
+ >
1482
+ <PageFooter
1483
+ brand={{ href: site.logo?.href || '/' }}
1484
+ columns={site.footer.links}
1485
+ socials={footerSocials}
1486
+ />
1487
+ </div>
1488
+ )}
1489
+
1490
+ {/* Ask-AI chatbot — slides in from the inline-end edge. */}
1491
+ <Chatbot
1492
+ open={chatOpen}
1493
+ seedQuestion={chatQuestion}
1494
+ onClose={() => setChatOpen(false)}
1495
+ />
1496
+ </div>
1497
+ );
1498
+ }
1499
+
1500
+ export default function App() {
1501
+ return (
1502
+ <Routes>
1503
+ <Route path="*" element={<DocsPage />} />
1504
+ </Routes>
1505
+ );
1506
+ }