lucent-ui 0.17.0 → 0.18.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.
File without changes
File without changes
File without changes
@@ -0,0 +1,272 @@
1
+ export const COMPONENT_MANIFEST = {
2
+ id: 'menu',
3
+ name: 'Menu',
4
+ tier: 'molecule',
5
+ domain: 'neutral',
6
+ specVersion: '0.1',
7
+ description: 'A dropdown menu triggered by clicking a host element. Renders in a portal to avoid overflow clipping, ' +
8
+ 'supports placement with auto-flip, keyboard navigation (arrow keys, Enter, Escape), and outside-click dismissal.',
9
+ designIntent: 'Menu provides a contextual action list that appears from a trigger element. It is a foundational ' +
10
+ 'overlay primitive — Select dropdowns, notification feeds, and context menus can all be composed from ' +
11
+ 'this building block.\n\n' +
12
+ '## Compound component API\n' +
13
+ 'Menu uses a compound component pattern (`Menu`, `MenuItem`, `MenuSeparator`, `MenuGroup`) rather than ' +
14
+ 'a flat data array. Menu items have divergent structures — actions, separators, groups, icons, shortcut ' +
15
+ 'hints, danger state — that compose naturally as JSX children rather than discriminated union objects.\n\n' +
16
+ '## Sizing\n' +
17
+ 'The `size` prop (`sm` | `md` | `lg`) flows from the root `Menu` through context to all sub-components. ' +
18
+ 'Font sizes are aligned with Button: sm → `font-size-sm`, md → `font-size-md`, lg → `font-size-lg`. ' +
19
+ 'Item padding and gap use `space-2` for sm/md and `space-3` for lg, matching MultiSelect dropdown spacing. ' +
20
+ 'Group labels stay one step smaller than item text. Checkmark icons scale from 12px to 16px.\n\n' +
21
+ '## Placement & auto-flip\n' +
22
+ 'The `placement` prop sets the preferred position (default `bottom-start`). When the popover would ' +
23
+ 'overflow the viewport, it automatically flips to the opposite side. Horizontal alignment (`-start`, ' +
24
+ '`-end`, or centered) is preserved during the flip. Position is computed with `getBoundingClientRect` ' +
25
+ 'on mount and rendered via `position: fixed` in a portal.\n\n' +
26
+ '## Portal rendering\n' +
27
+ 'The popover is portaled to `document.body` via `createPortal`. This prevents overflow clipping from ' +
28
+ 'parent containers with `overflow: hidden`. The trigger wrapper stays inline in the DOM tree so it ' +
29
+ 'participates in layout normally.\n\n' +
30
+ '## Keyboard navigation\n' +
31
+ 'Follows WAI-ARIA Menu Button pattern. Arrow keys cycle through enabled items (wrapping). Enter/Space ' +
32
+ 'selects the active item and closes the menu. Escape closes without selection. Tab closes and lets ' +
33
+ 'focus move naturally. Home/End jump to first/last enabled item.\n\n' +
34
+ '## Dismissal\n' +
35
+ 'Menus close on outside click (mousedown, deferred via `requestAnimationFrame` to avoid catching the ' +
36
+ 'opening click), Escape, Tab, and scroll (armed after 50ms to skip mount-triggered scroll events). ' +
37
+ 'After close, focus returns to the trigger element.\n\n' +
38
+ '## Selected state\n' +
39
+ 'MenuItem accepts a `selected` prop that renders a trailing accent-colored checkmark. The selected item ' +
40
+ 'gets a `color-mix(in srgb, accent-default 12%, surface-overlay)` background with `shadow-sm` elevation, ' +
41
+ 'making it visually stronger than the hover state (`surface-secondary`). Uses `role="menuitemcheckbox"` ' +
42
+ 'with `aria-checked` for accessibility.\n\n' +
43
+ '## Animation\n' +
44
+ 'Both entrance and exit use a subtle scale + fade (`scale(0.97) ↔ 1`, `opacity 0 ↔ 1`) over 120ms. ' +
45
+ 'Entrance uses `easing-decelerate`, exit uses `easing-default`. `transform-origin` is set based on the ' +
46
+ 'actual placement (after auto-flip). The portal stays mounted during the exit animation via a `visible` ' +
47
+ 'state with `pointerEvents: none` to prevent interaction while fading out.',
48
+ props: [
49
+ {
50
+ name: 'trigger',
51
+ type: 'ReactNode',
52
+ required: true,
53
+ description: 'The element that toggles the menu on click. Typically a Button with a chevron. ' +
54
+ 'Wrapped in a <span> that receives click, keyboard, and ARIA attributes.',
55
+ },
56
+ {
57
+ name: 'children',
58
+ type: 'ReactNode',
59
+ required: true,
60
+ description: 'MenuItem, MenuSeparator, and/or MenuGroup elements.',
61
+ },
62
+ {
63
+ name: 'size',
64
+ type: 'enum',
65
+ required: false,
66
+ default: 'md',
67
+ description: 'Size of the menu panel. Controls item padding, gap, font size, and checkmark icon size. ' +
68
+ 'Font sizes match Button at each tier: sm → font-size-sm, md → font-size-md, lg → font-size-lg.',
69
+ enumValues: ['sm', 'md', 'lg'],
70
+ },
71
+ {
72
+ name: 'placement',
73
+ type: 'enum',
74
+ required: false,
75
+ default: 'bottom-start',
76
+ description: 'Preferred placement relative to the trigger. Auto-flips when near viewport edges.',
77
+ enumValues: ['top', 'top-start', 'top-end', 'bottom', 'bottom-start', 'bottom-end', 'left', 'right'],
78
+ },
79
+ {
80
+ name: 'open',
81
+ type: 'boolean',
82
+ required: false,
83
+ description: 'Controlled open state. When provided, the menu becomes controlled.',
84
+ },
85
+ {
86
+ name: 'onOpenChange',
87
+ type: 'function',
88
+ required: false,
89
+ description: 'Callback fired when the menu opens or closes. Receives the new open state.',
90
+ },
91
+ {
92
+ name: 'style',
93
+ type: 'object',
94
+ required: false,
95
+ description: 'Inline style overrides for the popover panel.',
96
+ },
97
+ // MenuItem props
98
+ {
99
+ name: 'MenuItem.onSelect',
100
+ type: 'function',
101
+ required: true,
102
+ description: 'Fires when the item is selected via click or Enter/Space.',
103
+ },
104
+ {
105
+ name: 'MenuItem.children',
106
+ type: 'ReactNode',
107
+ required: true,
108
+ description: 'Label content. Rendered via the Text atom at the size determined by the parent Menu.',
109
+ },
110
+ {
111
+ name: 'MenuItem.icon',
112
+ type: 'ReactNode',
113
+ required: false,
114
+ description: 'Leading icon rendered before the label.',
115
+ },
116
+ {
117
+ name: 'MenuItem.shortcut',
118
+ type: 'string',
119
+ required: false,
120
+ description: 'Trailing shortcut hint (e.g. "Cmd+D"). Displayed in secondary text.',
121
+ },
122
+ {
123
+ name: 'MenuItem.disabled',
124
+ type: 'boolean',
125
+ required: false,
126
+ default: 'false',
127
+ description: 'Disables selection, grays out the item, and skips it during keyboard navigation.',
128
+ },
129
+ {
130
+ name: 'MenuItem.danger',
131
+ type: 'boolean',
132
+ required: false,
133
+ default: 'false',
134
+ description: 'Renders the item in danger color for destructive actions.',
135
+ },
136
+ {
137
+ name: 'MenuItem.selected',
138
+ type: 'boolean',
139
+ required: false,
140
+ default: 'false',
141
+ description: 'Marks the item as currently selected. Shows a trailing checkmark in accent color, ' +
142
+ 'accent-tinted background via color-mix, and shadow-sm elevation. Visually stronger than hover.',
143
+ },
144
+ {
145
+ name: 'MenuItem.style',
146
+ type: 'object',
147
+ required: false,
148
+ description: 'Inline style overrides for the item.',
149
+ },
150
+ // MenuSeparator props
151
+ {
152
+ name: 'MenuSeparator.style',
153
+ type: 'object',
154
+ required: false,
155
+ description: 'Inline style overrides for the separator line.',
156
+ },
157
+ // MenuGroup props
158
+ {
159
+ name: 'MenuGroup.label',
160
+ type: 'string',
161
+ required: true,
162
+ description: 'Label shown above the group. Rendered one font step smaller than item text.',
163
+ },
164
+ {
165
+ name: 'MenuGroup.children',
166
+ type: 'ReactNode',
167
+ required: true,
168
+ description: 'MenuItem elements within the group.',
169
+ },
170
+ {
171
+ name: 'MenuGroup.style',
172
+ type: 'object',
173
+ required: false,
174
+ description: 'Inline style overrides for the group wrapper.',
175
+ },
176
+ ],
177
+ usageExamples: [
178
+ {
179
+ title: 'Basic menu',
180
+ code: `<Menu trigger={<Button chevron>Actions</Button>}>
181
+ <MenuItem onSelect={() => console.log('edit')}>Edit</MenuItem>
182
+ <MenuItem onSelect={() => console.log('duplicate')}>Duplicate</MenuItem>
183
+ <MenuSeparator />
184
+ <MenuItem onSelect={() => console.log('delete')} danger>Delete</MenuItem>
185
+ </Menu>`,
186
+ },
187
+ {
188
+ title: 'With icons and shortcuts',
189
+ code: `<Menu trigger={<Button variant="outline" chevron>Options</Button>}>
190
+ <MenuItem icon={<EditIcon />} shortcut="⌘E" onSelect={edit}>Edit</MenuItem>
191
+ <MenuItem icon={<CopyIcon />} shortcut="⌘D" onSelect={duplicate}>Duplicate</MenuItem>
192
+ <MenuSeparator />
193
+ <MenuItem icon={<TrashIcon />} onSelect={remove} danger>Delete</MenuItem>
194
+ </Menu>`,
195
+ },
196
+ {
197
+ title: 'With groups',
198
+ code: `<Menu trigger={<Button chevron>File</Button>} placement="bottom-start">
199
+ <MenuGroup label="Document">
200
+ <MenuItem onSelect={newDoc}>New</MenuItem>
201
+ <MenuItem onSelect={openDoc}>Open</MenuItem>
202
+ </MenuGroup>
203
+ <MenuSeparator />
204
+ <MenuGroup label="Export">
205
+ <MenuItem onSelect={exportPdf}>PDF</MenuItem>
206
+ <MenuItem onSelect={exportCsv}>CSV</MenuItem>
207
+ </MenuGroup>
208
+ </Menu>`,
209
+ },
210
+ {
211
+ title: 'Selected items (e.g. sort order)',
212
+ code: `const [sort, setSort] = useState('name');
213
+ <Menu trigger={<Button variant="outline" chevron>Sort by</Button>}>
214
+ <MenuItem selected={sort === 'name'} onSelect={() => setSort('name')}>Name</MenuItem>
215
+ <MenuItem selected={sort === 'date'} onSelect={() => setSort('date')}>Date modified</MenuItem>
216
+ <MenuItem selected={sort === 'size'} onSelect={() => setSort('size')}>Size</MenuItem>
217
+ </Menu>`,
218
+ },
219
+ {
220
+ title: 'Size variants',
221
+ code: `<Menu size="sm" trigger={<Button size="sm" chevron>Small</Button>}>
222
+ <MenuItem onSelect={() => {}}>Option A</MenuItem>
223
+ <MenuItem onSelect={() => {}}>Option B</MenuItem>
224
+ </Menu>
225
+
226
+ <Menu size="lg" trigger={<Button size="lg" chevron>Large</Button>}>
227
+ <MenuItem onSelect={() => {}}>Option A</MenuItem>
228
+ <MenuItem onSelect={() => {}}>Option B</MenuItem>
229
+ </Menu>`,
230
+ },
231
+ {
232
+ title: 'Controlled open state',
233
+ code: `const [open, setOpen] = useState(false);
234
+ <Menu trigger={<Button>Menu</Button>} open={open} onOpenChange={setOpen}>
235
+ <MenuItem onSelect={() => {}}>Option A</MenuItem>
236
+ <MenuItem onSelect={() => {}}>Option B</MenuItem>
237
+ </Menu>`,
238
+ },
239
+ ],
240
+ compositionGraph: [
241
+ { componentId: 'text', componentName: 'Text', role: 'Item labels and group headers', required: true },
242
+ ],
243
+ accessibility: {
244
+ role: 'menu',
245
+ ariaAttributes: [
246
+ 'role="menu" on the popover panel',
247
+ 'role="menuitemcheckbox" with aria-checked on each MenuItem',
248
+ 'role="separator" on MenuSeparator',
249
+ 'role="group" with aria-label on MenuGroup',
250
+ 'aria-haspopup="menu" on the trigger wrapper',
251
+ 'aria-expanded on the trigger wrapper',
252
+ 'aria-controls linking trigger to popover via id',
253
+ 'aria-disabled on disabled MenuItems',
254
+ ],
255
+ keyboardInteractions: [
256
+ 'Enter/Space on trigger — toggle menu',
257
+ 'ArrowDown on trigger — open menu, focus first item',
258
+ 'ArrowUp on trigger — open menu, focus last item',
259
+ 'ArrowDown — focus next enabled item (wraps)',
260
+ 'ArrowUp — focus previous enabled item (wraps)',
261
+ 'Enter/Space — select focused item, close menu',
262
+ 'Escape — close menu, return focus to trigger',
263
+ 'Tab — close menu, let focus move naturally',
264
+ 'Home — focus first enabled item',
265
+ 'End — focus last enabled item',
266
+ ],
267
+ notes: 'Focus returns to the trigger element after the menu is dismissed via Escape or selection. ' +
268
+ 'Disabled items are skipped during keyboard navigation and have aria-disabled. ' +
269
+ 'Selected items use role="menuitemcheckbox" with aria-checked for screen reader announcement. ' +
270
+ 'The popover is portaled to document.body but remains semantically linked to the trigger via aria-controls.',
271
+ },
272
+ };
@@ -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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lucent-ui",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "An AI-first React component library with machine-readable manifests.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -25,6 +25,18 @@
25
25
  "dist-server",
26
26
  "dist-cli"
27
27
  ],
28
+ "scripts": {
29
+ "dev": "vite --config vite.dev.config.ts",
30
+ "build": "vite build",
31
+ "build:server": "tsc -p server/tsconfig.json",
32
+ "build:cli": "tsc -p cli/tsconfig.json && cp cli/template.manifest.json dist-cli/cli/template.manifest.json",
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "prepublishOnly": "tsc --noEmit && pnpm build && pnpm build:server && pnpm build:cli",
36
+ "changeset": "changeset",
37
+ "version-packages": "changeset version",
38
+ "release": "pnpm prepublishOnly && changeset publish"
39
+ },
28
40
  "keywords": [
29
41
  "react",
30
42
  "component-library",
@@ -39,6 +51,7 @@
39
51
  },
40
52
  "author": "Rozina Szogyenyi",
41
53
  "license": "MIT",
54
+ "packageManager": "pnpm@10.30.3",
42
55
  "peerDependencies": {
43
56
  "react": "^18.0.0 || ^19.0.0",
44
57
  "react-dom": "^18.0.0 || ^19.0.0"
@@ -60,16 +73,5 @@
60
73
  "dependencies": {
61
74
  "@modelcontextprotocol/sdk": "^1.27.1",
62
75
  "zod": "^4.3.6"
63
- },
64
- "scripts": {
65
- "dev": "vite --config vite.dev.config.ts",
66
- "build": "vite build",
67
- "build:server": "tsc -p server/tsconfig.json",
68
- "build:cli": "tsc -p cli/tsconfig.json && cp cli/template.manifest.json dist-cli/cli/template.manifest.json",
69
- "test": "vitest run",
70
- "test:watch": "vitest",
71
- "changeset": "changeset",
72
- "version-packages": "changeset version",
73
- "release": "pnpm prepublishOnly && changeset publish"
74
76
  }
75
- }
77
+ }