@backbay/glia-desktop 0.2.0-alpha.1

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 (58) hide show
  1. package/package.json +37 -0
  2. package/src/components/GliaErrorBoundary/GliaErrorBoundary.tsx +202 -0
  3. package/src/components/GliaErrorBoundary/index.ts +2 -0
  4. package/src/components/GliaErrorBoundary/useErrorBoundary.tsx +61 -0
  5. package/src/components/desktop/Desktop.tsx +204 -0
  6. package/src/components/desktop/DesktopIcon.tsx +293 -0
  7. package/src/components/desktop/FileBrowser.stories.tsx +287 -0
  8. package/src/components/desktop/FileBrowser.tsx +981 -0
  9. package/src/components/desktop/SnapZoneOverlay.tsx +230 -0
  10. package/src/components/desktop/index.ts +15 -0
  11. package/src/components/index.ts +16 -0
  12. package/src/components/shell/Clock.tsx +212 -0
  13. package/src/components/shell/ContextMenu.tsx +249 -0
  14. package/src/components/shell/GlassMenubar.stories.tsx +382 -0
  15. package/src/components/shell/GlassMenubar.tsx +632 -0
  16. package/src/components/shell/NotificationCenter.stories.tsx +515 -0
  17. package/src/components/shell/NotificationCenter.tsx +545 -0
  18. package/src/components/shell/NotificationToast.tsx +319 -0
  19. package/src/components/shell/StartMenu.stories.tsx +249 -0
  20. package/src/components/shell/StartMenu.tsx +568 -0
  21. package/src/components/shell/SystemTray.stories.tsx +492 -0
  22. package/src/components/shell/SystemTray.tsx +457 -0
  23. package/src/components/shell/Taskbar.tsx +387 -0
  24. package/src/components/shell/TaskbarButton.tsx +208 -0
  25. package/src/components/shell/index.ts +37 -0
  26. package/src/components/window/Window.tsx +751 -0
  27. package/src/components/window/WindowTitlebar.tsx +359 -0
  28. package/src/components/window/index.ts +10 -0
  29. package/src/core/desktop/fileBrowserTypes.ts +112 -0
  30. package/src/core/desktop/index.ts +8 -0
  31. package/src/core/desktop/types.ts +185 -0
  32. package/src/core/desktop/useFileBrowser.tsx +405 -0
  33. package/src/core/desktop/useSnapZones.tsx +203 -0
  34. package/src/core/index.ts +11 -0
  35. package/src/core/shell/__tests__/useNotifications.test.ts +155 -0
  36. package/src/core/shell/__tests__/useTaskbar.test.ts +99 -0
  37. package/src/core/shell/index.ts +10 -0
  38. package/src/core/shell/notificationTypes.ts +110 -0
  39. package/src/core/shell/types.ts +194 -0
  40. package/src/core/shell/useNotifications.tsx +259 -0
  41. package/src/core/shell/useStartMenu.tsx +242 -0
  42. package/src/core/shell/useSystemTray.tsx +175 -0
  43. package/src/core/shell/useTaskbar.tsx +320 -0
  44. package/src/core/useKeyboardNavigation.ts +41 -0
  45. package/src/core/window/__tests__/useWindowManager.test.ts +269 -0
  46. package/src/core/window/index.ts +6 -0
  47. package/src/core/window/types.ts +149 -0
  48. package/src/core/window/useWindowManager.tsx +1154 -0
  49. package/src/index.ts +146 -0
  50. package/src/lib/utils.ts +6 -0
  51. package/src/providers/DesktopOSProvider.tsx +391 -0
  52. package/src/providers/ThemeProvider.tsx +162 -0
  53. package/src/providers/index.ts +6 -0
  54. package/src/themes/default.ts +107 -0
  55. package/src/themes/index.ts +6 -0
  56. package/src/themes/types.ts +230 -0
  57. package/tsconfig.json +20 -0
  58. package/tsup.config.ts +16 -0
@@ -0,0 +1,545 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * @backbay/glia Desktop OS - NotificationCenter Component
5
+ *
6
+ * Slide-out panel displaying all notifications, grouped by key or date.
7
+ * Supports unread indicators, action buttons, dismiss, and mark-all-read.
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * const { isPanelOpen, closePanel } = useNotifications();
12
+ *
13
+ * <NotificationCenter
14
+ * isOpen={isPanelOpen}
15
+ * onClose={closePanel}
16
+ * />
17
+ * ```
18
+ */
19
+
20
+ import { useCallback, useEffect, type CSSProperties } from 'react';
21
+ import { AnimatePresence, motion } from 'framer-motion';
22
+ import { useNotifications } from '../../core/shell/useNotifications';
23
+ import type { Notification, NotificationAction } from '../../core/shell/notificationTypes';
24
+
25
+ // ═══════════════════════════════════════════════════════════════════════════
26
+ // Types
27
+ // ═══════════════════════════════════════════════════════════════════════════
28
+
29
+ export interface NotificationCenterProps {
30
+ /** Whether the panel is open */
31
+ isOpen: boolean;
32
+ /** Called when the panel should close */
33
+ onClose: () => void;
34
+ /** Additional CSS class name */
35
+ className?: string;
36
+ /** Additional inline styles */
37
+ style?: CSSProperties;
38
+ }
39
+
40
+ // ═══════════════════════════════════════════════════════════════════════════
41
+ // Constants
42
+ // ═══════════════════════════════════════════════════════════════════════════
43
+
44
+ const TYPE_ICONS: Record<string, string> = {
45
+ info: '\u2139\uFE0F',
46
+ warning: '\u26A0\uFE0F',
47
+ error: '\u274C',
48
+ success: '\u2705',
49
+ };
50
+
51
+ const TYPE_COLORS: Record<string, string> = {
52
+ info: 'var(--glia-color-text-soft, #888888)',
53
+ warning: 'var(--glia-color-accent-warning, #e6a817)',
54
+ error: 'var(--glia-color-accent-destructive, #c44444)',
55
+ success: 'var(--glia-color-accent-positive, #44c444)',
56
+ };
57
+
58
+ // ═══════════════════════════════════════════════════════════════════════════
59
+ // Styles
60
+ // ═══════════════════════════════════════════════════════════════════════════
61
+
62
+ const styles: Record<string, CSSProperties> = {
63
+ overlay: {
64
+ position: 'fixed',
65
+ inset: 0,
66
+ zIndex: 9998,
67
+ },
68
+ panel: {
69
+ position: 'fixed',
70
+ right: 0,
71
+ bottom: 'var(--glia-spacing-taskbar-height, 48px)',
72
+ top: 0,
73
+ width: '380px',
74
+ maxWidth: '100vw',
75
+ background: 'var(--glia-color-bg-elevated, rgba(10, 10, 10, 0.85))',
76
+ backdropFilter: 'blur(24px)',
77
+ WebkitBackdropFilter: 'blur(24px)',
78
+ borderLeft: '1px solid rgba(255, 255, 255, 0.06)',
79
+ display: 'flex',
80
+ flexDirection: 'column',
81
+ zIndex: 9999,
82
+ boxShadow: '-4px 0 24px rgba(0, 0, 0, 0.4), inset 1px 0 0 rgba(255, 255, 255, 0.02)',
83
+ },
84
+ header: {
85
+ display: 'flex',
86
+ alignItems: 'center',
87
+ justifyContent: 'space-between',
88
+ padding: '16px 16px 12px',
89
+ borderBottom: '1px solid var(--glia-color-border, #333333)',
90
+ flexShrink: 0,
91
+ },
92
+ headerLeft: {
93
+ display: 'flex',
94
+ alignItems: 'center',
95
+ gap: '8px',
96
+ },
97
+ title: {
98
+ fontFamily: 'var(--glia-font-mono)',
99
+ fontSize: '12px',
100
+ fontWeight: 600,
101
+ letterSpacing: '0.1em',
102
+ textTransform: 'uppercase' as const,
103
+ color: 'var(--glia-color-text-primary, #ffffff)',
104
+ margin: 0,
105
+ },
106
+ badge: {
107
+ fontFamily: 'var(--glia-font-mono)',
108
+ fontSize: '10px',
109
+ fontWeight: 600,
110
+ color: 'var(--glia-color-accent, #d4a84b)',
111
+ background: 'var(--glia-glass-active-shadow, rgba(212, 168, 75, 0.2))',
112
+ borderRadius: '10px',
113
+ padding: '2px 7px',
114
+ minWidth: '18px',
115
+ textAlign: 'center' as const,
116
+ },
117
+ headerActions: {
118
+ display: 'flex',
119
+ alignItems: 'center',
120
+ gap: '4px',
121
+ },
122
+ headerButton: {
123
+ background: 'transparent',
124
+ border: 'none',
125
+ fontFamily: 'var(--glia-font-mono)',
126
+ fontSize: '10px',
127
+ letterSpacing: '0.05em',
128
+ textTransform: 'uppercase' as const,
129
+ color: 'var(--glia-color-text-soft, #888888)',
130
+ cursor: 'pointer',
131
+ padding: '4px 8px',
132
+ borderRadius: '3px',
133
+ transition: 'all 0.1s ease',
134
+ },
135
+ closeButton: {
136
+ background: 'transparent',
137
+ border: 'none',
138
+ color: 'var(--glia-color-text-soft, #888888)',
139
+ cursor: 'pointer',
140
+ padding: '4px 6px',
141
+ borderRadius: '3px',
142
+ fontSize: '16px',
143
+ lineHeight: 1,
144
+ transition: 'all 0.1s ease',
145
+ },
146
+ scrollArea: {
147
+ flex: 1,
148
+ overflowY: 'auto' as const,
149
+ overflowX: 'hidden' as const,
150
+ scrollbarWidth: 'thin' as const,
151
+ },
152
+ groupHeader: {
153
+ fontFamily: 'var(--glia-font-mono)',
154
+ fontSize: '10px',
155
+ letterSpacing: '0.1em',
156
+ textTransform: 'uppercase' as const,
157
+ color: 'var(--glia-color-text-soft, #888888)',
158
+ padding: '12px 16px 6px',
159
+ margin: 0,
160
+ },
161
+ notificationItem: {
162
+ display: 'flex',
163
+ gap: '10px',
164
+ padding: '12px 16px',
165
+ borderBottom: '1px solid rgba(255, 255, 255, 0.06)',
166
+ cursor: 'default',
167
+ transition: 'background 0.1s ease',
168
+ position: 'relative' as const,
169
+ },
170
+ unreadIndicator: {
171
+ position: 'absolute' as const,
172
+ left: 0,
173
+ top: 0,
174
+ bottom: 0,
175
+ width: '3px',
176
+ background: 'var(--glia-color-accent, #d4a84b)',
177
+ borderRadius: '0 2px 2px 0',
178
+ },
179
+ typeIcon: {
180
+ fontSize: '16px',
181
+ flexShrink: 0,
182
+ marginTop: '1px',
183
+ width: '20px',
184
+ textAlign: 'center' as const,
185
+ },
186
+ content: {
187
+ flex: 1,
188
+ minWidth: 0,
189
+ },
190
+ notifTitle: {
191
+ fontFamily: 'var(--glia-font-body, sans-serif)',
192
+ fontSize: '13px',
193
+ fontWeight: 500,
194
+ color: 'var(--glia-color-text-primary, #ffffff)',
195
+ margin: 0,
196
+ lineHeight: 1.3,
197
+ },
198
+ notifMessage: {
199
+ fontFamily: 'var(--glia-font-body, sans-serif)',
200
+ fontSize: '12px',
201
+ color: 'var(--glia-color-text-muted, #cccccc)',
202
+ margin: '3px 0 0',
203
+ lineHeight: 1.4,
204
+ },
205
+ notifMeta: {
206
+ fontFamily: 'var(--glia-font-mono)',
207
+ fontSize: '10px',
208
+ color: 'var(--glia-color-text-soft, #888888)',
209
+ marginTop: '5px',
210
+ },
211
+ notifActions: {
212
+ display: 'flex',
213
+ gap: '6px',
214
+ marginTop: '8px',
215
+ },
216
+ actionButton: {
217
+ fontFamily: 'var(--glia-font-mono)',
218
+ fontSize: '10px',
219
+ letterSpacing: '0.05em',
220
+ textTransform: 'uppercase' as const,
221
+ padding: '4px 10px',
222
+ borderRadius: '3px',
223
+ border: 'none',
224
+ cursor: 'pointer',
225
+ transition: 'all 0.1s ease',
226
+ },
227
+ actionPrimary: {
228
+ background: 'var(--glia-glass-active-shadow, rgba(212, 168, 75, 0.2))',
229
+ color: 'var(--glia-color-accent, #d4a84b)',
230
+ },
231
+ actionSecondary: {
232
+ background: 'rgba(255, 255, 255, 0.06)',
233
+ color: 'var(--glia-color-text-muted, #cccccc)',
234
+ },
235
+ dismissButton: {
236
+ background: 'transparent',
237
+ border: 'none',
238
+ color: 'var(--glia-color-text-soft, #888888)',
239
+ cursor: 'pointer',
240
+ padding: '2px 4px',
241
+ fontSize: '12px',
242
+ lineHeight: 1,
243
+ flexShrink: 0,
244
+ borderRadius: '3px',
245
+ transition: 'all 0.1s ease',
246
+ opacity: 0.5,
247
+ },
248
+ emptyState: {
249
+ display: 'flex',
250
+ flexDirection: 'column' as const,
251
+ alignItems: 'center',
252
+ justifyContent: 'center',
253
+ padding: '48px 16px',
254
+ gap: '12px',
255
+ },
256
+ emptyIcon: {
257
+ fontSize: '32px',
258
+ opacity: 0.3,
259
+ },
260
+ emptyText: {
261
+ fontFamily: 'var(--glia-font-mono)',
262
+ fontSize: '12px',
263
+ color: 'var(--glia-color-text-soft, #888888)',
264
+ letterSpacing: '0.05em',
265
+ textTransform: 'uppercase' as const,
266
+ },
267
+ footer: {
268
+ display: 'flex',
269
+ justifyContent: 'center',
270
+ padding: '10px 16px',
271
+ borderTop: '1px solid var(--glia-color-border, #333333)',
272
+ flexShrink: 0,
273
+ },
274
+ clearAllButton: {
275
+ background: 'transparent',
276
+ border: 'none',
277
+ fontFamily: 'var(--glia-font-mono)',
278
+ fontSize: '10px',
279
+ letterSpacing: '0.05em',
280
+ textTransform: 'uppercase' as const,
281
+ color: 'var(--glia-color-accent-destructive, #c44444)',
282
+ cursor: 'pointer',
283
+ padding: '4px 12px',
284
+ borderRadius: '3px',
285
+ transition: 'all 0.1s ease',
286
+ },
287
+ };
288
+
289
+ // ═══════════════════════════════════════════════════════════════════════════
290
+ // Helpers
291
+ // ═══════════════════════════════════════════════════════════════════════════
292
+
293
+ function formatRelativeTime(timestamp: number): string {
294
+ const diff = Date.now() - timestamp;
295
+ const seconds = Math.floor(diff / 1000);
296
+ const minutes = Math.floor(seconds / 60);
297
+ const hours = Math.floor(minutes / 60);
298
+ const days = Math.floor(hours / 24);
299
+
300
+ if (seconds < 60) return 'just now';
301
+ if (minutes < 60) return `${minutes}m ago`;
302
+ if (hours < 24) return `${hours}h ago`;
303
+ return `${days}d ago`;
304
+ }
305
+
306
+ // ═══════════════════════════════════════════════════════════════════════════
307
+ // Sub-Components
308
+ // ═══════════════════════════════════════════════════════════════════════════
309
+
310
+ function NotificationItem({
311
+ notification,
312
+ onDismiss,
313
+ onMarkRead,
314
+ }: {
315
+ notification: Notification;
316
+ onDismiss: (id: string) => void;
317
+ onMarkRead: (id: string) => void;
318
+ }) {
319
+ const handleClick = useCallback(() => {
320
+ if (!notification.read) {
321
+ onMarkRead(notification.id);
322
+ }
323
+ }, [notification.id, notification.read, onMarkRead]);
324
+
325
+ const handleAction = useCallback(
326
+ (action: NotificationAction) => {
327
+ action.action();
328
+ onMarkRead(notification.id);
329
+ },
330
+ [notification.id, onMarkRead]
331
+ );
332
+
333
+ return (
334
+ <motion.div
335
+ style={styles.notificationItem}
336
+ onClick={handleClick}
337
+ initial={{ opacity: 0, x: 20 }}
338
+ animate={{ opacity: 1, x: 0 }}
339
+ exit={{ opacity: 0, x: 20, height: 0, padding: 0 }}
340
+ transition={{ duration: 0.15 }}
341
+ whileHover={{ background: 'rgba(255, 255, 255, 0.04)', boxShadow: '0 0 20px rgba(34, 211, 238, 0.04)' }}
342
+ >
343
+ {/* Unread indicator */}
344
+ {!notification.read && <div style={styles.unreadIndicator} />}
345
+
346
+ {/* Type icon */}
347
+ <div style={{ ...styles.typeIcon, color: TYPE_COLORS[notification.type] }}>
348
+ {notification.icon ?? TYPE_ICONS[notification.type]}
349
+ </div>
350
+
351
+ {/* Content */}
352
+ <div style={styles.content}>
353
+ <p style={styles.notifTitle}>{notification.title}</p>
354
+ {notification.message && (
355
+ <p style={styles.notifMessage}>{notification.message}</p>
356
+ )}
357
+ <div style={styles.notifMeta}>
358
+ {formatRelativeTime(notification.timestamp)}
359
+ {notification.appId && ` \u00B7 ${notification.appId}`}
360
+ </div>
361
+ {notification.actions && notification.actions.length > 0 && (
362
+ <div style={styles.notifActions}>
363
+ {notification.actions.map((action) => (
364
+ <button
365
+ key={action.id}
366
+ style={{
367
+ ...styles.actionButton,
368
+ ...(action.primary ? styles.actionPrimary : styles.actionSecondary),
369
+ }}
370
+ onClick={(e) => {
371
+ e.stopPropagation();
372
+ handleAction(action);
373
+ }}
374
+ >
375
+ {action.label}
376
+ </button>
377
+ ))}
378
+ </div>
379
+ )}
380
+ </div>
381
+
382
+ {/* Dismiss button */}
383
+ <button
384
+ style={styles.dismissButton}
385
+ onClick={(e) => {
386
+ e.stopPropagation();
387
+ onDismiss(notification.id);
388
+ }}
389
+ title="Dismiss"
390
+ >
391
+ \u2715
392
+ </button>
393
+ </motion.div>
394
+ );
395
+ }
396
+
397
+ // ═══════════════════════════════════════════════════════════════════════════
398
+ // Main Component
399
+ // ═══════════════════════════════════════════════════════════════════════════
400
+
401
+ export function NotificationCenter({
402
+ isOpen,
403
+ onClose,
404
+ className,
405
+ style,
406
+ }: NotificationCenterProps) {
407
+ const {
408
+ notifications,
409
+ unreadCount,
410
+ groups,
411
+ dismiss,
412
+ markRead,
413
+ markAllRead,
414
+ clearAll,
415
+ } = useNotifications();
416
+
417
+ // Escape to close
418
+ useEffect(() => {
419
+ if (!isOpen) return;
420
+
421
+ const handleKeyDown = (e: KeyboardEvent) => {
422
+ if (e.key === 'Escape') {
423
+ e.preventDefault();
424
+ onClose();
425
+ }
426
+ };
427
+
428
+ document.addEventListener('keydown', handleKeyDown);
429
+ return () => document.removeEventListener('keydown', handleKeyDown);
430
+ }, [isOpen, onClose]);
431
+
432
+ return (
433
+ <AnimatePresence>
434
+ {isOpen && (
435
+ <>
436
+ {/* Overlay */}
437
+ <motion.div
438
+ key="notification-overlay"
439
+ style={styles.overlay}
440
+ initial={{ opacity: 0 }}
441
+ animate={{ opacity: 1 }}
442
+ exit={{ opacity: 0 }}
443
+ onClick={onClose}
444
+ />
445
+
446
+ {/* Panel */}
447
+ <motion.div
448
+ key="notification-panel"
449
+ style={{ ...styles.panel, ...style }}
450
+ className={className}
451
+ initial={{ x: '100%' }}
452
+ animate={{ x: 0 }}
453
+ exit={{ x: '100%' }}
454
+ transition={{ type: 'spring', damping: 30, stiffness: 350 }}
455
+ >
456
+ {/* Header */}
457
+ <div style={styles.header}>
458
+ <div style={styles.headerLeft}>
459
+ <h2 style={styles.title}>Notifications</h2>
460
+ {unreadCount > 0 && (
461
+ <span style={styles.badge}>{unreadCount}</span>
462
+ )}
463
+ </div>
464
+ <div style={styles.headerActions}>
465
+ {unreadCount > 0 && (
466
+ <button
467
+ style={styles.headerButton}
468
+ onClick={markAllRead}
469
+ onMouseEnter={(e) => {
470
+ e.currentTarget.style.color = 'var(--glia-color-text-primary, #ffffff)';
471
+ e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)';
472
+ }}
473
+ onMouseLeave={(e) => {
474
+ e.currentTarget.style.color = 'var(--glia-color-text-soft, #888888)';
475
+ e.currentTarget.style.background = 'transparent';
476
+ }}
477
+ >
478
+ Mark All Read
479
+ </button>
480
+ )}
481
+ <button
482
+ style={styles.closeButton}
483
+ onClick={onClose}
484
+ title="Close"
485
+ onMouseEnter={(e) => {
486
+ e.currentTarget.style.color = 'var(--glia-color-text-primary, #ffffff)';
487
+ }}
488
+ onMouseLeave={(e) => {
489
+ e.currentTarget.style.color = 'var(--glia-color-text-soft, #888888)';
490
+ }}
491
+ >
492
+ {'\u2715'}
493
+ </button>
494
+ </div>
495
+ </div>
496
+
497
+ {/* Content */}
498
+ <div style={styles.scrollArea}>
499
+ {notifications.length === 0 ? (
500
+ <div style={styles.emptyState}>
501
+ <div style={styles.emptyIcon}>{'\uD83D\uDD14'}</div>
502
+ <span style={styles.emptyText}>No notifications</span>
503
+ </div>
504
+ ) : (
505
+ <AnimatePresence mode="popLayout">
506
+ {groups.map((group) => (
507
+ <div key={group.key}>
508
+ <h3 style={styles.groupHeader}>{group.label}</h3>
509
+ {group.notifications.map((notification) => (
510
+ <NotificationItem
511
+ key={notification.id}
512
+ notification={notification}
513
+ onDismiss={dismiss}
514
+ onMarkRead={markRead}
515
+ />
516
+ ))}
517
+ </div>
518
+ ))}
519
+ </AnimatePresence>
520
+ )}
521
+ </div>
522
+
523
+ {/* Footer */}
524
+ {notifications.length > 0 && (
525
+ <div style={styles.footer}>
526
+ <button
527
+ style={styles.clearAllButton}
528
+ onClick={clearAll}
529
+ onMouseEnter={(e) => {
530
+ e.currentTarget.style.background = 'rgba(196, 68, 68, 0.1)';
531
+ }}
532
+ onMouseLeave={(e) => {
533
+ e.currentTarget.style.background = 'transparent';
534
+ }}
535
+ >
536
+ Clear All
537
+ </button>
538
+ </div>
539
+ )}
540
+ </motion.div>
541
+ </>
542
+ )}
543
+ </AnimatePresence>
544
+ );
545
+ }