@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,1154 @@
1
+ /**
2
+ * @backbay/glia Desktop OS - useWindowManager Hook
3
+ *
4
+ * Core window management hook using Zustand.
5
+ * Provides all window lifecycle, focus, and state management operations.
6
+ */
7
+
8
+ import { create, createStore, useStore, type StoreApi } from 'zustand';
9
+ import { useShallow } from 'zustand/react/shallow';
10
+ import { createContext, useContext, useState, type ReactNode } from 'react';
11
+ import type {
12
+ WindowId,
13
+ WindowState,
14
+ WindowGroup,
15
+ WindowOpenConfig,
16
+ UseWindowManagerReturn,
17
+ TilePosition,
18
+ } from './types';
19
+
20
+ // ═══════════════════════════════════════════════════════════════════════════
21
+ // Constants
22
+ // ═══════════════════════════════════════════════════════════════════════════
23
+
24
+ const DEFAULT_TASKBAR_HEIGHT = 48;
25
+ const CASCADE_OFFSET = 32;
26
+ const MAX_CASCADE_STEPS = 8;
27
+
28
+ // ═══════════════════════════════════════════════════════════════════════════
29
+ // Helper Functions
30
+ // ═══════════════════════════════════════════════════════════════════════════
31
+
32
+ /**
33
+ * Generate a unique window ID using crypto.randomUUID() for SSR safety.
34
+ * Falls back to timestamp-based generation if crypto is unavailable.
35
+ */
36
+ function generateWindowId(): WindowId {
37
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
38
+ return `window-${crypto.randomUUID()}`;
39
+ }
40
+ // Fallback for environments without crypto.randomUUID
41
+ return `window-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
42
+ }
43
+
44
+ /**
45
+ * Generate a unique group ID using crypto.randomUUID() for SSR safety.
46
+ * Falls back to timestamp-based generation if crypto is unavailable.
47
+ */
48
+ function generateGroupId(): string {
49
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
50
+ return `group-${crypto.randomUUID()}`;
51
+ }
52
+ // Fallback for environments without crypto.randomUUID
53
+ return `group-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
54
+ }
55
+
56
+ function getViewportSize() {
57
+ if (typeof globalThis.innerWidth === 'undefined') {
58
+ return { width: 1920, height: 1080 };
59
+ }
60
+ return {
61
+ width: globalThis.innerWidth,
62
+ height: globalThis.innerHeight - DEFAULT_TASKBAR_HEIGHT,
63
+ };
64
+ }
65
+
66
+ function getTileDimensions(position: TilePosition) {
67
+ const { width: screenWidth, height: screenHeight } = getViewportSize();
68
+ const halfWidth = screenWidth / 2;
69
+ const halfHeight = screenHeight / 2;
70
+
71
+ switch (position) {
72
+ case 'left':
73
+ return { x: 0, y: 0, width: halfWidth, height: screenHeight };
74
+ case 'right':
75
+ return { x: halfWidth, y: 0, width: halfWidth, height: screenHeight };
76
+ case 'top-left':
77
+ return { x: 0, y: 0, width: halfWidth, height: halfHeight };
78
+ case 'top-right':
79
+ return { x: halfWidth, y: 0, width: halfWidth, height: halfHeight };
80
+ case 'bottom-left':
81
+ return { x: 0, y: halfHeight, width: halfWidth, height: halfHeight };
82
+ case 'bottom-right':
83
+ return { x: halfWidth, y: halfHeight, width: halfWidth, height: halfHeight };
84
+ }
85
+ }
86
+
87
+ function calculateInitialPosition(
88
+ config: WindowOpenConfig,
89
+ windowCount: number
90
+ ): { x: number; y: number } {
91
+ if (config.position) {
92
+ return config.position;
93
+ }
94
+
95
+ const viewport = getViewportSize();
96
+ const width = config.size?.width ?? 640;
97
+ const height = config.size?.height ?? 480;
98
+ const offset = (windowCount % MAX_CASCADE_STEPS) * CASCADE_OFFSET;
99
+
100
+ return {
101
+ x: Math.max(60, (viewport.width - width) / 2 + offset),
102
+ y: Math.max(60, (viewport.height - height) / 2 + offset),
103
+ };
104
+ }
105
+
106
+ // ═══════════════════════════════════════════════════════════════════════════
107
+ // Store Interface
108
+ // ═══════════════════════════════════════════════════════════════════════════
109
+
110
+ interface WindowManagerStore {
111
+ windows: Map<WindowId, WindowState>;
112
+ groups: Map<string, WindowGroup>;
113
+ focusedId: WindowId | null;
114
+ fullscreenId: WindowId | null;
115
+ nextZIndex: number;
116
+
117
+ // Internal actions (not exposed in hook return)
118
+ _open: (config: WindowOpenConfig) => WindowId;
119
+ _close: (id: WindowId) => void;
120
+ _focus: (id: WindowId) => void;
121
+ _minimize: (id: WindowId) => void;
122
+ _maximize: (id: WindowId) => void;
123
+ _restore: (id: WindowId) => void;
124
+ _fullscreen: (id: WindowId) => void;
125
+ _exitFullscreen: () => void;
126
+ _move: (id: WindowId, position: { x: number; y: number }) => void;
127
+ _resize: (id: WindowId, size: { width: number; height: number }) => void;
128
+ _tile: (id: WindowId, position: TilePosition) => void;
129
+ _untile: (id: WindowId) => void;
130
+ _cycleFocusNext: () => void;
131
+ _createGroup: (windowId1: WindowId, windowId2: WindowId) => string | null;
132
+ _addToGroup: (groupId: string, windowId: WindowId) => void;
133
+ _removeFromGroup: (windowId: WindowId) => void;
134
+ _setActiveTab: (groupId: string, windowId: WindowId) => void;
135
+ _minimizeAll: () => void;
136
+ _closeAll: () => void;
137
+ _cascade: () => void;
138
+ _tileAll: (layout: 'horizontal' | 'vertical' | 'grid') => void;
139
+ }
140
+
141
+ // ═══════════════════════════════════════════════════════════════════════════
142
+ // Store Factory
143
+ // ═══════════════════════════════════════════════════════════════════════════
144
+
145
+ function createWindowManagerStoreImpl(
146
+ set: StoreApi<WindowManagerStore>['setState'],
147
+ get: StoreApi<WindowManagerStore>['getState'],
148
+ ): WindowManagerStore {
149
+ return {
150
+ windows: new Map(),
151
+ groups: new Map(),
152
+ focusedId: null,
153
+ fullscreenId: null,
154
+ nextZIndex: 1,
155
+
156
+ _open: (config) => {
157
+ const id = generateWindowId();
158
+
159
+ set((state) => {
160
+ const position = calculateInitialPosition(config, state.windows.size);
161
+
162
+ const newWindow: WindowState = {
163
+ id,
164
+ title: config.title,
165
+ icon: config.icon,
166
+ position,
167
+ size: config.size ?? { width: 640, height: 480 },
168
+ minSize: config.minSize ?? { width: 320, height: 240 },
169
+ maxSize: config.maxSize,
170
+ isMinimized: config.isMinimized ?? false,
171
+ isMaximized: config.isMaximized ?? false,
172
+ isFullscreen: false,
173
+ isFocused: true,
174
+ zIndex: state.nextZIndex,
175
+ };
176
+
177
+ const newWindows = new Map(state.windows);
178
+ newWindows.set(id, newWindow);
179
+ return {
180
+ windows: newWindows,
181
+ focusedId: id,
182
+ nextZIndex: state.nextZIndex + 1,
183
+ };
184
+ });
185
+
186
+ return id;
187
+ },
188
+
189
+ _close: (id) => {
190
+ const { windows, groups, fullscreenId } = get();
191
+ const window = windows.get(id);
192
+ if (!window) return;
193
+
194
+ const wasFullscreen = fullscreenId === id || window.isFullscreen;
195
+
196
+ // Handle browser fullscreen exit
197
+ if (wasFullscreen && typeof document !== 'undefined') {
198
+ document.exitFullscreen?.().catch(() => {});
199
+ }
200
+
201
+ // Handle group cleanup
202
+ if (window.groupId) {
203
+ const group = groups.get(window.groupId);
204
+ if (group && group.windowIds.length <= 2) {
205
+ const remainingWindowId = group.windowIds.find((wId) => wId !== id);
206
+ if (remainingWindowId) {
207
+ get()._removeFromGroup(remainingWindowId);
208
+ }
209
+ } else {
210
+ get()._removeFromGroup(id);
211
+ }
212
+ }
213
+
214
+ set((state) => {
215
+ const newWindows = new Map(state.windows);
216
+ newWindows.delete(id);
217
+
218
+ // Focus topmost remaining window
219
+ let newFocusedId = state.focusedId;
220
+ if (state.focusedId === id) {
221
+ const remaining = Array.from(newWindows.values());
222
+ if (remaining.length > 0) {
223
+ newFocusedId = remaining.reduce((a, b) =>
224
+ a.zIndex > b.zIndex ? a : b
225
+ ).id;
226
+ } else {
227
+ newFocusedId = null;
228
+ }
229
+ }
230
+
231
+ return {
232
+ windows: newWindows,
233
+ focusedId: newFocusedId,
234
+ fullscreenId: wasFullscreen ? null : state.fullscreenId,
235
+ };
236
+ });
237
+ },
238
+
239
+ _focus: (id) => {
240
+ const { windows, focusedId, groups } = get();
241
+ const window = windows.get(id);
242
+
243
+ if (!window || id === focusedId) return;
244
+
245
+ // Restore if minimized
246
+ if (window.isMinimized) {
247
+ get()._restore(id);
248
+ return;
249
+ }
250
+
251
+ // If in a group, also set as active tab
252
+ if (window.groupId) {
253
+ const group = groups.get(window.groupId);
254
+ if (group && group.activeWindowId !== id) {
255
+ get()._setActiveTab(window.groupId, id);
256
+ return;
257
+ }
258
+ }
259
+
260
+ set((state) => {
261
+ const newWindows = new Map(state.windows);
262
+ const w = newWindows.get(id);
263
+ if (w) {
264
+ newWindows.set(id, { ...w, zIndex: state.nextZIndex, isFocused: true });
265
+ }
266
+ // Unfocus previous window
267
+ if (state.focusedId && state.focusedId !== id) {
268
+ const prev = newWindows.get(state.focusedId);
269
+ if (prev) {
270
+ newWindows.set(state.focusedId, { ...prev, isFocused: false });
271
+ }
272
+ }
273
+ return {
274
+ windows: newWindows,
275
+ focusedId: id,
276
+ nextZIndex: state.nextZIndex + 1,
277
+ };
278
+ });
279
+ },
280
+
281
+ _minimize: (id) => {
282
+ const { windows, groups } = get();
283
+ const window = windows.get(id);
284
+ if (!window) return;
285
+
286
+ // If in a group, minimize all windows in the group
287
+ const windowsToMinimize = window.groupId
288
+ ? groups.get(window.groupId)?.windowIds ?? [id]
289
+ : [id];
290
+
291
+ set((state) => {
292
+ const newWindows = new Map(state.windows);
293
+ for (const windowId of windowsToMinimize) {
294
+ const w = newWindows.get(windowId);
295
+ if (w) {
296
+ newWindows.set(windowId, { ...w, isMinimized: true });
297
+ }
298
+ }
299
+
300
+ // Focus next topmost non-minimized window
301
+ const visible = Array.from(newWindows.values()).filter(
302
+ (w) => !w.isMinimized
303
+ );
304
+ const newFocusedId =
305
+ visible.length > 0
306
+ ? visible.reduce((a, b) => (a.zIndex > b.zIndex ? a : b)).id
307
+ : null;
308
+
309
+ return { windows: newWindows, focusedId: newFocusedId };
310
+ });
311
+ },
312
+
313
+ _maximize: (id) => {
314
+ const { windows, groups } = get();
315
+ const window = windows.get(id);
316
+ if (!window || window.isMaximized) return;
317
+
318
+ const viewport = getViewportSize();
319
+ const windowsToMaximize = window.groupId
320
+ ? groups.get(window.groupId)?.windowIds ?? [id]
321
+ : [id];
322
+
323
+ set((state) => {
324
+ const newWindows = new Map(state.windows);
325
+ for (const windowId of windowsToMaximize) {
326
+ const w = newWindows.get(windowId);
327
+ if (w && !w.isMaximized) {
328
+ newWindows.set(windowId, {
329
+ ...w,
330
+ isMaximized: true,
331
+ preMaximize: {
332
+ x: w.position.x,
333
+ y: w.position.y,
334
+ width: w.size.width,
335
+ height: w.size.height,
336
+ },
337
+ position: { x: 0, y: 0 },
338
+ size: { width: viewport.width, height: viewport.height },
339
+ });
340
+ }
341
+ }
342
+ return { windows: newWindows };
343
+ });
344
+ },
345
+
346
+ _restore: (id) => {
347
+ const { windows, groups } = get();
348
+ const window = windows.get(id);
349
+ if (!window) return;
350
+
351
+ const windowsToRestore = window.groupId
352
+ ? groups.get(window.groupId)?.windowIds ?? [id]
353
+ : [id];
354
+
355
+ set((state) => {
356
+ const newWindows = new Map(state.windows);
357
+ for (const windowId of windowsToRestore) {
358
+ const w = newWindows.get(windowId);
359
+ if (w) {
360
+ if (w.isMaximized && w.preMaximize) {
361
+ newWindows.set(windowId, {
362
+ ...w,
363
+ isMaximized: false,
364
+ isMinimized: false,
365
+ position: { x: w.preMaximize.x, y: w.preMaximize.y },
366
+ size: { width: w.preMaximize.width, height: w.preMaximize.height },
367
+ preMaximize: undefined,
368
+ zIndex: state.nextZIndex,
369
+ });
370
+ } else {
371
+ newWindows.set(windowId, {
372
+ ...w,
373
+ isMinimized: false,
374
+ zIndex: state.nextZIndex,
375
+ });
376
+ }
377
+ }
378
+ }
379
+
380
+ const group = window.groupId ? state.groups.get(window.groupId) : null;
381
+ return {
382
+ windows: newWindows,
383
+ focusedId: group?.activeWindowId ?? id,
384
+ nextZIndex: state.nextZIndex + 1,
385
+ };
386
+ });
387
+ },
388
+
389
+ _fullscreen: (id) => {
390
+ const { windows, fullscreenId } = get();
391
+ const window = windows.get(id);
392
+ if (!window) return;
393
+
394
+ // Exit current fullscreen first
395
+ if (fullscreenId && fullscreenId !== id) {
396
+ get()._exitFullscreen();
397
+ }
398
+
399
+ if (window.isFullscreen) return;
400
+
401
+ // Request browser fullscreen
402
+ if (typeof document !== 'undefined') {
403
+ document.documentElement.requestFullscreen?.().catch(() => {});
404
+ }
405
+
406
+ const screenWidth =
407
+ typeof globalThis.innerWidth !== 'undefined'
408
+ ? globalThis.innerWidth
409
+ : 1920;
410
+ const screenHeight =
411
+ typeof globalThis.innerHeight !== 'undefined'
412
+ ? globalThis.innerHeight
413
+ : 1080;
414
+
415
+ set((state) => {
416
+ const newWindows = new Map(state.windows);
417
+ const w = newWindows.get(id);
418
+ if (w) {
419
+ newWindows.set(id, {
420
+ ...w,
421
+ isFullscreen: true,
422
+ isMaximized: false,
423
+ tilePosition: undefined,
424
+ preFullscreen: {
425
+ x: w.position.x,
426
+ y: w.position.y,
427
+ width: w.size.width,
428
+ height: w.size.height,
429
+ },
430
+ position: { x: 0, y: 0 },
431
+ size: { width: screenWidth, height: screenHeight },
432
+ zIndex: 10000,
433
+ });
434
+ }
435
+ return {
436
+ windows: newWindows,
437
+ fullscreenId: id,
438
+ focusedId: id,
439
+ };
440
+ });
441
+ },
442
+
443
+ _exitFullscreen: () => {
444
+ const { fullscreenId } = get();
445
+ if (!fullscreenId) return;
446
+
447
+ // Exit browser fullscreen
448
+ if (typeof document !== 'undefined' && document.fullscreenElement) {
449
+ document.exitFullscreen?.().catch(() => {});
450
+ }
451
+
452
+ set((state) => {
453
+ const newWindows = new Map(state.windows);
454
+ const w = newWindows.get(fullscreenId);
455
+ if (w) {
456
+ const pre = w.preFullscreen;
457
+ newWindows.set(fullscreenId, {
458
+ ...w,
459
+ isFullscreen: false,
460
+ position: { x: pre?.x ?? 100, y: pre?.y ?? 100 },
461
+ size: { width: pre?.width ?? 800, height: pre?.height ?? 600 },
462
+ preFullscreen: undefined,
463
+ zIndex: state.nextZIndex,
464
+ });
465
+ }
466
+ return {
467
+ windows: newWindows,
468
+ fullscreenId: null,
469
+ nextZIndex: state.nextZIndex + 1,
470
+ };
471
+ });
472
+ },
473
+
474
+ _move: (id, position) => {
475
+ const { windows, groups } = get();
476
+ const window = windows.get(id);
477
+ if (!window || window.isMaximized) return;
478
+
479
+ const windowsToMove = window.groupId
480
+ ? groups.get(window.groupId)?.windowIds ?? [id]
481
+ : [id];
482
+
483
+ set((state) => {
484
+ const newWindows = new Map(state.windows);
485
+ for (const windowId of windowsToMove) {
486
+ const w = newWindows.get(windowId);
487
+ if (w) {
488
+ newWindows.set(windowId, { ...w, position });
489
+ }
490
+ }
491
+ return { windows: newWindows };
492
+ });
493
+ },
494
+
495
+ _resize: (id, size) => {
496
+ const { windows, groups } = get();
497
+ const window = windows.get(id);
498
+ if (!window || window.isMaximized) return;
499
+
500
+ const windowsToResize = window.groupId
501
+ ? groups.get(window.groupId)?.windowIds ?? [id]
502
+ : [id];
503
+
504
+ set((state) => {
505
+ const newWindows = new Map(state.windows);
506
+ for (const windowId of windowsToResize) {
507
+ const w = newWindows.get(windowId);
508
+ if (w) {
509
+ newWindows.set(windowId, { ...w, size });
510
+ }
511
+ }
512
+ return { windows: newWindows };
513
+ });
514
+ },
515
+
516
+ _tile: (id, position) => {
517
+ const { windows, groups } = get();
518
+ const window = windows.get(id);
519
+ if (!window) return;
520
+
521
+ const tileDims = getTileDimensions(position);
522
+ const windowsToTile = window.groupId
523
+ ? groups.get(window.groupId)?.windowIds ?? [id]
524
+ : [id];
525
+
526
+ set((state) => {
527
+ const newWindows = new Map(state.windows);
528
+ for (const windowId of windowsToTile) {
529
+ const w = newWindows.get(windowId);
530
+ if (w) {
531
+ const preTile = w.tilePosition
532
+ ? w.preTile
533
+ : {
534
+ x: w.position.x,
535
+ y: w.position.y,
536
+ width: w.size.width,
537
+ height: w.size.height,
538
+ };
539
+
540
+ newWindows.set(windowId, {
541
+ ...w,
542
+ position: { x: tileDims.x, y: tileDims.y },
543
+ size: { width: tileDims.width, height: tileDims.height },
544
+ tilePosition: position,
545
+ preTile,
546
+ isMaximized: false,
547
+ preMaximize: undefined,
548
+ });
549
+ }
550
+ }
551
+ return { windows: newWindows };
552
+ });
553
+ },
554
+
555
+ _untile: (id) => {
556
+ const { windows, groups } = get();
557
+ const window = windows.get(id);
558
+ if (!window || !window.tilePosition || !window.preTile) return;
559
+
560
+ const windowsToUntile = window.groupId
561
+ ? groups.get(window.groupId)?.windowIds ?? [id]
562
+ : [id];
563
+
564
+ set((state) => {
565
+ const newWindows = new Map(state.windows);
566
+ for (const windowId of windowsToUntile) {
567
+ const w = newWindows.get(windowId);
568
+ if (w && w.tilePosition && w.preTile) {
569
+ newWindows.set(windowId, {
570
+ ...w,
571
+ position: { x: w.preTile.x, y: w.preTile.y },
572
+ size: { width: w.preTile.width, height: w.preTile.height },
573
+ tilePosition: undefined,
574
+ preTile: undefined,
575
+ });
576
+ }
577
+ }
578
+ return { windows: newWindows };
579
+ });
580
+ },
581
+
582
+ _cycleFocusNext: () => {
583
+ const { windows, focusedId } = get();
584
+ const windowList = Array.from(windows.values())
585
+ .filter((w) => !w.isMinimized)
586
+ .sort((a, b) => b.zIndex - a.zIndex);
587
+
588
+ if (windowList.length <= 1) return;
589
+
590
+ const currentIndex = windowList.findIndex((w) => w.id === focusedId);
591
+ const nextIndex = (currentIndex + 1) % windowList.length;
592
+ get()._focus(windowList[nextIndex].id);
593
+ },
594
+
595
+ _createGroup: (windowId1, windowId2) => {
596
+ const { windows } = get();
597
+ const window1 = windows.get(windowId1);
598
+ const window2 = windows.get(windowId2);
599
+
600
+ if (!window1 || !window2 || windowId1 === windowId2) return null;
601
+
602
+ // If either window is already in a group, add to that group
603
+ if (window1.groupId) {
604
+ get()._addToGroup(window1.groupId, windowId2);
605
+ return window1.groupId;
606
+ }
607
+ if (window2.groupId) {
608
+ get()._addToGroup(window2.groupId, windowId1);
609
+ return window2.groupId;
610
+ }
611
+
612
+ const groupId = generateGroupId();
613
+ const newGroup: WindowGroup = {
614
+ id: groupId,
615
+ windowIds: [windowId1, windowId2],
616
+ activeWindowId: windowId1,
617
+ };
618
+
619
+ set((state) => {
620
+ const newWindows = new Map(state.windows);
621
+ const newGroups = new Map(state.groups);
622
+
623
+ // Use window2's position/size as the container
624
+ newWindows.set(windowId1, {
625
+ ...window1,
626
+ groupId,
627
+ isGroupActive: true,
628
+ position: { ...window2.position },
629
+ size: { ...window2.size },
630
+ });
631
+
632
+ newWindows.set(windowId2, {
633
+ ...window2,
634
+ groupId,
635
+ isGroupActive: false,
636
+ });
637
+
638
+ newGroups.set(groupId, newGroup);
639
+
640
+ return {
641
+ windows: newWindows,
642
+ groups: newGroups,
643
+ focusedId: windowId1,
644
+ nextZIndex: state.nextZIndex + 1,
645
+ };
646
+ });
647
+
648
+ return groupId;
649
+ },
650
+
651
+ _addToGroup: (groupId, windowId) => {
652
+ const { windows, groups } = get();
653
+ const window = windows.get(windowId);
654
+ const group = groups.get(groupId);
655
+
656
+ if (!window || !group || window.groupId === groupId) return;
657
+
658
+ // Remove from existing group first
659
+ if (window.groupId) {
660
+ get()._removeFromGroup(windowId);
661
+ }
662
+
663
+ set((state) => {
664
+ const newWindows = new Map(state.windows);
665
+ const newGroups = new Map(state.groups);
666
+ const currentGroup = state.groups.get(groupId);
667
+ if (!currentGroup) return state;
668
+
669
+ const existingMember = state.windows.get(currentGroup.windowIds[0]);
670
+ if (!existingMember) return state;
671
+
672
+ newWindows.set(windowId, {
673
+ ...window,
674
+ groupId,
675
+ isGroupActive: false,
676
+ position: { ...existingMember.position },
677
+ size: { ...existingMember.size },
678
+ });
679
+
680
+ newGroups.set(groupId, {
681
+ ...currentGroup,
682
+ windowIds: [...currentGroup.windowIds, windowId],
683
+ });
684
+
685
+ return { windows: newWindows, groups: newGroups };
686
+ });
687
+ },
688
+
689
+ _removeFromGroup: (windowId) => {
690
+ const { windows, groups } = get();
691
+ const window = windows.get(windowId);
692
+
693
+ if (!window || !window.groupId) return;
694
+
695
+ const group = groups.get(window.groupId);
696
+ if (!group) return;
697
+
698
+ set((state) => {
699
+ const newWindows = new Map(state.windows);
700
+ const newGroups = new Map(state.groups);
701
+ const currentGroup = state.groups.get(window.groupId!);
702
+ if (!currentGroup) return state;
703
+
704
+ const remainingWindowIds = currentGroup.windowIds.filter(
705
+ (id) => id !== windowId
706
+ );
707
+
708
+ // Clear group info from this window
709
+ newWindows.set(windowId, {
710
+ ...window,
711
+ groupId: undefined,
712
+ isGroupActive: undefined,
713
+ });
714
+
715
+ if (remainingWindowIds.length <= 1) {
716
+ // Dissolve the group
717
+ for (const wId of remainingWindowIds) {
718
+ const w = newWindows.get(wId);
719
+ if (w) {
720
+ newWindows.set(wId, {
721
+ ...w,
722
+ groupId: undefined,
723
+ isGroupActive: undefined,
724
+ });
725
+ }
726
+ }
727
+ newGroups.delete(window.groupId!);
728
+ } else {
729
+ let newActiveId = currentGroup.activeWindowId;
730
+ if (currentGroup.activeWindowId === windowId) {
731
+ newActiveId = remainingWindowIds[0];
732
+ for (const wId of remainingWindowIds) {
733
+ const w = newWindows.get(wId);
734
+ if (w) {
735
+ newWindows.set(wId, {
736
+ ...w,
737
+ isGroupActive: wId === newActiveId,
738
+ });
739
+ }
740
+ }
741
+ }
742
+
743
+ newGroups.set(window.groupId!, {
744
+ ...currentGroup,
745
+ windowIds: remainingWindowIds,
746
+ activeWindowId: newActiveId,
747
+ });
748
+ }
749
+
750
+ return { windows: newWindows, groups: newGroups };
751
+ });
752
+ },
753
+
754
+ _setActiveTab: (groupId, windowId) => {
755
+ const { windows, groups } = get();
756
+ const group = groups.get(groupId);
757
+ const window = windows.get(windowId);
758
+
759
+ if (!group || !window) return;
760
+ if (!group.windowIds.includes(windowId)) return;
761
+ if (group.activeWindowId === windowId) return;
762
+
763
+ set((state) => {
764
+ const newWindows = new Map(state.windows);
765
+ const newGroups = new Map(state.groups);
766
+ const currentGroup = state.groups.get(groupId);
767
+ if (!currentGroup) return state;
768
+
769
+ for (const wId of currentGroup.windowIds) {
770
+ const w = newWindows.get(wId);
771
+ if (w) {
772
+ newWindows.set(wId, {
773
+ ...w,
774
+ isGroupActive: wId === windowId,
775
+ zIndex: wId === windowId ? state.nextZIndex : w.zIndex,
776
+ });
777
+ }
778
+ }
779
+
780
+ newGroups.set(groupId, {
781
+ ...currentGroup,
782
+ activeWindowId: windowId,
783
+ });
784
+
785
+ return {
786
+ windows: newWindows,
787
+ groups: newGroups,
788
+ focusedId: windowId,
789
+ nextZIndex: state.nextZIndex + 1,
790
+ };
791
+ });
792
+ },
793
+
794
+ _minimizeAll: () => {
795
+ set((state) => {
796
+ const newWindows = new Map(state.windows);
797
+ for (const [id, w] of newWindows) {
798
+ newWindows.set(id, { ...w, isMinimized: true });
799
+ }
800
+ return { windows: newWindows, focusedId: null };
801
+ });
802
+ },
803
+
804
+ _closeAll: () => {
805
+ // Exit fullscreen first
806
+ get()._exitFullscreen();
807
+ set(() => ({
808
+ windows: new Map(),
809
+ groups: new Map(),
810
+ focusedId: null,
811
+ fullscreenId: null,
812
+ }));
813
+ },
814
+
815
+ _cascade: () => {
816
+ set((state) => {
817
+ const windowList = Array.from(state.windows.values())
818
+ .filter((w) => !w.isMinimized)
819
+ .sort((a, b) => a.zIndex - b.zIndex);
820
+
821
+ const newWindows = new Map(state.windows);
822
+ let z = state.nextZIndex;
823
+ windowList.forEach((w, i) => {
824
+ const offset = i * CASCADE_OFFSET;
825
+ newWindows.set(w.id, {
826
+ ...w,
827
+ position: { x: 50 + offset, y: 50 + offset },
828
+ isMaximized: false,
829
+ tilePosition: undefined,
830
+ zIndex: z++,
831
+ });
832
+ });
833
+ return { windows: newWindows, nextZIndex: z };
834
+ });
835
+ },
836
+
837
+ _tileAll: (layout) => {
838
+ const { windows } = get();
839
+ const windowList = Array.from(windows.values()).filter(
840
+ (w) => !w.isMinimized
841
+ );
842
+ if (windowList.length === 0) return;
843
+
844
+ const viewport = getViewportSize();
845
+
846
+ set((state: WindowManagerStore) => {
847
+ const newWindows = new Map(state.windows);
848
+ const count = windowList.length;
849
+
850
+ if (layout === 'horizontal') {
851
+ const width = viewport.width / count;
852
+ windowList.forEach((w, i) => {
853
+ newWindows.set(w.id, {
854
+ ...w,
855
+ position: { x: i * width, y: 0 },
856
+ size: { width, height: viewport.height },
857
+ isMaximized: false,
858
+ tilePosition: undefined,
859
+ });
860
+ });
861
+ } else if (layout === 'vertical') {
862
+ const height = viewport.height / count;
863
+ windowList.forEach((w, i) => {
864
+ newWindows.set(w.id, {
865
+ ...w,
866
+ position: { x: 0, y: i * height },
867
+ size: { width: viewport.width, height },
868
+ isMaximized: false,
869
+ tilePosition: undefined,
870
+ });
871
+ });
872
+ } else {
873
+ // Grid
874
+ const cols = Math.ceil(Math.sqrt(count));
875
+ const rows = Math.ceil(count / cols);
876
+ const cellWidth = viewport.width / cols;
877
+ const cellHeight = viewport.height / rows;
878
+
879
+ windowList.forEach((w, i) => {
880
+ const col = i % cols;
881
+ const row = Math.floor(i / cols);
882
+ newWindows.set(w.id, {
883
+ ...w,
884
+ position: { x: col * cellWidth, y: row * cellHeight },
885
+ size: { width: cellWidth, height: cellHeight },
886
+ isMaximized: false,
887
+ tilePosition: undefined,
888
+ });
889
+ });
890
+ }
891
+
892
+ return { windows: newWindows };
893
+ });
894
+ },
895
+ };
896
+ }
897
+
898
+ /** Factory: creates an isolated WindowManager store instance. */
899
+ export function createWindowManagerStore() {
900
+ return createStore<WindowManagerStore>((set, get) => createWindowManagerStoreImpl(set, get));
901
+ }
902
+
903
+ export type WindowManagerStoreApi = ReturnType<typeof createWindowManagerStore>;
904
+
905
+ // Context for provider-scoped usage
906
+ const WindowManagerStoreContext = createContext<WindowManagerStoreApi | null>(null);
907
+
908
+ /** Provider that creates an isolated WindowManager store for its subtree. */
909
+ export function WindowManagerStoreProvider({ children }: { children: ReactNode }) {
910
+ const [store] = useState(() => createWindowManagerStore());
911
+ return (
912
+ <WindowManagerStoreContext.Provider value={store}>
913
+ {children}
914
+ </WindowManagerStoreContext.Provider>
915
+ );
916
+ }
917
+
918
+ // Legacy singleton (kept for backwards compatibility and barrel exports)
919
+ export const useWindowManagerStore = create<WindowManagerStore>((set, get) => createWindowManagerStoreImpl(set, get));
920
+
921
+ // ═══════════════════════════════════════════════════════════════════════════
922
+ // Context-aware store resolver
923
+ // ═══════════════════════════════════════════════════════════════════════════
924
+
925
+ /** Returns the context-provided store if available, otherwise the singleton. */
926
+ function useResolvedWindowManagerStore(): StoreApi<WindowManagerStore> {
927
+ const contextStore = useContext(WindowManagerStoreContext);
928
+ return contextStore ?? useWindowManagerStore;
929
+ }
930
+
931
+ // ═══════════════════════════════════════════════════════════════════════════
932
+ // Public Hook
933
+ // ═══════════════════════════════════════════════════════════════════════════
934
+
935
+ /**
936
+ * Hook for window management.
937
+ *
938
+ * Provides all window lifecycle, focus, and state management operations.
939
+ *
940
+ * @example
941
+ * ```tsx
942
+ * const { windows, open, close, focus } = useWindowManager();
943
+ *
944
+ * const handleOpenWindow = () => {
945
+ * const id = open({ title: 'My Window', size: { width: 800, height: 600 } });
946
+ * console.log('Opened window:', id);
947
+ * };
948
+ * ```
949
+ */
950
+ export function useWindowManager(): UseWindowManagerReturn {
951
+ const resolvedStore = useResolvedWindowManagerStore();
952
+ const store = useStore(
953
+ resolvedStore,
954
+ useShallow((state) => ({
955
+ windows: state.windows,
956
+ groups: state.groups,
957
+ focusedId: state.focusedId,
958
+ fullscreenId: state.fullscreenId,
959
+ open: state._open,
960
+ close: state._close,
961
+ focus: state._focus,
962
+ minimize: state._minimize,
963
+ maximize: state._maximize,
964
+ restore: state._restore,
965
+ fullscreen: state._fullscreen,
966
+ exitFullscreen: state._exitFullscreen,
967
+ move: state._move,
968
+ resize: state._resize,
969
+ tile: state._tile,
970
+ untile: state._untile,
971
+ cycleFocusNext: state._cycleFocusNext,
972
+ createGroup: state._createGroup,
973
+ addToGroup: state._addToGroup,
974
+ removeFromGroup: state._removeFromGroup,
975
+ setActiveTab: state._setActiveTab,
976
+ minimizeAll: state._minimizeAll,
977
+ closeAll: state._closeAll,
978
+ cascade: state._cascade,
979
+ tileAll: state._tileAll,
980
+ })),
981
+ );
982
+
983
+ return {
984
+ windows: Array.from(store.windows.values()),
985
+ focusedId: store.focusedId,
986
+ fullscreenId: store.fullscreenId,
987
+ groups: Array.from(store.groups.values()),
988
+ open: store.open,
989
+ close: store.close,
990
+ focus: store.focus,
991
+ cycleFocusNext: store.cycleFocusNext,
992
+ minimize: store.minimize,
993
+ maximize: store.maximize,
994
+ restore: store.restore,
995
+ fullscreen: store.fullscreen,
996
+ exitFullscreen: store.exitFullscreen,
997
+ move: store.move,
998
+ resize: store.resize,
999
+ tile: store.tile,
1000
+ untile: store.untile,
1001
+ createGroup: store.createGroup,
1002
+ addToGroup: store.addToGroup,
1003
+ removeFromGroup: store.removeFromGroup,
1004
+ setActiveTab: store.setActiveTab,
1005
+ minimizeAll: store.minimizeAll,
1006
+ closeAll: store.closeAll,
1007
+ cascade: store.cascade,
1008
+ tileAll: store.tileAll,
1009
+ getWindow: (id) => store.windows.get(id),
1010
+ getWindowGroup: (windowId) => {
1011
+ const w = store.windows.get(windowId);
1012
+ return w?.groupId ? store.groups.get(w.groupId) : undefined;
1013
+ },
1014
+ };
1015
+ }
1016
+
1017
+ // ═══════════════════════════════════════════════════════════════════════════
1018
+ // Optimized Selector Hooks
1019
+ // ═══════════════════════════════════════════════════════════════════════════
1020
+
1021
+ /**
1022
+ * Get array of window IDs - only re-renders when IDs change.
1023
+ *
1024
+ * @returns Array of all window IDs in the system
1025
+ *
1026
+ * @example
1027
+ * ```tsx
1028
+ * const windowIds = useWindowIds();
1029
+ * return windowIds.map(id => <WindowListItem key={id} windowId={id} />);
1030
+ * ```
1031
+ */
1032
+ export function useWindowIds(): WindowId[] {
1033
+ const resolvedStore = useResolvedWindowManagerStore();
1034
+ return useStore(
1035
+ resolvedStore,
1036
+ useShallow((state) => Array.from(state.windows.keys())),
1037
+ );
1038
+ }
1039
+
1040
+ /**
1041
+ * Get a single window by ID - only re-renders when that window changes.
1042
+ *
1043
+ * @param id - The window ID to retrieve
1044
+ * @returns The window state or undefined if not found
1045
+ *
1046
+ * @example
1047
+ * ```tsx
1048
+ * const window = useWindow(windowId);
1049
+ * if (!window) return null;
1050
+ * return <div>{window.title}</div>;
1051
+ * ```
1052
+ */
1053
+ export function useWindow(id: WindowId): WindowState | undefined {
1054
+ const resolvedStore = useResolvedWindowManagerStore();
1055
+ return useStore(resolvedStore, (state) => state.windows.get(id));
1056
+ }
1057
+
1058
+ /**
1059
+ * Check if a specific window is focused.
1060
+ *
1061
+ * @param id - The window ID to check
1062
+ * @returns True if the window is currently focused
1063
+ *
1064
+ * @example
1065
+ * ```tsx
1066
+ * const isFocused = useIsWindowFocused(windowId);
1067
+ * return <div className={isFocused ? 'focused' : ''}>...</div>;
1068
+ * ```
1069
+ */
1070
+ export function useIsWindowFocused(id: WindowId): boolean {
1071
+ const resolvedStore = useResolvedWindowManagerStore();
1072
+ return useStore(resolvedStore, (state) => state.focusedId === id);
1073
+ }
1074
+
1075
+ /**
1076
+ * Check if a specific window is fullscreen.
1077
+ *
1078
+ * @param id - The window ID to check
1079
+ * @returns True if the window is in fullscreen mode
1080
+ */
1081
+ export function useIsWindowFullscreen(id: WindowId): boolean {
1082
+ const resolvedStore = useResolvedWindowManagerStore();
1083
+ return useStore(resolvedStore, (state) => state.fullscreenId === id);
1084
+ }
1085
+
1086
+ /**
1087
+ * Check if any window is in fullscreen mode.
1088
+ *
1089
+ * @returns True if any window is currently fullscreen
1090
+ *
1091
+ * @example
1092
+ * ```tsx
1093
+ * const isFullscreen = useIsFullscreenActive();
1094
+ * return isFullscreen ? <FullscreenOverlay /> : <Taskbar />;
1095
+ * ```
1096
+ */
1097
+ export function useIsFullscreenActive(): boolean {
1098
+ const resolvedStore = useResolvedWindowManagerStore();
1099
+ return useStore(resolvedStore, (state) => state.fullscreenId !== null);
1100
+ }
1101
+
1102
+ /**
1103
+ * Get window actions with stable references.
1104
+ * Useful when you only need actions without subscribing to state changes.
1105
+ *
1106
+ * @returns Object containing all window action methods
1107
+ *
1108
+ * @example
1109
+ * ```tsx
1110
+ * const { close, minimize, maximize } = useWindowActions();
1111
+ * return <button onClick={() => close(windowId)}>Close</button>;
1112
+ * ```
1113
+ */
1114
+ export function useWindowActions() {
1115
+ const resolvedStore = useResolvedWindowManagerStore();
1116
+ return useStore(
1117
+ resolvedStore,
1118
+ useShallow((state) => ({
1119
+ open: state._open,
1120
+ close: state._close,
1121
+ focus: state._focus,
1122
+ minimize: state._minimize,
1123
+ maximize: state._maximize,
1124
+ restore: state._restore,
1125
+ move: state._move,
1126
+ resize: state._resize,
1127
+ tile: state._tile,
1128
+ untile: state._untile,
1129
+ fullscreen: state._fullscreen,
1130
+ exitFullscreen: state._exitFullscreen,
1131
+ })),
1132
+ );
1133
+ }
1134
+
1135
+ /**
1136
+ * Get a window group by ID.
1137
+ *
1138
+ * @param groupId - The group ID to retrieve, or undefined
1139
+ * @returns The window group or undefined if not found
1140
+ *
1141
+ * @example
1142
+ * ```tsx
1143
+ * const group = useWindowGroup(window.groupId);
1144
+ * if (group) {
1145
+ * return <TabBar windowIds={group.windowIds} activeId={group.activeWindowId} />;
1146
+ * }
1147
+ * ```
1148
+ */
1149
+ export function useWindowGroup(groupId: string | undefined): WindowGroup | undefined {
1150
+ const resolvedStore = useResolvedWindowManagerStore();
1151
+ return useStore(resolvedStore, (state) =>
1152
+ groupId ? state.groups.get(groupId) : undefined,
1153
+ );
1154
+ }