@veluai/velu 0.2.37 → 0.2.39

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 (44) hide show
  1. package/dist/cli.js +41 -41
  2. package/docs/aalam.md +44 -0
  3. package/docs/mintlify-migration.md +8 -3
  4. package/docs/thulir.md +23 -0
  5. package/docs/vepa.md +2 -1
  6. package/package.json +4 -2
  7. package/runtime/velu-ui/base.css +9 -0
  8. package/runtime/velu-ui/components/ApiReferencePage.jsx +4 -3
  9. package/runtime/velu-ui/components/ApiSamples.jsx +9 -2
  10. package/runtime/velu-ui/components/Callout.jsx +4 -1
  11. package/runtime/velu-ui/components/PageFooter.jsx +3 -1
  12. package/runtime/velu-ui/components/PageHeader.jsx +7 -1
  13. package/runtime/velu-ui/components/PageNav.jsx +98 -22
  14. package/runtime/velu-ui/components/Sidebar.jsx +21 -9
  15. package/runtime/velu-ui/components/ThemeToggle.jsx +1 -0
  16. package/runtime/velu-ui/components/TocBar.jsx +21 -2
  17. package/runtime/velu-ui/components/Update.jsx +22 -10
  18. package/runtime/velu-ui/components/VepaFooter.jsx +19 -17
  19. package/runtime/velu-ui/components/api-page.css +6 -3
  20. package/runtime/velu-ui/components/chatbot.css +5 -4
  21. package/runtime/velu-ui/components/docs-layout.css +99 -114
  22. package/runtime/velu-ui/components/page-footer.css +2 -3
  23. package/runtime/velu-ui/components/page-header.css +6 -0
  24. package/runtime/velu-ui/components/page-nav.css +192 -0
  25. package/runtime/velu-ui/components/sidebar.css +5 -13
  26. package/runtime/velu-ui/primitives/switcher.css +4 -0
  27. package/runtime/velu-ui/styles.css +2 -0
  28. package/runtime/velu-ui/themes/aalam.css +266 -0
  29. package/runtime/velu-ui/themes/thulir.css +428 -0
  30. package/runtime/velu-ui/themes/vepa.css +142 -10
  31. package/schema/velu.schema.json +23 -3
  32. package/src/navigation.js +22 -3
  33. package/src/runtime/App.jsx +182 -107
  34. package/templates/starter/ai-tools/claude-code.mdx +1 -0
  35. package/templates/starter/ai-tools/cursor.mdx +1 -0
  36. package/templates/starter/api-reference/introduction.mdx +1 -0
  37. package/templates/starter/development.mdx +1 -0
  38. package/templates/starter/essentials/code.mdx +1 -0
  39. package/templates/starter/essentials/images.mdx +1 -0
  40. package/templates/starter/essentials/markdown.mdx +1 -0
  41. package/templates/starter/essentials/navigation.mdx +1 -0
  42. package/templates/starter/essentials/settings.mdx +1 -0
  43. package/templates/starter/index.mdx +1 -0
  44. package/templates/starter/quickstart.mdx +1 -0
package/docs/aalam.md ADDED
@@ -0,0 +1,44 @@
1
+ # Aalam theme
2
+
3
+ Aalam (ஆலம், banyan) is Velu's Maple equivalent. Use `"theme": "aalam"` in `velu.json`. Running `velu migrate mintlify SOURCE --out DESTINATION` maps Mintlify's `"theme": "maple"` to Aalam automatically.
4
+
5
+ ```json
6
+ {
7
+ "name": "My docs",
8
+ "theme": "aalam",
9
+ "colors": {
10
+ "primary": "#C2410C",
11
+ "light": "#EA580C",
12
+ "dark": "#FB923C"
13
+ }
14
+ }
15
+ ```
16
+
17
+ The desktop layout places branding, search, and navigation in the left sidebar, with tabs above the article. Below 1024px, navigation becomes a left-opening drawer with a labeled section picker, focus trapping, Escape dismissal, and background scroll locking. Below 1280px, a sticky On this page dropdown replaces the right-hand TOC. Drawer and TOC hierarchy use indentation without vertical lines.
18
+
19
+ Aalam shares component styles and responsive behavior with Thulir. Its desktop layout was compared with the cached npm renderer `@mintlify/client@0.0.3566` using the same showcase content. This is not a guarantee of pixel-perfect parity: theme controls, icons, compatibility adapters, generated OpenAPI examples, and custom scripts still require review. The left-opening drawer and line-free navigation intentionally follow Velu's requested behavior.
20
+
21
+ Color semantics match the Mintlify presets: `colors.primary` for the light-mode accent, `colors.light` for the dark-mode accent, and `colors.dark` for CTA colors. The default font is Inter.
22
+
23
+ ## Page icons in the sidebar
24
+
25
+ Pages can show a Lucide icon next to their sidebar label:
26
+
27
+ 1. Frontmatter on the MDX page:
28
+
29
+ ```mdx
30
+ ---
31
+ title: Quickstart
32
+ icon: rocket
33
+ ---
34
+ ```
35
+
36
+ 2. Or a page object in `velu.json` navigation:
37
+
38
+ ```json
39
+ { "page": "quickstart", "icon": "zap" }
40
+ ```
41
+
42
+ Frontmatter is used when the nav entry is a plain path string. A nav `icon` wins when both are set.
43
+
44
+ For the migrated component showcase, run `node packages/velu-cli/test/aalam-browser.mjs` with Playwright installed and the preview running. `VELU_TEST_URL` overrides the default `http://localhost:8473`. The checks cover phone/tablet drawer focus, dismissal, route changes, horizontal overflow, desktop resizing, section selection, content tabs, and sticky TOC heading clearance.
@@ -52,7 +52,7 @@ filenames, endpoint paths, and diagnostic text; review before sharing.
52
52
 
53
53
  | Area | Migration behavior |
54
54
  | --- | --- |
55
- | Config | Projects supported values against the shipped Velu schema; reports unsupported nested fields individually and preserves the original source config |
55
+ | Config | Projects supported values against the shipped Velu schema; maps Mintlify `mint` to `thulir`, `sequoia` to `vepa`, and `maple` to `aalam`; reports unsupported nested fields individually and preserves the original source config |
56
56
  | Legacy config | Converts navigation arrays, prefix-based tabs, anchors, topbar links/CTA, footer socials, and API base URL/example languages |
57
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
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 |
@@ -86,6 +86,8 @@ Verify behavior after client-side navigation as well as a full refresh.
86
86
 
87
87
  The `css-dom` finding flags styles aimed at shared chrome IDs such as `#content`,
88
88
  `#sidebar-content`, and `#footer`. Vepa aligns these common hooks with Sequoia,
89
+ while Thulir preserves Mint's `#sidebar`, `#navigation-items`, `#page-title`,
90
+ `#content`, and `#footer` structure,
89
91
  including a content-body-only `#content` and route-specific body remounting, but
90
92
  a shared ID does not guarantee identical nesting or dimensions for every component.
91
93
  Selectors are preserved for review,
@@ -112,9 +114,11 @@ replaced with empty placeholders. Font Awesome/Tabler icon names may need Lucide
112
114
  equivalents. Original references in code examples and prose remain unchanged.
113
115
 
114
116
  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.
117
+ Mintlify's `theme: "mint"` is converted to `theme: "thulir"` and activates the [Thulir preset](thulir.md).
118
+ Mintlify's `theme: "maple"` is converted to `theme: "aalam"` and activates the [Aalam preset](aalam.md).
119
+ The presets are not a certification of pixel equality for every component or custom script.
116
120
 
117
- Features without full Velu equivalents require review: other Mintlify themes, advanced
121
+ Features without full Velu equivalents require review: remaining Mintlify themes, advanced
118
122
  fonts and styling, redirects, analytics/support integrations, hosting settings,
119
123
  authentication, personalization, hidden-page routing, legacy version filtering,
120
124
  complex dropdown/global navigation, AsyncAPI, manually authored `api:` pages,
@@ -137,6 +141,7 @@ Inspected on 2026-09-08:
137
141
  - 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
142
  - 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
143
  - Published [`@mintlify/prebuild@1.0.1286`](https://www.npmjs.com/package/@mintlify/prebuild/v/1.0.1286): split config references and config loading.
144
+ - The CLI-downloaded `@mintlify/client@0.0.3566` renderer: Mint theme dispatch, computed layout geometry, typography, navigation, API samples, responsive behavior, and footer. The renderer was inspected for interoperability research and is not redistributed.
140
145
  - [`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
146
 
142
147
  The current `@mintlify/validation@0.1.849` tarball was also retrieved, but it did
package/docs/thulir.md ADDED
@@ -0,0 +1,23 @@
1
+ # Thulir theme
2
+
3
+ Thulir is Velu's equivalent of Mintlify's `mint` theme. The name means a fresh sprout in Tamil.
4
+
5
+ ```json
6
+ {
7
+ "name": "My docs",
8
+ "theme": "thulir",
9
+ "colors": {
10
+ "primary": "#0D9373",
11
+ "light": "#12A481",
12
+ "dark": "#46D6AD"
13
+ }
14
+ }
15
+ ```
16
+
17
+ `velu migrate mintlify` converts `"theme": "mint"` to `"theme": "thulir"`. Thulir preserves Mint's main renderer hooks, including `#navbar`, `#sidebar`, `#navigation-items`, `#page-title`, `#content`, and `#footer`, so portable custom CSS and browser scripts retain their targets.
18
+
19
+ The preset includes Mint's desktop grid, Inter typography, navigation states, prose rhythm, compact previous/next links, API document and code columns, responsive drawer behavior, light/dark palette, and advanced footer.
20
+
21
+ Mint color semantics are preserved: `colors.primary` is the light-mode accent, `colors.light` is the dark-mode accent, and `colors.dark` supplies the header button background. Missing values fall back to `colors.primary`. Header and footer theme controls share the same light/dark/system preference.
22
+
23
+ The reference is the renderer downloaded by `@mintlify/cli@4.0.1479` (`@mintlify/client@0.0.3566`). Compare identical content at identical viewport sizes when checking parity. The reference fixture covers documentation, GET/POST API pages, desktop/tablet/mobile layouts, and dark mode. Generated OpenAPI example values, icon artwork, and playground interactions can still differ; matching the theme does not imply that every Mintlify component or script has identical behavior.
package/docs/vepa.md CHANGED
@@ -49,7 +49,8 @@ published package or used at runtime.
49
49
  | Table of contents | 288 px rail with 32 px side padding |
50
50
  | API examples | 472 px rail including a 24 px outer gutter; 448 px sample cards |
51
51
  | API rail breakpoint | Hidden below 1280 px; article reclaims its space |
52
- | Mobile navigation | Below 1024 px; 48 px topbar and 56 px breadcrumb strip |
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) |
53
54
  | Title | Inter, 500 weight, 36/40 px; 30/36 px below 640 px |
54
55
 
55
56
  The API rail deliberately has its own width and padding. Applying the normal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veluai/velu",
3
- "version": "0.2.37",
3
+ "version": "0.2.39",
4
4
  "type": "module",
5
5
  "bin": "./dist/cli.js",
6
6
  "publishConfig": {
@@ -18,7 +18,9 @@
18
18
  "schema/**",
19
19
  "templates/**",
20
20
  "docs/mintlify-migration.md",
21
- "docs/vepa.md"
21
+ "docs/vepa.md",
22
+ "docs/thulir.md",
23
+ "docs/aalam.md"
22
24
  ],
23
25
  "scripts": {
24
26
  "build": "node scripts/build.mjs",
@@ -389,6 +389,14 @@
389
389
  background: var(--muted-color);
390
390
  }
391
391
 
392
+ /* Kill document-level horizontal scroll. Sticky header + fixed rails
393
+ share the viewport scrollport — if anything widens the page, the
394
+ whole chrome (top bar included) slides sideways. Clip here so wide
395
+ children must scroll inside their own containers instead. */
396
+ html {
397
+ overflow-x: clip;
398
+ }
399
+
392
400
  /* Unitless line-height ratios (height ÷ font-size from Figma) */
393
401
  body {
394
402
  font-size: var(--f-body);
@@ -396,6 +404,7 @@ body {
396
404
  font-weight: var(--weight-normal);
397
405
  background: var(--page-bg);
398
406
  color: var(--text-color);
407
+ overflow-x: clip;
399
408
  }
400
409
  h1 { font-size: var(--f-h1); line-height: var(--lh-h1); font-weight: var(--weight-semibold); }
401
410
  h2 { font-size: var(--f-h2); line-height: var(--lh-h2); font-weight: var(--weight-medium); }
@@ -78,7 +78,7 @@ 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>}
81
+ {(preset === 'vepa' || ['thulir', 'aalam'].includes(preset)) && <div className="velu-api-page__inline-samples"><ApiSamples idPrefix="inline-" samples={samples} responses={operation.responses} preset={preset} title={operation.title} /></div>}
82
82
  {children && <div className="velu-api-page__custom-content">{children}</div>}
83
83
 
84
84
  {/* Authorizations */}
@@ -114,6 +114,7 @@ export default function ApiReferencePage({ operation, samples = [], apiOperation
114
114
  {operation.body?.fields?.length > 0 && (
115
115
  <api-section class="velu-api-page__section">
116
116
  <h2 data-component="api-section-heading">Body</h2>
117
+ {['thulir', 'aalam'].includes(preset) && operation.body.contentType && <span className="velu-api-body-type">{operation.body.contentType}</span>}
117
118
  {operation.body.fields.map((f, i) => (
118
119
  <Field key={i} name={f.name} type={f.type} required={f.required}>
119
120
  {f.description}
@@ -179,10 +180,10 @@ function EnumHint({ values }) {
179
180
  }
180
181
 
181
182
  function ResponseSections({ responses, preset }) {
182
- if (preset === 'vepa') return <div className="velu-vepa-responses">{responses.map((r, i) => <section key={i}>
183
+ if (preset === 'vepa' || ['thulir', 'aalam'].includes(preset)) return <div className="velu-vepa-responses">{responses.map((r, i) => <section key={i}>
183
184
  <div className="velu-vepa-responses__status">{r.status}{r.contentType && ` · ${r.contentType}`}</div>
184
185
  {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
+ {(['thulir', 'aalam'].includes(preset) ? [...(r.fields || [])].sort((a, b) => Number(Boolean(b.required)) - Number(Boolean(a.required))) : r.fields)?.map((f, j) => <Field key={f.name || j} name={f.name} type={f.type} required={f.required}>{f.description}</Field>)}
186
187
  </section>)}</div>;
187
188
  return (
188
189
  <AccordionGroup className="velu-api-resp">
@@ -12,12 +12,19 @@ import CodeBlock, { CodeGroup } from './CodeBlock.jsx';
12
12
  export default function ApiSamples({ samples = [], responses = [], preset, title, idPrefix = '' }) {
13
13
  const [selected, setSelected] = React.useState('');
14
14
  const sample = samples.find((s) => s.key === selected) || samples[0];
15
+ // Mint displays JSON request bodies across lines. Keep custom/non-JSON
16
+ // snippets intact, and change whitespace only in a valid JSON body.
17
+ const sampleCode = ['thulir', 'aalam'].includes(preset) && sample?.key === 'curl'
18
+ ? sample.code.replace(/(--data )'([\s\S]*)'$/, (original, prefix, body) => {
19
+ try { return `${prefix}'\n${JSON.stringify(JSON.parse(body), null, 2)}\n'`; }
20
+ catch { return original; }
21
+ }) : sample?.code;
15
22
  const withExample = responses.filter((r) => r.example != null);
16
23
  return (
17
24
  <div className="velu-api-samples">
18
- {sample && preset === 'vepa' ? <section id={idPrefix + 'request-example'} className="velu-vepa-request">
25
+ {sample && (preset === 'vepa' || ['thulir', 'aalam'].includes(preset)) ? <section id={idPrefix + 'request-example'} className="velu-vepa-request">
19
26
  <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>
27
+ <CodeBlock language={sample.language}>{sampleCode}</CodeBlock>
21
28
  </section> : samples.length > 0 && (
22
29
  <CodeGroup id={idPrefix + 'request-example'} className="velu-api-samples__req">
23
30
  {samples.map((s) => (
@@ -90,7 +90,10 @@ export default function Callout({
90
90
  >
91
91
  {iconNode && (
92
92
  <span className="velu-callout__icon" aria-hidden="true">
93
- {iconNode}
93
+ {type === 'note' && icon == null ? <>
94
+ <span className="velu-callout__default-note">{iconNode}</span>
95
+ <span className="velu-callout__mint-note" style={{ display: 'none' }}>{resolveIcon('circle-alert', { size: '1em' })}</span>
96
+ </> : iconNode}
94
97
  </span>
95
98
  )}
96
99
  {children}
@@ -77,7 +77,9 @@ export default function PageFooter({
77
77
  ...rest
78
78
  }) {
79
79
  const Link = linkComponent;
80
- if (preset === 'vepa') return <VepaFooter brand={brand} columns={columns} socials={socials} linkComponent={Link} />;
80
+ if (preset === 'vepa' || ['thulir', 'aalam'].includes(preset)) {
81
+ return <VepaFooter preset={preset} brand={brand} columns={columns} socials={socials} linkComponent={Link} />;
82
+ }
81
83
  const brandLabel = brand?.label ?? 'Velu';
82
84
  const brandHref = brand?.href;
83
85
 
@@ -243,6 +243,8 @@ export default function PageHeader({
243
243
  activeTab,
244
244
  breadcrumb,
245
245
  onMenuClick,
246
+ menuOpen,
247
+ menuId,
246
248
  linkComponent = 'a',
247
249
  className = '',
248
250
  ...rest
@@ -290,7 +292,7 @@ export default function PageHeader({
290
292
  return (
291
293
  <Stack
292
294
  as="header"
293
- space={preset === 'vepa' ? '0px' : 'var(--s0)'}
295
+ space={preset === 'vepa' || ['thulir', 'aalam'].includes(preset) ? '0px' : 'var(--s0)'}
294
296
  data-has-tabs={tabs.length > 0 ? 'true' : 'false'}
295
297
  className={`velu-header${scrolled ? ' velu-header--scrolled' : ''}${
296
298
  showTabsRow ? '' : ' velu-header--no-tabs'
@@ -391,6 +393,8 @@ export default function PageHeader({
391
393
  data-component="breadcrumb-list"
392
394
  onClick={onMenuClick}
393
395
  aria-label="Open navigation menu"
396
+ aria-expanded={menuOpen}
397
+ aria-controls={menuId}
394
398
  >
395
399
  {breadcrumb.map((c, i) => {
396
400
  const isLast = i === breadcrumb.length - 1;
@@ -432,6 +436,8 @@ export default function PageHeader({
432
436
  type="button"
433
437
  className="velu-header__menu"
434
438
  aria-label="Open navigation menu"
439
+ aria-expanded={menuOpen}
440
+ aria-controls={menuId}
435
441
  onClick={onMenuClick}
436
442
  >
437
443
  <Menu aria-hidden="true" focusable="false" />
@@ -5,29 +5,23 @@ import Switcher from '../primitives/Switcher.jsx';
5
5
 
6
6
  /**
7
7
  * PageNav — previous / next page navigation for the foot of a docs
8
- * page. Two cards: the previous page (content flush to the inline
9
- * start, a "‹ Previous" label) and the next page (flush to the inline
10
- * end, "Next ›").
8
+ * page.
11
9
  *
12
- * <PageNav
13
- * linkComponent={RouterLink}
14
- * prev={{ label: 'docs.json schema reference', href: '/schema' }}
15
- * next={{ label: 'Pages', href: '/pages' }}
16
- * />
17
- *
18
- * Either side may be omitted (first / last page) — the present card
19
- * keeps its half-width slot. Cards sit side by side and stack on narrow
20
- * widths via <Switcher>. Router-agnostic through `linkComponent`.
10
+ * Default / Thulir: two text cards ("‹ Previous" / "Next ›").
11
+ * Aalam (Maple): padded tray with a featured destination card
12
+ * (title + description + label) and a compact Previous when both
13
+ * sides exist matching Mintlify Maple's pagination.
21
14
  *
22
15
  * @typedef {Object} PageRef
23
- * @property {string} label page title shown on the card
16
+ * @property {string} label
24
17
  * @property {string} [href]
18
+ * @property {string} [description]
25
19
  *
26
- * @param {{ prev?: PageRef, next?: PageRef,
20
+ * @param {{ prev?: PageRef, next?: PageRef, variant?: 'default'|'aalam',
27
21
  * linkComponent?: React.ElementType, className?: string }} props
28
22
  */
29
23
 
30
- function NavCard({ page, dir, Link }) {
24
+ function DefaultCard({ page, dir, Link }) {
31
25
  const isPrev = dir === 'prev';
32
26
  return (
33
27
  <Link
@@ -36,11 +30,7 @@ function NavCard({ page, dir, Link }) {
36
30
  href={page.href ?? '#'}
37
31
  >
38
32
  <pagination-title class="velu-pagenav__title">{page.label}</pagination-title>
39
- <Cluster
40
- space="var(--s-4)"
41
- align="center"
42
- className="velu-pagenav__dir"
43
- >
33
+ <Cluster space="var(--s-4)" align="center" className="velu-pagenav__dir">
44
34
  {isPrev && resolveIcon('chevron-left', { size: '1em' })}
45
35
  <span>{isPrev ? 'Previous' : 'Next'}</span>
46
36
  {!isPrev && resolveIcon('chevron-right', { size: '1em' })}
@@ -49,13 +39,99 @@ function NavCard({ page, dir, Link }) {
49
39
  );
50
40
  }
51
41
 
42
+ /** Maple featured destination card: title + description | Next/Previous. */
43
+ function MapleFeatured({ page, dir, Link }) {
44
+ const isPrev = dir === 'prev';
45
+ const meta = (
46
+ <div className="velu-pagenav__meta">
47
+ <pagination-title class="velu-pagenav__title">{page.label}</pagination-title>
48
+ {page.description ? (
49
+ <span className="velu-pagenav__desc">{page.description}</span>
50
+ ) : null}
51
+ </div>
52
+ );
53
+ const rule = <div className="velu-pagenav__rule" aria-hidden="true" />;
54
+ const label = (
55
+ <div className="velu-pagenav__dir">
56
+ {isPrev ? resolveIcon('chevron-left', { size: 12, strokeWidth: 3 }) : null}
57
+ <span>{isPrev ? 'Previous' : 'Next'}</span>
58
+ {!isPrev ? resolveIcon('chevron-right', { size: 12, strokeWidth: 3 }) : null}
59
+ </div>
60
+ );
61
+ return (
62
+ <Link
63
+ className={`velu-pagenav__featured velu-pagenav__featured--${dir}`}
64
+ data-component={`pagination-${dir}`}
65
+ href={page.href ?? '#'}
66
+ >
67
+ <div className="velu-pagenav__featured-inner">
68
+ {isPrev ? (
69
+ <>
70
+ {label}
71
+ {rule}
72
+ {meta}
73
+ </>
74
+ ) : (
75
+ <>
76
+ {meta}
77
+ {rule}
78
+ {label}
79
+ </>
80
+ )}
81
+ </div>
82
+ </Link>
83
+ );
84
+ }
85
+
86
+ /** Compact Previous control when Next is the featured card. */
87
+ function MapleCompactPrev({ page, Link }) {
88
+ return (
89
+ <Link
90
+ className="velu-pagenav__compact velu-pagenav__compact--prev"
91
+ data-component="pagination-prev"
92
+ href={page.href ?? '#'}
93
+ >
94
+ {resolveIcon('chevron-left', { size: 12, strokeWidth: 3 })}
95
+ <span>Previous</span>
96
+ </Link>
97
+ );
98
+ }
99
+
100
+ function AalamNav({ prev, next, Link }) {
101
+ // Maple: when both exist, Previous is compact and Next is featured.
102
+ // Only-prev / only-next: the single side is the featured card.
103
+ if (prev && next) {
104
+ return (
105
+ <>
106
+ <MapleCompactPrev page={prev} Link={Link} />
107
+ <MapleFeatured page={next} dir="next" Link={Link} />
108
+ </>
109
+ );
110
+ }
111
+ if (next) return <MapleFeatured page={next} dir="next" Link={Link} />;
112
+ if (prev) return <MapleFeatured page={prev} dir="prev" Link={Link} />;
113
+ return null;
114
+ }
115
+
52
116
  export default function PageNav({
53
117
  prev,
54
118
  next,
119
+ variant = 'default',
55
120
  linkComponent = 'a',
56
121
  className = '',
57
122
  ...rest
58
123
  }) {
124
+ if (variant === 'aalam') {
125
+ return (
126
+ <nav
127
+ className={`velu-pagenav velu-pagenav--aalam ${className}`.trim()}
128
+ {...rest}
129
+ >
130
+ <AalamNav prev={prev} next={next} Link={linkComponent} />
131
+ </nav>
132
+ );
133
+ }
134
+
59
135
  return (
60
136
  <Switcher
61
137
  space="var(--s0)"
@@ -64,12 +140,12 @@ export default function PageNav({
64
140
  {...rest}
65
141
  >
66
142
  {prev ? (
67
- <NavCard page={prev} dir="prev" Link={linkComponent} />
143
+ <DefaultCard page={prev} dir="prev" Link={linkComponent} />
68
144
  ) : (
69
145
  <div aria-hidden="true" />
70
146
  )}
71
147
  {next ? (
72
- <NavCard page={next} dir="next" Link={linkComponent} />
148
+ <DefaultCard page={next} dir="next" Link={linkComponent} />
73
149
  ) : (
74
150
  <div aria-hidden="true" />
75
151
  )}
@@ -152,16 +152,28 @@ export default function Sidebar({
152
152
  );
153
153
 
154
154
  // Bring the active item into view inside the parent scroll container
155
- // whenever the active route changes e.g. after navigation, the
156
- // selected page is already visible without the user scrolling the
157
- // sidebar manually. Uses the shared util (instead of native
158
- // scrollIntoView) so `scroll-padding-*` on the parent — which the
159
- // docs layout uses to mark the footer-eclipsed band as off-limits —
160
- // is reliably honoured.
155
+ // whenever the active route changes. useLayoutEffect + a frame wait
156
+ // so nested <details> have opened and sticky headings have laid out
157
+ // before we measure otherwise a deep page can stay clipped.
161
158
  const rootRef = React.useRef(null);
162
- React.useEffect(() => {
163
- const el = rootRef.current?.querySelector('[aria-current="page"]');
164
- scrollIntoNearestView(el);
159
+ useIsoLayoutEffect(() => {
160
+ const nav = rootRef.current;
161
+ if (!nav) return undefined;
162
+ let cancelled = false;
163
+ const reveal = () => {
164
+ if (cancelled) return;
165
+ const el = nav.querySelector('[aria-current="page"]');
166
+ if (!el) return;
167
+ // Clear sticky section headings so the active row isn't tucked
168
+ // under a pinned title (honours scroll-padding on the scroller).
169
+ scrollIntoNearestView(el);
170
+ };
171
+ reveal();
172
+ const id = requestAnimationFrame(reveal);
173
+ return () => {
174
+ cancelled = true;
175
+ cancelAnimationFrame(id);
176
+ };
165
177
  }, [activeHref]);
166
178
 
167
179
  // The single gliding active indicator (crimson tint + flush-left bar). One
@@ -16,6 +16,7 @@ import ThemePreferenceMenu from './ThemePreferenceMenu.jsx';
16
16
  * visits, per the anti-flash script in the template).
17
17
  */
18
18
  export default function ThemeToggle({ className = '', variant, ...rest }) {
19
+ if (variant === 'inline') return <ThemePreferenceMenu inline />;
19
20
  if (variant === 'menu') return <ThemePreferenceMenu />;
20
21
  function toggle() {
21
22
  const root = document.documentElement;
@@ -83,9 +83,11 @@ export default function TocBar({
83
83
  activeId,
84
84
  onSelect = () => {},
85
85
  className = '',
86
+ label,
86
87
  ...rest
87
88
  }) {
88
89
  const [expanded, setExpanded] = React.useState(false);
90
+ const listId = React.useId();
89
91
 
90
92
  // Reading progress through the page: scrollY / (scrollable-distance).
91
93
  // Listens to window scroll + resize; clamped to [0, 1]. Stays at 0
@@ -138,6 +140,17 @@ export default function TocBar({
138
140
  // right-rail Toc uses — keeps the active item visible if the list is
139
141
  // long enough to scroll inside the bar.
140
142
  const rootRef = React.useRef(null);
143
+ React.useEffect(() => {
144
+ if (!expanded) return;
145
+ const close = (event) => {
146
+ if (event.type === 'keydown' && event.key === 'Escape') {
147
+ setExpanded(false); rootRef.current?.querySelector('button')?.focus();
148
+ } else if (event.type === 'pointerdown' && !rootRef.current?.contains(event.target)) setExpanded(false);
149
+ };
150
+ document.addEventListener('keydown', close);
151
+ document.addEventListener('pointerdown', close);
152
+ return () => { document.removeEventListener('keydown', close); document.removeEventListener('pointerdown', close); };
153
+ }, [expanded]);
141
154
  React.useEffect(() => {
142
155
  if (!expanded || activeId == null) return;
143
156
  const el = rootRef.current?.querySelector('[data-toc-bar-active="true"]');
@@ -156,10 +169,11 @@ export default function TocBar({
156
169
  className="velu-toc-bar__toggle"
157
170
  onClick={() => setExpanded((v) => !v)}
158
171
  aria-expanded={expanded}
172
+ aria-controls={listId}
159
173
  >
160
174
  {/* Reading-progress ring — fills as the reader scrolls. */}
161
175
  <ProgressRing progress={progress} />
162
- <span className="velu-toc-bar__title">{headerLabel}</span>
176
+ <span className="velu-toc-bar__title">{label ?? headerLabel}</span>
163
177
  <ChevronDown
164
178
  className="velu-toc-bar__chevron"
165
179
  aria-hidden="true"
@@ -171,7 +185,7 @@ export default function TocBar({
171
185
  parent's `data-expanded` attribute via CSS, not by
172
186
  conditional rendering. `aria-hidden` keeps the collapsed
173
187
  list out of the accessibility tree. */}
174
- <ul className="velu-toc-bar__list" aria-hidden={!expanded}>
188
+ <ul id={listId} className="velu-toc-bar__list" aria-hidden={!expanded}>
175
189
  {flat.map((item) => {
176
190
  const active = item.id === activeId;
177
191
  return (
@@ -183,6 +197,11 @@ export default function TocBar({
183
197
  }}
184
198
  data-toc-bar-active={active ? 'true' : undefined}
185
199
  onClick={() => handleSelect(item.id)}
200
+ role="link"
201
+ aria-current={active ? 'location' : undefined}
202
+ onKeyDown={(event) => {
203
+ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handleSelect(item.id); }
204
+ }}
186
205
  tabIndex={expanded ? 0 : -1}
187
206
  >
188
207
  {item.label}
@@ -10,9 +10,11 @@ import resolveIcon from '../lib/resolveIcon.jsx';
10
10
  * description and tags) and a right column with the entry body (the MDX
11
11
  * children — usually `##` sub-headings and prose).
12
12
  *
13
- * The section gets a stable `id` derived from the label so it is linkable
14
- * and shows up in the table of contents. The id MUST match the slug the
15
- * `extract-toc` remark plugin computes for the same label (see
13
+ * The section gets a stable `id` derived from description (preferred) or
14
+ * label so each entry is linkable and shows up in the table of contents.
15
+ * Preferring `description` keeps same-month releases unique (e.g. two
16
+ * "September 2026" rows with 0.2.37 / 0.2.36). The id MUST match the slug
17
+ * the `extract-toc` remark plugin computes (see
16
18
  * velu-cli/src/mdx-plugins/extract-toc.js → `slugifyUpdate`), so click→scroll
17
19
  * and scroll-spy line up.
18
20
  *
@@ -25,12 +27,20 @@ import resolveIcon from '../lib/resolveIcon.jsx';
25
27
  * tags?: string | string[], rss?: any, children?: React.ReactNode,
26
28
  * className?: string }} props
27
29
  */
28
- export function slugifyUpdate(value) {
29
- const base = String(value || '')
30
- .toLowerCase()
31
- .replace(/[^a-z0-9]+/g, '-')
32
- .replace(/^-+|-+$/g, '');
33
- return `update-${base || 'item'}`;
30
+ export function slugifyUpdate(label, description) {
31
+ const slugPart = (value) =>
32
+ String(value || '')
33
+ .toLowerCase()
34
+ .replace(/[^a-z0-9]+/g, '-')
35
+ .replace(/^-+|-+$/g, '');
36
+ const labelPart = slugPart(label);
37
+ const descPart = description == null || description === '' ? '' : slugPart(description);
38
+ const base = descPart
39
+ ? labelPart
40
+ ? `${labelPart}-${descPart}`
41
+ : descPart
42
+ : labelPart || 'item';
43
+ return `update-${base}`;
34
44
  }
35
45
 
36
46
  function toTagList(value) {
@@ -51,7 +61,9 @@ export default function Update({
51
61
  }) {
52
62
  void rss;
53
63
  const updateLabel = String(label ?? date ?? 'Update');
54
- const anchorId = slugifyUpdate(updateLabel);
64
+ const updateDescription =
65
+ description == null || description === '' ? undefined : String(description);
66
+ const anchorId = slugifyUpdate(updateLabel, updateDescription);
55
67
  const tagList = toTagList(tags);
56
68
 
57
69
  return (