@veluai/velu 0.2.0 → 0.2.1

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.
package/dist/cli.js CHANGED
@@ -25,7 +25,7 @@ ${L.join(`,
25
25
  `),$.join(`
26
26
  `),"","export const pages = {",c.join(`
27
27
  `),"};",`export const navigation = ${JSON.stringify(i)};`,`export const site = ${JSON.stringify(p)};`,""].join(`
28
- `)}var Ot,At,Lt=j(()=>{B();oe();Y();Be();Ot="virtual:velu-site",At="\0"+Ot});import{visit as lr}from"unist-util-visit";function cr(e,t){let n=Pt(e).replace(/[?#].*$/,""),r=Pt(t).replace(/\/+$/,"");return r&&n.toLowerCase().startsWith(r.toLowerCase())?n.slice(r.length).replace(/^\/+/,""):null}function _t(e,t){return()=>(n,r)=>{typeof e=="function"&&lr(n,o=>{if(o.type!=="mdxJsxFlowElement"&&o.type!=="mdxJsxTextElement"||typeof o.name!="string"||!/^[A-Z]/.test(o.name)||o.name.includes("."))return;let i={};for(let s of o.attributes||[])s.type==="mdxJsxAttribute"&&(s.value==null?i[s.name]=!0:typeof s.value=="string"&&(i[s.name]=s.value));let a=e(o.name,i,{checkRequired:!1});if(a){let s=new Error(a.title);throw s.veluIssue={...a,file:cr(r?.path,t),line:o.position?.start?.line??null,column:o.position?.start?.column??null},s.loc={line:s.veluIssue.line,column:s.veluIssue.column},s}})}}var Pt,Ct=j(()=>{Pt=e=>String(e||"").replace(/\\/g,"/")});import ve from"node:path";import ur from"node:fs";import{createRequire as pr}from"node:module";import fr from"@tailwindcss/vite";import mr from"@vitejs/plugin-react";import dr from"@mdx-js/rollup";import gr from"remark-frontmatter";import hr from"remark-mdx-frontmatter";import yr from"remark-gfm";import vr from"rehype-slug";function br(e){let n=pr(import.meta.url).resolve("@mdx-js/react"),r=ve.resolve(e,"runtime","velu-ui"),o=ur.existsSync(r)?r:ve.resolve(e,"..","velu-ui","src");return[{find:"@mdx-js/react",replacement:n},{find:/^velu-ui$/,replacement:ve.join(o,"index.js")},{find:/^velu-ui\//,replacement:o+"/"}]}function Et({targetDir:e,VITE_ROOT:t,validateProps:n}){return{root:t,plugins:[Tt(e),dr({remarkPlugins:[gr,hr,yr,ne,_t(n,e)],rehypePlugins:[vr],providerImportSource:"@mdx-js/react"}),mr(),fr({optimize:!1})],cacheDir:ve.resolve(e,".velu","vite-cache"),optimizeDeps:{include:["react","react-dom","react-dom/client","react-router-dom","@mdx-js/react","lucide-react","prism-react-renderer","iso-639-1","github-slugger"],exclude:["tailwindcss","@tailwindcss/oxide","playwright","playwright-core","@apidevtools/swagger-parser","satori","@resvg/resvg-js"],esbuildOptions:{loader:{".mdx":"jsx"}}},resolve:{alias:br(t),dedupe:["react","react-dom","@mdx-js/react"]}}}var Ft=j(()=>{Re();Lt();Ct()});function A(e){return{category:"render-crash",severity:"error",file:null,line:null,column:null,frame:null,title:"Something went wrong",detail:"",hint:"",suggestion:null,...e}}function xr(e,t){if(e=String(e),t=String(t),e===t)return 0;if(!e.length)return t.length;if(!t.length)return e.length;let n=Array.from({length:t.length+1},(o,i)=>i),r=new Array(t.length+1);for(let o=1;o<=e.length;o++){r[0]=o;for(let i=1;i<=t.length;i++){let a=e[o-1]===t[i-1]?0:1;r[i]=Math.min(r[i-1]+1,n[i]+1,n[i-1]+a)}[n,r]=[r,n]}return n[t.length]}function be(e,t=[]){if(!e)return null;let n=String(e).toLowerCase(),r=null,o=1/0;for(let a of t){let s=xr(n,String(a).toLowerCase());s<o&&(o=s,r=a)}let i=Math.min(3,Math.max(2,Math.floor(n.length/4)));return o<=i?r:null}function $r(e,t){return e==="warning"?E(t,_.yellow,"\u26A0"):e==="info"?E(t,_.cyan,"\u2139"):E(t,_.red,"\u2716")}function Je(e,{color:t=!1}={}){let n=Ge[e.category]||e.category,r=e.line?`${e.line}${e.column?`:${e.column}`:""}`:"",o=e.file?`${e.file}${r?`:${r}`:""}`:r,i=[];if(i.push(`${$r(e.severity,t)} ${E(t,_.bold,n)}`+(o?` ${E(t,_.dim,o)}`:"")),e.title&&i.push(` ${e.title}`),e.detail&&e.detail!==e.title&&i.push(` ${E(t,_.dim,e.detail)}`),e.frame)for(let a of String(e.frame).split(`
28
+ `)}var Ot,At,Lt=j(()=>{B();oe();Y();Be();Ot="virtual:velu-site",At="\0"+Ot});import{visit as lr}from"unist-util-visit";function cr(e,t){let n=Pt(e).replace(/[?#].*$/,""),r=Pt(t).replace(/\/+$/,"");return r&&n.toLowerCase().startsWith(r.toLowerCase())?n.slice(r.length).replace(/^\/+/,""):null}function _t(e,t){return()=>(n,r)=>{typeof e=="function"&&lr(n,o=>{if(o.type!=="mdxJsxFlowElement"&&o.type!=="mdxJsxTextElement"||typeof o.name!="string"||!/^[A-Z]/.test(o.name)||o.name.includes("."))return;let i={};for(let s of o.attributes||[])s.type==="mdxJsxAttribute"&&(s.value==null?i[s.name]=!0:typeof s.value=="string"&&(i[s.name]=s.value));let a=e(o.name,i,{checkRequired:!1});if(a){let s=new Error(a.title);throw s.veluIssue={...a,file:cr(r?.path,t),line:o.position?.start?.line??null,column:o.position?.start?.column??null},s.loc={line:s.veluIssue.line,column:s.veluIssue.column},s}})}}var Pt,Ct=j(()=>{Pt=e=>String(e||"").replace(/\\/g,"/")});import ve from"node:path";import ur from"node:fs";import{createRequire as pr}from"node:module";import fr from"@tailwindcss/vite";import mr from"@vitejs/plugin-react";import dr from"@mdx-js/rollup";import gr from"remark-frontmatter";import hr from"remark-mdx-frontmatter";import yr from"remark-gfm";import vr from"rehype-slug";function br(e){let n=pr(import.meta.url).resolve("@mdx-js/react"),r=ve.resolve(e,"runtime","velu-ui"),o=ur.existsSync(r)?r:ve.resolve(e,"..","velu-ui","src");return[{find:"@mdx-js/react",replacement:n},{find:/^velu-ui$/,replacement:ve.join(o,"index.js")},{find:/^velu-ui\//,replacement:o+"/"}]}function Et({targetDir:e,VITE_ROOT:t,validateProps:n}){return{root:t,plugins:[Tt(e),dr({remarkPlugins:[gr,hr,yr,ne,_t(n,e)],rehypePlugins:[vr],providerImportSource:"@mdx-js/react"}),mr(),fr({optimize:!1})],cacheDir:ve.resolve(e,".velu","vite-cache"),optimizeDeps:{include:["react","react-dom","react-dom/client","react-router-dom","@mdx-js/react","lucide-react","prism-react-renderer","iso-639-1","github-slugger"],exclude:["tailwindcss","@tailwindcss/oxide","playwright","playwright-core","@apidevtools/swagger-parser","satori","@resvg/resvg-js","pagefind"],esbuildOptions:{loader:{".mdx":"jsx"}}},resolve:{alias:br(t),dedupe:["react","react-dom","@mdx-js/react"]}}}var Ft=j(()=>{Re();Lt();Ct()});function A(e){return{category:"render-crash",severity:"error",file:null,line:null,column:null,frame:null,title:"Something went wrong",detail:"",hint:"",suggestion:null,...e}}function xr(e,t){if(e=String(e),t=String(t),e===t)return 0;if(!e.length)return t.length;if(!t.length)return e.length;let n=Array.from({length:t.length+1},(o,i)=>i),r=new Array(t.length+1);for(let o=1;o<=e.length;o++){r[0]=o;for(let i=1;i<=t.length;i++){let a=e[o-1]===t[i-1]?0:1;r[i]=Math.min(r[i-1]+1,n[i]+1,n[i-1]+a)}[n,r]=[r,n]}return n[t.length]}function be(e,t=[]){if(!e)return null;let n=String(e).toLowerCase(),r=null,o=1/0;for(let a of t){let s=xr(n,String(a).toLowerCase());s<o&&(o=s,r=a)}let i=Math.min(3,Math.max(2,Math.floor(n.length/4)));return o<=i?r:null}function $r(e,t){return e==="warning"?E(t,_.yellow,"\u26A0"):e==="info"?E(t,_.cyan,"\u2139"):E(t,_.red,"\u2716")}function Je(e,{color:t=!1}={}){let n=Ge[e.category]||e.category,r=e.line?`${e.line}${e.column?`:${e.column}`:""}`:"",o=e.file?`${e.file}${r?`:${r}`:""}`:r,i=[];if(i.push(`${$r(e.severity,t)} ${E(t,_.bold,n)}`+(o?` ${E(t,_.dim,o)}`:"")),e.title&&i.push(` ${e.title}`),e.detail&&e.detail!==e.title&&i.push(` ${E(t,_.dim,e.detail)}`),e.frame)for(let a of String(e.frame).split(`
29
29
  `))i.push(` ${a}`);return e.suggestion&&i.push(` ${E(t,_.cyan,`Did you mean <${e.suggestion}>?`)}`),e.hint&&i.push(` ${E(t,_.dim,`\u2192 ${e.hint}`)}`),i.join(`
30
30
  `)}function xe(e,{color:t=!1}={}){if(!e.length)return E(t,_.cyan,"\u2713 No issues found.");let n=new Map;for(let s of e){let l=s.file||"velu.json";n.has(l)||n.set(l,[]),n.get(l).push(s)}let r=[];for(let[s,l]of n){r.push(E(t,_.bold,s));for(let p of l)r.push(Je({...p,file:null},{color:t}).replace(/^/gm," "));r.push("")}let o=e.filter(s=>s.severity!=="warning"&&s.severity!=="info").length,i=e.length-o,a=`${o} error${o===1?"":"s"}`+(i?`, ${i} warning${i===1?"":"s"}`:"");return r.push(E(t,o?_.red:_.yellow,a)),r.join(`
31
31
  `)}var Ge,_,E,J=j(()=>{Ge={"mdx-syntax":"MDX syntax",frontmatter:"Frontmatter","unknown-component":"Unknown component","invalid-props":"Invalid options","render-crash":"Render error","config-json":"velu.json","config-schema":"velu.json","config-nav":"Navigation","config-asset":"Asset"};_={reset:"\x1B[0m",red:"\x1B[31m",yellow:"\x1B[33m",cyan:"\x1B[36m",dim:"\x1B[2m",bold:"\x1B[1m"},E=(e,t,n)=>e?`${t}${n}${_.reset}`:n});function wr(e){let t=e,n=0;for(;t&&t.cause&&n<3&&!(t.loc||t.line||/`[^`]+` to be defined/.test(t.message||""));)t=t.cause,n+=1;return t||e}function Ve(e,t){if(!e)return null;let n=Nt(e).replace(/[?#].*$/,""),r=t?Nt(t).replace(/\/+$/,""):"";return r&&n.toLowerCase().startsWith(r.toLowerCase())?n.slice(r.length).replace(/^\/+/,""):!/^([a-zA-Z]:\/|\/)/.test(n)&&!/node_modules/.test(n)?n:null}function Sr(e){return e?String(e).replace(/([a-zA-Z]:\\|\/)[^\s'"()]+/g,t=>/node_modules|[\\/]vite[\\/]|@veluai|velu-cli|velu-ui/.test(t)?"\u2026":t).trim():""}function kr(e){let t=/\((\d+):(\d+)(?:-\d+:\d+)?\)\s*$/.exec(String(e||""));return t?{line:Number(t[1]),column:Number(t[2])}:null}function jr(e){return String(e||"").replace(/\s*\(\d+:\d+(?:-\d+:\d+)?\)\s*$/,"").trim()}function Or(e,t){if(!e||!t)return null;let n=String(e);return/node_modules|[\\/]vite[\\/]|@veluai[\\/]/.test(n)?null:n}function ae(e,t={}){let{projectDir:n="",knownComponents:r=[],file:o=null}=t,i=null;for(let h=e,w=0;h&&w<6;h=h.cause,w+=1)if(h.veluIssue){i=h.veluIssue;break}if(i)return A({...i,file:i.file||o});let a=wr(e)||{},s=a.message||String(e||""),l=/Expected component `([^`]+)` to be defined/.exec(s);if(l){let h=l[1],w=/referenced in your code at `(\d+):(\d+)/.exec(s),F=/\bin `([^`]+)`/.exec(s),x=F?Ve(F[1],n):null;return A({category:"unknown-component",file:x||o,line:w?Number(w[1]):null,column:w?Number(w[2]):null,title:`<${h}> is not a built-in component`,detail:"",hint:"Components are built in \u2014 check the spelling and capitalization (you don\u2019t import them).",suggestion:be(h,r)})}let p=a.loc||(a.line!=null?{line:a.line,column:a.column,file:a.file}:null)||kr(a.reason||s),u=a.id||p&&p.file||a.file||null,c=Ve(u,n)||o,b=!!c&&Ve(u,n)!=null,d=Or(a.frame,b||!!o),$=Sr(jr(a.reason||s)),m=Ar.test(s),L=a.source==="remark-frontmatter"||/\byaml\b|frontmatter/i.test(s)||p&&p.line===1&&/unexpected|expected/i.test(s)&&/---/.test(s);return p||m||d&&b?A({category:L?"frontmatter":"mdx-syntax",file:c,line:p?p.line:null,column:p?p.column:null,frame:d,title:$||(L?"Invalid frontmatter":"MDX syntax error"),detail:"",hint:L?"Check the YAML between the --- markers \u2014 indentation, quotes, and colons.":"Make sure every <Component> has a matching closing tag and that { } braces are balanced."}):/\.map is not a function|is not iterable|reading '|undefined \(reading/i.test(s)?A({category:"invalid-props",file:c,title:"A component received an invalid value",detail:$,hint:"Check the props/options you passed to a component on this page."}):A({category:"render-crash",file:c,title:"This page failed to render",detail:$,hint:"Set VELU_DEBUG=1 for the full stack trace."})}var Nt,Ar,qe=j(()=>{J();Nt=e=>String(e).replace(/\\/g,"/");Ar=/closing tag|unexpected closing|could not parse (?:expression|import|export)|unexpected character|unexpected end of|expected a closing|expected the closing|unexpected `|in expression|misnested|before name/i});function Tr(e){let t=Ge[e.category]||e.category,n=e.file?`${e.file}${e.line?`:${e.line}${e.column?`:${e.column}`:""}`:""}`:"",r=[];return r.push('<div class="velu-err-card">'),r.push('<div class="velu-err-head">'),r.push(`<span class="velu-err-pill">${I(t)}</span>`),r.push("</div>"),e.title&&r.push(`<div class="velu-err-title">${I(e.title)}</div>`),n&&r.push(`<a class="velu-err-loc" title="${I(n)}">${I(n)}</a>`),e.detail&&r.push(`<div class="velu-err-detail">${I(e.detail)}</div>`),e.frame&&r.push(`<pre class="velu-err-frame">${I(e.frame)}</pre>`),e.suggestion&&r.push(`<div class="velu-err-suggest">Did you mean <code>&lt;${I(e.suggestion)}&gt;</code>?</div>`),e.hint&&r.push(`<div class="velu-err-hint">${I(e.hint)}</div>`),r.push("</div>"),r.join("")}function He(e,t={}){let n=Array.isArray(e)?e:[e],r=t.title||(n.length>1?`${n.length} problems to fix`:"There\u2019s a problem to fix"),o=n.map(Tr).join(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veluai/velu",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "bin": "./dist/cli.js",
6
6
  "publishConfig": {
@@ -56,6 +56,7 @@
56
56
  "@fontsource/outfit": "^5.0.0",
57
57
  "@resvg/resvg-js": "^2.6.2",
58
58
  "esbuild": "^0.28.0",
59
+ "pagefind": "^1.3.0",
59
60
  "playwright": "^1.48.0",
60
61
  "satori": "^0.12.0"
61
62
  }
@@ -203,6 +203,7 @@ export default function ContextMenu({
203
203
  justify="space-between"
204
204
  align="flex-end"
205
205
  className="velu-context-bar"
206
+ data-pagefind-ignore=""
206
207
  >
207
208
  {eyebrow ? <span className="velu-context-bar__eyebrow">{eyebrow}</span> : <span />}
208
209
 
@@ -162,10 +162,14 @@ function PaletteRow({ item, selected, navMode, onHover, onSelect }) {
162
162
  );
163
163
  }
164
164
 
165
- /* The revealed palette — scrim + centered panel. */
166
- function SearchPalette({ results, placeholder, onSelect, onClose }) {
165
+ /* The revealed palette — scrim + centered panel. When `search` (an async
166
+ query results function, e.g. the Pagefind client) is provided, results
167
+ come from it; otherwise the static `results` list is filtered in-memory. */
168
+ function SearchPalette({ results, search, placeholder, onSelect, onClose }) {
167
169
  const [query, setQuery] = useState('');
168
170
  const [selected, setSelected] = useState(0);
171
+ const [asyncResults, setAsyncResults] = useState([]);
172
+ const [searching, setSearching] = useState(false);
169
173
  const inputRef = useRef(null);
170
174
  // 'keyboard' | 'mouse' — which input last moved the selection; gates
171
175
  // the rows' scrollIntoView so mouse hover doesn't jitter the list.
@@ -175,7 +179,32 @@ function SearchPalette({ results, placeholder, onSelect, onClose }) {
175
179
  inputRef.current?.focus();
176
180
  }, []);
177
181
 
182
+ // Async source (Pagefind): debounce the query, fetch results.
183
+ useEffect(() => {
184
+ if (!search) return undefined;
185
+ const q = query.trim();
186
+ if (!q) {
187
+ setAsyncResults([]);
188
+ setSearching(false);
189
+ return undefined;
190
+ }
191
+ let cancelled = false;
192
+ setSearching(true);
193
+ const t = setTimeout(async () => {
194
+ const r = await search(q).catch(() => []);
195
+ if (!cancelled) {
196
+ setAsyncResults(Array.isArray(r) ? r : []);
197
+ setSearching(false);
198
+ }
199
+ }, 150);
200
+ return () => {
201
+ cancelled = true;
202
+ clearTimeout(t);
203
+ };
204
+ }, [query, search]);
205
+
178
206
  const filtered = useMemo(() => {
207
+ if (search) return asyncResults;
179
208
  const q = query.trim().toLowerCase();
180
209
  if (!q) {
181
210
  // Empty query → just the recents.
@@ -185,15 +214,18 @@ function SearchPalette({ results, placeholder, onSelect, onClose }) {
185
214
  const hay = `${r.title} ${r.breadcrumb.join(' ')} ${r.desc}`.toLowerCase();
186
215
  return hay.includes(q);
187
216
  });
188
- }, [query, results]);
217
+ }, [search, asyncResults, query, results]);
189
218
 
190
219
  const grouped = useMemo(() => {
191
220
  const map = new Map();
192
221
  filtered.forEach((r) => {
193
- if (!map.has(r.group)) map.set(r.group, []);
194
- map.get(r.group).push(r);
222
+ const g = r.group || 'Pages';
223
+ if (!map.has(g)) map.set(g, []);
224
+ map.get(g).push(r);
195
225
  });
196
- return GROUP_ORDER.filter((g) => map.has(g)).map((g) => [g, map.get(g)]);
226
+ const known = GROUP_ORDER.filter((g) => map.has(g));
227
+ const extra = [...map.keys()].filter((g) => !GROUP_ORDER.includes(g));
228
+ return [...known, ...extra].map((g) => [g, map.get(g)]);
197
229
  }, [filtered]);
198
230
 
199
231
  const flat = useMemo(
@@ -266,19 +298,34 @@ function SearchPalette({ results, placeholder, onSelect, onClose }) {
266
298
  </div>
267
299
 
268
300
  <div className="velu-search__list" role="listbox">
269
- {grouped.length === 0 && (
270
- <div className="velu-search__empty">
271
- <span className="velu-search__empty-icon" aria-hidden="true">
272
- {resolveIcon('search-x', { size: '1em' })}
273
- </span>
274
- <div className="velu-search__empty-title">
275
- No results for &ldquo;{query}&rdquo;
301
+ {grouped.length === 0 &&
302
+ (search && !query.trim() ? (
303
+ <div className="velu-search__empty">
304
+ <span className="velu-search__empty-icon" aria-hidden="true">
305
+ {resolveIcon('search', { size: '1em' })}
306
+ </span>
307
+ <div className="velu-search__empty-title">Search the docs</div>
308
+ <div className="velu-search__empty-sub">
309
+ Type to find pages and sections.
310
+ </div>
276
311
  </div>
277
- <div className="velu-search__empty-sub">
278
- Try a different keyword or browse the sidebar.
312
+ ) : searching ? (
313
+ <div className="velu-search__empty">
314
+ <div className="velu-search__empty-sub">Searching…</div>
279
315
  </div>
280
- </div>
281
- )}
316
+ ) : (
317
+ <div className="velu-search__empty">
318
+ <span className="velu-search__empty-icon" aria-hidden="true">
319
+ {resolveIcon('search-x', { size: '1em' })}
320
+ </span>
321
+ <div className="velu-search__empty-title">
322
+ No results for &ldquo;{query}&rdquo;
323
+ </div>
324
+ <div className="velu-search__empty-sub">
325
+ Try a different keyword or browse the sidebar.
326
+ </div>
327
+ </div>
328
+ ))}
282
329
  {grouped.map(([group, items]) => (
283
330
  <div key={group}>
284
331
  <div className="velu-search__group">{group}</div>
@@ -346,6 +393,7 @@ function SearchUnavailable({ message, onClose }) {
346
393
 
347
394
  export default function Search({
348
395
  results = DEFAULT_RESULTS,
396
+ search,
349
397
  placeholder = 'Search documentation',
350
398
  onSelect,
351
399
  unavailable = false,
@@ -401,6 +449,7 @@ export default function Search({
401
449
  ) : (
402
450
  <SearchPalette
403
451
  results={results}
452
+ search={search}
404
453
  placeholder={placeholder}
405
454
  onSelect={onSelect}
406
455
  onClose={() => setOpen(false)}
@@ -41,6 +41,11 @@ import scrollIntoNearestView from '../lib/scrollIntoNearestView.js';
41
41
 
42
42
  const SidebarCtx = React.createContext({ activeHref: undefined, Link: 'a' });
43
43
 
44
+ // Positioning must happen before paint (no flash); fall back to useEffect on
45
+ // the server so React doesn't warn about useLayoutEffect during SSR.
46
+ const useIsoLayoutEffect =
47
+ typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect;
48
+
44
49
  function isActive(item, activeHref) {
45
50
  return Boolean(
46
51
  item.active || (item.href != null && item.href === activeHref)
@@ -166,6 +171,47 @@ export default function Sidebar({
166
171
  scrollIntoNearestView(el);
167
172
  }, [activeHref]);
168
173
 
174
+ // The single gliding active indicator (crimson tint + flush-left bar). One
175
+ // element animates its position/height to the active item, so selection feels
176
+ // continuous rather than each item popping its own bar. It lives in the scroll
177
+ // content, so its offset is scroll-stable (no re-measure on scroll) and a
178
+ // pinned sticky heading's opaque background covers it when an item tucks under.
179
+ const [ind, setInd] = React.useState({ top: 0, h: 0, show: false });
180
+ const measure = React.useCallback(() => {
181
+ const nav = rootRef.current;
182
+ if (!nav) return;
183
+ const el = nav.querySelector('[aria-current="page"]');
184
+ if (!el) {
185
+ setInd((s) => (s.show ? { ...s, show: false } : s));
186
+ return;
187
+ }
188
+ const top = el.getBoundingClientRect().top - nav.getBoundingClientRect().top;
189
+ const h = el.offsetHeight;
190
+ // Bail when nothing moved → no needless re-render (and no animation jitter).
191
+ setInd((s) =>
192
+ s.show && Math.abs(s.top - top) < 0.5 && Math.abs(s.h - h) < 0.5
193
+ ? s
194
+ : { top, h, show: true },
195
+ );
196
+ }, []);
197
+ // Re-measure on selection / tab change…
198
+ useIsoLayoutEffect(() => {
199
+ measure();
200
+ }, [activeHref, sections, measure]);
201
+ // …and whenever the nav's layout changes (a group expands/collapses → the
202
+ // active item moves), plus on viewport resize.
203
+ React.useEffect(() => {
204
+ const nav = rootRef.current;
205
+ if (!nav || typeof ResizeObserver === 'undefined') return undefined;
206
+ const ro = new ResizeObserver(() => measure());
207
+ ro.observe(nav);
208
+ window.addEventListener('resize', measure);
209
+ return () => {
210
+ ro.disconnect();
211
+ window.removeEventListener('resize', measure);
212
+ };
213
+ }, [measure]);
214
+
169
215
  return (
170
216
  <SidebarCtx.Provider value={ctx}>
171
217
  <Stack
@@ -176,6 +222,15 @@ export default function Sidebar({
176
222
  aria-label="Documentation"
177
223
  {...rest}
178
224
  >
225
+ <span
226
+ className="velu-sidebar__indicator"
227
+ aria-hidden="true"
228
+ style={{
229
+ transform: `translateY(${ind.top}px)`,
230
+ height: `${ind.h}px`,
231
+ opacity: ind.show ? 1 : 0,
232
+ }}
233
+ />
179
234
  {/* Each section is its own Stack so the heading sits TIGHT to
180
235
  its list (small inner gap), while the nav's larger gap
181
236
  separates one section from the next — compact but still
@@ -3,7 +3,10 @@
3
3
  tokens, light/dark via [data-theme]. */
4
4
 
5
5
  .velu-context-bar {
6
- margin-block-end: var(--s0);
6
+ /* Tight to the page title below — the eyebrow reads as a kicker on the
7
+ title, not a detached row. Negative pull cancels the h1's line-height
8
+ leading so the cap-height of the title sits right under the eyebrow. */
9
+ margin-block-end: calc(-1 * var(--s-5));
7
10
  }
8
11
 
9
12
  /* Section/group label, top-left — small, uppercase, accent (matches the
@@ -102,6 +102,16 @@
102
102
  /* Region wrapping the scroll area + the overlay arrows. Fills the
103
103
  remaining aside height; relative so the arrows position to its
104
104
  top/bottom edges. */
105
+ /* Hairline between the top anchor links and the nav sections (the sidebar
106
+ design's anchor↔sidebar separator). Tokenized; sits flush in the aside flow. */
107
+ .velu-docs-context-divider {
108
+ flex: none;
109
+ block-size: var(--border-width);
110
+ background: var(--border-color);
111
+ margin-block: var(--s-2);
112
+ margin-inline: var(--s-2);
113
+ }
114
+
105
115
  .velu-docs-nav-region {
106
116
  position: relative;
107
117
  flex: 1 1 auto;
@@ -200,10 +210,13 @@
200
210
  .velu-docs-layout__aside--right[data-fade-top='true'] {
201
211
  --vf-top: var(--s2);
202
212
  }
203
- /* The left nav's under-heading fade (the ::after below each sticky
204
- section heading) is shown only while the nav is scrolled up, so a
205
- section's first item isn't dimmed at rest. */
206
- .velu-docs-nav-scroll[data-fade-top='true'] .velu-sidebar__section::after {
213
+ /* The left nav's under-heading fade (the ::after below a sticky section
214
+ heading) is shown only while the nav is scrolled up AND only under the
215
+ currently-PINNED section ([data-stuck], set by the runtime). Other sections'
216
+ headings scroll normally, so their first item must not be dimmed — only the
217
+ item sliding under the top-pinned heading fades. */
218
+ .velu-docs-nav-scroll[data-fade-top='true']
219
+ .velu-sidebar__section[data-stuck='true']::after {
207
220
  opacity: 1;
208
221
  }
209
222
  .velu-docs-nav-scroll[data-fade-bottom='true'],
@@ -147,6 +147,7 @@
147
147
  color: var(--text-color);
148
148
  }
149
149
  .velu-header__action--outlined:hover {
150
+ background: var(--surface-color);
150
151
  border-color: var(--accent-color);
151
152
  color: var(--accent-color);
152
153
  }
@@ -7,10 +7,51 @@
7
7
  rotation, and centralized icon sizing/stroke. All values are tokens. */
8
8
 
9
9
  .velu-sidebar {
10
- border-left: var(--border-width) solid var(--surface-color);
10
+ position: relative; /* anchors the gliding active indicator */
11
11
  margin-left: var(--s1);
12
12
  }
13
13
 
14
+ /* The single active indicator — a crimson tint band with a flush-left 2px bar
15
+ that GLIDES between items (one element animating its position/height) instead
16
+ of each item painting its own active state. z-index 0 keeps it BEHIND the
17
+ items + sticky headings (both positioned, z-index 1), so the text reads over
18
+ the tint and a pinned heading's opaque background covers the indicator when an
19
+ item scrolls under it. Driven imperatively from JS (transform/height/opacity);
20
+ the easing curve makes the slide feel continuous. */
21
+ .velu-sidebar__indicator {
22
+ position: absolute;
23
+ inset-inline-start: 0;
24
+ inset-inline-end: var(--s-3);
25
+ inset-block-start: 0;
26
+ height: 0;
27
+ opacity: 0;
28
+ z-index: 0;
29
+ pointer-events: none;
30
+ background: color-mix(in srgb, var(--accent-color) 8%, transparent);
31
+ border-radius: var(--radius-sm);
32
+ transition:
33
+ transform 0.3s cubic-bezier(0.32, 0.72, 0, 1),
34
+ height 0.3s cubic-bezier(0.32, 0.72, 0, 1),
35
+ opacity 0.2s ease;
36
+ will-change: transform, height;
37
+ }
38
+ .velu-sidebar__indicator::before {
39
+ content: '';
40
+ position: absolute;
41
+ inset-inline-start: 0;
42
+ inset-block: 15%;
43
+ inline-size: 2px;
44
+ border-start-end-radius: 2px;
45
+ border-end-end-radius: 2px;
46
+ background: var(--accent-color);
47
+ }
48
+ /* Honour reduced-motion: snap instead of glide. */
49
+ @media (prefers-reduced-motion: reduce) {
50
+ .velu-sidebar__indicator {
51
+ transition: opacity 0.2s ease;
52
+ }
53
+ }
54
+
14
55
  .velu-sidebar__section {
15
56
  /* Sticky section header: each heading pins to the top of the scroll
16
57
  region until its own section scrolls past, then the next heading
@@ -21,7 +62,10 @@
21
62
  bar is invisible until it's actually covering scrolled content. */
22
63
  position: sticky;
23
64
  inset-block-start: 0;
24
- z-index: 1;
65
+ /* Above the items (z-index 1) so scrolled rows tuck UNDER the heading (its
66
+ opaque bg + the ::after fade hide them), not over it; above the gliding
67
+ indicator (z-index 0) too. */
68
+ z-index: 2;
25
69
  background: var(--page-bg);
26
70
  display: flex;
27
71
  align-items: center;
@@ -94,21 +138,38 @@
94
138
  so these stay CSS until/if a Cluster/Inline primitive exists. */
95
139
  .velu-sidebar__item,
96
140
  .velu-sidebar__subitem {
141
+ /* Positioned above the gliding indicator (z-index 0) so the label reads over
142
+ the tint band. */
143
+ position: relative;
144
+ z-index: 1;
97
145
  display: flex;
98
146
  align-items: center;
99
147
  gap: var(--s-3);
100
148
  padding-inline: var(--s0);
101
149
  color: var(--text-color);
102
150
  text-decoration: none;
151
+ transition: color 0.2s ease;
103
152
  }
104
153
 
105
154
  .velu-sidebar__subitem {
106
155
  padding-block: var(--s-5);
107
156
  }
108
157
 
158
+ /* Active = colour + weight only; the moving indicator paints the bar + tint. */
109
159
  .velu-sidebar__item--active {
110
160
  color: var(--accent-color);
111
- border-left: 2px solid var(--accent-color);
161
+ font-weight: 500;
162
+ }
163
+ .velu-sidebar__item--active .velu-sidebar__icon {
164
+ color: var(--accent-color);
165
+ }
166
+
167
+ /* The label fills the row so the group chevron (and any external-link icon)
168
+ sit flush at the row's RIGHT end, not crowded against the label — faithful
169
+ to the design's `flex: 1` label. */
170
+ .velu-sidebar__label {
171
+ flex: 1;
172
+ min-inline-size: 0;
112
173
  }
113
174
 
114
175
  .velu-sidebar__summary {
@@ -39,6 +39,7 @@
39
39
  transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
40
40
  }
41
41
  .velu-theme-toggle:hover {
42
+ background: var(--surface-color);
42
43
  border-color: var(--accent-color);
43
44
  }
44
45
  /* Tactile press feedback on click. */
@@ -42,6 +42,7 @@ export {
42
42
  VeluMark,
43
43
  } from './components/PageHeader.jsx';
44
44
  export { default as Search } from './components/Search.jsx';
45
+ export { default as pagefindSearch } from './lib/pagefind.js';
45
46
  export { default as Image } from './components/Image.jsx';
46
47
  export {
47
48
  default as CodeBlock,
@@ -6,9 +6,10 @@ import React from 'react';
6
6
  * like lucide — no SVG loader). Each takes a `size` prop, mirroring lucide's
7
7
  * icon API so they're drop-in in the menu.
8
8
  *
9
- * Monochrome marks (OpenAI, Cursor) use `currentColor` so they follow the menu
10
- * text/hover color across light + dark. Multi-color marks (Claude, Perplexity,
11
- * VS Code) keep their brand colors legible on both themes.
9
+ * ALL marks use `currentColor` so the icon column reads as one uniform set:
10
+ * muted/grey at rest, accent on hover (matching the lucide icons beside them).
11
+ * The brand glyphs keep their distinctive shapes; only the fill follows the
12
+ * menu's text/hover color across light + dark.
12
13
  */
13
14
 
14
15
  export function MarkdownIcon({ size = '1em', ...rest }) {
@@ -39,7 +40,7 @@ export function OpenAIIcon({ size = '1em', ...rest }) {
39
40
  export function ClaudeIcon({ size = '1em', ...rest }) {
40
41
  return (
41
42
  <svg width={size} height={size} viewBox="0 0 256 257" aria-hidden="true" {...rest}>
42
- <path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z" />
43
+ <path fill="currentColor" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z" />
43
44
  </svg>
44
45
  );
45
46
  }
@@ -47,7 +48,7 @@ export function ClaudeIcon({ size = '1em', ...rest }) {
47
48
  export function PerplexityIcon({ size = '1em', ...rest }) {
48
49
  return (
49
50
  <svg width={size} height={size} viewBox="0 0 48 48" fill="none" aria-hidden="true" {...rest}>
50
- <g stroke="#20808d" strokeLinecap="round" strokeLinejoin="round">
51
+ <g stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
51
52
  <path d="M24 4.5v39M13.73 16.573v-9.99L24 16.573m0 14.5L13.73 41.417V27.01L24 16.573m0 0l10.27-9.99v9.99" />
52
53
  <path d="M13.73 31.396H9.44V16.573h29.12v14.823h-4.29" />
53
54
  <path d="M24 16.573L34.27 27.01v14.407L24 31.073" />
@@ -85,15 +86,15 @@ export function VscodeIcon({ size = '1em', ...rest }) {
85
86
  </mask>
86
87
  <g mask="url(#velu-vsc-mask)">
87
88
  <path
88
- fill="#0065A9"
89
+ fill="currentColor"
89
90
  d="M96.461 10.796 75.857.876a6.23 6.23 0 0 0-7.107 1.207l-67.451 61.5a4.167 4.167 0 0 0 .004 6.162l5.51 5.009a4.167 4.167 0 0 0 5.32.236l81.228-61.62c2.725-2.067 6.639-.124 6.639 3.297v-.24a6.25 6.25 0 0 0-3.539-5.63Z"
90
91
  />
91
92
  <path
92
- fill="#007ACC"
93
+ fill="currentColor"
93
94
  d="m96.461 89.204-20.604 9.92a6.229 6.229 0 0 1-7.107-1.207l-67.451-61.5a4.167 4.167 0 0 1 .004-6.162l5.51-5.009a4.167 4.167 0 0 1 5.32-.236l81.228 61.62c2.725 2.067 6.639.124 6.639-3.297v.24a6.25 6.25 0 0 1-3.539 5.63Z"
94
95
  />
95
96
  <path
96
- fill="#1F9CF0"
97
+ fill="currentColor"
97
98
  d="M75.858 99.126a6.232 6.232 0 0 1-7.108-1.21c2.306 2.307 6.25.674 6.25-2.588V4.672c0-3.262-3.944-4.895-6.25-2.589a6.232 6.232 0 0 1 7.108-1.21l20.6 9.908A6.25 6.25 0 0 1 100 16.413v67.174a6.25 6.25 0 0 1-3.541 5.633l-20.601 9.906Z"
98
99
  />
99
100
  </g>
@@ -0,0 +1,113 @@
1
+ // Pagefind client — queries the static search index that `velu build` writes to
2
+ // /pagefind/. The index is a chunked, lazy-loaded WASM bundle: the browser only
3
+ // downloads the fragments it needs per query, so search scales to huge sites
4
+ // without shipping one giant index. Generated at build time; absent in dev
5
+ // (the dev preview shows search as "unavailable").
6
+ //
7
+ // The runtime is loaded via a NON-analyzable dynamic import (a Function-wrapped
8
+ // import) so neither Vite nor the bundler tries to resolve /pagefind/pagefind.js
9
+ // at build time — it only exists in the deployed output.
10
+
11
+ let _pf = null;
12
+ let _loading = null;
13
+
14
+ async function loadRuntime() {
15
+ if (_pf) return _pf;
16
+ if (_loading) return _loading;
17
+ _loading = (async () => {
18
+ const importer = new Function('p', 'return import(p)');
19
+ const mod = await importer('/pagefind/pagefind.js');
20
+ if (typeof mod.init === 'function') await mod.init();
21
+ _pf = mod;
22
+ return mod;
23
+ })();
24
+ try {
25
+ return await _loading;
26
+ } catch (err) {
27
+ _loading = null;
28
+ throw err;
29
+ }
30
+ }
31
+
32
+ const stripHtml = (s) => String(s || '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
33
+
34
+ // Pagefind returns directory URLs (/quickstart/, /a/b/, /); normalize to the
35
+ // router's path form (/quickstart, /a/b, /). Preserve any #anchor.
36
+ function normalizeUrl(url) {
37
+ if (!url) return '/';
38
+ let [path, hash] = String(url).split('#');
39
+ path = path.replace(/index\.html?$/i, '').replace(/\.html?$/i, '');
40
+ if (path.length > 1) path = path.replace(/\/+$/, '');
41
+ if (!path) path = '/';
42
+ return hash ? `${path}#${hash}` : path;
43
+ }
44
+
45
+ // Breadcrumb from the URL path (everything but the leaf), title-cased.
46
+ function crumbsFromUrl(url) {
47
+ const segs = String(url).split('#')[0].split('/').filter(Boolean);
48
+ segs.pop();
49
+ return segs.map((s) => s.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()));
50
+ }
51
+
52
+ /**
53
+ * Run a query against the static Pagefind index.
54
+ * @returns {Promise<Array<SearchResult>|null>} palette-shaped results, or null
55
+ * when the index can't be loaded (→ caller can show "unavailable").
56
+ */
57
+ export default async function pagefindSearch(query) {
58
+ const q = String(query || '').trim();
59
+ if (!q) return [];
60
+
61
+ let pf;
62
+ try {
63
+ pf = await loadRuntime();
64
+ } catch {
65
+ return null; // index not present (e.g. not built) → unavailable
66
+ }
67
+
68
+ let response;
69
+ try {
70
+ response = await pf.search(q);
71
+ } catch {
72
+ return [];
73
+ }
74
+
75
+ const hits = await Promise.all(response.results.slice(0, 8).map((r) => r.data()));
76
+ const out = [];
77
+ const seen = new Set(); // dedupe by href
78
+ for (const hit of hits) {
79
+ const href = normalizeUrl(hit.url);
80
+ const title = hit.meta?.title || crumbsFromUrl(hit.url).pop() || href;
81
+ if (!seen.has(href)) {
82
+ seen.add(href);
83
+ out.push({
84
+ id: href,
85
+ group: 'Pages',
86
+ breadcrumb: crumbsFromUrl(hit.url),
87
+ title,
88
+ kind: 'page',
89
+ desc: stripHtml(hit.excerpt),
90
+ href,
91
+ });
92
+ }
93
+ // Heading-level matches → deep-linkable anchor rows. Skip the page's own
94
+ // top section (pagefind repeats it as a sub-result with the page title).
95
+ for (const sub of hit.sub_results || []) {
96
+ const subHref = normalizeUrl(sub.url);
97
+ if (seen.has(subHref) || !subHref.includes('#') || sub.title === title) continue;
98
+ seen.add(subHref);
99
+ out.push({
100
+ id: subHref,
101
+ group: 'Pages',
102
+ breadcrumb: [title],
103
+ title: sub.title || title,
104
+ kind: 'anchor',
105
+ desc: stripHtml(sub.excerpt),
106
+ href: subHref,
107
+ });
108
+ if (out.length >= 12) break;
109
+ }
110
+ if (out.length >= 12) break;
111
+ }
112
+ return out;
113
+ }
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- import { Routes, Route, useLocation, Link } from 'react-router-dom';
2
+ import { Routes, Route, useLocation, useNavigate, Link } from 'react-router-dom';
3
3
  import { MDXProvider } from '@mdx-js/react';
4
4
  // The project's pages + navigation, generated from velu.json by
5
5
  // vite-plugin-velu-site (see src/vite-plugin-velu-site.js). `pages` is
@@ -39,6 +39,7 @@ import {
39
39
  defaultMdxComponents,
40
40
  resolveIcon,
41
41
  Search,
42
+ pagefindSearch,
42
43
  Logo,
43
44
  SocialLinks,
44
45
  Tree,
@@ -719,6 +720,7 @@ function DocsPage() {
719
720
  // SSR and client compute these from the same pathname + the same
720
721
  // shared `resolve()`, so the render is identical (hydration-safe).
721
722
  const location = useLocation();
723
+ const navigate = useNavigate();
722
724
  const pathname = normalizeUrl(location.pathname);
723
725
  const entry = pages[pathname];
724
726
  const nav = React.useMemo(
@@ -726,6 +728,22 @@ function DocsPage() {
726
728
  [pathname],
727
729
  );
728
730
 
731
+ // Search source: Pagefind for content/excerpts, but resolve each result's
732
+ // breadcrumb from the real NAVIGATION (a page's nav group can differ from its
733
+ // URL path — e.g. essentials/markdown.mdx lives under the "Writing Content"
734
+ // group). Page rows show the path to the page; anchor rows include the page.
735
+ const searchDocs = React.useCallback(async (q) => {
736
+ const results = await pagefindSearch(q);
737
+ if (!Array.isArray(results)) return results;
738
+ return results.map((r) => {
739
+ const res = resolve(r.href.split('#')[0], navigation, pages);
740
+ if (!res?.breadcrumb?.length) return r;
741
+ const labels = res.breadcrumb.map((c) => c.label);
742
+ const crumbs = r.kind === 'anchor' ? labels : labels.slice(0, -1);
743
+ return crumbs.length ? { ...r, breadcrumb: crumbs } : r;
744
+ });
745
+ }, []);
746
+
729
747
  const frontmatter = entry?.frontmatter ?? {};
730
748
  const PageComponent = entry?.Component ?? null;
731
749
  const pageToc = entry?.toc ?? [];
@@ -1116,7 +1134,12 @@ function DocsPage() {
1116
1134
  tabsTrailing={languageSwitcher || undefined}
1117
1135
  center={
1118
1136
  <Cluster space="var(--s-6)" align="center">
1119
- <Search style={{ inlineSize: '30ch' }} unavailable={IS_DEV_PREVIEW} />
1137
+ <Search
1138
+ style={{ inlineSize: '30ch' }}
1139
+ unavailable={IS_DEV_PREVIEW}
1140
+ search={IS_DEV_PREVIEW ? undefined : searchDocs}
1141
+ onSelect={(item) => item.href && navigate(item.href)}
1142
+ />
1120
1143
  {/* Ask AI talks to the deployed site's AI backend — hidden in
1121
1144
  the local dev preview where there's nothing to talk to. */}
1122
1145
  {!IS_DEV_PREVIEW && (
@@ -1276,6 +1299,11 @@ function DocsPage() {
1276
1299
  )}
1277
1300
  </Stack>
1278
1301
  )}
1302
+ {/* Hairline separating the top anchor links from the nav sections
1303
+ (faithful to the sidebar design's anchor↔sidebar divider). */}
1304
+ {anchors.length > 0 && (
1305
+ <div className="velu-docs-context-divider" aria-hidden="true" />
1306
+ )}
1279
1307
  {/* Only this region scrolls — the context zone above stays
1280
1308
  pinned. The up/down arrows overlay its top/bottom edges and
1281
1309
  appear (via the data-fade-* attrs the scroll handler sets)
@@ -1437,7 +1465,7 @@ function DocsPage() {
1437
1465
  })}
1438
1466
  </span>
1439
1467
  </button>
1440
- <div className="velu-docs-layout__article">
1468
+ <div className="velu-docs-layout__article" data-pagefind-body="">
1441
1469
  {/* Per-page agent/IDE action bar: the section eyebrow + a
1442
1470
  "Copy Page" split-button whose dropdown is driven by the
1443
1471
  Mintlify-compatible `contextual` config. Renders nothing