lucent-ui 0.25.1 → 0.27.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/index.cjs +55 -55
- package/dist/index.d.ts +55 -6
- package/dist/index.js +2078 -2058
- package/dist-cli/cli/mapper.js +2 -3
- package/dist-server/server/index.js +112 -3
- package/dist-server/server/recipe-registry.js +16 -0
- package/dist-server/src/components/atoms/Divider/Divider.manifest.js +1 -1
- package/dist-server/src/components/atoms/Table/Table.manifest.js +3 -3
- package/dist-server/src/components/molecules/Card/Card.manifest.js +1 -1
- package/dist-server/src/components/molecules/Menu/Menu.manifest.js +1 -1
- package/dist-server/src/components/molecules/PageLayout/PageLayout.manifest.js +2 -1
- package/dist-server/src/manifest/recipes/action-bar.recipe.js +91 -0
- package/dist-server/src/manifest/recipes/collapsible-card.recipe.js +100 -0
- package/dist-server/src/manifest/recipes/empty-state-card.recipe.js +72 -0
- package/dist-server/src/manifest/recipes/form-layout.recipe.js +98 -0
- package/dist-server/src/manifest/recipes/index.js +7 -0
- package/dist-server/src/manifest/recipes/profile-card.recipe.js +101 -0
- package/dist-server/src/manifest/recipes/settings-panel.recipe.js +167 -0
- package/dist-server/src/manifest/recipes/stats-row.recipe.js +106 -0
- package/package.json +1 -1
package/dist-cli/cli/mapper.js
CHANGED
|
@@ -9,13 +9,12 @@ const LUCENT_TOKEN_KEYS = new Set([
|
|
|
9
9
|
'bgBase', 'bgSubtle', 'bgOverlay',
|
|
10
10
|
'surface', 'surfaceSecondary', 'surfaceRaised', 'surfaceOverlay',
|
|
11
11
|
'borderDefault', 'borderSubtle', 'borderStrong',
|
|
12
|
-
'textPrimary', 'textSecondary', 'textDisabled', 'textInverse',
|
|
13
|
-
'accentDefault', 'accentHover', '
|
|
12
|
+
'textPrimary', 'textSecondary', 'textDisabled', 'textInverse',
|
|
13
|
+
'accentDefault', 'accentHover', 'accentSubtle', 'accentBorder', 'accentFg',
|
|
14
14
|
'successDefault', 'successSubtle', 'successText',
|
|
15
15
|
'warningDefault', 'warningSubtle', 'warningText',
|
|
16
16
|
'dangerDefault', 'dangerHover', 'dangerSubtle', 'dangerText',
|
|
17
17
|
'infoDefault', 'infoSubtle', 'infoText',
|
|
18
|
-
'focusRing',
|
|
19
18
|
// TypographyTokens
|
|
20
19
|
'fontFamilyBase', 'fontFamilyMono', 'fontFamilyDisplay',
|
|
21
20
|
'fontSizeXs', 'fontSizeSm', 'fontSizeMd', 'fontSizeLg',
|
|
@@ -3,6 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { ALL_MANIFESTS } from './registry.js';
|
|
6
|
+
import { ALL_RECIPES } from './recipe-registry.js';
|
|
6
7
|
import { PALETTES, SHAPES, DENSITIES, SHADOWS, COMBINED, generatePresetConfig } from './presets.js';
|
|
7
8
|
// ─── Auth stub ───────────────────────────────────────────────────────────────
|
|
8
9
|
// LUCENT_API_KEY is reserved for the future paid tier.
|
|
@@ -37,6 +38,29 @@ function scoreManifest(m, query) {
|
|
|
37
38
|
}
|
|
38
39
|
return score;
|
|
39
40
|
}
|
|
41
|
+
function findRecipe(nameOrId) {
|
|
42
|
+
const q = nameOrId.trim().toLowerCase();
|
|
43
|
+
return ALL_RECIPES.find((r) => r.id.toLowerCase() === q || r.name.toLowerCase() === q);
|
|
44
|
+
}
|
|
45
|
+
function scoreRecipe(r, query) {
|
|
46
|
+
const q = query.toLowerCase();
|
|
47
|
+
let score = 0;
|
|
48
|
+
if (r.name.toLowerCase().includes(q))
|
|
49
|
+
score += 10;
|
|
50
|
+
if (r.id.toLowerCase().includes(q))
|
|
51
|
+
score += 8;
|
|
52
|
+
if (r.category.toLowerCase().includes(q))
|
|
53
|
+
score += 5;
|
|
54
|
+
if (r.description.toLowerCase().includes(q))
|
|
55
|
+
score += 4;
|
|
56
|
+
if (r.designNotes.toLowerCase().includes(q))
|
|
57
|
+
score += 3;
|
|
58
|
+
for (const c of r.components) {
|
|
59
|
+
if (c.toLowerCase().includes(q))
|
|
60
|
+
score += 2;
|
|
61
|
+
}
|
|
62
|
+
return score;
|
|
63
|
+
}
|
|
40
64
|
// ─── MCP Server ───────────────────────────────────────────────────────────────
|
|
41
65
|
const server = new McpServer({
|
|
42
66
|
name: 'lucent-mcp',
|
|
@@ -86,8 +110,8 @@ server.tool('get_component_manifest', 'Returns the full manifest JSON for a Luce
|
|
|
86
110
|
};
|
|
87
111
|
});
|
|
88
112
|
// Tool: search_components
|
|
89
|
-
server.tool('search_components', 'Searches Lucent UI components by description or concept. Returns matching components ranked by relevance.', { query: z.string().describe('Natural language or keyword query, e.g. "loading indicator"
|
|
90
|
-
const
|
|
113
|
+
server.tool('search_components', 'Searches Lucent UI components and composition recipes by description or concept. Returns matching components and recipes ranked by relevance.', { query: z.string().describe('Natural language or keyword query, e.g. "loading indicator", "form validation", or "profile card"') }, async ({ query }) => {
|
|
114
|
+
const componentResults = ALL_MANIFESTS
|
|
91
115
|
.map((m) => ({ manifest: m, score: scoreManifest(m, query) }))
|
|
92
116
|
.filter(({ score }) => score > 0)
|
|
93
117
|
.sort((a, b) => b.score - a.score)
|
|
@@ -98,11 +122,96 @@ server.tool('search_components', 'Searches Lucent UI components by description o
|
|
|
98
122
|
description: manifest.description,
|
|
99
123
|
score,
|
|
100
124
|
}));
|
|
125
|
+
const recipeResults = ALL_RECIPES
|
|
126
|
+
.map((r) => ({ recipe: r, score: scoreRecipe(r, query) }))
|
|
127
|
+
.filter(({ score }) => score > 0)
|
|
128
|
+
.sort((a, b) => b.score - a.score)
|
|
129
|
+
.map(({ recipe, score }) => ({
|
|
130
|
+
id: recipe.id,
|
|
131
|
+
name: recipe.name,
|
|
132
|
+
category: recipe.category,
|
|
133
|
+
description: recipe.description,
|
|
134
|
+
score,
|
|
135
|
+
}));
|
|
101
136
|
return {
|
|
102
137
|
content: [
|
|
103
138
|
{
|
|
104
139
|
type: 'text',
|
|
105
|
-
text: JSON.stringify({ query,
|
|
140
|
+
text: JSON.stringify({ query, components: componentResults, recipes: recipeResults }, null, 2),
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
// Tool: get_composition_recipe
|
|
146
|
+
server.tool('get_composition_recipe', 'Returns a full composition recipe with structure tree, working JSX code, variants, and design notes. Query by recipe name/id or by category to get all recipes in that category.', {
|
|
147
|
+
name: z.string().optional().describe('Recipe name or id, e.g. "Profile Card" or "settings-panel"'),
|
|
148
|
+
category: z.string().optional().describe('Recipe category: "card", "form", "nav", "dashboard", "settings", or "action"'),
|
|
149
|
+
}, async ({ name, category }) => {
|
|
150
|
+
if (name) {
|
|
151
|
+
const recipe = findRecipe(name);
|
|
152
|
+
if (!recipe) {
|
|
153
|
+
return {
|
|
154
|
+
content: [
|
|
155
|
+
{
|
|
156
|
+
type: 'text',
|
|
157
|
+
text: JSON.stringify({
|
|
158
|
+
error: `Recipe "${name}" not found.`,
|
|
159
|
+
available: ALL_RECIPES.map((r) => ({ id: r.id, name: r.name, category: r.category })),
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
isError: true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
content: [
|
|
168
|
+
{
|
|
169
|
+
type: 'text',
|
|
170
|
+
text: JSON.stringify(recipe, null, 2),
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (category) {
|
|
176
|
+
const cat = category.trim().toLowerCase();
|
|
177
|
+
const recipes = ALL_RECIPES.filter((r) => r.category === cat);
|
|
178
|
+
if (recipes.length === 0) {
|
|
179
|
+
return {
|
|
180
|
+
content: [
|
|
181
|
+
{
|
|
182
|
+
type: 'text',
|
|
183
|
+
text: JSON.stringify({
|
|
184
|
+
error: `No recipes found in category "${category}".`,
|
|
185
|
+
availableCategories: [...new Set(ALL_RECIPES.map((r) => r.category))],
|
|
186
|
+
}),
|
|
187
|
+
},
|
|
188
|
+
],
|
|
189
|
+
isError: true,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
content: [
|
|
194
|
+
{
|
|
195
|
+
type: 'text',
|
|
196
|
+
text: JSON.stringify({ category: cat, recipes }, null, 2),
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// No filter — return all recipes
|
|
202
|
+
return {
|
|
203
|
+
content: [
|
|
204
|
+
{
|
|
205
|
+
type: 'text',
|
|
206
|
+
text: JSON.stringify({
|
|
207
|
+
recipes: ALL_RECIPES.map((r) => ({
|
|
208
|
+
id: r.id,
|
|
209
|
+
name: r.name,
|
|
210
|
+
category: r.category,
|
|
211
|
+
description: r.description,
|
|
212
|
+
components: r.components,
|
|
213
|
+
})),
|
|
214
|
+
}, null, 2),
|
|
106
215
|
},
|
|
107
216
|
],
|
|
108
217
|
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { RECIPE as ProfileCard } from '../src/manifest/recipes/profile-card.recipe.js';
|
|
2
|
+
import { RECIPE as SettingsPanel } from '../src/manifest/recipes/settings-panel.recipe.js';
|
|
3
|
+
import { RECIPE as StatsRow } from '../src/manifest/recipes/stats-row.recipe.js';
|
|
4
|
+
import { RECIPE as ActionBar } from '../src/manifest/recipes/action-bar.recipe.js';
|
|
5
|
+
import { RECIPE as FormLayout } from '../src/manifest/recipes/form-layout.recipe.js';
|
|
6
|
+
import { RECIPE as EmptyStateCard } from '../src/manifest/recipes/empty-state-card.recipe.js';
|
|
7
|
+
import { RECIPE as CollapsibleCard } from '../src/manifest/recipes/collapsible-card.recipe.js';
|
|
8
|
+
export const ALL_RECIPES = [
|
|
9
|
+
ProfileCard,
|
|
10
|
+
SettingsPanel,
|
|
11
|
+
StatsRow,
|
|
12
|
+
ActionBar,
|
|
13
|
+
FormLayout,
|
|
14
|
+
EmptyStateCard,
|
|
15
|
+
CollapsibleCard,
|
|
16
|
+
];
|
|
@@ -10,7 +10,7 @@ export const COMPONENT_MANIFEST = {
|
|
|
10
10
|
props: [
|
|
11
11
|
{ name: 'orientation', type: 'enum', required: false, default: 'horizontal', description: 'Direction of the divider line.', enumValues: ['horizontal', 'vertical'] },
|
|
12
12
|
{ name: 'label', type: 'string', required: false, description: 'Optional centered label (horizontal only). Common use: "OR", "AND", section titles.' },
|
|
13
|
-
{ name: 'spacing', type: 'string', required: false, default: '
|
|
13
|
+
{ name: 'spacing', type: 'string', required: false, default: '0', description: 'Margin on the axis perpendicular to the line. Defaults to 0 so parent gap-based layouts (Stack, Row) control spacing. Pass an explicit value like "var(--lucent-space-4)" for standalone use outside flex/grid containers.' },
|
|
14
14
|
],
|
|
15
15
|
usageExamples: [
|
|
16
16
|
{ title: 'Section separator', code: `<Divider />` },
|
|
@@ -17,13 +17,13 @@ export const COMPONENT_MANIFEST = {
|
|
|
17
17
|
type: 'boolean',
|
|
18
18
|
required: false,
|
|
19
19
|
default: 'false',
|
|
20
|
-
description: 'Applies alternating
|
|
20
|
+
description: 'Applies alternating tinted backgrounds to even tbody rows via color-mix(transparent).',
|
|
21
21
|
},
|
|
22
22
|
{
|
|
23
23
|
name: 'Table.Head',
|
|
24
24
|
type: 'component',
|
|
25
25
|
required: false,
|
|
26
|
-
description: 'Renders <thead> with
|
|
26
|
+
description: 'Renders <thead> with a subtle tinted background. Accepts Table.Row children.',
|
|
27
27
|
},
|
|
28
28
|
{
|
|
29
29
|
name: 'Table.Body',
|
|
@@ -35,7 +35,7 @@ export const COMPONENT_MANIFEST = {
|
|
|
35
35
|
name: 'Table.Foot',
|
|
36
36
|
type: 'component',
|
|
37
37
|
required: false,
|
|
38
|
-
description: 'Renders <tfoot> with
|
|
38
|
+
description: 'Renders <tfoot> with a subtle tinted background.',
|
|
39
39
|
},
|
|
40
40
|
{
|
|
41
41
|
name: 'Table.Row',
|
|
@@ -51,7 +51,7 @@ export const COMPONENT_MANIFEST = {
|
|
|
51
51
|
'- `ghost` and `outline` use `transparent` — they inherit from whatever they\'re placed on. ' +
|
|
52
52
|
'The border is the only visual differentiator for `outline`.\n' +
|
|
53
53
|
'- Never use `bgBase` or `bgSubtle` on a Card — those tokens are reserved for the page canvas.\n' +
|
|
54
|
-
'- Content nested inside a Card that needs a tinted fill should use `
|
|
54
|
+
'- Content nested inside a Card that needs a tinted fill should use `color-mix(in srgb, var(--lucent-text-primary) 5%, transparent)` for accent-neutral insets.',
|
|
55
55
|
props: [
|
|
56
56
|
{
|
|
57
57
|
name: 'variant',
|
|
@@ -39,7 +39,7 @@ export const COMPONENT_MANIFEST = {
|
|
|
39
39
|
'## Selected state\n' +
|
|
40
40
|
'MenuItem accepts a `selected` prop that renders a trailing accent-colored checkmark. The selected item ' +
|
|
41
41
|
'gets a `color-mix(in srgb, accent-default 12%, surface-overlay)` background with `shadow-sm` elevation, ' +
|
|
42
|
-
'making it visually stronger than the hover state (`
|
|
42
|
+
'making it visually stronger than the hover state (subtle `color-mix` tint). Uses `role="menuitemcheckbox"` ' +
|
|
43
43
|
'with `aria-checked` for accessibility.\n\n' +
|
|
44
44
|
'## Animation\n' +
|
|
45
45
|
'Both entrance and exit use a subtle scale + fade (`scale(0.97) ↔ 1`, `opacity 0 ↔ 1`) over 120ms. ' +
|
|
@@ -97,7 +97,8 @@ export const COMPONENT_MANIFEST = {
|
|
|
97
97
|
description: 'Background token for chrome regions (header, sidebar, footer). ' +
|
|
98
98
|
'"bgBase" uses the page canvas color so the main content card feels elevated; ' +
|
|
99
99
|
'"bgSubtle" uses a subtle shade of bgBase for chrome distinction; ' +
|
|
100
|
-
'"surface" matches the old behavior where chrome and content share the same background
|
|
100
|
+
'"surface" matches the old behavior where chrome and content share the same background; ' +
|
|
101
|
+
'"surfaceSecondary" uses the tinted fill token for a more visible chrome/stage separation.',
|
|
101
102
|
},
|
|
102
103
|
{
|
|
103
104
|
name: 'mainStyle',
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export const RECIPE = {
|
|
2
|
+
id: 'action-bar',
|
|
3
|
+
name: 'Action Bar',
|
|
4
|
+
description: 'Page-level or card-level header pairing a title with action buttons. Page headers use breadcrumb, large display title, and a divider below; card headers are compact with small text.',
|
|
5
|
+
category: 'action',
|
|
6
|
+
components: ['text', 'button', 'row', 'stack', 'breadcrumb', 'divider', 'card'],
|
|
7
|
+
structure: `
|
|
8
|
+
Page header:
|
|
9
|
+
Stack gap="4"
|
|
10
|
+
├── Breadcrumb ← navigation context
|
|
11
|
+
├── Row justify="between" align="end"
|
|
12
|
+
│ ├── Stack gap="1"
|
|
13
|
+
│ │ ├── Text (h1, 3xl, bold, display) ← page title
|
|
14
|
+
│ │ └── Text (sm, secondary) ← subtitle
|
|
15
|
+
│ └── Row gap="2" ← actions
|
|
16
|
+
│ ├── Button (outline, sm)
|
|
17
|
+
│ └── Button (primary, sm)
|
|
18
|
+
└── Divider
|
|
19
|
+
|
|
20
|
+
Card header:
|
|
21
|
+
Row justify="between" align="start"
|
|
22
|
+
├── Stack gap="1"
|
|
23
|
+
│ ├── Text (xs, secondary, uppercase, tight) ← category label
|
|
24
|
+
│ └── Text (md, semibold, tight) ← section title
|
|
25
|
+
└── Row gap="1" ← compact actions (top-aligned)
|
|
26
|
+
└── Button[] (ghost, xs)
|
|
27
|
+
`.trim(),
|
|
28
|
+
code: `<Stack gap="4">
|
|
29
|
+
<Breadcrumb items={[
|
|
30
|
+
{ label: 'Home', href: '#' },
|
|
31
|
+
{ label: 'Projects', href: '#' },
|
|
32
|
+
{ label: 'Acme Corp' },
|
|
33
|
+
]} />
|
|
34
|
+
<Row justify="between" align="end">
|
|
35
|
+
<Stack gap="1">
|
|
36
|
+
<Text as="h1" size="3xl" weight="bold" family="display">Acme Corp</Text>
|
|
37
|
+
<Text size="sm" color="secondary">Last updated 5 minutes ago</Text>
|
|
38
|
+
</Stack>
|
|
39
|
+
<Row gap="2">
|
|
40
|
+
<Button variant="outline" size="sm">Export</Button>
|
|
41
|
+
<Button variant="primary" size="sm">New report</Button>
|
|
42
|
+
</Row>
|
|
43
|
+
</Row>
|
|
44
|
+
<Divider />
|
|
45
|
+
</Stack>`,
|
|
46
|
+
variants: [
|
|
47
|
+
{
|
|
48
|
+
title: 'Page header — danger zone',
|
|
49
|
+
code: `<Stack gap="4">
|
|
50
|
+
<Breadcrumb items={[
|
|
51
|
+
{ label: 'Home', href: '#' },
|
|
52
|
+
{ label: 'Settings', href: '#' },
|
|
53
|
+
{ label: 'Danger zone' },
|
|
54
|
+
]} />
|
|
55
|
+
<Row justify="between" align="end">
|
|
56
|
+
<Stack gap="1">
|
|
57
|
+
<Text as="h1" size="3xl" weight="bold" family="display">Danger zone</Text>
|
|
58
|
+
<Text size="sm" color="secondary">These actions are irreversible.</Text>
|
|
59
|
+
</Stack>
|
|
60
|
+
<Button variant="danger" size="sm">Delete project</Button>
|
|
61
|
+
</Row>
|
|
62
|
+
<Divider />
|
|
63
|
+
</Stack>`,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
title: 'Card header (compact with label)',
|
|
67
|
+
code: `<Card variant="outline" padding="md" style={{ width: 400 }}>
|
|
68
|
+
<Stack gap="4">
|
|
69
|
+
<Row justify="between" align="start">
|
|
70
|
+
<Stack gap="1">
|
|
71
|
+
<Text size="xs" color="secondary" weight="medium" style={{ letterSpacing: 'var(--lucent-letter-spacing-tight)', textTransform: 'uppercase' }}>Activity</Text>
|
|
72
|
+
<Text size="md" weight="semibold" style={{ letterSpacing: 'var(--lucent-letter-spacing-base)' }}>Recent activity</Text>
|
|
73
|
+
</Stack>
|
|
74
|
+
<Row gap="1">
|
|
75
|
+
<Button variant="ghost" size="xs">Filter</Button>
|
|
76
|
+
<Button variant="ghost" size="xs">Export</Button>
|
|
77
|
+
</Row>
|
|
78
|
+
</Row>
|
|
79
|
+
<Text size="xs" color="secondary">No activity to show yet.</Text>
|
|
80
|
+
</Stack>
|
|
81
|
+
</Card>`,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
designNotes: 'Page headers and card headers have very different scales. Page headers use 3xl ' +
|
|
85
|
+
'display font for the title, Breadcrumb for navigation context, and a Divider to ' +
|
|
86
|
+
'separate the header from page content below. align="end" on the Row anchors the ' +
|
|
87
|
+
'buttons to the baseline of the title block so they sit level with the subtitle. ' +
|
|
88
|
+
'Card headers are compact: sm semibold text with xs/ghost buttons that recede ' +
|
|
89
|
+
'visually. The primary action is always rightmost following natural reading order. ' +
|
|
90
|
+
'Both patterns use justify="between" to push title and actions to opposite edges.',
|
|
91
|
+
};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export const RECIPE = {
|
|
2
|
+
id: 'collapsible-card',
|
|
3
|
+
name: 'Collapsible Card',
|
|
4
|
+
description: 'Card with an expandable/collapsible section using smooth height animation, available in all card variants.',
|
|
5
|
+
category: 'card',
|
|
6
|
+
components: ['card', 'collapsible', 'text'],
|
|
7
|
+
structure: `
|
|
8
|
+
Card (padding="none", hoverable)
|
|
9
|
+
└── Collapsible
|
|
10
|
+
├── trigger: Text (sm, semibold) ← clickable header
|
|
11
|
+
└── children ← collapsible body
|
|
12
|
+
└── Text (sm, secondary) ← content
|
|
13
|
+
`.trim(),
|
|
14
|
+
code: `<Card variant="outline" padding="none" hoverable style={{ width: 360 }}>
|
|
15
|
+
<Collapsible
|
|
16
|
+
trigger={<Text as="span" weight="semibold" size="sm">Details</Text>}
|
|
17
|
+
defaultOpen
|
|
18
|
+
>
|
|
19
|
+
<Text size="sm" color="secondary">
|
|
20
|
+
The Collapsible auto-detects its Card parent and bleeds the trigger
|
|
21
|
+
full-width. Content inherits the card's padding.
|
|
22
|
+
</Text>
|
|
23
|
+
</Collapsible>
|
|
24
|
+
</Card>`,
|
|
25
|
+
variants: [
|
|
26
|
+
{
|
|
27
|
+
title: 'Ghost variant',
|
|
28
|
+
code: `<Card variant="ghost" padding="none" hoverable style={{ width: 360 }}>
|
|
29
|
+
<Collapsible trigger={<Text as="span" weight="semibold" size="sm">FAQ item</Text>}>
|
|
30
|
+
<Text size="sm" color="secondary">
|
|
31
|
+
Transparent container — content floats directly on the page surface.
|
|
32
|
+
</Text>
|
|
33
|
+
</Collapsible>
|
|
34
|
+
</Card>`,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
title: 'Elevated variant',
|
|
38
|
+
code: `<Card variant="elevated" padding="none" hoverable style={{ width: 360 }}>
|
|
39
|
+
<Collapsible trigger={<Text as="span" weight="semibold" size="sm">Advanced options</Text>}>
|
|
40
|
+
<Text size="sm" color="secondary">
|
|
41
|
+
Elevated cards cast a shadow — use for prominent collapsible sections.
|
|
42
|
+
</Text>
|
|
43
|
+
</Collapsible>
|
|
44
|
+
</Card>`,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
title: 'Combo (two-tone nested cards)',
|
|
48
|
+
code: `<Card variant="filled" padding="none" hoverable style={{ width: 360 }}>
|
|
49
|
+
<Collapsible
|
|
50
|
+
padded={false}
|
|
51
|
+
trigger={<Text as="span" weight="semibold" size="sm">Combo layout</Text>}
|
|
52
|
+
>
|
|
53
|
+
<Card
|
|
54
|
+
variant="elevated"
|
|
55
|
+
padding="sm"
|
|
56
|
+
style={{ margin: 'var(--lucent-space-1) var(--lucent-space-2) var(--lucent-space-2)' }}
|
|
57
|
+
>
|
|
58
|
+
<Text size="sm" color="secondary">
|
|
59
|
+
Two-tone layout — flat trigger surface, elevated body.
|
|
60
|
+
</Text>
|
|
61
|
+
</Card>
|
|
62
|
+
</Collapsible>
|
|
63
|
+
</Card>`,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
title: 'With localStorage persistence',
|
|
67
|
+
code: `function CollapsibleSection({ id, title, children }) {
|
|
68
|
+
const storageKey = \`collapsible-\${id}\`;
|
|
69
|
+
const [open, setOpen] = React.useState(() => {
|
|
70
|
+
const saved = localStorage.getItem(storageKey);
|
|
71
|
+
return saved !== null ? saved === 'true' : true;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const handleChange = (next) => {
|
|
75
|
+
setOpen(next);
|
|
76
|
+
localStorage.setItem(storageKey, String(next));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Card variant="outline" padding="none" hoverable>
|
|
81
|
+
<Collapsible
|
|
82
|
+
open={open}
|
|
83
|
+
onOpenChange={handleChange}
|
|
84
|
+
trigger={<Text as="span" weight="semibold" size="sm">{title}</Text>}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</Collapsible>
|
|
88
|
+
</Card>
|
|
89
|
+
);
|
|
90
|
+
}`,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
designNotes: 'Card uses padding="none" because the Collapsible auto-detects its Card parent ' +
|
|
94
|
+
'via CardPaddingContext and bleeds the trigger full-width while applying card ' +
|
|
95
|
+
'padding to the body content. The hoverable prop gives hover lift feedback ' +
|
|
96
|
+
'without making the card itself interactive — the Collapsible trigger handles ' +
|
|
97
|
+
'clicks. For the combo variant, padded={false} on Collapsible removes its built-in ' +
|
|
98
|
+
'content padding so the nested elevated Card can control its own spacing with ' +
|
|
99
|
+
'custom margin. This creates a two-tone visual: flat trigger + elevated body.',
|
|
100
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export const RECIPE = {
|
|
2
|
+
id: 'empty-state-card',
|
|
3
|
+
name: 'Empty State Card',
|
|
4
|
+
description: 'Centered empty state with illustration icon, heading, description, and call-to-action button inside a card.',
|
|
5
|
+
category: 'card',
|
|
6
|
+
components: ['card', 'empty-state', 'icon', 'button'],
|
|
7
|
+
structure: `
|
|
8
|
+
Card (outline, padding="lg")
|
|
9
|
+
└── EmptyState
|
|
10
|
+
├── illustration: Icon (xl) ← decorative SVG
|
|
11
|
+
├── title: string ← heading
|
|
12
|
+
├── description: string ← explanatory text
|
|
13
|
+
└── action: Button (secondary / primary) ← CTA
|
|
14
|
+
`.trim(),
|
|
15
|
+
code: `<Card variant="outline" padding="lg" style={{ width: 400 }}>
|
|
16
|
+
<EmptyState
|
|
17
|
+
illustration={
|
|
18
|
+
<Icon size="xl">
|
|
19
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
|
20
|
+
<circle cx={11} cy={11} r={8} />
|
|
21
|
+
<path d="M21 21l-4.35-4.35" />
|
|
22
|
+
</svg>
|
|
23
|
+
</Icon>
|
|
24
|
+
}
|
|
25
|
+
title="No results found"
|
|
26
|
+
description="Try adjusting your search or filters to find what you're looking for."
|
|
27
|
+
action={<Button variant="secondary" size="sm">Clear filters</Button>}
|
|
28
|
+
/>
|
|
29
|
+
</Card>`,
|
|
30
|
+
variants: [
|
|
31
|
+
{
|
|
32
|
+
title: 'Getting started empty state',
|
|
33
|
+
code: `<Card variant="elevated" padding="lg" style={{ width: 400 }}>
|
|
34
|
+
<EmptyState
|
|
35
|
+
illustration={
|
|
36
|
+
<Icon size="xl">
|
|
37
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
|
38
|
+
<path d="M12 5v14M5 12h14" />
|
|
39
|
+
</svg>
|
|
40
|
+
</Icon>
|
|
41
|
+
}
|
|
42
|
+
title="No projects yet"
|
|
43
|
+
description="Create your first project to get started."
|
|
44
|
+
action={<Button variant="primary" size="sm">Create project</Button>}
|
|
45
|
+
/>
|
|
46
|
+
</Card>`,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
title: 'Error empty state with retry',
|
|
50
|
+
code: `<Card variant="outline" padding="lg" style={{ width: 400 }}>
|
|
51
|
+
<EmptyState
|
|
52
|
+
illustration={
|
|
53
|
+
<Icon size="xl">
|
|
54
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
|
|
55
|
+
<circle cx={12} cy={12} r={10} />
|
|
56
|
+
<path d="M12 8v4M12 16h.01" />
|
|
57
|
+
</svg>
|
|
58
|
+
</Icon>
|
|
59
|
+
}
|
|
60
|
+
title="Something went wrong"
|
|
61
|
+
description="We couldn't load your data. Please try again."
|
|
62
|
+
action={<Button variant="outline" size="sm">Retry</Button>}
|
|
63
|
+
/>
|
|
64
|
+
</Card>`,
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
designNotes: 'EmptyState handles internal centering and spacing — no need for manual Stack/Row ' +
|
|
68
|
+
'layout inside it. The illustration uses Icon at xl size for visual weight without ' +
|
|
69
|
+
'overwhelming the text. Card variant should match the context: outline for inline ' +
|
|
70
|
+
'empty states (e.g. a table with no rows), elevated for standalone pages. The ' +
|
|
71
|
+
'action button uses size="sm" to avoid competing with the heading for attention.',
|
|
72
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export const RECIPE = {
|
|
2
|
+
id: 'form-layout',
|
|
3
|
+
name: 'Form Layout',
|
|
4
|
+
description: 'Stacked form with grouped sections, FormField labels, validation hints, and a submit/cancel footer.',
|
|
5
|
+
category: 'form',
|
|
6
|
+
components: ['card', 'text', 'input', 'select', 'textarea', 'checkbox', 'button', 'stack', 'row', 'divider', 'form-field'],
|
|
7
|
+
structure: `
|
|
8
|
+
Card (elevated, padding="lg")
|
|
9
|
+
└── Stack as="form" gap="6"
|
|
10
|
+
├── Stack gap="1" ← section header
|
|
11
|
+
│ ├── Text (lg, semibold) ← form title
|
|
12
|
+
│ └── Text (sm, secondary) ← description
|
|
13
|
+
├── Stack gap="4" ← field group 1
|
|
14
|
+
│ ├── Row gap="4" ← side-by-side fields
|
|
15
|
+
│ │ ├── FormField (label, required)
|
|
16
|
+
│ │ │ └── Input
|
|
17
|
+
│ │ └── FormField (label, required)
|
|
18
|
+
│ │ └── Input
|
|
19
|
+
│ ├── FormField (label)
|
|
20
|
+
│ │ └── Input
|
|
21
|
+
│ └── FormField (label)
|
|
22
|
+
│ └── Select
|
|
23
|
+
├── Divider
|
|
24
|
+
├── Stack gap="4" ← field group 2
|
|
25
|
+
│ ├── FormField (label)
|
|
26
|
+
│ │ └── Textarea
|
|
27
|
+
│ └── Checkbox (contained)
|
|
28
|
+
└── Row gap="2" justify="end" ← actions
|
|
29
|
+
├── Button (ghost)
|
|
30
|
+
└── Button (primary)
|
|
31
|
+
`.trim(),
|
|
32
|
+
code: `<Card variant="elevated" padding="lg" style={{ width: 480 }}>
|
|
33
|
+
<Stack as="form" gap="6">
|
|
34
|
+
<Stack gap="1">
|
|
35
|
+
<Text as="h2" size="lg" weight="semibold">Create project</Text>
|
|
36
|
+
<Text size="sm" color="secondary">
|
|
37
|
+
Fill in the details to set up your new project.
|
|
38
|
+
</Text>
|
|
39
|
+
</Stack>
|
|
40
|
+
<Stack gap="4">
|
|
41
|
+
<Row gap="4">
|
|
42
|
+
<FormField label="First name" htmlFor="fname" required style={{ flex: 1 }}>
|
|
43
|
+
<Input id="fname" placeholder="Jane" />
|
|
44
|
+
</FormField>
|
|
45
|
+
<FormField label="Last name" htmlFor="lname" required style={{ flex: 1 }}>
|
|
46
|
+
<Input id="lname" placeholder="Doe" />
|
|
47
|
+
</FormField>
|
|
48
|
+
</Row>
|
|
49
|
+
<FormField label="Email" htmlFor="email" helperText="We'll never share your email.">
|
|
50
|
+
<Input id="email" type="email" placeholder="jane@example.com" />
|
|
51
|
+
</FormField>
|
|
52
|
+
<FormField label="Role" htmlFor="role">
|
|
53
|
+
<Select
|
|
54
|
+
id="role"
|
|
55
|
+
placeholder="Select a role..."
|
|
56
|
+
options={[
|
|
57
|
+
{ value: 'admin', label: 'Admin' },
|
|
58
|
+
{ value: 'editor', label: 'Editor' },
|
|
59
|
+
{ value: 'viewer', label: 'Viewer' },
|
|
60
|
+
]}
|
|
61
|
+
/>
|
|
62
|
+
</FormField>
|
|
63
|
+
</Stack>
|
|
64
|
+
<Divider />
|
|
65
|
+
<Stack gap="4">
|
|
66
|
+
<FormField label="Bio" htmlFor="bio">
|
|
67
|
+
<Textarea id="bio" placeholder="Tell us about yourself..." rows={3} />
|
|
68
|
+
</FormField>
|
|
69
|
+
<Checkbox label="I agree to the terms" contained />
|
|
70
|
+
</Stack>
|
|
71
|
+
<Row gap="2" justify="end">
|
|
72
|
+
<Button variant="ghost">Cancel</Button>
|
|
73
|
+
<Button variant="primary">Create</Button>
|
|
74
|
+
</Row>
|
|
75
|
+
</Stack>
|
|
76
|
+
</Card>`,
|
|
77
|
+
variants: [
|
|
78
|
+
{
|
|
79
|
+
title: 'Inline form (no card)',
|
|
80
|
+
code: `<Stack as="form" gap="4" style={{ maxWidth: 400 }}>
|
|
81
|
+
<FormField label="Username" htmlFor="user" required helperText="Letters and numbers only, 3–20 chars.">
|
|
82
|
+
<Input id="user" placeholder="yourname" />
|
|
83
|
+
</FormField>
|
|
84
|
+
<FormField label="Password" htmlFor="pass" required>
|
|
85
|
+
<Input id="pass" type="password" />
|
|
86
|
+
</FormField>
|
|
87
|
+
<Button variant="primary">Sign in</Button>
|
|
88
|
+
</Stack>`,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
designNotes: 'The form uses Stack as="form" to render a semantic <form> element while keeping ' +
|
|
92
|
+
'token-based vertical spacing. gap="6" between sections provides clear visual ' +
|
|
93
|
+
'grouping; gap="4" within a section keeps fields tightly related. Side-by-side ' +
|
|
94
|
+
'fields use Row with flex: 1 on each FormField so they share width equally. ' +
|
|
95
|
+
'The Divider separates logical sections (identity fields vs. profile fields). ' +
|
|
96
|
+
'Actions are right-aligned (justify="end") following the convention that the ' +
|
|
97
|
+
'primary action is the rightmost button.',
|
|
98
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { RECIPE as ProfileCard } from './profile-card.recipe.js';
|
|
2
|
+
export { RECIPE as SettingsPanel } from './settings-panel.recipe.js';
|
|
3
|
+
export { RECIPE as StatsRow } from './stats-row.recipe.js';
|
|
4
|
+
export { RECIPE as ActionBar } from './action-bar.recipe.js';
|
|
5
|
+
export { RECIPE as FormLayout } from './form-layout.recipe.js';
|
|
6
|
+
export { RECIPE as EmptyStateCard } from './empty-state-card.recipe.js';
|
|
7
|
+
export { RECIPE as CollapsibleCard } from './collapsible-card.recipe.js';
|