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.
@@ -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', 'textOnAccent',
13
- 'accentDefault', 'accentHover', 'accentActive', 'accentSubtle',
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" 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 />` },
@@ -17,13 +17,13 @@ export const COMPONENT_MANIFEST = {
17
17
  type: 'boolean',
18
18
  required: false,
19
19
  default: 'false',
20
- description: 'Applies alternating surfaceSecondary backgrounds to even tbody rows.',
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 surfaceSecondary background. Accepts Table.Row children.',
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 surfaceSecondary background.',
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 `surfaceSecondary`.',
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 (`surface-secondary`). Uses `role="menuitemcheckbox"` ' +
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';