@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
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@backbay/glia-desktop",
3
+ "version": "0.2.0-alpha.1",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
12
+ "./core": { "types": "./dist/core/index.d.ts", "import": "./dist/core/index.js" },
13
+ "./themes": { "types": "./dist/themes/index.d.ts", "import": "./dist/themes/index.js" }
14
+ },
15
+ "scripts": {
16
+ "build": "tsup",
17
+ "typecheck": "tsc --noEmit"
18
+ },
19
+ "dependencies": {
20
+ "zustand": "^5.0.5",
21
+ "class-variance-authority": "^0.7.1",
22
+ "clsx": "^2.1.1",
23
+ "tailwind-merge": "^3.3.0"
24
+ },
25
+ "peerDependencies": {
26
+ "react": "^18 || ^19",
27
+ "react-dom": "^18 || ^19",
28
+ "framer-motion": "^12"
29
+ },
30
+ "optionalDependencies": {
31
+ "react-rnd": "^10.4.13"
32
+ },
33
+ "devDependencies": {
34
+ "tsup": "^8.5.1",
35
+ "typescript": "^5.8.3"
36
+ }
37
+ }
@@ -0,0 +1,202 @@
1
+ "use client";
2
+
3
+ import React, { Component, type ReactNode, type ErrorInfo } from "react";
4
+ import { ErrorThrower } from "./useErrorBoundary";
5
+
6
+ // ============================================================================
7
+ // TYPES
8
+ // ============================================================================
9
+
10
+ export interface ErrorFallbackProps {
11
+ error: Error;
12
+ reset: () => void;
13
+ }
14
+
15
+ export interface GliaErrorBoundaryProps {
16
+ children: ReactNode;
17
+ /** Custom fallback UI. Receives error + reset function. */
18
+ fallback?: ReactNode | ((props: ErrorFallbackProps) => ReactNode);
19
+ /** Called when an error is caught. Use for telemetry/logging. */
20
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
21
+ /** Auto-retry after this many ms (0 = no auto-retry) */
22
+ autoRetryMs?: number;
23
+ /** Glass-themed fallback styling */
24
+ variant?: "inline" | "card" | "fullscreen";
25
+ }
26
+
27
+ interface GliaErrorBoundaryState {
28
+ error: Error | null;
29
+ }
30
+
31
+ // ============================================================================
32
+ // DEFAULT FALLBACK (glass-themed)
33
+ // ============================================================================
34
+
35
+ function DefaultFallback({
36
+ error,
37
+ reset,
38
+ variant = "card",
39
+ }: ErrorFallbackProps & { variant?: "inline" | "card" | "fullscreen" }) {
40
+ const isFullscreen = variant === "fullscreen";
41
+ const isInline = variant === "inline";
42
+
43
+ const containerStyle: React.CSSProperties = isFullscreen
44
+ ? {
45
+ position: "fixed",
46
+ inset: 0,
47
+ display: "flex",
48
+ alignItems: "center",
49
+ justifyContent: "center",
50
+ zIndex: 9999,
51
+ background: "rgba(0, 0, 0, 0.6)",
52
+ backdropFilter: "blur(12px)",
53
+ WebkitBackdropFilter: "blur(12px)",
54
+ }
55
+ : isInline
56
+ ? {
57
+ display: "inline-flex",
58
+ alignItems: "center",
59
+ gap: 8,
60
+ padding: "4px 8px",
61
+ borderRadius: 6,
62
+ background: "rgba(255, 60, 60, 0.08)",
63
+ border: "1px solid rgba(255, 60, 60, 0.2)",
64
+ }
65
+ : {};
66
+
67
+ const cardStyle: React.CSSProperties = {
68
+ background: "rgba(20, 20, 30, 0.85)",
69
+ backdropFilter: "blur(16px)",
70
+ WebkitBackdropFilter: "blur(16px)",
71
+ border: "1px solid rgba(255, 255, 255, 0.08)",
72
+ borderRadius: 12,
73
+ padding: isInline ? undefined : "24px 28px",
74
+ maxWidth: isFullscreen ? 480 : "100%",
75
+ width: isFullscreen ? "90%" : undefined,
76
+ color: "rgba(255, 255, 255, 0.9)",
77
+ fontFamily: "system-ui, -apple-system, sans-serif",
78
+ };
79
+
80
+ if (isInline) {
81
+ return (
82
+ <span style={containerStyle}>
83
+ <span style={{ color: "rgba(255, 100, 100, 0.9)", fontSize: 12 }}>
84
+ Error: {error.message}
85
+ </span>
86
+ <button
87
+ onClick={reset}
88
+ style={{
89
+ background: "none",
90
+ border: "none",
91
+ color: "rgba(100, 180, 255, 0.9)",
92
+ cursor: "pointer",
93
+ fontSize: 12,
94
+ textDecoration: "underline",
95
+ padding: 0,
96
+ }}
97
+ >
98
+ Retry
99
+ </button>
100
+ </span>
101
+ );
102
+ }
103
+
104
+ return (
105
+ <div style={containerStyle}>
106
+ <div style={cardStyle}>
107
+ <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
108
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="rgba(255, 100, 100, 0.9)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
109
+ <circle cx="12" cy="12" r="10" />
110
+ <line x1="12" y1="8" x2="12" y2="12" />
111
+ <line x1="12" y1="16" x2="12.01" y2="16" />
112
+ </svg>
113
+ <span style={{ fontWeight: 600, fontSize: 15 }}>Something went wrong</span>
114
+ </div>
115
+ <p style={{ color: "rgba(255, 255, 255, 0.5)", fontSize: 13, lineHeight: 1.5, margin: "0 0 16px", wordBreak: "break-word" }}>
116
+ {error.message}
117
+ </p>
118
+ <button
119
+ onClick={reset}
120
+ style={{
121
+ background: "rgba(255, 255, 255, 0.08)",
122
+ border: "1px solid rgba(255, 255, 255, 0.12)",
123
+ borderRadius: 8,
124
+ padding: "8px 16px",
125
+ color: "rgba(255, 255, 255, 0.9)",
126
+ cursor: "pointer",
127
+ fontSize: 13,
128
+ fontWeight: 500,
129
+ transition: "background 0.15s",
130
+ }}
131
+ onMouseEnter={(e) => {
132
+ (e.currentTarget as HTMLElement).style.background = "rgba(255, 255, 255, 0.14)";
133
+ }}
134
+ onMouseLeave={(e) => {
135
+ (e.currentTarget as HTMLElement).style.background = "rgba(255, 255, 255, 0.08)";
136
+ }}
137
+ >
138
+ Try again
139
+ </button>
140
+ </div>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ // ============================================================================
146
+ // ERROR BOUNDARY (class component)
147
+ // ============================================================================
148
+
149
+ export class GliaErrorBoundary extends Component<GliaErrorBoundaryProps, GliaErrorBoundaryState> {
150
+ static displayName = "GliaErrorBoundary";
151
+ private autoRetryTimer: ReturnType<typeof setTimeout> | null = null;
152
+
153
+ constructor(props: GliaErrorBoundaryProps) {
154
+ super(props);
155
+ this.state = { error: null };
156
+ }
157
+
158
+ static getDerivedStateFromError(error: Error): GliaErrorBoundaryState {
159
+ return { error };
160
+ }
161
+
162
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
163
+ this.props.onError?.(error, errorInfo);
164
+
165
+ if (this.props.autoRetryMs && this.props.autoRetryMs > 0) {
166
+ this.autoRetryTimer = setTimeout(() => {
167
+ this.reset();
168
+ }, this.props.autoRetryMs);
169
+ }
170
+ }
171
+
172
+ componentWillUnmount(): void {
173
+ if (this.autoRetryTimer) {
174
+ clearTimeout(this.autoRetryTimer);
175
+ }
176
+ }
177
+
178
+ reset = () => {
179
+ if (this.autoRetryTimer) {
180
+ clearTimeout(this.autoRetryTimer);
181
+ this.autoRetryTimer = null;
182
+ }
183
+ this.setState({ error: null });
184
+ };
185
+
186
+ render() {
187
+ const { error } = this.state;
188
+ const { children, fallback, variant = "card" } = this.props;
189
+
190
+ if (error) {
191
+ if (typeof fallback === "function") {
192
+ return fallback({ error, reset: this.reset });
193
+ }
194
+ if (fallback !== undefined) {
195
+ return fallback;
196
+ }
197
+ return <DefaultFallback error={error} reset={this.reset} variant={variant} />;
198
+ }
199
+
200
+ return <ErrorThrower>{children}</ErrorThrower>;
201
+ }
202
+ }
@@ -0,0 +1,2 @@
1
+ export { GliaErrorBoundary, type GliaErrorBoundaryProps, type ErrorFallbackProps } from "./GliaErrorBoundary";
2
+ export { useErrorBoundary, type ErrorBoundaryContextValue } from "./useErrorBoundary";
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useCallback, useState, type ReactNode } from "react";
4
+
5
+ // ============================================================================
6
+ // CONTEXT
7
+ // ============================================================================
8
+
9
+ export interface ErrorBoundaryContextValue {
10
+ throwError: (error: Error) => void;
11
+ }
12
+
13
+ export const ErrorBoundaryContext = createContext<ErrorBoundaryContextValue | null>(null);
14
+
15
+ // ============================================================================
16
+ // HOOK
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Hook that provides a `throwError` function to trigger the nearest
21
+ * GliaErrorBoundary from a function component.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * const { throwError } = useErrorBoundary();
26
+ * stream.on('error', (err) => throwError(err));
27
+ * ```
28
+ */
29
+ export function useErrorBoundary(): ErrorBoundaryContextValue {
30
+ const ctx = useContext(ErrorBoundaryContext);
31
+ if (!ctx) {
32
+ throw new Error("useErrorBoundary must be used within a GliaErrorBoundary");
33
+ }
34
+ return ctx;
35
+ }
36
+
37
+ // ============================================================================
38
+ // ERROR THROWER (internal helper)
39
+ // ============================================================================
40
+
41
+ /**
42
+ * Internal component that catches programmatic errors thrown via context
43
+ * and re-throws them during render so React's error boundary catches them.
44
+ */
45
+ export function ErrorThrower({ children }: { children: ReactNode }) {
46
+ const [error, setError] = useState<Error | null>(null);
47
+
48
+ if (error) {
49
+ throw error;
50
+ }
51
+
52
+ const throwError = useCallback((err: Error) => {
53
+ setError(err);
54
+ }, []);
55
+
56
+ return (
57
+ <ErrorBoundaryContext.Provider value={{ throwError }}>
58
+ {children}
59
+ </ErrorBoundaryContext.Provider>
60
+ );
61
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @backbay/glia Desktop OS - Desktop Component
3
+ *
4
+ * Main desktop surface that renders icons and provides window snap zone previews.
5
+ * Uses CSS variables for theming (--glia-color-*, --glia-spacing-*, etc.)
6
+ */
7
+
8
+ import React, { useCallback, useState } from 'react';
9
+ import type { DesktopIcon as DesktopIconType } from '../../core/desktop/types';
10
+ import { useSnapZones } from '../../core/desktop/useSnapZones';
11
+ import { DesktopIcon } from './DesktopIcon';
12
+ import { SnapZoneOverlay } from './SnapZoneOverlay';
13
+
14
+ // ═══════════════════════════════════════════════════════════════════════════
15
+ // Styles (using CSS custom properties)
16
+ // ═══════════════════════════════════════════════════════════════════════════
17
+
18
+ const containerStyles: React.CSSProperties = {
19
+ position: 'fixed',
20
+ inset: 0,
21
+ display: 'flex',
22
+ flexDirection: 'column',
23
+ background: 'var(--glia-color-bg-body, #010100)',
24
+ overflow: 'hidden',
25
+ };
26
+
27
+ const desktopAreaStyles: React.CSSProperties = {
28
+ flex: 1,
29
+ position: 'relative',
30
+ padding: '16px',
31
+ paddingBottom: 'calc(16px + var(--glia-spacing-taskbar-height, 48px))',
32
+ };
33
+
34
+ const iconGridStyles: React.CSSProperties = {
35
+ display: 'grid',
36
+ gridTemplateColumns: 'repeat(auto-fill, var(--glia-spacing-icon-size, 80px))',
37
+ gridAutoRows: 'var(--glia-spacing-icon-size, 80px)',
38
+ gap: 'var(--glia-spacing-icon-gap, 16px)',
39
+ padding: '8px',
40
+ alignContent: 'start',
41
+ height: '100%',
42
+ overflow: 'auto',
43
+ };
44
+
45
+ // ═══════════════════════════════════════════════════════════════════════════
46
+ // Types
47
+ // ═══════════════════════════════════════════════════════════════════════════
48
+
49
+ export interface DesktopProps {
50
+ /** Windows render here */
51
+ children?: React.ReactNode;
52
+ /** Desktop icons to display */
53
+ icons?: DesktopIconType[];
54
+ /** Callback when an icon is activated (double-click) */
55
+ onIconActivate?: (iconId: string) => void;
56
+ /** Whether to show snap zone overlays during window dragging */
57
+ showSnapZones?: boolean;
58
+ /** Additional CSS class */
59
+ className?: string;
60
+ /** Inline styles */
61
+ style?: React.CSSProperties;
62
+ /** Icon renderer - allows custom icon component */
63
+ renderIcon?: (icon: DesktopIconType, props: {
64
+ selected: boolean;
65
+ onClick: (e: React.MouseEvent) => void;
66
+ onDoubleClick: () => void;
67
+ }) => React.ReactNode;
68
+ }
69
+
70
+ // ═══════════════════════════════════════════════════════════════════════════
71
+ // Component
72
+ // ═══════════════════════════════════════════════════════════════════════════
73
+
74
+ /**
75
+ * Desktop surface component.
76
+ *
77
+ * Renders the main desktop area with:
78
+ * - Icon grid for desktop shortcuts
79
+ * - Snap zone overlays for window tiling
80
+ * - Children (windows) layered on top
81
+ *
82
+ * @example
83
+ * ```tsx
84
+ * <Desktop
85
+ * icons={myIcons}
86
+ * onIconActivate={(id) => launchApp(id)}
87
+ * showSnapZones={isDragging}
88
+ * >
89
+ * {windows.map(w => <Window key={w.id} {...w} />)}
90
+ * </Desktop>
91
+ * ```
92
+ */
93
+ export function Desktop({
94
+ children,
95
+ icons = [],
96
+ onIconActivate,
97
+ showSnapZones = true,
98
+ className,
99
+ style,
100
+ renderIcon,
101
+ }: DesktopProps) {
102
+ const { activeZone } = useSnapZones();
103
+ const [selectedIconIds, setSelectedIconIds] = useState<Set<string>>(new Set());
104
+
105
+ // Handle icon click (selection)
106
+ const handleIconClick = useCallback(
107
+ (iconId: string, e: React.MouseEvent) => {
108
+ setSelectedIconIds((prev) => {
109
+ const next = new Set(prev);
110
+ const additive = e.metaKey || e.ctrlKey;
111
+
112
+ if (additive) {
113
+ // Toggle selection
114
+ if (next.has(iconId)) {
115
+ next.delete(iconId);
116
+ } else {
117
+ next.add(iconId);
118
+ }
119
+ } else {
120
+ // Replace selection
121
+ next.clear();
122
+ next.add(iconId);
123
+ }
124
+ return next;
125
+ });
126
+ },
127
+ []
128
+ );
129
+
130
+ // Handle icon double-click (activation)
131
+ const handleIconDoubleClick = useCallback(
132
+ (iconId: string) => {
133
+ onIconActivate?.(iconId);
134
+ },
135
+ [onIconActivate]
136
+ );
137
+
138
+ // Clear selection when clicking desktop background
139
+ const handleBackgroundClick = useCallback(
140
+ (e: React.MouseEvent) => {
141
+ // Only clear if clicking the desktop area itself, not children
142
+ if (e.target === e.currentTarget) {
143
+ setSelectedIconIds(new Set());
144
+ }
145
+ },
146
+ []
147
+ );
148
+
149
+ return (
150
+ <div
151
+ className={className}
152
+ style={{ ...containerStyles, ...style }}
153
+ data-bb-desktop
154
+ >
155
+ {/* Desktop area with icons */}
156
+ <div
157
+ style={desktopAreaStyles}
158
+ onClick={handleBackgroundClick}
159
+ data-bb-desktop-area
160
+ >
161
+ {/* Icon grid */}
162
+ {icons.length > 0 && (
163
+ <div style={iconGridStyles} data-bb-icon-grid>
164
+ {icons.map((icon) => {
165
+ const isSelected = selectedIconIds.has(icon.id);
166
+ const clickHandler = (e: React.MouseEvent) => handleIconClick(icon.id, e);
167
+ const doubleClickHandler = () => handleIconDoubleClick(icon.id);
168
+
169
+ if (renderIcon) {
170
+ return (
171
+ <React.Fragment key={icon.id}>
172
+ {renderIcon(icon, {
173
+ selected: isSelected,
174
+ onClick: clickHandler,
175
+ onDoubleClick: doubleClickHandler,
176
+ })}
177
+ </React.Fragment>
178
+ );
179
+ }
180
+
181
+ return (
182
+ <DesktopIcon
183
+ key={icon.id}
184
+ id={icon.id}
185
+ icon={icon.icon}
186
+ label={icon.label}
187
+ selected={isSelected}
188
+ onClick={clickHandler}
189
+ onDoubleClick={doubleClickHandler}
190
+ />
191
+ );
192
+ })}
193
+ </div>
194
+ )}
195
+
196
+ {/* Windows render on top */}
197
+ {children}
198
+ </div>
199
+
200
+ {/* Snap zone overlay (visible during window drag) */}
201
+ {showSnapZones && activeZone && <SnapZoneOverlay />}
202
+ </div>
203
+ );
204
+ }