lucent-ui 0.33.0 → 0.34.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.
Files changed (26) hide show
  1. package/dist/{LucentProvider-BAYI38i6.js → LucentProvider-BoiXYqA4.js} +2 -2
  2. package/dist/{LucentProvider-DQ46gmxN.cjs → LucentProvider-we0nRXn-.cjs} +2 -2
  3. package/dist/devtools.cjs +1 -1
  4. package/dist/devtools.js +1 -1
  5. package/dist/index.cjs +1 -1
  6. package/dist/index.js +2 -2
  7. package/dist-cli/cli/entry.js +0 -0
  8. package/dist-cli/cli/index.js +0 -0
  9. package/dist-server/server/index.js +0 -0
  10. package/dist-server/server/pattern-registry.js +8 -0
  11. package/dist-server/server/recipe-registry.js +18 -0
  12. package/dist-server/src/manifest/patterns/bulk-action-bar.pattern.js +71 -0
  13. package/dist-server/src/manifest/patterns/confirmation-dialog.pattern.js +95 -0
  14. package/dist-server/src/manifest/patterns/notification-card.pattern.js +108 -0
  15. package/dist-server/src/manifest/patterns/product-item-card.pattern.js +110 -0
  16. package/dist-server/src/manifest/recipes/action-bar.recipe.js +91 -0
  17. package/dist-server/src/manifest/recipes/collapsible-card.recipe.js +100 -0
  18. package/dist-server/src/manifest/recipes/empty-state-card.recipe.js +72 -0
  19. package/dist-server/src/manifest/recipes/form-layout.recipe.js +98 -0
  20. package/dist-server/src/manifest/recipes/index.js +8 -0
  21. package/dist-server/src/manifest/recipes/profile-card.recipe.js +101 -0
  22. package/dist-server/src/manifest/recipes/search-filter-bar.recipe.js +122 -0
  23. package/dist-server/src/manifest/recipes/settings-panel.recipe.js +167 -0
  24. package/dist-server/src/manifest/recipes/stats-row.recipe.js +106 -0
  25. package/dist-server/src/manifest/validate.test.js +28 -0
  26. package/package.json +15 -13
@@ -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,8 @@
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';
8
+ export { RECIPE as SearchFilterBar } from './search-filter-bar.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
+ };
@@ -0,0 +1,122 @@
1
+ export const RECIPE = {
2
+ id: 'search-filter-bar',
3
+ name: 'Search / Filter Bar',
4
+ description: 'Compact toolbar of filter molecules — FilterSearch, FilterSelect, FilterMultiSelect, FilterDateRange — with sort control and view toggle. Designed to sit above a DataTable or list.',
5
+ category: 'dashboard',
6
+ components: ['filter-search', 'filter-select', 'filter-multi-select', 'filter-date-range', 'segmented-control', 'row', 'text', 'button'],
7
+ structure: `
8
+ Row gap="2" align="center" wrap
9
+ ├── FilterSearch ← icon button, expands to Input
10
+ ├── FilterSelect (label="Availability") ← single-select dropdown
11
+ ├── FilterMultiSelect (label="Status") ← multi-select, stays open, count badge
12
+ ├── FilterMultiSelect (label="Tags") ← multi-select with swatch chips
13
+ ├── FilterDateRange (label="Date range") ← button trigger → calendar popover
14
+ ├── Text + Button (ghost, "Clear all") ← conditional result count row
15
+ ├── flex spacer
16
+ └── Row gap="2" align="center"
17
+ ├── FilterSelect (icon=sort, "Newest") ← sort control
18
+ └── SegmentedControl (grid/list icons) ← view toggle
19
+ `.trim(),
20
+ code: `const [search, setSearch] = useState('');
21
+ const [statuses, setStatuses] = useState<string[]>([]);
22
+ const [tags, setTags] = useState<string[]>([]);
23
+ const [dateRange, setDateRange] = useState<DateRange | undefined>();
24
+
25
+ const hasFilters = statuses.length > 0 || tags.length > 0 || dateRange !== undefined;
26
+ const clearAll = () => { setStatuses([]); setTags([]); setDateRange(undefined); setSearch(''); };
27
+
28
+ <Row gap="2" align="center" wrap>
29
+ <FilterSearch placeholder="Search…" value={search} onChange={setSearch} />
30
+
31
+ <FilterSelect label="Availability" options={[
32
+ { value: 'available', label: 'Available' },
33
+ { value: 'notice', label: 'On notice' },
34
+ { value: 'unavailable', label: 'Unavailable' },
35
+ ]} />
36
+
37
+ <FilterMultiSelect label="Status" options={[
38
+ { value: 'active', label: 'Active' },
39
+ { value: 'archived', label: 'Archived' },
40
+ { value: 'on-hold', label: 'On hold' },
41
+ ]} value={statuses} onChange={setStatuses} />
42
+
43
+ <FilterMultiSelect label="Tags" options={[
44
+ { value: 'react', label: 'React', swatch: '#3b82f6' },
45
+ { value: 'devops', label: 'DevOps', swatch: '#10b981' },
46
+ { value: 'data', label: 'Data Science', swatch: '#6366f1' },
47
+ ]} value={tags} onChange={setTags} />
48
+
49
+ <FilterDateRange label="Date range" value={dateRange} onChange={setDateRange} />
50
+
51
+ <div style={{ flex: 1 }} />
52
+
53
+ <Row gap="2" align="center">
54
+ <FilterSelect label="Newest first" icon={<SortIcon />} options={[
55
+ { value: 'newest', label: 'Newest first' },
56
+ { value: 'oldest', label: 'Oldest first' },
57
+ { value: 'name', label: 'Name A–Z' },
58
+ ]} />
59
+ <SegmentedControl size="sm" defaultValue="grid" options={[
60
+ { value: 'grid', label: <GridIcon /> },
61
+ { value: 'list', label: <ListIcon /> },
62
+ ]} />
63
+ </Row>
64
+ </Row>
65
+
66
+ {/* Result count between filter bar and DataTable */}
67
+ {hasFilters && (
68
+ <Row gap="2" align="center">
69
+ <Text size="sm" color="secondary">
70
+ Showing {filteredRows.length} of {allRows.length} candidates
71
+ </Text>
72
+ <Button variant="ghost" size="xs" onClick={clearAll}>Clear all</Button>
73
+ </Row>
74
+ )}`,
75
+ variants: [
76
+ {
77
+ title: 'Minimal — search + sort only',
78
+ code: `<Row gap="2" align="center">
79
+ <FilterSearch placeholder="Search…" />
80
+ <div style={{ flex: 1 }} />
81
+ <FilterSelect label="Newest first" icon={<SortIcon />} options={sortOptions} />
82
+ </Row>`,
83
+ },
84
+ {
85
+ title: 'With DataTable',
86
+ code: `const [search, setSearch] = useState('');
87
+ const [statuses, setStatuses] = useState<string[]>([]);
88
+
89
+ const filtered = candidates.filter(c => {
90
+ if (search && !c.name.toLowerCase().includes(search.toLowerCase())) return false;
91
+ if (statuses.length > 0 && !statuses.includes(c.status.toLowerCase())) return false;
92
+ return true;
93
+ });
94
+
95
+ <Stack gap="3">
96
+ <Row gap="2" align="center" wrap>
97
+ <FilterSearch placeholder="Search…" value={search} onChange={setSearch} />
98
+ <FilterMultiSelect label="Status" options={statusOptions}
99
+ value={statuses} onChange={setStatuses} />
100
+ </Row>
101
+ {statuses.length > 0 && (
102
+ <Row gap="2" align="center">
103
+ <Text size="sm" color="secondary">
104
+ Showing {filtered.length} of {candidates.length}
105
+ </Text>
106
+ <Button variant="ghost" size="xs" onClick={() => setStatuses([])}>Clear all</Button>
107
+ </Row>
108
+ )}
109
+ <DataTable columns={columns} rows={filtered} pageSize={5} />
110
+ </Stack>`,
111
+ },
112
+ ],
113
+ designNotes: 'Four dedicated filter molecules replace the previous boilerplate of manually wiring Menu open state, ' +
114
+ 'keepOpen hacks, count badges, and clear footers. FilterSearch collapses to a square icon-only button ' +
115
+ 'and expands on click. FilterSelect wraps Menu for single-select with a "Clear" footer. ' +
116
+ 'FilterMultiSelect builds its own portal dropdown (not Menu) so items toggle without closing — ' +
117
+ 'it renders Checkbox + optional Chip (with swatch) per option and an always-visible "Clear all" footer. ' +
118
+ 'FilterDateRange wraps DateRangePicker with the trigger prop to render a secondary Button. ' +
119
+ 'A flex spacer pushes sort and view controls to the right edge. ' +
120
+ 'Between the filter bar and DataTable, a conditional result count row shows ' +
121
+ '"Showing X of Y" with a ghost "Clear all" button when filters are active.',
122
+ };
@@ -0,0 +1,167 @@
1
+ export const RECIPE = {
2
+ id: 'settings-panel',
3
+ name: 'Settings Panel',
4
+ description: 'Settings card with section header, toggle rows with descriptions, select dropdown, and action buttons.',
5
+ category: 'settings',
6
+ components: ['card', 'text', 'badge', 'toggle', 'select', 'button', 'stack', 'row', 'divider', 'nav-menu', 'form-field', 'input', 'textarea'],
7
+ structure: `
8
+ Card (elevated, padding="lg")
9
+ └── Stack gap="5"
10
+ ├── Row gap="2" align="center" ← section header
11
+ │ ├── Text (lg, semibold) ← title
12
+ │ └── Badge (accent) ← version/plan badge
13
+ ├── Divider
14
+ ├── Stack gap="4" ← toggle settings
15
+ │ ├── Row justify="between" ← setting row
16
+ │ │ ├── Stack gap="1"
17
+ │ │ │ ├── Text (sm, medium) ← setting label
18
+ │ │ │ └── Text (xs, secondary) ← description
19
+ │ │ └── Toggle
20
+ │ ├── Row justify="between"
21
+ │ │ ├── Stack gap="1"
22
+ │ │ │ ├── Text (sm, medium)
23
+ │ │ │ └── Text (xs, secondary)
24
+ │ │ └── Toggle
25
+ │ └── Row justify="between"
26
+ │ ├── Stack gap="1"
27
+ │ │ ├── Text (sm, medium)
28
+ │ │ └── Text (xs, secondary)
29
+ │ └── Select
30
+ ├── Divider
31
+ └── Row gap="2" justify="end" ← actions
32
+ ├── Button (ghost, sm)
33
+ └── Button (primary, sm)
34
+ `.trim(),
35
+ code: `<Card variant="elevated" padding="lg" style={{ width: 400 }}>
36
+ <Stack gap="5">
37
+ <Row gap="2" align="center">
38
+ <Text size="lg" weight="semibold">Notifications</Text>
39
+ <Badge variant="accent">Pro</Badge>
40
+ </Row>
41
+ <Divider />
42
+ <Stack gap="4">
43
+ <Row justify="between">
44
+ <Stack gap="1">
45
+ <Text size="sm" weight="medium">Email alerts</Text>
46
+ <Text size="xs" color="secondary">
47
+ Get notified when someone mentions you.
48
+ </Text>
49
+ </Stack>
50
+ <Toggle defaultChecked />
51
+ </Row>
52
+ <Row justify="between">
53
+ <Stack gap="1">
54
+ <Text size="sm" weight="medium">Push notifications</Text>
55
+ <Text size="xs" color="secondary">
56
+ Receive push notifications on your device.
57
+ </Text>
58
+ </Stack>
59
+ <Toggle />
60
+ </Row>
61
+ <Row justify="between">
62
+ <Stack gap="1">
63
+ <Text size="sm" weight="medium">Digest frequency</Text>
64
+ <Text size="xs" color="secondary">
65
+ How often to send summary emails.
66
+ </Text>
67
+ </Stack>
68
+ <Select
69
+ defaultValue="weekly"
70
+ options={[
71
+ { value: 'daily', label: 'Daily' },
72
+ { value: 'weekly', label: 'Weekly' },
73
+ { value: 'monthly', label: 'Monthly' },
74
+ ]}
75
+ size="sm"
76
+ style={{ width: 120 }}
77
+ />
78
+ </Row>
79
+ </Stack>
80
+ <Divider />
81
+ <Row gap="2" justify="end">
82
+ <Button variant="ghost" size="sm">Reset</Button>
83
+ <Button variant="primary" size="sm">Save changes</Button>
84
+ </Row>
85
+ </Stack>
86
+ </Card>`,
87
+ variants: [
88
+ {
89
+ title: 'Drill-down with NavMenu',
90
+ code: `const [tab, setTab] = useState('profile');
91
+
92
+ <Card variant="elevated" padding="none" style={{ width: 560 }}>
93
+ <Row gap="0" align="stretch">
94
+ <div style={{ width: 160, borderRight: '1px solid var(--lucent-border-default)', padding: 'var(--lucent-space-3)', flexShrink: 0 }}>
95
+ <NavMenu orientation="vertical" size="sm">
96
+ <NavMenu.Item as="button" isActive={tab === 'profile'} onClick={() => setTab('profile')}>Profile</NavMenu.Item>
97
+ <NavMenu.Item as="button" isActive={tab === 'notifications'} onClick={() => setTab('notifications')}>Notifications</NavMenu.Item>
98
+ <NavMenu.Item as="button" isActive={tab === 'security'} onClick={() => setTab('security')}>Security</NavMenu.Item>
99
+ </NavMenu>
100
+ </div>
101
+ <div style={{ flex: 1, padding: 'var(--lucent-space-5)' }}>
102
+ {tab === 'profile' && (
103
+ <Stack gap="4">
104
+ <Text size="sm" weight="semibold">Profile</Text>
105
+ <FormField label="Display name" htmlFor="s-name">
106
+ <Input id="s-name" placeholder="Jane Doe" size="sm" />
107
+ </FormField>
108
+ <FormField label="Bio" htmlFor="s-bio">
109
+ <Textarea id="s-bio" placeholder="Tell us about yourself..." rows={2} />
110
+ </FormField>
111
+ <Row gap="2" justify="end">
112
+ <Button variant="primary" size="sm">Save</Button>
113
+ </Row>
114
+ </Stack>
115
+ )}
116
+ {tab === 'notifications' && (
117
+ <Stack gap="4">
118
+ <Text size="sm" weight="semibold">Notifications</Text>
119
+ <Row justify="between">
120
+ <Stack gap="1">
121
+ <Text size="sm" weight="medium">Email alerts</Text>
122
+ <Text size="xs" color="secondary">Get notified on mentions.</Text>
123
+ </Stack>
124
+ <Toggle defaultChecked />
125
+ </Row>
126
+ <Row justify="between">
127
+ <Stack gap="1">
128
+ <Text size="sm" weight="medium">Push notifications</Text>
129
+ <Text size="xs" color="secondary">Receive push on your device.</Text>
130
+ </Stack>
131
+ <Toggle />
132
+ </Row>
133
+ </Stack>
134
+ )}
135
+ {tab === 'security' && (
136
+ <Stack gap="4">
137
+ <Text size="sm" weight="semibold">Security</Text>
138
+ <Row justify="between">
139
+ <Stack gap="1">
140
+ <Text size="sm" weight="medium">Two-factor auth</Text>
141
+ <Text size="xs" color="secondary">Add an extra layer of security.</Text>
142
+ </Stack>
143
+ <Toggle />
144
+ </Row>
145
+ <FormField label="Current password" htmlFor="s-pass">
146
+ <Input id="s-pass" type="password" size="sm" />
147
+ </FormField>
148
+ <Row gap="2" justify="end">
149
+ <Button variant="primary" size="sm">Update password</Button>
150
+ </Row>
151
+ </Stack>
152
+ )}
153
+ </div>
154
+ </Row>
155
+ </Card>`,
156
+ },
157
+ ],
158
+ designNotes: 'Each setting row uses Row with justify="between" to push the control to the far ' +
159
+ 'right. The label + description pair is a Stack with gap="1" for tight coupling. ' +
160
+ 'Dividers separate the header, settings body, and action footer into visual ' +
161
+ 'sections. The Select is constrained to a fixed width (120px) to prevent the row ' +
162
+ 'from shifting when option labels vary in length. Actions are right-aligned with ' +
163
+ 'a ghost Reset so it visually recedes next to the primary Save. ' +
164
+ 'The drill-down variant uses NavMenu as a left sidebar with a border-right separator. ' +
165
+ 'Card uses padding="none" so the nav and content pane each control their own padding. ' +
166
+ 'Row with align="stretch" ensures the nav border extends full height.',
167
+ };
@@ -0,0 +1,106 @@
1
+ export const RECIPE = {
2
+ id: 'stats-row',
3
+ name: 'Stats Row',
4
+ description: 'Row of individual stat cards with label, large display-font value, and trend chip with comparison.',
5
+ category: 'dashboard',
6
+ components: ['card', 'text', 'chip', 'avatar', 'stack', 'row'],
7
+ structure: `
8
+ Row gap="3" wrap
9
+ ├── Card (outline, padding="md", flex=1)
10
+ │ └── Stack gap="3"
11
+ │ ├── Text (xs, secondary, medium) ← metric label
12
+ │ ├── Text (2xl, bold, display) ← value
13
+ │ └── Row gap="2" align="center"
14
+ │ ├── Chip (success/danger, sm) ← trend %
15
+ │ └── Text (xs, secondary) ← comparison
16
+ ├── Card ...
17
+ └── Card ...
18
+ `.trim(),
19
+ code: `<Row gap="3" wrap>
20
+ <Card variant="outline" padding="md" style={{ flex: 1, minWidth: 180 }}>
21
+ <Stack gap="3">
22
+ <Text size="xs" color="secondary" weight="medium">Total Events</Text>
23
+ <Text size="2xl" weight="bold" family="display">32</Text>
24
+ <Row gap="2" align="center">
25
+ <Chip variant="success" size="sm" borderless>+20%</Chip>
26
+ <Text size="xs" color="secondary">25 last week</Text>
27
+ </Row>
28
+ </Stack>
29
+ </Card>
30
+ <Card variant="outline" padding="md" style={{ flex: 1, minWidth: 180 }}>
31
+ <Stack gap="3">
32
+ <Text size="xs" color="secondary" weight="medium">Total Hours</Text>
33
+ <Text size="2xl" weight="bold" family="display">38.2 hr</Text>
34
+ <Row gap="2" align="center">
35
+ <Chip variant="danger" size="sm" borderless>-8%</Chip>
36
+ <Text size="xs" color="secondary">42.0 hr last week</Text>
37
+ </Row>
38
+ </Stack>
39
+ </Card>
40
+ <Card variant="outline" padding="md" style={{ flex: 1, minWidth: 180 }}>
41
+ <Stack gap="3">
42
+ <Text size="xs" color="secondary" weight="medium">Focus Time</Text>
43
+ <Text size="2xl" weight="bold" family="display">16.8 hr</Text>
44
+ <Row gap="2" align="center">
45
+ <Chip variant="success" size="sm" borderless>+12%</Chip>
46
+ <Text size="xs" color="secondary">14.4 hr last week</Text>
47
+ </Row>
48
+ </Stack>
49
+ </Card>
50
+ </Row>`,
51
+ variants: [
52
+ {
53
+ title: 'Revenue cards with avatar header',
54
+ code: `<Row gap="3" wrap>
55
+ <Card variant="filled" padding="md" style={{ flex: 1, minWidth: 180 }}>
56
+ <Stack gap="3">
57
+ <Row gap="2" align="center">
58
+ <Avatar alt="Airbnb" size="sm" />
59
+ <Stack gap="0">
60
+ <Text size="sm" weight="semibold">Airbnb</Text>
61
+ <Text size="xs" color="secondary">Travel and tourism</Text>
62
+ </Stack>
63
+ </Row>
64
+ <Row justify="between" align="end">
65
+ <Stack gap="1">
66
+ <Row gap="2" align="baseline">
67
+ <Text size="lg" weight="bold" family="display">$33.2k</Text>
68
+ <Chip variant="success" size="sm" borderless>+37%</Chip>
69
+ </Row>
70
+ <Text size="xs" color="secondary">Recurring Revenue</Text>
71
+ </Stack>
72
+ </Row>
73
+ </Stack>
74
+ </Card>
75
+ <Card variant="filled" padding="md" style={{ flex: 1, minWidth: 180 }}>
76
+ <Stack gap="3">
77
+ <Row gap="2" align="center">
78
+ <Avatar alt="MailChimp" size="sm" />
79
+ <Stack gap="0">
80
+ <Text size="sm" weight="semibold">MailChimp</Text>
81
+ <Text size="xs" color="secondary">Email Marketing</Text>
82
+ </Stack>
83
+ </Row>
84
+ <Row justify="between" align="end">
85
+ <Stack gap="1">
86
+ <Row gap="2" align="baseline">
87
+ <Text size="lg" weight="bold" family="display">$3.2k</Text>
88
+ <Chip variant="danger" size="sm" borderless>-23%</Chip>
89
+ </Row>
90
+ <Text size="xs" color="secondary">Recurring Revenue</Text>
91
+ </Stack>
92
+ </Row>
93
+ </Stack>
94
+ </Card>
95
+ </Row>`,
96
+ },
97
+ ],
98
+ designNotes: 'Each stat lives in its own Card so it reads as a discrete metric. flex: 1 with ' +
99
+ 'minWidth ensures equal sizing that wraps gracefully. gap="3" in the Stack creates ' +
100
+ 'three clear visual tiers: label (top), value (middle), trend context (bottom). ' +
101
+ 'Display font on the value creates instant hierarchy. The trend row pairs a ' +
102
+ 'color-coded Chip (success/danger) with a secondary comparison value so the user ' +
103
+ 'gets both relative change and absolute context. The revenue variant adds an ' +
104
+ 'Avatar + name header for entity-scoped metrics — align="baseline" on the ' +
105
+ 'value + chip row keeps the trend visually anchored to the number.',
106
+ };
@@ -0,0 +1,28 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { validateManifest } from './validate.js';
3
+ // Auto-discover all component manifests
4
+ const manifestModules = import.meta.glob('../components/**/*.manifest.ts', { eager: true });
5
+ const manifests = Object.entries(manifestModules).map(([path, mod]) => {
6
+ const m = mod;
7
+ const manifest = m['COMPONENT_MANIFEST'];
8
+ return { path, manifest };
9
+ });
10
+ describe('Component manifests', () => {
11
+ test('at least one manifest was discovered', () => {
12
+ expect(manifests.length).toBeGreaterThan(0);
13
+ });
14
+ for (const { path, manifest } of manifests) {
15
+ const label = path.replace('../components/', '').replace('.manifest.ts', '');
16
+ test(`${label} — exports COMPONENT_MANIFEST`, () => {
17
+ expect(manifest).toBeDefined();
18
+ });
19
+ test(`${label} — passes schema validation`, () => {
20
+ const result = validateManifest(manifest);
21
+ if (!result.valid) {
22
+ const messages = result.errors.map(e => ` ${e.field}: ${e.message}`).join('\n');
23
+ throw new Error(`Invalid manifest:\n${messages}`);
24
+ }
25
+ expect(result.valid).toBe(true);
26
+ });
27
+ }
28
+ });