@veluai/velu 0.1.16 → 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.
@@ -0,0 +1,273 @@
1
+ import React from 'react';
2
+ import {
3
+ Copy,
4
+ Check,
5
+ ChevronDown,
6
+ ArrowUpRight,
7
+ Sparkles,
8
+ Terminal,
9
+ Download,
10
+ Bot,
11
+ Wind,
12
+ } from 'lucide-react';
13
+ import Cluster from '../primitives/Cluster.jsx';
14
+ import {
15
+ MarkdownIcon,
16
+ OpenAIIcon,
17
+ ClaudeIcon,
18
+ PerplexityIcon,
19
+ CursorIcon,
20
+ VscodeIcon,
21
+ } from '../lib/brand-icons.jsx';
22
+
23
+ /**
24
+ * ContextMenu — the per-page agent/IDE action bar shown at the top of every
25
+ * page: the section eyebrow on the left, and a "Copy Page" split-button with a
26
+ * dropdown on the right. The dropdown items are driven by the Mintlify-
27
+ * compatible `contextual.options` config.
28
+ *
29
+ * The primary button copies the page's Markdown (the `.md` twin). Items either
30
+ * copy, open the Markdown, download the spec, or open the page in an AI tool /
31
+ * IDE. All side effects happen in click handlers (SSR-safe — no window at
32
+ * render).
33
+ *
34
+ * @param {{
35
+ * eyebrow?: string, // section/group label
36
+ * pageUrl: string, // page path, e.g. "/quickstart"
37
+ * title?: string, // page title (for AI prompts / filenames)
38
+ * isApi?: boolean, // API page → enable download-spec
39
+ * siteName?: string, // for MCP deep-link labels
40
+ * options?: Array<string|{title,description,href,icon}>,
41
+ * onAssistant?: () => void, // 'assistant' option → in-site Ask AI
42
+ * }} props
43
+ */
44
+
45
+ // The Markdown URL for a page path (`/` → `/index.md`).
46
+ const mdUrlForPage = (url) => (url === '/' ? '/index.md' : `${url}.md`);
47
+
48
+ // key → { icon, label, desc, external?, apiOnly?, kind, url? }
49
+ const REGISTRY = {
50
+ copy: { icon: Copy, label: 'Copy page', desc: 'Copy page as Markdown for LLMs', kind: 'copy' },
51
+ view: { icon: MarkdownIcon, label: 'View as Markdown', desc: 'Open the raw Markdown', kind: 'view', external: true },
52
+ 'download-spec': { icon: Download, label: 'Download OpenAPI spec', desc: 'Save this endpoint as YAML', kind: 'spec', apiOnly: true },
53
+ assistant: { icon: Sparkles, label: 'Ask AI', desc: 'Ask the docs assistant about this page', kind: 'assistant' },
54
+ chatgpt: { icon: OpenAIIcon, label: 'Open in ChatGPT', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://chatgpt.com/?hints=search&q=${q}` },
55
+ claude: { icon: ClaudeIcon, label: 'Open in Claude', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://claude.ai/new?q=${q}` },
56
+ perplexity: { icon: PerplexityIcon, label: 'Open in Perplexity', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://www.perplexity.ai/search?q=${q}` },
57
+ grok: { icon: Bot, label: 'Open in Grok', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://grok.com/?q=${q}` },
58
+ aistudio: { icon: Sparkles, label: 'Open in AI Studio', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://aistudio.google.com/app/prompts/new_chat?prompt=${q}` },
59
+ devin: { icon: Bot, label: 'Open in Devin', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://app.devin.ai/?prompt=${q}` },
60
+ windsurf: { icon: Wind, label: 'Open in Windsurf', desc: 'Ask questions about this page', kind: 'ai', external: true, url: (q) => `https://windsurf.com/?q=${q}` },
61
+ mcp: { icon: Terminal, label: 'Copy MCP install command', desc: 'Copy npx command to install MCP server', kind: 'mcp' },
62
+ 'add-mcp': { icon: Terminal, label: 'Add MCP server', desc: 'Copy command to add the MCP server', kind: 'mcp' },
63
+ cursor: { icon: CursorIcon, label: 'Connect to Cursor', desc: 'Install MCP Server on Cursor', kind: 'cursor', external: true },
64
+ vscode: { icon: VscodeIcon, label: 'Connect to VS Code', desc: 'Install MCP Server on VS Code', kind: 'vscode', external: true },
65
+ 'devin-mcp': { icon: Terminal, label: 'Connect to Devin', desc: 'Install MCP Server on Devin', kind: 'cursor', external: true },
66
+ };
67
+
68
+ export default function ContextMenu({
69
+ eyebrow,
70
+ pageUrl,
71
+ title = '',
72
+ isApi = false,
73
+ siteName = 'docs',
74
+ options = [],
75
+ onAssistant,
76
+ }) {
77
+ const [open, setOpen] = React.useState(false);
78
+ const [copied, setCopied] = React.useState(false);
79
+ const rootRef = React.useRef(null);
80
+ const copiedTimer = React.useRef(null);
81
+
82
+ React.useEffect(() => {
83
+ if (!open) return;
84
+ const onDoc = (e) => {
85
+ if (!rootRef.current?.contains(e.target)) setOpen(false);
86
+ };
87
+ const onKey = (e) => {
88
+ if (e.key === 'Escape') setOpen(false);
89
+ };
90
+ document.addEventListener('mousedown', onDoc);
91
+ document.addEventListener('keydown', onKey);
92
+ return () => {
93
+ document.removeEventListener('mousedown', onDoc);
94
+ document.removeEventListener('keydown', onKey);
95
+ };
96
+ }, [open]);
97
+
98
+ React.useEffect(() => () => clearTimeout(copiedTimer.current), []);
99
+
100
+ const flashCopied = () => {
101
+ setCopied(true);
102
+ clearTimeout(copiedTimer.current);
103
+ copiedTimer.current = setTimeout(() => setCopied(false), 1600);
104
+ };
105
+
106
+ const mdUrl = mdUrlForPage(pageUrl);
107
+
108
+ // Build the descriptor list from the configured options (skip API-only items
109
+ // off API pages, and any unsupported keys).
110
+ const items = [];
111
+ for (const o of options) {
112
+ if (typeof o === 'object' && o) {
113
+ items.push({ icon: ArrowUpRight, label: o.title, desc: o.description, external: true, kind: 'custom', href: o.href });
114
+ continue;
115
+ }
116
+ const def = REGISTRY[o];
117
+ if (!def) continue; // e.g. download-pdf — no static pipeline
118
+ if (def.apiOnly && !isApi) continue;
119
+ items.push({ ...def, key: o });
120
+ }
121
+
122
+ const copyText = async (text) => {
123
+ try {
124
+ await navigator.clipboard.writeText(text);
125
+ flashCopied();
126
+ } catch {
127
+ /* clipboard blocked — no-op */
128
+ }
129
+ };
130
+
131
+ const copyPage = async () => {
132
+ try {
133
+ const md = await fetch(mdUrl).then((r) => r.text());
134
+ await copyText(md);
135
+ } catch {
136
+ /* fetch failed — no-op */
137
+ }
138
+ };
139
+
140
+ const run = async (it) => {
141
+ setOpen(false);
142
+ const origin = window.location.origin;
143
+ const absMd = origin + mdUrl;
144
+ switch (it.kind) {
145
+ case 'copy':
146
+ return copyPage();
147
+ case 'view':
148
+ return void window.open(mdUrl, '_blank', 'noopener');
149
+ case 'spec': {
150
+ try {
151
+ const text = await fetch(mdUrl).then((r) => r.text());
152
+ const blob = new Blob([text], { type: 'text/yaml' });
153
+ const a = document.createElement('a');
154
+ a.href = URL.createObjectURL(blob);
155
+ a.download = `${(title || 'openapi').replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.yaml`;
156
+ a.click();
157
+ URL.revokeObjectURL(a.href);
158
+ } catch {
159
+ /* no-op */
160
+ }
161
+ return;
162
+ }
163
+ case 'assistant':
164
+ return onAssistant?.();
165
+ case 'ai': {
166
+ const q = encodeURIComponent(`Read ${absMd} and answer my questions about this page.`);
167
+ return void window.open(it.url(q), '_blank', 'noopener');
168
+ }
169
+ case 'mcp':
170
+ // Stubbed: the Velu MCP server isn't hosted yet — this is the intended
171
+ // command, wired to <origin>/mcp once it exists.
172
+ return copyText(`npx -y @veluai/mcp@latest ${origin}/mcp`);
173
+ case 'cursor': {
174
+ const cfg =
175
+ typeof btoa === 'function' ? btoa(JSON.stringify({ url: `${origin}/mcp` })) : '';
176
+ return void window.open(
177
+ `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(siteName)}&config=${cfg}`,
178
+ '_blank',
179
+ 'noopener',
180
+ );
181
+ }
182
+ case 'vscode': {
183
+ const cfg = encodeURIComponent(JSON.stringify({ name: siteName, url: `${origin}/mcp` }));
184
+ return void window.open(
185
+ `https://insiders.vscode.dev/redirect/mcp/install?${cfg}`,
186
+ '_blank',
187
+ 'noopener',
188
+ );
189
+ }
190
+ case 'custom':
191
+ return void window.open(it.href, '_blank', 'noopener');
192
+ default:
193
+ return undefined;
194
+ }
195
+ };
196
+
197
+ // Nothing to show → render nothing (keeps the page top clean).
198
+ if (!eyebrow && !items.length) return null;
199
+
200
+ return (
201
+ <Cluster
202
+ space="var(--s-2)"
203
+ justify="space-between"
204
+ align="flex-end"
205
+ className="velu-context-bar"
206
+ data-pagefind-ignore=""
207
+ >
208
+ {eyebrow ? <span className="velu-context-bar__eyebrow">{eyebrow}</span> : <span />}
209
+
210
+ {items.length > 0 && (
211
+ <div ref={rootRef} className="velu-context-menu" data-open={open ? 'true' : 'false'}>
212
+ <div className="velu-context-menu__split">
213
+ <button
214
+ type="button"
215
+ className="velu-context-menu__copy"
216
+ onClick={copyPage}
217
+ aria-label="Copy page as Markdown"
218
+ >
219
+ <span className="velu-context-menu__copy-icon" aria-hidden="true">
220
+ {copied ? <Check size="1em" /> : <Copy size="1em" />}
221
+ </span>
222
+ <span>{copied ? 'Copied' : 'Copy Page'}</span>
223
+ </button>
224
+ <button
225
+ type="button"
226
+ className="velu-context-menu__toggle"
227
+ aria-haspopup="menu"
228
+ aria-expanded={open}
229
+ aria-label="More actions"
230
+ onClick={() => setOpen((v) => !v)}
231
+ >
232
+ <ChevronDown size="1em" aria-hidden="true" focusable="false" />
233
+ </button>
234
+ </div>
235
+
236
+ <ul className="velu-context-menu__menu" role="menu" aria-hidden={!open}>
237
+ {items.map((it, i) => {
238
+ const Icon = it.icon;
239
+ return (
240
+ <li key={it.key ?? it.href ?? i} role="none">
241
+ <button
242
+ type="button"
243
+ role="menuitem"
244
+ className="velu-context-menu__item"
245
+ tabIndex={open ? 0 : -1}
246
+ onClick={() => run(it)}
247
+ >
248
+ <span className="velu-context-menu__item-icon" aria-hidden="true">
249
+ <Icon size="1.1em" />
250
+ </span>
251
+ <span className="velu-context-menu__item-text">
252
+ <span className="velu-context-menu__item-title">
253
+ {it.label}
254
+ {it.external && (
255
+ <ArrowUpRight
256
+ className="velu-context-menu__item-ext"
257
+ size="0.85em"
258
+ aria-hidden="true"
259
+ />
260
+ )}
261
+ </span>
262
+ {it.desc && <span className="velu-context-menu__item-desc">{it.desc}</span>}
263
+ </span>
264
+ </button>
265
+ </li>
266
+ );
267
+ })}
268
+ </ul>
269
+ </div>
270
+ )}
271
+ </Cluster>
272
+ );
273
+ }
@@ -0,0 +1,63 @@
1
+ import React from 'react';
2
+ import resolveIcon from '../lib/resolveIcon.jsx';
3
+
4
+ /**
5
+ * NotFound — the 404 page ("Direction 1 · Classic" from the design):
6
+ * a big accent-colored numeral, a short message, and a three-action row
7
+ * (Back to Home / Search the docs / Ask AI). Centered; the host layout
8
+ * supplies the header + footer. Pure tokens, so light/dark follow
9
+ * [data-theme] automatically.
10
+ *
11
+ * @param {{
12
+ * homeHref?: string,
13
+ * linkComponent?: React.ElementType,
14
+ * onSearch?: () => void,
15
+ * onAskAI?: () => void,
16
+ * eyebrow?: string, title?: string, body?: string,
17
+ * }} props
18
+ */
19
+ export default function NotFound({
20
+ homeHref = '/',
21
+ linkComponent = 'a',
22
+ onSearch,
23
+ onAskAI,
24
+ eyebrow = 'Error 404',
25
+ title = 'This page wandered off',
26
+ body = 'The page you’re looking for doesn’t exist or may have moved. Pick up the trail below.',
27
+ }) {
28
+ const Link = linkComponent;
29
+ return (
30
+ <section className="velu-404">
31
+ <p className="velu-404__eyebrow">{eyebrow}</p>
32
+ <div className="velu-404__num" aria-hidden="true">
33
+ 404
34
+ </div>
35
+ <h1 className="velu-404__title">{title}</h1>
36
+ <p className="velu-404__body">{body}</p>
37
+ <div className="velu-404__actions">
38
+ <Link className="velu-404__btn velu-404__btn--primary" href={homeHref}>
39
+ <span className="velu-404__btn-ic" aria-hidden="true">
40
+ {resolveIcon('house', { size: '1em' })}
41
+ </span>
42
+ Back to Home
43
+ </Link>
44
+ {onSearch && (
45
+ <button type="button" className="velu-404__btn" onClick={onSearch}>
46
+ <span className="velu-404__btn-ic" aria-hidden="true">
47
+ {resolveIcon('search', { size: '1em' })}
48
+ </span>
49
+ Search the docs
50
+ </button>
51
+ )}
52
+ {onAskAI && (
53
+ <button type="button" className="velu-404__btn" onClick={onAskAI}>
54
+ <span className="velu-404__btn-ic" aria-hidden="true">
55
+ {resolveIcon('sparkles', { size: '1em' })}
56
+ </span>
57
+ Ask AI
58
+ </button>
59
+ )}
60
+ </div>
61
+ </section>
62
+ );
63
+ }
@@ -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
@@ -139,13 +139,6 @@
139
139
  gap: var(--s2);
140
140
  }
141
141
 
142
- /* Playground right column: live response (when present) stacked above the
143
- same request/response code samples shown in the page's right rail. */
144
- .velu-api-pg-aside {
145
- display: flex;
146
- flex-direction: column;
147
- gap: var(--s2);
148
- }
149
142
  /* Breathing room inside each sample group — the code panels were reading
150
143
  as cramped against the rail edge. */
151
144
  .velu-api-samples > * {
@@ -265,6 +265,9 @@
265
265
  overflow: hidden;
266
266
  max-inline-size: 100%;
267
267
  min-inline-size: 0;
268
+ /* Own query container so the body grid reflows to the client's width
269
+ (it lives in a modal, outside the docs layout container). */
270
+ container: api-client / inline-size;
268
271
  }
269
272
 
270
273
  /* Modal close button — sits in its own row at the top of the client,
@@ -413,25 +416,62 @@
413
416
  background: transparent;
414
417
  }
415
418
 
416
- /* Two-column body. Falls back to single column on narrow widths via
417
- a Switcher-style flex-wrap trick: flex-basis with a calc() that
418
- flips when the container drops below the threshold. */
419
+ /* Body grid with named areas so the three slots form / live response /
420
+ code samples can be placed AND reordered independently.
421
+ Wide (the client is its own query container, set on .velu-api-client):
422
+ form fills the left column; response sits top-right with the samples
423
+ below it. When there's no response yet, the samples take the whole right
424
+ column.
425
+ Narrow (single column): the live response moves ABOVE the form so a Send
426
+ result is visible without scrolling past the inputs; form then samples
427
+ follow. */
419
428
  .velu-api-client__body {
420
- display: flex;
421
- flex-wrap: wrap;
429
+ display: grid;
430
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
431
+ grid-template-areas: 'form aside';
432
+ align-items: start;
422
433
  gap: var(--s2);
423
434
  padding: var(--s2);
424
435
  }
425
- .velu-api-client__left,
436
+ .velu-api-client__body[data-has-response='true'] {
437
+ grid-template-areas:
438
+ 'form response'
439
+ 'form aside';
440
+ }
441
+ .velu-api-client__left {
442
+ grid-area: form;
443
+ }
444
+ .velu-api-client__response {
445
+ grid-area: response;
446
+ }
426
447
  .velu-api-client__right {
427
- flex-grow: 1;
428
- flex-basis: calc((40rem - 100%) * 999);
448
+ grid-area: aside;
449
+ }
450
+ .velu-api-client__left,
451
+ .velu-api-client__right,
452
+ .velu-api-client__response {
429
453
  display: flex;
430
454
  flex-direction: column;
431
455
  gap: var(--s1);
432
456
  min-inline-size: 0;
433
457
  }
434
458
 
459
+ @container api-client (max-width: 40rem) {
460
+ .velu-api-client__body,
461
+ .velu-api-client__body[data-has-response='true'] {
462
+ grid-template-columns: 1fr;
463
+ grid-template-areas:
464
+ 'form'
465
+ 'aside';
466
+ }
467
+ .velu-api-client__body[data-has-response='true'] {
468
+ grid-template-areas:
469
+ 'response'
470
+ 'form'
471
+ 'aside';
472
+ }
473
+ }
474
+
435
475
  .velu-api-client__title {
436
476
  margin: 0;
437
477
  font-size: var(--f-h3);