lucent-ui 0.18.0 → 0.19.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
@@ -55,6 +55,13 @@ export const COMPONENT_MANIFEST = {
55
55
  default: 'false',
56
56
  description: 'Prevents interaction and dims the label.',
57
57
  },
58
+ {
59
+ name: 'portalContainer',
60
+ type: 'HTMLElement | null',
61
+ required: false,
62
+ description: 'DOM element to portal the popover into. Defaults to document.body. ' +
63
+ 'Set this to a wrapper element to preserve CSS custom property inheritance for per-section theming.',
64
+ },
58
65
  {
59
66
  name: 'id',
60
67
  type: 'string',
@@ -24,9 +24,10 @@ export const COMPONENT_MANIFEST = {
24
24
  '`-end`, or centered) is preserved during the flip. Position is computed with `getBoundingClientRect` ' +
25
25
  'on mount and rendered via `position: fixed` in a portal.\n\n' +
26
26
  '## Portal rendering\n' +
27
- 'The popover is portaled to `document.body` via `createPortal`. This prevents overflow clipping from ' +
27
+ 'The popover is portaled via `createPortal` (default: `document.body`). This prevents overflow clipping from ' +
28
28
  'parent containers with `overflow: hidden`. The trigger wrapper stays inline in the DOM tree so it ' +
29
- 'participates in layout normally.\n\n' +
29
+ 'participates in layout normally. The `portalContainer` prop lets consumers render the portal inside a ' +
30
+ 'wrapper element that sets `--lucent-*` CSS custom property overrides, preserving per-section theming.\n\n' +
30
31
  '## Keyboard navigation\n' +
31
32
  'Follows WAI-ARIA Menu Button pattern. Arrow keys cycle through enabled items (wrapping). Enter/Space ' +
32
33
  'selects the active item and closes the menu. Escape closes without selection. Tab closes and lets ' +
@@ -88,6 +89,13 @@ export const COMPONENT_MANIFEST = {
88
89
  required: false,
89
90
  description: 'Callback fired when the menu opens or closes. Receives the new open state.',
90
91
  },
92
+ {
93
+ name: 'portalContainer',
94
+ type: 'HTMLElement | null',
95
+ required: false,
96
+ description: 'DOM element to portal the popover into. Defaults to document.body. ' +
97
+ 'Set this to a wrapper element to preserve CSS custom property inheritance for per-section theming.',
98
+ },
91
99
  {
92
100
  name: 'style',
93
101
  type: 'object',
@@ -267,6 +275,6 @@ export const COMPONENT_MANIFEST = {
267
275
  notes: 'Focus returns to the trigger element after the menu is dismissed via Escape or selection. ' +
268
276
  'Disabled items are skipped during keyboard navigation and have aria-disabled. ' +
269
277
  '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.',
278
+ 'The popover is portaled to document.body (or portalContainer) but remains semantically linked to the trigger via aria-controls.',
271
279
  },
272
280
  };
@@ -0,0 +1,233 @@
1
+ export const COMPONENT_MANIFEST = {
2
+ id: 'toast',
3
+ name: 'Toast',
4
+ tier: 'molecule',
5
+ domain: 'neutral',
6
+ specVersion: '0.1',
7
+ description: 'Ephemeral notification system with auto-dismiss, cascading card-stack, ' +
8
+ 'hover-to-expand, action buttons/links, and animated enter/exit transitions. ' +
9
+ 'Comprises ToastProvider (context + portal), useToast hook, and toast card rendering.',
10
+ designIntent: 'Toast provides ephemeral feedback for actions that don\'t require the user to navigate away or ' +
11
+ 'acknowledge inline. It fills the gap between Alert (persistent, inline) and Modal (blocking). ' +
12
+ '\n\n' +
13
+ '**Architecture**: ToastProvider wraps the app and manages a queue of toast entries. It renders ' +
14
+ 'toasts into a React portal (document.body by default, configurable via portalContainer) so they ' +
15
+ 'float above all page content regardless of stacking contexts. The useToast() hook exposes an ' +
16
+ 'imperative `toast()` function that returns a dismissible id, and a `dismiss()` function. ' +
17
+ '\n\n' +
18
+ '**Positioning**: The viewport is fixed-positioned with a stable anchor edge. For bottom positions, ' +
19
+ 'the toast\'s top edge is pinned 120px from the viewport bottom (ANCHOR_INSET_BOTTOM), so taller ' +
20
+ 'toasts extend downward toward the screen edge — the top never jumps. For top positions, the top ' +
21
+ 'edge is pinned 40px from the viewport top (ANCHOR_INSET), keeping toasts close to the top bar ' +
22
+ 'or header area. Both distances are separate constants to allow independent tuning. Six positions ' +
23
+ 'are supported: top-left, top-center, top-right, bottom-left, bottom-center, bottom-right. ' +
24
+ '\n\n' +
25
+ '**Cascading stack**: When multiple toasts are active, only the newest (front) toast shows its full ' +
26
+ 'content. Older toasts render as empty card shells (border + background, no content) that peek ' +
27
+ 'behind the front toast with progressive scaleX reduction and opacity fade. Each shell is ' +
28
+ 'height-matched to the front toast via measurement so the peek gaps are perfectly uniform. ' +
29
+ 'Up to 3 stacked shells are visible behind the front toast. ' +
30
+ '\n\n' +
31
+ '**Hover to expand**: Hovering the stack smoothly expands all toasts into a full vertical list ' +
32
+ 'with content fading in, card heights animating to natural size, and shadows appearing. The ' +
33
+ 'expanded stack uses flex column-reverse for bottom positions so older toasts fan upward into ' +
34
+ 'the viewport. A 150ms debounced collapse timer prevents flicker when the cursor moves between ' +
35
+ 'toasts in the expanded list. ' +
36
+ '\n\n' +
37
+ '**Enter/exit animations**: New toasts slide in from the screen edge with opacity fade and slight ' +
38
+ 'scale-up (requestAnimationFrame double-raf triggers the CSS transition). Dismissed toasts slide ' +
39
+ 'back toward the edge and fade out over 200ms. ' +
40
+ '\n\n' +
41
+ '**Auto-dismiss**: Each toast auto-dismisses after a configurable duration (default 5s). Pass ' +
42
+ 'Infinity to disable. The timer is managed per-toast via useEffect, so each toast dismisses ' +
43
+ 'independently. ' +
44
+ '\n\n' +
45
+ '**Actions**: Toasts support an inline action rendered as either a bordered button (default, ' +
46
+ 'matching the Undo pattern from Sonner/Google) or an underlined link. Clicking the action fires ' +
47
+ 'the callback and auto-dismisses the toast. ' +
48
+ '\n\n' +
49
+ '**Variants**: default (neutral border), success, warning, danger, info — matching Alert\'s ' +
50
+ 'semantic token usage. Each variant sets border color and icon color from status tokens. ' +
51
+ 'Non-default variants include a built-in 16×16 SVG icon (same as Alert). ' +
52
+ '\n\n' +
53
+ '**Multi-line text**: title is always shown (semibold). Optional description supports newlines ' +
54
+ 'via white-space: pre-line. All text uses the Text atom.',
55
+ props: [
56
+ // ─── ToastProvider props ──────────────────────────────────────────
57
+ {
58
+ name: 'position',
59
+ type: 'enum',
60
+ required: false,
61
+ default: 'bottom-right',
62
+ description: 'Screen edge and alignment where the toast stack appears.',
63
+ enumValues: ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'],
64
+ },
65
+ {
66
+ name: 'duration',
67
+ type: 'number',
68
+ required: false,
69
+ default: '5000',
70
+ description: 'Default auto-dismiss duration in milliseconds. Applies to all toasts unless overridden per-toast.',
71
+ },
72
+ {
73
+ name: 'max',
74
+ type: 'number',
75
+ required: false,
76
+ default: '5',
77
+ description: 'Maximum number of toasts in the queue. When exceeded, the oldest toast is dismissed with an exit animation.',
78
+ },
79
+ {
80
+ name: 'portalContainer',
81
+ type: 'object',
82
+ required: false,
83
+ description: 'DOM element to render the toast portal into. Defaults to document.body. Useful for scoping toasts to a specific container (e.g. inside an iframe or shadow DOM).',
84
+ },
85
+ // ─── ToastOptions (per-toast, passed to toast()) ─────────────────
86
+ {
87
+ name: 'title',
88
+ type: 'string',
89
+ required: true,
90
+ description: 'Primary message line, rendered in semibold. Always visible.',
91
+ },
92
+ {
93
+ name: 'description',
94
+ type: 'string',
95
+ required: false,
96
+ description: 'Secondary description text below the title. Supports multi-line via newline characters (\\n). Rendered in secondary color.',
97
+ },
98
+ {
99
+ name: 'variant',
100
+ type: 'enum',
101
+ required: false,
102
+ default: 'default',
103
+ description: 'Visual variant controlling border color, icon, and icon color. Matches Alert\'s semantic status tokens.',
104
+ enumValues: ['default', 'success', 'warning', 'danger', 'info'],
105
+ },
106
+ {
107
+ name: 'duration (per-toast)',
108
+ type: 'number',
109
+ required: false,
110
+ default: '5000',
111
+ description: 'Override the provider-level auto-dismiss duration for this specific toast. Pass Infinity to disable auto-dismiss.',
112
+ },
113
+ {
114
+ name: 'action',
115
+ type: 'object',
116
+ required: false,
117
+ description: 'Inline action rendered beside the dismiss button. Object with { label: string, onClick: () => void, style?: "button" | "link" }. Button style renders a bordered pill button (default). Link style renders an underlined text link. Clicking fires onClick and auto-dismisses the toast.',
118
+ },
119
+ {
120
+ name: 'icon',
121
+ type: 'ReactNode',
122
+ required: false,
123
+ description: 'Custom icon to replace the built-in variant icon. Only used when variant is not "default". Pass null to suppress the icon entirely.',
124
+ },
125
+ ],
126
+ usageExamples: [
127
+ {
128
+ title: 'Provider setup',
129
+ description: 'Wrap your app with ToastProvider inside LucentProvider. Position and duration are configurable at the provider level.',
130
+ code: `<LucentProvider>
131
+ <ToastProvider position="bottom-right" duration={5000}>
132
+ <App />
133
+ </ToastProvider>
134
+ </LucentProvider>`,
135
+ },
136
+ {
137
+ title: 'Basic toast',
138
+ description: 'Minimal toast with just a title. Uses the default variant (neutral border, no icon).',
139
+ code: `const { toast } = useToast();
140
+ toast({ title: 'Changes saved' });`,
141
+ },
142
+ {
143
+ title: 'Success with description',
144
+ description: 'Two-line toast with a success variant. The description supports newlines via \\n.',
145
+ code: `toast({
146
+ title: 'Order placed!',
147
+ description: 'You\\'ll receive a confirmation email shortly.',
148
+ variant: 'success',
149
+ });`,
150
+ },
151
+ {
152
+ title: 'Danger with undo button',
153
+ description: 'Destructive action feedback with an inline undo button. Clicking the action auto-dismisses.',
154
+ code: `toast({
155
+ title: 'Message deleted',
156
+ variant: 'danger',
157
+ action: {
158
+ label: 'Undo',
159
+ onClick: () => restoreMessage(id),
160
+ },
161
+ });`,
162
+ },
163
+ {
164
+ title: 'Action as link',
165
+ description: 'Action rendered as an underlined link instead of a bordered button.',
166
+ code: `toast({
167
+ title: 'Event created',
168
+ description: 'Sunday, December 03, 2023 at 9:00 AM',
169
+ action: {
170
+ label: 'View',
171
+ style: 'link',
172
+ onClick: () => navigate('/events/123'),
173
+ },
174
+ });`,
175
+ },
176
+ {
177
+ title: 'Multi-line description',
178
+ description: 'Description with embedded newlines rendered via white-space: pre-line.',
179
+ code: `toast({
180
+ title: 'Approaching limit',
181
+ description: 'You have used 80% of your monthly quota.\\nConsider upgrading your plan.',
182
+ variant: 'warning',
183
+ });`,
184
+ },
185
+ {
186
+ title: 'Persistent (no auto-dismiss)',
187
+ description: 'Pass Infinity as duration to keep the toast visible until manually dismissed.',
188
+ code: `toast({
189
+ title: 'Upload in progress',
190
+ description: 'Please do not close this tab.',
191
+ duration: Infinity,
192
+ });`,
193
+ },
194
+ {
195
+ title: 'Programmatic dismiss',
196
+ description: 'toast() returns an id that can be passed to dismiss() to remove it programmatically.',
197
+ code: `const { toast, dismiss } = useToast();
198
+ const id = toast({ title: 'Processing...', duration: Infinity });
199
+ // Later:
200
+ dismiss(id);`,
201
+ },
202
+ {
203
+ title: 'Custom icon',
204
+ description: 'Override the built-in variant icon with a custom SVG.',
205
+ code: `toast({
206
+ title: 'Synced',
207
+ variant: 'success',
208
+ icon: <CloudSyncIcon />,
209
+ });`,
210
+ },
211
+ ],
212
+ compositionGraph: [
213
+ { componentId: 'text', componentName: 'Text', role: 'Renders title and description text with correct typography', required: true },
214
+ { componentId: 'icon', componentName: 'Icon (built-in SVGs)', role: 'Status variant icons (info, success, warning, danger)', required: false },
215
+ ],
216
+ accessibility: {
217
+ role: 'status',
218
+ ariaAttributes: ['aria-live', 'aria-hidden', 'aria-label'],
219
+ keyboardInteractions: [
220
+ 'Tab: focuses the dismiss button and action button within the toast',
221
+ 'Enter/Space: activates the focused dismiss or action button',
222
+ 'Escape: no default behavior (consider adding dismiss-on-escape if needed)',
223
+ ],
224
+ notes: 'Each toast uses role="status" with aria-live="polite" so screen readers announce the content ' +
225
+ 'without interrupting the current task. This is appropriate for non-urgent status messages like ' +
226
+ '"Changes saved" or "Profile updated". For truly urgent errors that must interrupt the user, ' +
227
+ 'consider wrapping the ToastProvider output in a role="alert" region or using the Alert component ' +
228
+ 'instead. The dismiss button carries aria-label="Dismiss" for screen reader clarity. Stacked ' +
229
+ 'toasts behind the front toast are marked aria-hidden="true" to prevent screen readers from ' +
230
+ 'announcing duplicate or hidden content. When the stack expands on hover, aria-hidden is removed ' +
231
+ 'so all toasts become accessible.',
232
+ },
233
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lucent-ui",
3
- "version": "0.18.0",
3
+ "version": "0.19.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,18 +25,6 @@
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
- },
40
28
  "keywords": [
41
29
  "react",
42
30
  "component-library",
@@ -51,7 +39,6 @@
51
39
  },
52
40
  "author": "Rozina Szogyenyi",
53
41
  "license": "MIT",
54
- "packageManager": "pnpm@10.30.3",
55
42
  "peerDependencies": {
56
43
  "react": "^18.0.0 || ^19.0.0",
57
44
  "react-dom": "^18.0.0 || ^19.0.0"
@@ -73,5 +60,16 @@
73
60
  "dependencies": {
74
61
  "@modelcontextprotocol/sdk": "^1.27.1",
75
62
  "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"
76
74
  }
77
- }
75
+ }
@@ -1,28 +0,0 @@
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
- });