@veluai/velu 0.1.16 → 0.2.0
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 +45 -39
- package/package.json +8 -3
- package/runtime/velu-ui/components/ApiClient.jsx +13 -3
- package/runtime/velu-ui/components/ApiReferencePage.jsx +9 -13
- package/runtime/velu-ui/components/ContextMenu.jsx +272 -0
- package/runtime/velu-ui/components/NotFound.jsx +63 -0
- package/runtime/velu-ui/components/api-page.css +0 -7
- package/runtime/velu-ui/components/api.css +48 -8
- package/runtime/velu-ui/components/context-menu.css +170 -0
- package/runtime/velu-ui/components/docs-layout.css +18 -0
- package/runtime/velu-ui/components/not-found.css +94 -0
- package/runtime/velu-ui/components/powered-by.css +6 -0
- package/runtime/velu-ui/index.js +2 -0
- package/runtime/velu-ui/lib/brand-icons.jsx +102 -0
- package/runtime/velu-ui/styles.css +2 -0
- package/schema/velu.schema.json +112 -37
- package/src/runtime/App.jsx +62 -0
- package/templates/starter/velu.json +4 -0
|
@@ -0,0 +1,272 @@
|
|
|
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
|
+
>
|
|
207
|
+
{eyebrow ? <span className="velu-context-bar__eyebrow">{eyebrow}</span> : <span />}
|
|
208
|
+
|
|
209
|
+
{items.length > 0 && (
|
|
210
|
+
<div ref={rootRef} className="velu-context-menu" data-open={open ? 'true' : 'false'}>
|
|
211
|
+
<div className="velu-context-menu__split">
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
className="velu-context-menu__copy"
|
|
215
|
+
onClick={copyPage}
|
|
216
|
+
aria-label="Copy page as Markdown"
|
|
217
|
+
>
|
|
218
|
+
<span className="velu-context-menu__copy-icon" aria-hidden="true">
|
|
219
|
+
{copied ? <Check size="1em" /> : <Copy size="1em" />}
|
|
220
|
+
</span>
|
|
221
|
+
<span>{copied ? 'Copied' : 'Copy Page'}</span>
|
|
222
|
+
</button>
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
className="velu-context-menu__toggle"
|
|
226
|
+
aria-haspopup="menu"
|
|
227
|
+
aria-expanded={open}
|
|
228
|
+
aria-label="More actions"
|
|
229
|
+
onClick={() => setOpen((v) => !v)}
|
|
230
|
+
>
|
|
231
|
+
<ChevronDown size="1em" aria-hidden="true" focusable="false" />
|
|
232
|
+
</button>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<ul className="velu-context-menu__menu" role="menu" aria-hidden={!open}>
|
|
236
|
+
{items.map((it, i) => {
|
|
237
|
+
const Icon = it.icon;
|
|
238
|
+
return (
|
|
239
|
+
<li key={it.key ?? it.href ?? i} role="none">
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
role="menuitem"
|
|
243
|
+
className="velu-context-menu__item"
|
|
244
|
+
tabIndex={open ? 0 : -1}
|
|
245
|
+
onClick={() => run(it)}
|
|
246
|
+
>
|
|
247
|
+
<span className="velu-context-menu__item-icon" aria-hidden="true">
|
|
248
|
+
<Icon size="1.1em" />
|
|
249
|
+
</span>
|
|
250
|
+
<span className="velu-context-menu__item-text">
|
|
251
|
+
<span className="velu-context-menu__item-title">
|
|
252
|
+
{it.label}
|
|
253
|
+
{it.external && (
|
|
254
|
+
<ArrowUpRight
|
|
255
|
+
className="velu-context-menu__item-ext"
|
|
256
|
+
size="0.85em"
|
|
257
|
+
aria-hidden="true"
|
|
258
|
+
/>
|
|
259
|
+
)}
|
|
260
|
+
</span>
|
|
261
|
+
{it.desc && <span className="velu-context-menu__item-desc">{it.desc}</span>}
|
|
262
|
+
</span>
|
|
263
|
+
</button>
|
|
264
|
+
</li>
|
|
265
|
+
);
|
|
266
|
+
})}
|
|
267
|
+
</ul>
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
</Cluster>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
/*
|
|
417
|
-
|
|
418
|
-
|
|
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:
|
|
421
|
-
|
|
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-
|
|
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
|
-
|
|
428
|
-
|
|
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);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/* ContextMenu — the per-page agent/IDE action bar (eyebrow + Copy Page
|
|
2
|
+
split-button + dropdown). Mirrors NavSelect's dropdown recipe; all values are
|
|
3
|
+
tokens, light/dark via [data-theme]. */
|
|
4
|
+
|
|
5
|
+
.velu-context-bar {
|
|
6
|
+
margin-block-end: var(--s0);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* Section/group label, top-left — small, uppercase, accent (matches the
|
|
10
|
+
crimson eyebrow in the reference). */
|
|
11
|
+
.velu-context-bar__eyebrow {
|
|
12
|
+
min-inline-size: 0;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
text-overflow: ellipsis;
|
|
15
|
+
white-space: nowrap;
|
|
16
|
+
font-size: var(--f-h6);
|
|
17
|
+
font-weight: 600;
|
|
18
|
+
letter-spacing: 0.06em;
|
|
19
|
+
text-transform: uppercase;
|
|
20
|
+
color: var(--accent-color);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.velu-context-menu {
|
|
24
|
+
position: relative;
|
|
25
|
+
flex: none;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* The split button: "Copy Page" + a divider + a chevron toggle, joined as one
|
|
29
|
+
segmented control. */
|
|
30
|
+
.velu-context-menu__split {
|
|
31
|
+
display: inline-flex;
|
|
32
|
+
align-items: stretch;
|
|
33
|
+
border: var(--border-width) solid var(--border-color);
|
|
34
|
+
border-radius: var(--radius-sm);
|
|
35
|
+
background: var(--page-bg);
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
transition: border-color 0.12s ease;
|
|
38
|
+
}
|
|
39
|
+
.velu-context-menu__split:hover {
|
|
40
|
+
border-color: var(--accent-color);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.velu-context-menu__copy,
|
|
44
|
+
.velu-context-menu__toggle {
|
|
45
|
+
display: inline-flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
gap: var(--s-3);
|
|
48
|
+
background: transparent;
|
|
49
|
+
border: 0;
|
|
50
|
+
font: inherit;
|
|
51
|
+
font-size: var(--f-h6);
|
|
52
|
+
color: var(--text-color);
|
|
53
|
+
cursor: pointer;
|
|
54
|
+
transition: background 0.12s ease, color 0.12s ease;
|
|
55
|
+
}
|
|
56
|
+
.velu-context-menu__copy {
|
|
57
|
+
padding-block: var(--s-3);
|
|
58
|
+
padding-inline: var(--s-1);
|
|
59
|
+
font-weight: 500;
|
|
60
|
+
}
|
|
61
|
+
.velu-context-menu__toggle {
|
|
62
|
+
padding-inline: var(--s-3);
|
|
63
|
+
border-inline-start: var(--border-width) solid var(--border-color);
|
|
64
|
+
}
|
|
65
|
+
.velu-context-menu__copy:hover,
|
|
66
|
+
.velu-context-menu__toggle:hover {
|
|
67
|
+
background: var(--surface-color);
|
|
68
|
+
color: var(--accent-color);
|
|
69
|
+
}
|
|
70
|
+
.velu-context-menu__copy-icon {
|
|
71
|
+
display: inline-flex;
|
|
72
|
+
flex: none;
|
|
73
|
+
}
|
|
74
|
+
.velu-context-menu__toggle svg {
|
|
75
|
+
transition: transform 0.18s ease;
|
|
76
|
+
}
|
|
77
|
+
.velu-context-menu[data-open='true'] .velu-context-menu__toggle svg {
|
|
78
|
+
transform: rotate(180deg);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* Menu — anchored to the trailing edge below the button; fades + slides in.
|
|
82
|
+
Always in DOM so the transition runs both ways; visibility delayed so the
|
|
83
|
+
closed menu isn't focusable. */
|
|
84
|
+
.velu-context-menu__menu {
|
|
85
|
+
position: absolute;
|
|
86
|
+
inset-block-start: calc(100% + var(--s-3));
|
|
87
|
+
inset-inline-end: 0;
|
|
88
|
+
min-inline-size: 17rem;
|
|
89
|
+
margin: 0;
|
|
90
|
+
padding-block: var(--s-3);
|
|
91
|
+
padding-inline: 0.5rem; /* 8px */
|
|
92
|
+
list-style: none;
|
|
93
|
+
background: var(--page-bg);
|
|
94
|
+
border: var(--border-width) solid var(--border-color);
|
|
95
|
+
border-radius: 0.75rem; /* 12px */
|
|
96
|
+
box-shadow: 0 var(--s-1) var(--s2) color-mix(in srgb, #000 14%, transparent);
|
|
97
|
+
opacity: 0;
|
|
98
|
+
visibility: hidden;
|
|
99
|
+
transform: translateY(-0.25rem);
|
|
100
|
+
pointer-events: none;
|
|
101
|
+
transition:
|
|
102
|
+
opacity 0.15s ease,
|
|
103
|
+
transform 0.15s ease,
|
|
104
|
+
visibility 0s linear 0.15s;
|
|
105
|
+
z-index: 40;
|
|
106
|
+
}
|
|
107
|
+
.velu-context-menu[data-open='true'] .velu-context-menu__menu {
|
|
108
|
+
opacity: 1;
|
|
109
|
+
visibility: visible;
|
|
110
|
+
transform: translateY(0);
|
|
111
|
+
pointer-events: auto;
|
|
112
|
+
transition:
|
|
113
|
+
opacity 0.15s ease,
|
|
114
|
+
transform 0.15s ease,
|
|
115
|
+
visibility 0s;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.velu-context-menu__item {
|
|
119
|
+
display: flex;
|
|
120
|
+
align-items: flex-start;
|
|
121
|
+
gap: var(--s-2);
|
|
122
|
+
inline-size: 100%;
|
|
123
|
+
padding-block: var(--s-3);
|
|
124
|
+
padding-inline: var(--s-3);
|
|
125
|
+
border-radius: var(--radius-sm);
|
|
126
|
+
background: transparent;
|
|
127
|
+
border: 0;
|
|
128
|
+
font: inherit;
|
|
129
|
+
text-align: start;
|
|
130
|
+
color: var(--text-color);
|
|
131
|
+
cursor: pointer;
|
|
132
|
+
transition: background 0.12s ease;
|
|
133
|
+
}
|
|
134
|
+
.velu-context-menu__item:hover {
|
|
135
|
+
background: var(--surface-color);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.velu-context-menu__item-icon {
|
|
139
|
+
display: inline-flex;
|
|
140
|
+
flex: none;
|
|
141
|
+
margin-block-start: 0.1em;
|
|
142
|
+
color: var(--muted-color);
|
|
143
|
+
}
|
|
144
|
+
.velu-context-menu__item:hover .velu-context-menu__item-icon {
|
|
145
|
+
color: var(--accent-color);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.velu-context-menu__item-text {
|
|
149
|
+
display: flex;
|
|
150
|
+
flex-direction: column;
|
|
151
|
+
gap: 0.1em;
|
|
152
|
+
min-inline-size: 0;
|
|
153
|
+
}
|
|
154
|
+
.velu-context-menu__item-title {
|
|
155
|
+
display: inline-flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
gap: var(--s-4);
|
|
158
|
+
font-size: var(--f-h6);
|
|
159
|
+
font-weight: 500;
|
|
160
|
+
color: var(--text-color);
|
|
161
|
+
}
|
|
162
|
+
.velu-context-menu__item-ext {
|
|
163
|
+
flex: none;
|
|
164
|
+
color: var(--muted-color);
|
|
165
|
+
}
|
|
166
|
+
.velu-context-menu__item-desc {
|
|
167
|
+
font-size: var(--f-h6);
|
|
168
|
+
line-height: var(--lh-tight, 1.3);
|
|
169
|
+
color: var(--muted-color);
|
|
170
|
+
}
|
|
@@ -782,3 +782,21 @@
|
|
|
782
782
|
display: block;
|
|
783
783
|
}
|
|
784
784
|
}
|
|
785
|
+
|
|
786
|
+
/* ── 404 takeover ─────────────────────────────────────────────────────── */
|
|
787
|
+
/* On a not-found route the docs chrome (both fixed asides) is hidden and the
|
|
788
|
+
centre column spans full width to center the <NotFound> content. The header
|
|
789
|
+
and (configured) footer stay. */
|
|
790
|
+
.velu-docs-layout[data-not-found='true'] .velu-docs-layout__aside {
|
|
791
|
+
display: none;
|
|
792
|
+
}
|
|
793
|
+
.velu-docs-layout[data-not-found='true'] .velu-docs-layout__center {
|
|
794
|
+
margin-inline: 0;
|
|
795
|
+
}
|
|
796
|
+
.velu-404-main {
|
|
797
|
+
display: flex;
|
|
798
|
+
align-items: center;
|
|
799
|
+
justify-content: center;
|
|
800
|
+
min-block-size: 70vh;
|
|
801
|
+
padding: var(--s4) var(--s2);
|
|
802
|
+
}
|