@veluai/velu 0.1.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 +11 -0
- package/package.json +52 -0
- package/runtime/velu-ui/base.css +311 -0
- package/runtime/velu-ui/components/Accordion.jsx +64 -0
- package/runtime/velu-ui/components/ApiClient.jsx +121 -0
- package/runtime/velu-ui/components/ApiField.jsx +87 -0
- package/runtime/velu-ui/components/ApiPath.jsx +63 -0
- package/runtime/velu-ui/components/ApiSidebar.jsx +122 -0
- package/runtime/velu-ui/components/AskBar.jsx +71 -0
- package/runtime/velu-ui/components/Callout.jsx +114 -0
- package/runtime/velu-ui/components/Card.jsx +131 -0
- package/runtime/velu-ui/components/Chatbot.jsx +596 -0
- package/runtime/velu-ui/components/CodeBlock.jsx +375 -0
- package/runtime/velu-ui/components/Columns.jsx +56 -0
- package/runtime/velu-ui/components/Field.jsx +81 -0
- package/runtime/velu-ui/components/Image.jsx +163 -0
- package/runtime/velu-ui/components/MethodBadge.jsx +31 -0
- package/runtime/velu-ui/components/NavSelect.jsx +108 -0
- package/runtime/velu-ui/components/PageFeedback.jsx +219 -0
- package/runtime/velu-ui/components/PageFooter.jsx +213 -0
- package/runtime/velu-ui/components/PageHeader.jsx +414 -0
- package/runtime/velu-ui/components/PageNav.jsx +77 -0
- package/runtime/velu-ui/components/PoweredBy.jsx +51 -0
- package/runtime/velu-ui/components/Prompt.jsx +115 -0
- package/runtime/velu-ui/components/Search.jsx +366 -0
- package/runtime/velu-ui/components/Sidebar.jsx +191 -0
- package/runtime/velu-ui/components/Steps.jsx +65 -0
- package/runtime/velu-ui/components/ThemeToggle.jsx +48 -0
- package/runtime/velu-ui/components/Toc.jsx +537 -0
- package/runtime/velu-ui/components/TocBar.jsx +195 -0
- package/runtime/velu-ui/components/Tree.jsx +87 -0
- package/runtime/velu-ui/components/TryItBar.jsx +90 -0
- package/runtime/velu-ui/components/accordion.css +92 -0
- package/runtime/velu-ui/components/api.css +479 -0
- package/runtime/velu-ui/components/ask-bar.css +94 -0
- package/runtime/velu-ui/components/card.css +105 -0
- package/runtime/velu-ui/components/chatbot.css +617 -0
- package/runtime/velu-ui/components/code-block.css +263 -0
- package/runtime/velu-ui/components/docs-layout.css +775 -0
- package/runtime/velu-ui/components/field.css +82 -0
- package/runtime/velu-ui/components/image.css +237 -0
- package/runtime/velu-ui/components/nav-select.css +157 -0
- package/runtime/velu-ui/components/page-feedback.css +241 -0
- package/runtime/velu-ui/components/page-footer.css +130 -0
- package/runtime/velu-ui/components/page-header.css +520 -0
- package/runtime/velu-ui/components/page-nav.css +50 -0
- package/runtime/velu-ui/components/powered-by.css +66 -0
- package/runtime/velu-ui/components/prompt.css +99 -0
- package/runtime/velu-ui/components/search.css +307 -0
- package/runtime/velu-ui/components/sidebar.css +144 -0
- package/runtime/velu-ui/components/steps.css +77 -0
- package/runtime/velu-ui/components/theme-toggle.css +70 -0
- package/runtime/velu-ui/components/toc-bar.css +234 -0
- package/runtime/velu-ui/components/tree.css +49 -0
- package/runtime/velu-ui/index.js +45 -0
- package/runtime/velu-ui/lib/copyText.js +64 -0
- package/runtime/velu-ui/lib/lang-icons.jsx +156 -0
- package/runtime/velu-ui/lib/prism-langs.js +957 -0
- package/runtime/velu-ui/lib/prism-loader.js +74 -0
- package/runtime/velu-ui/lib/resolveIcon.jsx +29 -0
- package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -0
- package/runtime/velu-ui/mdx-components.jsx +85 -0
- package/runtime/velu-ui/primitives/Cluster.jsx +49 -0
- package/runtime/velu-ui/primitives/Stack.jsx +63 -0
- package/runtime/velu-ui/primitives/Switcher.jsx +57 -0
- package/runtime/velu-ui/primitives/stack.css +3 -0
- package/runtime/velu-ui/primitives/switcher.css +25 -0
- package/runtime/velu-ui/styles.css +43 -0
- package/runtime/velu-ui/tokens.css +4 -0
- package/schema/velu.schema.json +167 -0
- package/src/navigation.js +434 -0
- package/src/runtime/App.jsx +1473 -0
- package/src/runtime/client-entry.jsx +22 -0
- package/src/runtime/server-entry.jsx +16 -0
- package/src/template.html +48 -0
- package/templates/starter/ai-tools/claude-code.mdx +26 -0
- package/templates/starter/ai-tools/cursor.mdx +17 -0
- package/templates/starter/api-reference/endpoint/create.mdx +24 -0
- package/templates/starter/api-reference/endpoint/get.mdx +27 -0
- package/templates/starter/api-reference/introduction.mdx +28 -0
- package/templates/starter/development.mdx +19 -0
- package/templates/starter/essentials/code.mdx +28 -0
- package/templates/starter/essentials/images.mdx +29 -0
- package/templates/starter/essentials/markdown.mdx +25 -0
- package/templates/starter/essentials/navigation.mdx +39 -0
- package/templates/starter/essentials/settings.mdx +30 -0
- package/templates/starter/favicon.svg +6 -0
- package/templates/starter/index.mdx +31 -0
- package/templates/starter/quickstart.mdx +31 -0
- package/templates/starter/velu.json +33 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import Cluster from '../primitives/Cluster.jsx';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ApiPath — monospace URL path renderer. The path is split into two
|
|
6
|
+
* kinds of segments and laid out via <Cluster>:
|
|
7
|
+
*
|
|
8
|
+
* - hardcoded segment the static chunks ("/project/preview/")
|
|
9
|
+
* - dynamic segment the {param} placeholders (rendered as
|
|
10
|
+
* method-tinted pills)
|
|
11
|
+
*
|
|
12
|
+
* <ApiPath method="POST" path="/project/preview/{projectId}" />
|
|
13
|
+
* → [ "/project/preview/" ] [ {projectId} ]
|
|
14
|
+
*
|
|
15
|
+
* Hardcoded chunks stay intact (never break mid-text), so narrow widths
|
|
16
|
+
* wrap between segments rather than mid-word. Dynamic pills wrap to a
|
|
17
|
+
* new line as a unit.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
|
|
21
|
+
|
|
22
|
+
export default function ApiPath({
|
|
23
|
+
method = 'GET',
|
|
24
|
+
path = '',
|
|
25
|
+
className = '',
|
|
26
|
+
...rest
|
|
27
|
+
}) {
|
|
28
|
+
const key = String(method).toLowerCase();
|
|
29
|
+
const safe = METHODS.includes(key) ? key : 'get';
|
|
30
|
+
|
|
31
|
+
// Split only on {param} groups — keep the hardcoded text between
|
|
32
|
+
// params as single segments so narrow-width wrapping breaks at the
|
|
33
|
+
// segment boundary, never mid-word.
|
|
34
|
+
const tokens = String(path).split(/(\{[^}]+\})/).filter(Boolean);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<Cluster
|
|
38
|
+
as="span"
|
|
39
|
+
space="var(--s-2)"
|
|
40
|
+
align="center"
|
|
41
|
+
className={`velu-api-path ${className}`.trim()}
|
|
42
|
+
{...rest}
|
|
43
|
+
>
|
|
44
|
+
{tokens.map((tok, i) => {
|
|
45
|
+
if (tok.startsWith('{') && tok.endsWith('}')) {
|
|
46
|
+
return (
|
|
47
|
+
<span
|
|
48
|
+
key={i}
|
|
49
|
+
className={`velu-api-path__param velu-api-path__param--${safe}`}
|
|
50
|
+
>
|
|
51
|
+
{tok}
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return (
|
|
56
|
+
<span key={i} className="velu-api-path__segment">
|
|
57
|
+
{tok}
|
|
58
|
+
</span>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</Cluster>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import Stack from '../primitives/Stack.jsx';
|
|
3
|
+
import resolveIcon from '../lib/resolveIcon.jsx';
|
|
4
|
+
import MethodBadge from './MethodBadge.jsx';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ApiSidebar — navigation for API reference pages. Like <Sidebar> but
|
|
8
|
+
* flat (no nested folders): every entry is a single endpoint shown as
|
|
9
|
+
* a method pill + name. Entries are organized into groups, each with
|
|
10
|
+
* an optional icon.
|
|
11
|
+
*
|
|
12
|
+
* <ApiSidebar
|
|
13
|
+
* activeHref="/api/trigger"
|
|
14
|
+
* linkComponent={RouterLink}
|
|
15
|
+
* sections={[
|
|
16
|
+
* {
|
|
17
|
+
* title: 'Admin',
|
|
18
|
+
* icon: 'rocket',
|
|
19
|
+
* endpoints: [
|
|
20
|
+
* { method: 'POST', label: 'Trigger', href: '/api/trigger' },
|
|
21
|
+
* { method: 'GET', label: 'Get deployment-status',
|
|
22
|
+
* href: '/api/status' },
|
|
23
|
+
* { method: 'DELETE', label: 'Trigger Preview deployment',
|
|
24
|
+
* href: '/api/preview' },
|
|
25
|
+
* ],
|
|
26
|
+
* },
|
|
27
|
+
* ]}
|
|
28
|
+
* />
|
|
29
|
+
*
|
|
30
|
+
* Vertical rhythm = the Stack primitive (DRY, same as <Sidebar>);
|
|
31
|
+
* api.css holds only the rail / active state / row layout / icon size.
|
|
32
|
+
* Selection is route-driven (`endpoint.href === activeHref`, or
|
|
33
|
+
* `endpoint.active: true`). Router-agnostic via `linkComponent`.
|
|
34
|
+
*
|
|
35
|
+
* @typedef {Object} ApiEndpoint
|
|
36
|
+
* @property {string} method HTTP method — drives the MethodBadge color
|
|
37
|
+
* @property {string} label endpoint display name
|
|
38
|
+
* @property {string} [href]
|
|
39
|
+
* @property {boolean} [active]
|
|
40
|
+
*
|
|
41
|
+
* @param {{ sections: { title?: string, icon?: string|React.ReactNode,
|
|
42
|
+
* endpoints: ApiEndpoint[] }[], activeHref?: string,
|
|
43
|
+
* linkComponent?: React.ElementType, className?: string }} props
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
const Ctx = React.createContext({ activeHref: undefined, Link: 'a' });
|
|
47
|
+
|
|
48
|
+
function Icon({ icon }) {
|
|
49
|
+
const node = resolveIcon(icon);
|
|
50
|
+
return node ? (
|
|
51
|
+
<span className="velu-api-sidebar__icon" aria-hidden="true">
|
|
52
|
+
{node}
|
|
53
|
+
</span>
|
|
54
|
+
) : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function Endpoint({ endpoint }) {
|
|
58
|
+
const { activeHref, Link } = React.useContext(Ctx);
|
|
59
|
+
const { method = 'GET', label, href = '#', active } = endpoint;
|
|
60
|
+
const isActive = Boolean(
|
|
61
|
+
active || (href != null && href === activeHref),
|
|
62
|
+
);
|
|
63
|
+
const cls = `velu-api-sidebar__item${
|
|
64
|
+
isActive ? ' velu-api-sidebar__item--active' : ''
|
|
65
|
+
}`;
|
|
66
|
+
return (
|
|
67
|
+
<li>
|
|
68
|
+
<Link
|
|
69
|
+
className={cls}
|
|
70
|
+
href={href}
|
|
71
|
+
aria-current={isActive ? 'page' : undefined}
|
|
72
|
+
>
|
|
73
|
+
<MethodBadge method={method} />
|
|
74
|
+
<span className="velu-api-sidebar__label">{label}</span>
|
|
75
|
+
</Link>
|
|
76
|
+
</li>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default function ApiSidebar({
|
|
81
|
+
sections = [],
|
|
82
|
+
activeHref,
|
|
83
|
+
linkComponent = 'a',
|
|
84
|
+
className = '',
|
|
85
|
+
...rest
|
|
86
|
+
}) {
|
|
87
|
+
const ctx = React.useMemo(
|
|
88
|
+
() => ({ activeHref, Link: linkComponent }),
|
|
89
|
+
[activeHref, linkComponent],
|
|
90
|
+
);
|
|
91
|
+
return (
|
|
92
|
+
<Ctx.Provider value={ctx}>
|
|
93
|
+
<Stack
|
|
94
|
+
as="nav"
|
|
95
|
+
space="var(--s1)"
|
|
96
|
+
className={`velu-api-sidebar ${className}`.trim()}
|
|
97
|
+
aria-label="API reference"
|
|
98
|
+
{...rest}
|
|
99
|
+
>
|
|
100
|
+
{sections.map((section, i) => (
|
|
101
|
+
<React.Fragment key={i}>
|
|
102
|
+
{section.title != null && (
|
|
103
|
+
<h5 className="velu-api-sidebar__section">
|
|
104
|
+
<Icon icon={section.icon} />
|
|
105
|
+
{section.title}
|
|
106
|
+
</h5>
|
|
107
|
+
)}
|
|
108
|
+
<Stack
|
|
109
|
+
as="ul"
|
|
110
|
+
space="var(--s0)"
|
|
111
|
+
className="velu-api-sidebar__list"
|
|
112
|
+
>
|
|
113
|
+
{(section.endpoints ?? []).map((ep, j) => (
|
|
114
|
+
<Endpoint key={j} endpoint={ep} />
|
|
115
|
+
))}
|
|
116
|
+
</Stack>
|
|
117
|
+
</React.Fragment>
|
|
118
|
+
))}
|
|
119
|
+
</Stack>
|
|
120
|
+
</Ctx.Provider>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import resolveIcon from '../lib/resolveIcon.jsx';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AskBar — an "ask a question" input card.
|
|
6
|
+
*
|
|
7
|
+
* <AskBar onSubmit={(q) => …} />
|
|
8
|
+
*
|
|
9
|
+
* The component owns only the card visuals (border, glow-on-focus,
|
|
10
|
+
* multi-line input, circular send button). PLACEMENT is the consumer's
|
|
11
|
+
* job — the docs page pins it `position: absolute` to the bottom of
|
|
12
|
+
* the article column so content scrolls behind its opaque surface.
|
|
13
|
+
*
|
|
14
|
+
* - `placeholder` input placeholder (default "Ask a question…")
|
|
15
|
+
* - `onSubmit(value)` fired on the send button or Enter (Shift+Enter
|
|
16
|
+
* inserts a newline). Dummy-friendly — omit it and the bar is inert.
|
|
17
|
+
*
|
|
18
|
+
* The send affordance is a circular accent button with an up-arrow;
|
|
19
|
+
* the card border glows in the accent color while the input is focused.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export default function AskBar({
|
|
23
|
+
placeholder = 'Ask a question…',
|
|
24
|
+
onSubmit,
|
|
25
|
+
className = '',
|
|
26
|
+
...rest
|
|
27
|
+
}) {
|
|
28
|
+
const [value, setValue] = useState('');
|
|
29
|
+
|
|
30
|
+
function submit() {
|
|
31
|
+
const q = value.trim();
|
|
32
|
+
if (!q) return;
|
|
33
|
+
onSubmit?.(q);
|
|
34
|
+
setValue('');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function onKeyDown(e) {
|
|
38
|
+
// Enter sends; Shift+Enter inserts a newline.
|
|
39
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
submit();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Disable the send button until there's non-whitespace input. The
|
|
46
|
+
// CSS reads :disabled to render it muted + un-hoverable; React reads
|
|
47
|
+
// the attribute to short-circuit clicks too.
|
|
48
|
+
const isEmpty = value.trim().length === 0;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className={`velu-ask-bar ${className}`.trim()} {...rest}>
|
|
52
|
+
<textarea
|
|
53
|
+
className="velu-ask-bar__input"
|
|
54
|
+
placeholder={placeholder}
|
|
55
|
+
value={value}
|
|
56
|
+
onChange={(e) => setValue(e.target.value)}
|
|
57
|
+
onKeyDown={onKeyDown}
|
|
58
|
+
rows={2}
|
|
59
|
+
/>
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
className="velu-ask-bar__send"
|
|
63
|
+
onClick={submit}
|
|
64
|
+
disabled={isEmpty}
|
|
65
|
+
aria-label="Ask"
|
|
66
|
+
>
|
|
67
|
+
{resolveIcon('arrow-up', { size: '1em' })}
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import resolveIcon from '../lib/resolveIcon.jsx';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Callout — admonition box (note/warning/info/tip/check/danger/callout).
|
|
6
|
+
*
|
|
7
|
+
* Fully tokenized: each `type` maps to its Figma bg + stroke token pair
|
|
8
|
+
* (base.css, light + dark), so theming is automatic via [data-theme]. Border
|
|
9
|
+
* + icon use the stroke; surface uses the bg; body uses --text-color.
|
|
10
|
+
* Spacing/radius from the modular scale.
|
|
11
|
+
*
|
|
12
|
+
* Layout: the icon is FLOATED into the text flow (not a fixed column) — it
|
|
13
|
+
* sits on the first line and the text wraps beside it, then subsequent/wrapped
|
|
14
|
+
* lines flow back UNDER the icon (no hanging indent). `display: flow-root`
|
|
15
|
+
* contains the float cleanly (no clipping, no clearfix hack).
|
|
16
|
+
*
|
|
17
|
+
* Content-driven & extensible: `icon` is a **lucide icon id string**
|
|
18
|
+
* (kebab-case), resolved by the shared resolveIcon util (also accepts a
|
|
19
|
+
* node). `stroke` / `bg` override the variant colors for custom callouts.
|
|
20
|
+
*
|
|
21
|
+
* SSR-safe & presentational (no effects/refs).
|
|
22
|
+
*
|
|
23
|
+
* @param {{ type?: 'note'|'warning'|'info'|'tip'|'check'|'danger'|'callout',
|
|
24
|
+
* icon?: string|React.ReactNode, stroke?: string, bg?: string,
|
|
25
|
+
* children: React.ReactNode, className?: string }} props
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Variant icon defaults are lucide IDS (not components) → one resolution path.
|
|
29
|
+
const VARIANTS = {
|
|
30
|
+
note: { bg: '--note-bg-color', stroke: '--note-stroke-color', icon: 'pencil' },
|
|
31
|
+
warning: {
|
|
32
|
+
bg: '--warning-bg-color',
|
|
33
|
+
stroke: '--warning-stroke-color',
|
|
34
|
+
icon: 'triangle-alert',
|
|
35
|
+
},
|
|
36
|
+
info: { bg: '--info-bg-color', stroke: '--info-stroke-color', icon: 'info' },
|
|
37
|
+
tip: {
|
|
38
|
+
bg: '--tip-bg-color',
|
|
39
|
+
stroke: '--tip-stroke-color',
|
|
40
|
+
icon: 'lightbulb',
|
|
41
|
+
},
|
|
42
|
+
check: {
|
|
43
|
+
bg: '--check-bg-color',
|
|
44
|
+
stroke: '--check-stroke-color',
|
|
45
|
+
icon: 'circle-check',
|
|
46
|
+
},
|
|
47
|
+
danger: {
|
|
48
|
+
bg: '--danger-bg-color',
|
|
49
|
+
stroke: '--danger-stroke-color',
|
|
50
|
+
icon: 'shield-alert',
|
|
51
|
+
},
|
|
52
|
+
callout: {
|
|
53
|
+
bg: '--callout-bg-color',
|
|
54
|
+
stroke: '--callout-stroke-color',
|
|
55
|
+
icon: 'message-circle',
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export default function Callout({
|
|
60
|
+
type = 'note',
|
|
61
|
+
icon,
|
|
62
|
+
stroke,
|
|
63
|
+
bg,
|
|
64
|
+
children,
|
|
65
|
+
className = '',
|
|
66
|
+
style,
|
|
67
|
+
...rest
|
|
68
|
+
}) {
|
|
69
|
+
const v = VARIANTS[type] || VARIANTS.note;
|
|
70
|
+
const strokeColor = stroke ?? `var(${v.stroke})`;
|
|
71
|
+
const bgColor = bg ?? `var(${v.bg})`;
|
|
72
|
+
// size only; stroke weight comes from the global --icon-stroke token
|
|
73
|
+
// (.lucide rule in base.css), not set per-icon.
|
|
74
|
+
const iconNode = resolveIcon(icon ?? v.icon, { size: '1em' });
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
className={className}
|
|
79
|
+
style={{
|
|
80
|
+
display: 'flow-root', // contains the floated icon, no clipping
|
|
81
|
+
padding: 'var(--s0) var(--s3)',
|
|
82
|
+
background: bgColor,
|
|
83
|
+
border: `var(--border-width) solid ${strokeColor}`,
|
|
84
|
+
borderRadius: 'var(--radius-sm)',
|
|
85
|
+
color: 'var(--text-color)',
|
|
86
|
+
fontSize: 'var(--f-body)',
|
|
87
|
+
lineHeight: 'var(--lh-body)',
|
|
88
|
+
...style,
|
|
89
|
+
}}
|
|
90
|
+
{...rest}
|
|
91
|
+
>
|
|
92
|
+
{iconNode && (
|
|
93
|
+
<span
|
|
94
|
+
aria-hidden="true"
|
|
95
|
+
style={{
|
|
96
|
+
float: 'left',
|
|
97
|
+
display: 'inline-flex',
|
|
98
|
+
alignItems: 'center',
|
|
99
|
+
// one line-box tall (body font-size × body line-height token) so
|
|
100
|
+
// the icon is centered on the FIRST line; text wraps beside it
|
|
101
|
+
// then flows back under it (no hanging indent).
|
|
102
|
+
blockSize: 'calc(var(--f-body) * var(--lh-body))',
|
|
103
|
+
marginInlineEnd: 'var(--s-1)',
|
|
104
|
+
color: strokeColor,
|
|
105
|
+
fontSize: 'var(--f-body)',
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
{iconNode}
|
|
109
|
+
</span>
|
|
110
|
+
)}
|
|
111
|
+
{children}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import resolveIcon from '../lib/resolveIcon.jsx';
|
|
3
|
+
import Switcher from '../primitives/Switcher.jsx';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Card + CardGroup — content / horizontal / image / CTA cards.
|
|
7
|
+
*
|
|
8
|
+
* The card itself is NEVER a link and has NO hover state. The only
|
|
9
|
+
* interactive element is the CTA: pass `cta={{ label, href }}` to render a
|
|
10
|
+
* single "Click Here ›" link. Everything else is static presentation.
|
|
11
|
+
*
|
|
12
|
+
* Fully tokenized (border-width, border-color, radius scale, spacing, type,
|
|
13
|
+
* line-height, accent, icon-stroke) → light/dark automatic via [data-theme].
|
|
14
|
+
* SSR-safe & presentational.
|
|
15
|
+
*
|
|
16
|
+
* Variants (combinable):
|
|
17
|
+
* - basic: icon (accent, --f-h2) + title (--f-h4) + body
|
|
18
|
+
* - `horizontal`: icon vertically centered on the left, title+body right
|
|
19
|
+
* - `img`: image fills the top, clipped to the radius
|
|
20
|
+
* - `cta`: { label, href } → the single clickable "Click Here ›" link
|
|
21
|
+
*
|
|
22
|
+
* `icon` is a lucide id string (shared resolveIcon).
|
|
23
|
+
*
|
|
24
|
+
* @param {{ title?: React.ReactNode, icon?: string, horizontal?: boolean,
|
|
25
|
+
* img?: string, imgAlt?: string,
|
|
26
|
+
* cta?: {label: React.ReactNode, href: string},
|
|
27
|
+
* children?: React.ReactNode, className?: string }} props
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export function Card({
|
|
31
|
+
title,
|
|
32
|
+
icon,
|
|
33
|
+
horizontal = false,
|
|
34
|
+
img,
|
|
35
|
+
imgAlt = '',
|
|
36
|
+
cta,
|
|
37
|
+
children,
|
|
38
|
+
className = '',
|
|
39
|
+
...rest
|
|
40
|
+
}) {
|
|
41
|
+
const cls = [
|
|
42
|
+
'velu-card',
|
|
43
|
+
horizontal && 'velu-card--horizontal',
|
|
44
|
+
img && 'velu-card--image',
|
|
45
|
+
cta && 'velu-card--cta',
|
|
46
|
+
className,
|
|
47
|
+
]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join(' ');
|
|
50
|
+
|
|
51
|
+
const iconEl = icon ? (
|
|
52
|
+
<span className="velu-card__icon" aria-hidden="true">
|
|
53
|
+
{resolveIcon(icon, { size: '1em' })}
|
|
54
|
+
</span>
|
|
55
|
+
) : null;
|
|
56
|
+
|
|
57
|
+
const ctaEl = cta ? (
|
|
58
|
+
<a className="velu-card__cta" href={cta.href}>
|
|
59
|
+
{cta.label}
|
|
60
|
+
<span className="velu-card__cta-icon" aria-hidden="true">
|
|
61
|
+
{resolveIcon('chevron-right', { size: '1em' })}
|
|
62
|
+
</span>
|
|
63
|
+
</a>
|
|
64
|
+
) : null;
|
|
65
|
+
|
|
66
|
+
const titleEl = title ? (
|
|
67
|
+
<div className="velu-card__title">{title}</div>
|
|
68
|
+
) : null;
|
|
69
|
+
const textEl = children ? (
|
|
70
|
+
<div className="velu-card__text">{children}</div>
|
|
71
|
+
) : null;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className={cls} {...rest}>
|
|
75
|
+
{img && (
|
|
76
|
+
<div className="velu-card__media">
|
|
77
|
+
<img src={img} alt={imgAlt} loading="lazy" />
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
|
|
81
|
+
<div className="velu-card__body">
|
|
82
|
+
{horizontal ? (
|
|
83
|
+
<>
|
|
84
|
+
{iconEl}
|
|
85
|
+
<div className="velu-card__content">
|
|
86
|
+
{titleEl}
|
|
87
|
+
{textEl}
|
|
88
|
+
{ctaEl}
|
|
89
|
+
</div>
|
|
90
|
+
</>
|
|
91
|
+
) : (
|
|
92
|
+
<>
|
|
93
|
+
{!img ? iconEl : null}
|
|
94
|
+
{titleEl}
|
|
95
|
+
{textEl}
|
|
96
|
+
{ctaEl}
|
|
97
|
+
</>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* CardGroup — a row of Cards that switches to a FULL vertical stack the
|
|
106
|
+
* moment any one card would fall below the per-card minimum. Thin wrapper
|
|
107
|
+
* over the Switcher primitive: just supplies `min` (Switcher does the
|
|
108
|
+
* count-aware threshold math). `limit` hard-caps items per row.
|
|
109
|
+
*/
|
|
110
|
+
export function CardGroup({
|
|
111
|
+
space = 'var(--s0)',
|
|
112
|
+
min = 'var(--card-min, 18ch)',
|
|
113
|
+
limit = 3,
|
|
114
|
+
children,
|
|
115
|
+
className = '',
|
|
116
|
+
...rest
|
|
117
|
+
}) {
|
|
118
|
+
return (
|
|
119
|
+
<Switcher
|
|
120
|
+
className={`velu-card-group ${className}`.trim()}
|
|
121
|
+
space={space}
|
|
122
|
+
min={min}
|
|
123
|
+
limit={limit}
|
|
124
|
+
{...rest}
|
|
125
|
+
>
|
|
126
|
+
{children}
|
|
127
|
+
</Switcher>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default Card;
|