@veluai/velu 0.2.36 → 0.2.38

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.
@@ -0,0 +1,159 @@
1
+ # Migrate Mintlify documentation to Velu
2
+
3
+ ```sh
4
+ velu migrate mintlify ./mintlify-docs --out ./velu-docs --dry-run
5
+ velu migrate mintlify ./mintlify-docs --out ./velu-docs
6
+ cd velu-docs
7
+ velu validate .
8
+ velu dev .
9
+ ```
10
+
11
+ From this repository, replace `velu` with `node packages/velu-cli/bin/cli.js`.
12
+ The command is included in the bundled public CLI. It has not been published
13
+ until a package release is made.
14
+
15
+ The source is a local repository/content directory containing `docs.json` or
16
+ legacy `mint.json`. The command creates a **new directory** and leaves the source
17
+ untouched. Without `--out`, it creates a sibling named `<source>-velu`. Existing
18
+ destinations, destinations inside the source, and filename collisions are rejected.
19
+ Files are staged before the completed directory is moved into place.
20
+
21
+ `docs.json` takes precedence if both configs exist. Use `--config mint.json` to
22
+ select the legacy file. JSON comments/trailing commas and local JSON/YAML config
23
+ `$ref` files are supported. Config references cannot escape the source directory;
24
+ remote references, fragments, and cycles fail with an explicit error.
25
+
26
+ ## Preview and CI
27
+
28
+ | Option | Behavior |
29
+ | --- | --- |
30
+ | `--dry-run` | Runs analysis, transformations, and MDX compilation in memory; writes nothing |
31
+ | `--json` | Prints only the machine-readable report |
32
+ | `--strict` | Refuses to write output if there are warnings or errors |
33
+ | `--out <directory>` | Sets the new destination |
34
+ | `--config docs.json\|mint.json` | Selects the source configuration |
35
+ | `--help` | Prints usage |
36
+
37
+ Exit codes are `0` for completion without compatibility errors, `1` for invocation
38
+ or filesystem failures, `2` for compatibility errors, and `3` when strict mode
39
+ requires review. **Normal mode writes a reviewable migration even with code 2.**
40
+ Warnings alone return 0 unless strict mode is enabled. The report still labels
41
+ any migration with findings `needs-review`.
42
+
43
+ Every migration writes `VELU-MIGRATION.md` and `velu-migration-report.json`.
44
+ It also archives the fully resolved original config in
45
+ `.velu-migration/source-config.json`, outside the publicly served assets.
46
+ The JSON report includes per-file actions and SHA-256 output hashes, script
47
+ conversions, skipped paths, and findings with source locations. Reports do not
48
+ contain source script bodies or unsupported config values. They can contain
49
+ filenames, endpoint paths, and diagnostic text; review before sharing.
50
+
51
+ ## Coverage
52
+
53
+ | Area | Migration behavior |
54
+ | --- | --- |
55
+ | Config | Projects supported values against the shipped Velu schema; reports unsupported nested fields individually and preserves the original source config |
56
+ | Legacy config | Converts navigation arrays, prefix-based tabs, anchors, topbar links/CTA, footer socials, and API base URL/example languages |
57
+ | Navigation | Retains groups, tabs, products, versions, languages, root pages, and supported OpenAPI references; maps simple dropdown containers to tabs and merges global anchors/tabs with a review finding |
58
+ | Branding | Copies logos/favicons, colors, navbar/footer, contextual menu and SEO; maps `description` to SEO metadata, body font family to `font`, and a dual favicon to one local variant |
59
+ | Content | Converts content `.md` to `.mdx`; preserves frontmatter, prose, fenced examples, MDX expressions, and local components; compiles every migrated MDX file |
60
+ | Components | Maps Note/Tip/Warning/Info/Check/Danger to Callout, ParamField/ResponseField to Field, Expandable to Accordion, CardGroup `cols` to `limit`, and Accordion `default-open` to `defaultOpen` |
61
+ | React adapters | Adds an editable `snippets/velu-mintlify-compat.jsx` for Tabs/Tab, Frame, Tooltip, Badge, Button, and Panel; imports only used adapters into each MDX page |
62
+ | Snippets | Preserves JSX/TSX modules; converts snippet-directory `.js` files to `.jsx` to avoid global execution; updates MDX and JS/TS module imports, including root-relative and extensionless imports |
63
+ | Custom JavaScript | Preserves browser `.js` files byte-for-byte and inventories their execution risks; Velu loads them after hydration, once per full page load, in path order |
64
+ | Custom CSS | Preserves styles; parses declarations to rewrite relative `url(...)` asset references to site-root paths; reports selectors/imports needing review |
65
+ | Assets | Copies files and mirrors ordinary images, fonts, media, downloads, CSS/JS, and explicitly linked data into `public/` for existing URLs; retains originals for branding and local imports |
66
+ | OpenAPI navigation | Preserves specs and references, reports missing/remote sources and the need to compare generated routes |
67
+ | OpenAPI MDX pages | Materializes `openapi: spec.json GET /path`, or `GET /path` with a root spec reference, into an editable API playground snapshot without changing the page path or removing its body; supports single local specs with internal references |
68
+ | npm scripts | Rewrites exact `mint`/`mintlify` dev, validate, version/v, and update invocations, including common package-manager launchers; adds the running Velu version as a dev dependency when Mintlify usage is found |
69
+ | Ignore rules | Honors `.mintignore` using gitignore semantics, including negation; excludes dependencies, build caches, environment files, and known tooling directories |
70
+ | Project tooling | Preserves shell scripts, package manifests, lockfiles, and CI workflows; flags Mintlify commands requiring manual replacement |
71
+
72
+ ## Custom scripts and remaining work
73
+
74
+ Migration never executes project scripts, installs dependencies, or downloads
75
+ remote assets/specs. Run your package manager afterward when `package.json` has
76
+ changed; lockfiles and Mintlify dependencies are retained for review.
77
+
78
+ Mintlify and Velu share many DOM selectors, but arbitrary JavaScript cannot be
79
+ translated reliably without understanding its behavior. The report identifies
80
+ Mintlify browser APIs (`window.mintlify`, user events), Next.js internals,
81
+ DOMContentLoaded/load timing, module syntax in classic scripts, Node.js code in
82
+ globally loaded `.js` files, and DOM-dependent code. These checks are static
83
+ heuristics, not proof of complete runtime parity. For DOM-ready callbacks, check
84
+ `document.readyState` and initialize immediately when the event has already fired.
85
+ Verify behavior after client-side navigation as well as a full refresh.
86
+
87
+ The `css-dom` finding flags styles aimed at shared chrome IDs such as `#content`,
88
+ `#sidebar-content`, and `#footer`. Vepa aligns these common hooks with Sequoia,
89
+ including a content-body-only `#content` and route-specific body remounting, but
90
+ a shared ID does not guarantee identical nesting or dimensions for every component.
91
+ Selectors are preserved for review,
92
+ not automatically rewritten. Compare desktop and mobile pages with custom CSS
93
+ enabled; successful asset copying is not a visual parity score.
94
+
95
+ Endpoint snapshots are exported as `veluApi` metadata in MDX. Velu uses this
96
+ metadata for API layout, request/response samples, and sidebar method badges;
97
+ the original MDX body appears between the Try It bar and schema fields. Keep
98
+ the export when editing a migrated endpoint. Re-migrate after OpenAPI changes
99
+ to regenerate the snapshot.
100
+
101
+ Files under `public/` and dot directories are not automatically loaded by Velu;
102
+ their inclusion needs review. Root `.js` files are deliberately not rewritten
103
+ because blindly changing lifecycle logic, globals, or selectors can alter behavior.
104
+ Place Node tooling outside the docs content root or use `.cjs`/`.mjs` so it is not
105
+ loaded as a browser script.
106
+
107
+ The React adapters preserve basic interactions, not every Mintlify prop or exact
108
+ appearance. Panel renders inline, Tooltip uses a text-only native tooltip, and
109
+ Tabs supports keyboard navigation but does not synchronize selections across
110
+ separate tab groups. Unknown components are reported as errors instead of being
111
+ replaced with empty placeholders. Font Awesome/Tabler icon names may need Lucide
112
+ equivalents. Original references in code examples and prose remain unchanged.
113
+
114
+ Mintlify's `theme: "sequoia"` is converted to `theme: "vepa"` and activates the [Vepa preset](vepa.md).
115
+ The preset is not a certification of pixel equality for every component or custom script.
116
+
117
+ Features without full Velu equivalents require review: other Mintlify themes, advanced
118
+ fonts and styling, redirects, analytics/support integrations, hosting settings,
119
+ authentication, personalization, hidden-page routing, legacy version filtering,
120
+ complex dropdown/global navigation, AsyncAPI, manually authored `api:` pages,
121
+ multi-file/remote endpoint snapshots, OpenAPI overlays and `x-mint` extensions.
122
+ Access/visibility settings are reported as errors; they must be migrated before
123
+ publishing. Unlisted content is copied but Velu currently renders navigation
124
+ pages only. Snapshot API pages must be regenerated or edited when their specs
125
+ change. Existing generated robots/sitemap/LLM files can still contain old domains.
126
+
127
+ `mint broken-links`, `mint a11y`, `mint export`, cloud/account commands, compound
128
+ shell commands, and CLI flags have no automatically assumed equivalent. These
129
+ scripts and workflows are retained with findings. In particular, `velu validate`
130
+ is not a complete replacement for Mintlify's separate broken-link/a11y tools,
131
+ and the public Velu package does not expose the internal static build command.
132
+
133
+ ## Parity research
134
+
135
+ Inspected on 2026-09-08:
136
+
137
+ - Published [`@mintlify/cli@4.0.1479`](https://www.npmjs.com/package/@mintlify/cli/v/4.0.1479): command dispatch/options in `bin/cli.js`; starter source in `bin/init.js`.
138
+ - Published [`@mintlify/common@1.0.1132`](https://www.npmjs.com/package/@mintlify/common/v/1.0.1132): content/snippet categories and `.mintignore` semantics in `dist/getFileCategory.js` and `dist/mintIgnore.js`.
139
+ - Published [`@mintlify/prebuild@1.0.1286`](https://www.npmjs.com/package/@mintlify/prebuild/v/1.0.1286): split config references and config loading.
140
+ - [`docs.json` schema](https://www.mintlify.com/docs.json), [custom scripts documentation](https://www.mintlify.com/docs/customize/custom-scripts), and [official starter](https://github.com/mintlify/starter).
141
+
142
+ The current `@mintlify/validation@0.1.849` tarball was also retrieved, but it did
143
+ not contain its declared `dist/` implementation. Legacy conversion is implemented
144
+ locally; it does not depend on executing Mintlify's upgrade code. No Mintlify
145
+ implementation code or package is vendored as a runtime dependency.
146
+
147
+ ## Verification
148
+
149
+ ```sh
150
+ node --test packages/velu-cli/test/migrate.test.mjs
151
+ node --test packages/velu-cli/test/*.test.mjs
152
+ node packages/velu-cli/scripts/build.mjs
153
+ node packages/velu-cli/dist/cli.js migrate --help
154
+ ```
155
+
156
+ Tests cover clean and review-required outputs, source immutability, JSONC/YAML
157
+ references, ignore negation, collisions/junctions, script preservation, AST
158
+ rewrites, assets, endpoint snapshots, generated-project validation, and CLI exit
159
+ codes. No network is needed for the migration tests.
package/docs/vepa.md ADDED
@@ -0,0 +1,97 @@
1
+ # Vepa (வேப்பம்) compatibility
2
+
3
+ Enable the preset in `velu.json`:
4
+
5
+ ```json
6
+ {
7
+ "name": "Documentation",
8
+ "theme": "vepa",
9
+ "colors": { "primary": "#16A34A", "light": "#07C983" },
10
+ "navigation": { "groups": [{ "group": "Guide", "pages": ["index"] }] }
11
+ }
12
+ ```
13
+
14
+ Mintlify migration converts `theme: "sequoia"` to `theme: "vepa"`. The normal Velu appearance is
15
+ still the default when `theme` is omitted. The visual preset is separate from
16
+ the reader's Light / Dark / System preference.
17
+
18
+ For Vepa, `colors.primary` is the light-mode accent and `colors.light` is the
19
+ dark-mode accent, matching Mintlify. The primary color also tints the gray ramp.
20
+ `font.family` overrides Inter. Development, prerendered builds, and the browser
21
+ preview share these normalizers.
22
+
23
+ ## Reference inspected
24
+
25
+ The npm CLI does not ship the renderer inside its own package. Its previewing
26
+ dependency retrieves a separately versioned client from Mintlify's release host.
27
+ Research was pinned on 2026-09-08 to:
28
+
29
+ - [`@mintlify/cli@4.0.1479`](https://www.npmjs.com/package/@mintlify/cli/v/4.0.1479)
30
+ - [`@mintlify/previewing@4.0.1358`](https://www.npmjs.com/package/@mintlify/previewing/v/4.0.1358), `dist/constants.js`, `dist/util.js`, and `dist/local-preview/client.js`
31
+ - [Client 0.0.3566](https://releases.mintlify.com/mint-0.0.3566.tar.gz), compiled Sequoia topbar, sidebar, page-header, and TOC modules
32
+ - [`@mintlify/common@1.0.1132`](https://www.npmjs.com/package/@mintlify/common/v/1.0.1132), color composition
33
+
34
+ Client archive SHA-256:
35
+ `a2b758fd67159a066e14fa7e78599047b0fd7f5ae78477e7364efdc232ef7ee5`
36
+
37
+ The downloaded client is research material only. It is not included in Velu's
38
+ published package or used at runtime.
39
+
40
+ ## Measured geometry
41
+
42
+ | Region | Vepa preset |
43
+ | --- | --- |
44
+ | Desktop topbar | 48 px; navigation tabs add 48 px |
45
+ | Logo | 22 px high |
46
+ | Desktop search trigger | 256 × 32 px |
47
+ | Left sidebar | 288 px |
48
+ | Default article at 1440 px viewport | 648 px; left edge 396 px |
49
+ | Table of contents | 288 px rail with 32 px side padding |
50
+ | API examples | 472 px rail including a 24 px outer gutter; 448 px sample cards |
51
+ | API rail breakpoint | Hidden below 1280 px; article reclaims its space |
52
+ | On-page TOC bar | Sticky TocBar below 1280 px (when the right rail hides), same as default Velu |
53
+ | Mobile navigation | Below 640 px; crumbs + burger (tabs stay on tablet with horizontal scroll) |
54
+ | Title | Inter, 500 weight, 36/40 px; 30/36 px below 640 px |
55
+
56
+ The API rail deliberately has its own width and padding. Applying the normal
57
+ TOC's width leaves too little room for request tabs and response examples. Long
58
+ code lines scroll inside their code panel instead of widening the page.
59
+
60
+ The preset includes a plain TOC, tinted light/dark surfaces, a three-way theme
61
+ menu, responsive columns, and mode-specific content widths. Card links retain
62
+ their destinations, and navigation uses `sidebarTitle` when supplied.
63
+
64
+ For custom styles, Vepa exposes `#page-title` on the title, `#content` on the
65
+ ordinary MDX body (excluding the header), and `#sidebar-content` on the sidebar.
66
+ The MDX body remounts on route changes so DOM decorations do not leak into the
67
+ next page. Body font overrides inherit into prose; component headings retain
68
+ their own typography. API pages use their own content structure.
69
+
70
+ Migrated endpoint MDX exports a `veluApi` snapshot containing the operation,
71
+ samples, and API settings. Both runtimes use it to activate the API layout and
72
+ navigation method badges. Author imports and exports stay at module scope;
73
+ custom content renders after the Try It bar and before the schema fields.
74
+
75
+ ## Verification and limits
76
+
77
+ ```sh
78
+ node --test packages/velu-cli/test/vepa.test.mjs
79
+ node packages/velu-cli/scripts/check-vepa-layout.mjs http://localhost:8358 /api-reference/products/listproducts /api-reference/products/addproduct
80
+ ```
81
+
82
+ The browser check covers GET/POST pages at 1440, 1280, 1100, 768, and 390 px in
83
+ light/dark mode. It checks rail size, sample width, column overlap, document
84
+ overflow, closed drawers, language tabs, system preference changes, and runtime
85
+ errors. Supply routes from your own running site.
86
+
87
+ This is a source-informed compatibility preset, **not yet verified pixel-for-pixel
88
+ across Mintlify's complete component/state matrix**. Velu still uses its own
89
+ search, API playground, syntax highlighting, icons, feedback, and content
90
+ components. Font availability can also affect screenshots. Hosted Mintlify
91
+ services are not reproduced by copying theme settings. Custom scripts and CSS
92
+ that depend on Mintlify's exact DOM, globals, lifecycle, or unsupported components
93
+ need testing on the migrated site.
94
+
95
+ Below the API rail breakpoint, samples move inline after the Try It bar and
96
+ before custom content. Inline and desktop samples use distinct IDs. This
97
+ document records the current scope rather than asserting complete parity.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veluai/velu",
3
- "version": "0.2.36",
3
+ "version": "0.2.38",
4
4
  "type": "module",
5
5
  "bin": "./dist/cli.js",
6
6
  "publishConfig": {
@@ -16,7 +16,9 @@
16
16
  "src/lib/known-components.js",
17
17
  "runtime/velu-ui/**",
18
18
  "schema/**",
19
- "templates/**"
19
+ "templates/**",
20
+ "docs/mintlify-migration.md",
21
+ "docs/vepa.md"
20
22
  ],
21
23
  "scripts": {
22
24
  "build": "node scripts/build.mjs",
@@ -26,6 +28,7 @@
26
28
  },
27
29
  "dependencies": {
28
30
  "@apidevtools/swagger-parser": "^10.1.0",
31
+ "@babel/parser": "7.28.0",
29
32
  "@icons-pack/react-simple-icons": ">=13.0.0 <13.13.0",
30
33
  "@mdx-js/mdx": "^3.1.1",
31
34
  "@mdx-js/react": "^3.1.1",
@@ -36,10 +39,13 @@
36
39
  "estree-util-value-to-estree": "^3.5.0",
37
40
  "express": "^4.21.1",
38
41
  "github-slugger": "^2.0.0",
42
+ "ignore": "7.0.5",
39
43
  "iso-639-1": "^3.1.5",
40
44
  "js-yaml": "^4.1.0",
45
+ "jsonc-parser": "3.3.1",
41
46
  "lucide-react": "^0.460.0",
42
47
  "mdast-util-to-string": "^4.0.0",
48
+ "postcss": "8.5.0",
43
49
  "prism-react-renderer": "^2.4.0",
44
50
  "prismjs": "^1.29.0",
45
51
  "react": "^18.3.1",
@@ -27,7 +27,7 @@ import { sendApiRequest } from '../lib/api-send.js';
27
27
  */
28
28
  const LOCATION_LABEL = { path: 'Path Parameters', query: 'Query Parameters', header: 'Header Parameters' };
29
29
 
30
- export default function ApiReferencePage({ operation, samples = [], apiOperations = [], api = {} }) {
30
+ export default function ApiReferencePage({ operation, samples = [], apiOperations = [], api = {}, children, preset }) {
31
31
  const [open, setOpen] = useState(false);
32
32
  // Which operation the playground is currently showing. Defaults to this
33
33
  // page's own operation; the dropdown can switch it to any sibling.
@@ -78,6 +78,9 @@ export default function ApiReferencePage({ operation, samples = [], apiOperation
78
78
  className="velu-api-page__tryit"
79
79
  />
80
80
 
81
+ {preset === 'vepa' && <div className="velu-api-page__inline-samples"><ApiSamples idPrefix="inline-" samples={samples} responses={operation.responses} preset={preset} title={operation.title} /></div>}
82
+ {children && <div className="velu-api-page__custom-content">{children}</div>}
83
+
81
84
  {/* Authorizations */}
82
85
  {operation.auth?.length > 0 && (
83
86
  <api-section class="velu-api-page__section">
@@ -124,7 +127,7 @@ export default function ApiReferencePage({ operation, samples = [], apiOperation
124
127
  {operation.responses?.length > 0 && (
125
128
  <api-section class="velu-api-page__section">
126
129
  <h2 data-component="api-section-heading">Response</h2>
127
- <ResponseSections responses={operation.responses} />
130
+ <ResponseSections responses={operation.responses} preset={preset} />
128
131
  </api-section>
129
132
  )}
130
133
 
@@ -175,7 +178,12 @@ function EnumHint({ values }) {
175
178
  );
176
179
  }
177
180
 
178
- function ResponseSections({ responses }) {
181
+ function ResponseSections({ responses, preset }) {
182
+ if (preset === 'vepa') return <div className="velu-vepa-responses">{responses.map((r, i) => <section key={i}>
183
+ <div className="velu-vepa-responses__status">{r.status}{r.contentType && ` · ${r.contentType}`}</div>
184
+ {r.description && <p>{r.description}</p>}
185
+ {r.fields?.map((f, j) => <Field key={j} name={f.name} type={f.type} required={f.required}>{f.description}</Field>)}
186
+ </section>)}</div>;
179
187
  return (
180
188
  <AccordionGroup className="velu-api-resp">
181
189
  {responses.map((r, i) => {
@@ -9,12 +9,17 @@ import CodeBlock, { CodeGroup } from './CodeBlock.jsx';
9
9
  *
10
10
  * @param {{ samples: {key,label,language,code}[], responses: object[] }} props
11
11
  */
12
- export default function ApiSamples({ samples = [], responses = [] }) {
12
+ export default function ApiSamples({ samples = [], responses = [], preset, title, idPrefix = '' }) {
13
+ const [selected, setSelected] = React.useState('');
14
+ const sample = samples.find((s) => s.key === selected) || samples[0];
13
15
  const withExample = responses.filter((r) => r.example != null);
14
16
  return (
15
17
  <div className="velu-api-samples">
16
- {samples.length > 0 && (
17
- <CodeGroup id="request-example" className="velu-api-samples__req">
18
+ {sample && preset === 'vepa' ? <section id={idPrefix + 'request-example'} className="velu-vepa-request">
19
+ <div className="velu-vepa-request__header"><span>{title}</span><select aria-label="Request example language" value={sample.key} onChange={(e) => setSelected(e.target.value)}>{samples.map((s) => <option key={s.key} value={s.key}>{s.label}</option>)}</select></div>
20
+ <CodeBlock language={sample.language}>{sample.code}</CodeBlock>
21
+ </section> : samples.length > 0 && (
22
+ <CodeGroup id={idPrefix + 'request-example'} className="velu-api-samples__req">
18
23
  {samples.map((s) => (
19
24
  <CodeBlock key={s.key} title={s.label} language={s.language}>
20
25
  {s.code}
@@ -23,7 +28,7 @@ export default function ApiSamples({ samples = [], responses = [] }) {
23
28
  </CodeGroup>
24
29
  )}
25
30
  {withExample.length > 0 && (
26
- <CodeGroup id="response-example" className="velu-api-samples__res">
31
+ <CodeGroup id={idPrefix + 'response-example'} className="velu-api-samples__res">
27
32
  {withExample.map((r) => (
28
33
  <CodeBlock key={r.status} title={r.status} language="json">
29
34
  {JSON.stringify(r.example, null, 2)}
@@ -5,9 +5,9 @@ import Switcher from '../primitives/Switcher.jsx';
5
5
  /**
6
6
  * Card + CardGroup — content / horizontal / image / CTA cards.
7
7
  *
8
- * The card itself is NEVER a link and has NO hover state. The only
9
- * interactive element is the CTA: pass `cta={{ label, href }}` to render a
10
- * single "Click Here ›" link. Everything else is static presentation.
8
+ * Pass `href` (without `cta`) to make the whole card a stretched link.
9
+ * Nested links/buttons inside the card stay clickable above that overlay.
10
+ * Pass `cta={{ label, href }}` for a single "Click Here ›" control instead.
11
11
  *
12
12
  * Fully tokenized (border-width, border-color, radius scale, spacing, type,
13
13
  * line-height, accent, icon-stroke) → light/dark automatic via [data-theme].
@@ -22,7 +22,7 @@ import Switcher from '../primitives/Switcher.jsx';
22
22
  * `icon` is a lucide id string (shared resolveIcon).
23
23
  *
24
24
  * @param {{ title?: React.ReactNode, icon?: string, horizontal?: boolean,
25
- * img?: string, imgAlt?: string,
25
+ * img?: string, imgAlt?: string, href?: string,
26
26
  * cta?: {label: React.ReactNode, href: string},
27
27
  * children?: React.ReactNode, className?: string }} props
28
28
  */
@@ -34,6 +34,7 @@ export function Card({
34
34
  img,
35
35
  imgAlt = '',
36
36
  cta,
37
+ href,
37
38
  children,
38
39
  className = '',
39
40
  ...rest
@@ -72,6 +73,7 @@ export function Card({
72
73
 
73
74
  return (
74
75
  <card className={cls} {...rest}>
76
+ {href && !cta && <a className="velu-card__link" href={href} aria-label={typeof title === 'string' ? title : 'Read more'} />}
75
77
  {img && (
76
78
  <div className="velu-card__media" data-component-part="card-image">
77
79
  <img src={img} alt={imgAlt} loading="lazy" />
@@ -122,6 +124,7 @@ export function CardGroup({
122
124
  space={space}
123
125
  min={min}
124
126
  limit={limit}
127
+ style={{ '--vepa-columns': Math.max(1, Math.min(4, Number(limit) || 2)) }}
125
128
  {...rest}
126
129
  >
127
130
  {children}
@@ -336,7 +336,7 @@ function renderStream(tokens, sources, onNavigate) {
336
336
  }
337
337
 
338
338
  /* ── Header ─────────────────────────────────────────────────────────── */
339
- function ChatHeader({ onClose, onNew, onHistory, historyOpen }) {
339
+ function ChatHeader({ onClose, onNew, onHistory, historyOpen, showClose = true }) {
340
340
  return (
341
341
  <Cluster
342
342
  as="chat-assistant-sheet-header"
@@ -370,6 +370,7 @@ function ChatHeader({ onClose, onNew, onHistory, historyOpen }) {
370
370
  >
371
371
  {resolveIcon('plus', { size: '1em' })}
372
372
  </button>
373
+ {showClose && (
373
374
  <button
374
375
  type="button"
375
376
  className="velu-chatbot__iconbtn"
@@ -378,6 +379,7 @@ function ChatHeader({ onClose, onNew, onHistory, historyOpen }) {
378
379
  >
379
380
  {resolveIcon('x', { size: '1em' })}
380
381
  </button>
382
+ )}
381
383
  </Cluster>
382
384
  </Cluster>
383
385
  );
@@ -655,6 +657,7 @@ export default function Chatbot({
655
657
  listHistory,
656
658
  loadConversation,
657
659
  suggestions,
660
+ layout = 'panel',
658
661
  className = '',
659
662
  ...rest
660
663
  }) {
@@ -882,8 +885,9 @@ export default function Chatbot({
882
885
  const isBottomSheet = () =>
883
886
  typeof window !== 'undefined' &&
884
887
  window.matchMedia('(max-width: 1024px)').matches;
888
+ const isPage = layout === 'page';
885
889
  const onDragStart = (e) => {
886
- if (!isBottomSheet()) return;
890
+ if (isPage || !isBottomSheet()) return;
887
891
  dragRef.current = { active: true, startY: e.clientY, currentY: 0 };
888
892
  e.currentTarget.setPointerCapture?.(e.pointerId);
889
893
  };
@@ -906,7 +910,7 @@ export default function Chatbot({
906
910
  if (!open) setDragOffset(0);
907
911
  }, [open]);
908
912
 
909
- const cls = `velu-chatbot${open ? ' velu-chatbot--open' : ''} ${className}`.trim();
913
+ const cls = `velu-chatbot${open || isPage ? ' velu-chatbot--open' : ''}${isPage ? ' velu-chatbot--page' : ''} ${className}`.trim();
910
914
  const dragStyle =
911
915
  dragOffset > 0
912
916
  ? { transform: `translateY(${dragOffset}px)`, transition: 'none' }
@@ -917,7 +921,7 @@ export default function Chatbot({
917
921
  id="chat-assistant-sheet"
918
922
  data-component="chat-assistant-sheet"
919
923
  className={cls}
920
- aria-hidden={!open}
924
+ aria-hidden={isPage ? false : !open}
921
925
  aria-label="Ask AI"
922
926
  style={dragStyle}
923
927
  {...rest}
@@ -939,6 +943,7 @@ export default function Chatbot({
939
943
  onNew={newChat}
940
944
  onHistory={() => setHistoryOpen((o) => !o)}
941
945
  historyOpen={historyOpen}
946
+ showClose={!isPage}
942
947
  />
943
948
 
944
949
  {historyOpen && (
@@ -49,6 +49,7 @@ export default function Columns({
49
49
  space={space}
50
50
  min={min}
51
51
  limit={cols}
52
+ style={{ '--vepa-columns': Math.max(1, Math.min(4, Number(cols) || 2)) }}
52
53
  {...rest}
53
54
  >
54
55
  {children}
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import VepaFooter from './VepaFooter.jsx';
2
3
  import Stack from '../primitives/Stack.jsx';
3
4
  import Cluster from '../primitives/Cluster.jsx';
4
5
  import SocialLinks from './SocialLinks.jsx';
@@ -67,6 +68,7 @@ function VeluMark() {
67
68
  }
68
69
 
69
70
  export default function PageFooter({
71
+ preset,
70
72
  brand,
71
73
  columns = [],
72
74
  socials = [],
@@ -75,6 +77,7 @@ export default function PageFooter({
75
77
  ...rest
76
78
  }) {
77
79
  const Link = linkComponent;
80
+ if (preset === 'vepa') return <VepaFooter brand={brand} columns={columns} socials={socials} linkComponent={Link} />;
78
81
  const brandLabel = brand?.label ?? 'Velu';
79
82
  const brandHref = brand?.href;
80
83
 
@@ -74,7 +74,7 @@ function ActionButton({ action, Link, id }) {
74
74
  // Mintlify-compatible hooks: `#topbar-cta-button` on the first primary
75
75
  // action, `[data-component="navbar-link"]` on every action (overridable per
76
76
  // action, e.g. component: 'logout-link').
77
- const hooks = { id, 'data-component': component ?? 'navbar-link' };
77
+ const hooks = { id, 'data-component': component ?? 'navbar-link', 'data-component-name': kind === 'primary' ? 'primary-header-button' : undefined };
78
78
  const content = (
79
79
  <>
80
80
  {icon && (
@@ -232,6 +232,7 @@ function KebabMenu({ actions, Link }) {
232
232
  }
233
233
 
234
234
  export default function PageHeader({
235
+ preset,
235
236
  brand,
236
237
  brandTrailing,
237
238
  center,
@@ -266,6 +267,12 @@ export default function PageHeader({
266
267
  return () => window.removeEventListener('scroll', onScroll);
267
268
  }, []);
268
269
 
270
+ const showTabsRow =
271
+ tabs.length > 0 ||
272
+ (breadcrumb && breadcrumb.length > 0) ||
273
+ Boolean(tabsTrailing) ||
274
+ Boolean(onMenuClick);
275
+
269
276
  // A configured logo replaces the mark + wordmark entirely (Mintlify-style).
270
277
  const brandBlock = (
271
278
  <Cluster space="var(--s-3)" align="center" className="velu-header__brand" style={{ flexWrap: 'nowrap' }}>
@@ -283,8 +290,11 @@ export default function PageHeader({
283
290
  return (
284
291
  <Stack
285
292
  as="header"
286
- space="var(--s0)"
287
- className={`velu-header${scrolled ? ' velu-header--scrolled' : ''} ${className}`.trim()}
293
+ space={preset === 'vepa' ? '0px' : 'var(--s0)'}
294
+ data-has-tabs={tabs.length > 0 ? 'true' : 'false'}
295
+ className={`velu-header${scrolled ? ' velu-header--scrolled' : ''}${
296
+ showTabsRow ? '' : ' velu-header--no-tabs'
297
+ } ${className}`.trim()}
288
298
  {...rest}
289
299
  >
290
300
  {/* Top row — 3-column grid: brand | center | actions. */}
@@ -349,10 +359,7 @@ export default function PageHeader({
349
359
  takes their place. Burger stays at the trailing edge via
350
360
  margin-inline-start: auto on the burger button itself, so
351
361
  its position is unaffected by the breadcrumb's length. */}
352
- {(tabs.length > 0 ||
353
- (breadcrumb && breadcrumb.length > 0) ||
354
- tabsTrailing ||
355
- onMenuClick) && (
362
+ {showTabsRow && (
356
363
  <Cluster
357
364
  as="nav-tabs"
358
365
  space="var(--s0)"
@@ -0,0 +1,57 @@
1
+ import React from 'react';
2
+ import { Sun, Moon, Monitor, Check } from 'lucide-react';
3
+
4
+ export default function ThemePreferenceMenu({ inline = false }) {
5
+ const [open, setOpen] = React.useState(false);
6
+ const [preference, setPreference] = React.useState('system');
7
+ const root = React.useRef(null);
8
+ const trigger = React.useRef(null);
9
+ React.useEffect(() => {
10
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
11
+ const sync = () => {
12
+ let pref = 'system';
13
+ try { pref = localStorage.getItem('velu-theme') || 'system'; } catch {}
14
+ setPreference(pref);
15
+ document.documentElement.dataset.theme = pref === 'system' ? (media.matches ? 'dark' : 'light') : pref;
16
+ };
17
+ sync();
18
+ media.addEventListener('change', sync);
19
+ window.addEventListener('storage', sync);
20
+ window.addEventListener('velu-theme-change', sync);
21
+ return () => { media.removeEventListener('change', sync); window.removeEventListener('storage', sync); window.removeEventListener('velu-theme-change', sync); };
22
+ }, []);
23
+ React.useEffect(() => {
24
+ if (!open) return;
25
+ root.current?.querySelector('[aria-checked="true"]')?.focus();
26
+ const outside = (e) => { if (!root.current?.contains(e.target)) setOpen(false); };
27
+ document.addEventListener('pointerdown', outside);
28
+ return () => document.removeEventListener('pointerdown', outside);
29
+ }, [open]);
30
+ const choose = (value) => {
31
+ setPreference(value);
32
+ try { value === 'system' ? localStorage.removeItem('velu-theme') : localStorage.setItem('velu-theme', value); } catch {}
33
+ document.documentElement.dataset.theme = value === 'system' ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : value;
34
+ window.dispatchEvent(new Event('velu-theme-change'));
35
+ setOpen(false);
36
+ trigger.current?.focus();
37
+ };
38
+ const Icon = preference === 'system' ? Monitor : preference === 'dark' ? Moon : Sun;
39
+ if (inline) return <div className="vepa-footer-theme" role="group" aria-label="Color theme">
40
+ {[[Monitor, 'system', 'System'], [Sun, 'light', 'Light'], [Moon, 'dark', 'Dark']].map(([Glyph, value, label]) => <button key={value} type="button" aria-label={`${label} theme`} aria-pressed={preference === value} onClick={() => choose(value)}><Glyph size={16} aria-hidden="true" /></button>)}
41
+ </div>;
42
+ return <div ref={root} className="velu-theme-menu" onKeyDown={(e) => {
43
+ if (e.key === 'Escape') { setOpen(false); trigger.current?.focus(); }
44
+ if (e.key === 'Tab') setOpen(false);
45
+ if (open && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) {
46
+ e.preventDefault();
47
+ const items = [...root.current.querySelectorAll('[role="menuitemradio"]')];
48
+ const i = items.indexOf(document.activeElement);
49
+ items[e.key === 'Home' ? 0 : e.key === 'End' ? 2 : (i + (e.key === 'ArrowUp' ? 2 : 1)) % 3]?.focus();
50
+ }
51
+ }}>
52
+ <button ref={trigger} type="button" className="velu-theme-menu__trigger" aria-label="Theme preference" aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(!open)}><Icon size={16} aria-hidden="true" /></button>
53
+ {open && <div className="velu-theme-menu__options" role="menu" aria-label="Theme preference">
54
+ {[[Sun, 'light', 'Light'], [Moon, 'dark', 'Dark'], [Monitor, 'system', 'System']].map(([Glyph, value, label]) => <button key={value} type="button" role="menuitemradio" aria-checked={preference === value} tabIndex={-1} onClick={() => choose(value)}><Glyph size={16} aria-hidden="true" /><span>{label}</span>{preference === value && <Check size={14} aria-hidden="true" />}</button>)}
55
+ </div>}
56
+ </div>;
57
+ }
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
2
  import { Sun, Moon } from 'lucide-react';
3
+ import ThemePreferenceMenu from './ThemePreferenceMenu.jsx';
3
4
 
4
5
  /**
5
6
  * ThemeToggle — pill-shaped two-state light/dark switch. The thumb
@@ -14,7 +15,8 @@ import { Sun, Moon } from 'lucide-react';
14
15
  * explicit choice (which then overrides the OS preference on future
15
16
  * visits, per the anti-flash script in the template).
16
17
  */
17
- export default function ThemeToggle({ className = '', ...rest }) {
18
+ export default function ThemeToggle({ className = '', variant, ...rest }) {
19
+ if (variant === 'menu') return <ThemePreferenceMenu />;
18
20
  function toggle() {
19
21
  const root = document.documentElement;
20
22
  const next = root.dataset.theme === 'dark' ? 'light' : 'dark';