lucent-ui 0.25.1 → 0.26.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.
@@ -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" or "form validation"') }, async ({ query }) => {
90
- const results = ALL_MANIFESTS
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, results }, null, 2),
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: 'var(--lucent-space-4)', description: 'Margin on the axis perpendicular to the line.' },
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 />` },
@@ -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';
@@ -0,0 +1,101 @@
1
+ export const RECIPE = {
2
+ id: 'profile-card',
3
+ name: 'Profile Card',
4
+ description: 'User profile card with avatar, name/role, bio, tag chips, stats row, and action buttons.',
5
+ category: 'card',
6
+ components: ['card', 'avatar', 'text', 'chip', 'button', 'stack', 'row', 'divider'],
7
+ structure: `
8
+ Card (elevated, equal padding via style)
9
+ └── Stack gap="5"
10
+ ├── Row gap="3" align="center" ← avatar + name block
11
+ │ ├── Avatar (lg)
12
+ │ └── Stack gap="1"
13
+ │ ├── Row gap="2" align="center"
14
+ │ │ ├── Text (lg, semibold, display) ← full name
15
+ │ │ └── Chip (success, sm) ← status badge
16
+ │ └── Text (sm, secondary) ← role / subtitle
17
+ ├── Text (sm) ← bio paragraph
18
+ ├── Row gap="2" wrap ← skill/tag chips
19
+ │ └── Chip[] (neutral, borderless, clickable)
20
+ ├── Divider
21
+ ├── Row gap="6" justify="around" ← stats
22
+ │ └── Stack[] gap="0" align="center"
23
+ │ ├── Text (2xl, bold, display) ← stat value
24
+ │ └── Text (xs, secondary) ← stat label
25
+ └── Row gap="3" ← actions
26
+ ├── Button (primary, full width)
27
+ └── Button (outline, full width)
28
+ `.trim(),
29
+ code: `<Card variant="elevated" padding="none" style={{ width: 340, padding: 'var(--lucent-space-6)' }}>
30
+ <Stack gap="5">
31
+ <Row gap="3" align="center">
32
+ <Avatar src="/avatars/jane.jpg" alt="Jane Doe" size="lg" />
33
+ <Stack gap="1">
34
+ <Row gap="2" align="center">
35
+ <Text size="lg" weight="semibold" family="display">Jane Doe</Text>
36
+ <Chip variant="success" size="sm" dot>Pro</Chip>
37
+ </Row>
38
+ <Text size="sm" color="secondary">Software Engineer</Text>
39
+ </Stack>
40
+ </Row>
41
+ <Text size="sm">
42
+ Building design systems and component libraries.
43
+ Passionate about accessible, token-driven UI.
44
+ </Text>
45
+ <Row gap="2" wrap>
46
+ <Chip variant="neutral" borderless onClick={() => {}}>React</Chip>
47
+ <Chip variant="neutral" borderless onClick={() => {}}>TypeScript</Chip>
48
+ <Chip variant="neutral" borderless onClick={() => {}}>Design Systems</Chip>
49
+ </Row>
50
+ <Divider />
51
+ <Row gap="6" justify="around">
52
+ <Stack gap="0" align="center">
53
+ <Text size="2xl" weight="bold" family="display">128</Text>
54
+ <Text size="xs" color="secondary">Posts</Text>
55
+ </Stack>
56
+ <Stack gap="0" align="center">
57
+ <Text size="2xl" weight="bold" family="display">4.2k</Text>
58
+ <Text size="xs" color="secondary">Followers</Text>
59
+ </Stack>
60
+ <Stack gap="0" align="center">
61
+ <Text size="2xl" weight="bold" family="display">312</Text>
62
+ <Text size="xs" color="secondary">Following</Text>
63
+ </Stack>
64
+ </Row>
65
+ <Row gap="3">
66
+ <Button variant="primary" style={{ flex: 1 }}>Follow</Button>
67
+ <Button variant="outline" style={{ flex: 1 }}>Message</Button>
68
+ </Row>
69
+ </Stack>
70
+ </Card>`,
71
+ variants: [
72
+ {
73
+ title: 'Compact collapsible profile',
74
+ code: `<Card variant="filled" padding="none" hoverable style={{ width: 280 }}>
75
+ <Collapsible
76
+ trigger={
77
+ <Row gap="3" align="center">
78
+ <Avatar alt="Jane Doe" size="md" />
79
+ <Stack gap="1">
80
+ <Text as="span" size="sm" weight="semibold">Jane Doe</Text>
81
+ <Text as="span" size="xs" color="secondary">Software Engineer</Text>
82
+ </Stack>
83
+ </Row>
84
+ }
85
+ defaultOpen
86
+ >
87
+ <Row gap="2" justify="end">
88
+ <Button variant="outline" size="sm">Message</Button>
89
+ <Button variant="primary" size="sm">Follow</Button>
90
+ </Row>
91
+ </Collapsible>
92
+ </Card>`,
93
+ },
94
+ ],
95
+ designNotes: 'Avatar and name are grouped in a Row with center alignment so the text block ' +
96
+ 'vertically centers against the avatar regardless of line count. The name row ' +
97
+ 'uses gap="2" for tight coupling between name and status chip. Stats use ' +
98
+ 'justify="around" to distribute evenly without fixed widths. Action buttons ' +
99
+ 'use flex: 1 to split the row equally. The Divider separates informational ' +
100
+ 'content (above) from interactive content (below).',
101
+ };