flexdesk 0.1.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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/THIRD_PARTY_LICENSES.md +53 -0
  4. package/css/base.css +3439 -0
  5. package/css/flexdesk.css +5797 -0
  6. package/css/overrides.css +2349 -0
  7. package/css/reset.css +46 -0
  8. package/css/tokens.css +137 -0
  9. package/dist/charts.js +780 -0
  10. package/dist/charts.js.map +7 -0
  11. package/dist/chunk-3PHCPZHT.js +222 -0
  12. package/dist/chunk-3PHCPZHT.js.map +7 -0
  13. package/dist/chunk-CT4YXXLP.js +1770 -0
  14. package/dist/chunk-CT4YXXLP.js.map +7 -0
  15. package/dist/chunk-DRYCDMEG.js +2217 -0
  16. package/dist/chunk-DRYCDMEG.js.map +7 -0
  17. package/dist/chunk-DVU44T77.js +855 -0
  18. package/dist/chunk-DVU44T77.js.map +7 -0
  19. package/dist/chunk-FL5KFNQH.js +640 -0
  20. package/dist/chunk-FL5KFNQH.js.map +7 -0
  21. package/dist/chunk-FOOS3T5L.js +117 -0
  22. package/dist/chunk-FOOS3T5L.js.map +7 -0
  23. package/dist/chunk-JYWURG5T.js +32 -0
  24. package/dist/chunk-JYWURG5T.js.map +7 -0
  25. package/dist/chunk-TLZUUFOE.js +276 -0
  26. package/dist/chunk-TLZUUFOE.js.map +7 -0
  27. package/dist/chunk-UCJ2WD4D.js +625 -0
  28. package/dist/chunk-UCJ2WD4D.js.map +7 -0
  29. package/dist/chunk-WVFGV5FT.js +205 -0
  30. package/dist/chunk-WVFGV5FT.js.map +7 -0
  31. package/dist/core.js +1098 -0
  32. package/dist/core.js.map +7 -0
  33. package/dist/editor.js +1661 -0
  34. package/dist/editor.js.map +7 -0
  35. package/dist/flexdesk.css +5797 -0
  36. package/dist/host.js +132 -0
  37. package/dist/host.js.map +7 -0
  38. package/dist/reset.css +46 -0
  39. package/dist/tile_registry-6WZPTUZV.js +28 -0
  40. package/dist/tile_registry-6WZPTUZV.js.map +7 -0
  41. package/dist/tiles.js +2068 -0
  42. package/dist/tiles.js.map +7 -0
  43. package/dist/tokens.css +137 -0
  44. package/dist/widgets.js +5438 -0
  45. package/dist/widgets.js.map +7 -0
  46. package/dist/wm.js +4830 -0
  47. package/dist/wm.js.map +7 -0
  48. package/package.json +80 -0
  49. package/src/charts/chart_types.js +699 -0
  50. package/src/charts/data_series_plot_window.js +761 -0
  51. package/src/charts/downsample.js +119 -0
  52. package/src/charts/plot_config.js +176 -0
  53. package/src/charts/plot_popout_window.js +1560 -0
  54. package/src/charts/plotly_wrapper.js +432 -0
  55. package/src/core/event_bus.js +230 -0
  56. package/src/core/logging.js +300 -0
  57. package/src/core/settings.js +813 -0
  58. package/src/core/state_guard.js +382 -0
  59. package/src/core/state_guard_config.js +309 -0
  60. package/src/core/state_machine.js +258 -0
  61. package/src/editor/editor_pane.js +264 -0
  62. package/src/editor/monaco_editor_factory.js +449 -0
  63. package/src/editor/monaco_loader.js +74 -0
  64. package/src/editor/notebook_search.js +460 -0
  65. package/src/editor/notebook_tab_bar.js +456 -0
  66. package/src/editor/notebook_undo_manager.js +108 -0
  67. package/src/editor/split_pane_container.js +197 -0
  68. package/src/help/help_modal.js +409 -0
  69. package/src/help/help_registry.js +65 -0
  70. package/src/host/host.js +69 -0
  71. package/src/host/pywebview_host.js +133 -0
  72. package/src/tiles/config_schema.js +256 -0
  73. package/src/tiles/layout_persistence.js +662 -0
  74. package/src/tiles/tile_base.js +785 -0
  75. package/src/tiles/tile_grid.js +905 -0
  76. package/src/tiles/tile_registry.js +136 -0
  77. package/src/tiling/command_palette.js +328 -0
  78. package/src/tiling/content_registry.js +110 -0
  79. package/src/tiling/desktops.js +133 -0
  80. package/src/tiling/entity_sources.js +179 -0
  81. package/src/tiling/keymap.js +156 -0
  82. package/src/tiling/kind_taxonomy.js +172 -0
  83. package/src/tiling/landing_table.js +717 -0
  84. package/src/tiling/loading_overlay.js +33 -0
  85. package/src/tiling/nav_panel.js +142 -0
  86. package/src/tiling/page_factory.js +215 -0
  87. package/src/tiling/panel_keys.js +145 -0
  88. package/src/tiling/shell.js +437 -0
  89. package/src/tiling/tile_breadcrumb.js +222 -0
  90. package/src/tiling/tile_renderer.js +459 -0
  91. package/src/tiling/tile_tab_menu.js +340 -0
  92. package/src/tiling/tile_tree.js +880 -0
  93. package/src/tiling/wm.js +1352 -0
  94. package/src/ui/base/component_base.js +30 -0
  95. package/src/ui/base/controller_base.js +48 -0
  96. package/src/ui/base/page_base.js +102 -0
  97. package/src/ui/components/about_dialog.js +224 -0
  98. package/src/ui/components/action_dropdown.js +389 -0
  99. package/src/ui/components/attribute_list_editor.js +652 -0
  100. package/src/ui/components/autocomplete_field.js +732 -0
  101. package/src/ui/components/computing_status_window.js +81 -0
  102. package/src/ui/components/confirm_dialog.js +307 -0
  103. package/src/ui/components/context_menu.js +87 -0
  104. package/src/ui/components/data_table.js +2228 -0
  105. package/src/ui/components/detail_header.js +177 -0
  106. package/src/ui/components/drag_reorder.js +0 -0
  107. package/src/ui/components/gallery_picker.js +147 -0
  108. package/src/ui/components/inline_renamer.js +129 -0
  109. package/src/ui/components/managed_window.js +804 -0
  110. package/src/ui/components/modal.js +512 -0
  111. package/src/ui/components/notification_history.js +264 -0
  112. package/src/ui/components/slide_out_panel.js +185 -0
  113. package/src/ui/components/slider_field.js +122 -0
  114. package/src/ui/components/sortable_list.js +309 -0
  115. package/src/ui/components/table_state_store.js +81 -0
  116. package/src/ui/components/toast.js +59 -0
  117. package/src/ui/components/tree_view.js +460 -0
  118. package/src/ui/components/window_taskbar.js +143 -0
  119. package/src/ui/controllers/panel_state_machine.js +634 -0
  120. package/src/ui/controllers/window_chrome_controller.js +659 -0
  121. package/src/ui/notification_center.js +576 -0
  122. package/src/ui/utils/overlay_scrollbar.js +492 -0
  123. package/src/ui/utils/raf_resize_observer.js +31 -0
  124. package/src/ui/utils/tooltip_service.js +406 -0
@@ -0,0 +1,804 @@
1
+ /**
2
+ * ManagedWindow - A reusable window component with drag, resize, minimize, and persistence.
3
+ *
4
+ * Usage:
5
+ * const win = new ManagedWindow({
6
+ * id: 'my-window',
7
+ * title: 'My Window',
8
+ * icon: 'folder_open', // Material Symbols icon name (optional)
9
+ * content: myDomElement,
10
+ * minWidth: 400,
11
+ * minHeight: 300,
12
+ * defaultWidth: 600,
13
+ * defaultHeight: 400,
14
+ * canMaximize: true,
15
+ * onClose: () => { ... }
16
+ * });
17
+ * win.show();
18
+ */
19
+
20
+ import { getSetting } from '../../core/settings.js';
21
+
22
+ const STORAGE_KEY = 'ecosim.managedWindows.v1';
23
+ const BASE_Z_INDEX = 6000;
24
+ const MAX_Z_INDEX = 6999;
25
+
26
+ // Bar heights for maximize bounds. Must match the tiling shell's
27
+ // own constants (host.css locks `.global-bottom-bar` to 22 px) so a
28
+ // maximized window stops above the bottom bar instead of slipping
29
+ // under it.
30
+ const TOP_BAR_HEIGHT = 35;
31
+ const BOTTOM_BAR_HEIGHT = 22;
32
+
33
+ // Default icon for windows
34
+ const DEFAULT_ICON = 'web_asset';
35
+
36
+ // Global state for z-order management
37
+ let _zIndexCounter = 0;
38
+ let _activeWindows = new Map(); // id -> ManagedWindow instance
39
+
40
+ /** Collect the focusable elements inside `root`, in DOM order. Used
41
+ * by the modal focus trap so Tab cycles within the modal frame and
42
+ * can't escape into the underlying tiles. */
43
+ function _collectFocusable(root) {
44
+ if (!root) return [];
45
+ const sel = [
46
+ 'a[href]',
47
+ 'button:not([disabled])',
48
+ 'input:not([disabled]):not([type="hidden"])',
49
+ 'select:not([disabled])',
50
+ 'textarea:not([disabled])',
51
+ '[tabindex]:not([tabindex="-1"])',
52
+ ].join(',');
53
+ return Array.from(root.querySelectorAll(sel)).filter((el) => {
54
+ // Skip hidden / zero-size elements — Tab visits only what the
55
+ // user can actually see.
56
+ if (el.hidden) return false;
57
+ if (el.closest('[hidden]')) return false;
58
+ const r = el.getBoundingClientRect();
59
+ return r.width > 0 || r.height > 0;
60
+ });
61
+ }
62
+
63
+ // Global state cache
64
+ let _stateCache = null;
65
+
66
+ function _loadState() {
67
+ if (_stateCache) return _stateCache;
68
+ try {
69
+ const raw = localStorage.getItem(STORAGE_KEY);
70
+ _stateCache = raw ? JSON.parse(raw) : {};
71
+ } catch {
72
+ _stateCache = {};
73
+ }
74
+ return _stateCache;
75
+ }
76
+
77
+ function _saveState(state) {
78
+ _stateCache = state;
79
+ try {
80
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
81
+ } catch (err) {
82
+ console.warn('[ManagedWindow] Failed to save state:', err);
83
+ }
84
+ }
85
+
86
+ function _getWindowState(id) {
87
+ const state = _loadState();
88
+ return state[id] || null;
89
+ }
90
+
91
+ function _setWindowState(id, windowState) {
92
+ const state = _loadState();
93
+ state[id] = windowState;
94
+ _saveState(state);
95
+ }
96
+
97
+ export class ManagedWindow {
98
+ /**
99
+ * @param {Object} options
100
+ * @param {string} options.id - Unique window ID for persistence
101
+ * @param {string} options.title - Window title
102
+ * @param {string} [options.icon] - Material Symbols icon name (default: 'web_asset')
103
+ * @param {HTMLElement|Function} options.content - Content element or render function
104
+ * @param {number} [options.minWidth=400] - Minimum width
105
+ * @param {number} [options.minHeight=300] - Minimum height
106
+ * @param {number} [options.defaultWidth=600] - Default width
107
+ * @param {number} [options.defaultHeight=400] - Default height
108
+ * @param {boolean} [options.canMinimize=true] - Whether minimize button is shown
109
+ * @param {boolean} [options.canMaximize=true] - Whether maximize button is shown
110
+ * @param {boolean} [options.canResize=true] - Whether window can be resized
111
+ * @param {boolean} [options.canDrag=true] - Whether window can be dragged
112
+ * @param {boolean} [options.modal=false] - Whether to show backdrop
113
+ * @param {Function} [options.onClose] - Callback when window is closed
114
+ * @param {Function} [options.beforeClose] - Guard called before close. Return false (or a Promise resolving to false) to prevent closing.
115
+ * @param {Function} [options.onMinimize] - Callback when window is minimized
116
+ */
117
+ constructor(options) {
118
+ this.id = options.id;
119
+ this.title = options.title || 'Window';
120
+ this.icon = options.icon || DEFAULT_ICON;
121
+ this.content = options.content;
122
+ this.minWidth = options.minWidth || 400;
123
+ this.minHeight = options.minHeight || 300;
124
+ this.defaultWidth = options.defaultWidth || 600;
125
+ this.defaultHeight = options.defaultHeight || 400;
126
+ this.canMinimize = options.canMinimize ?? true;
127
+ this.canMaximize = options.canMaximize ?? true;
128
+ this.canResize = options.canResize ?? true;
129
+ this.modal = options.modal ?? false;
130
+ // Backdrop appearance (modal only). `undefined` → inherit the CSS
131
+ // default (blur 2px over a 50% dim). Override per-modal when the
132
+ // background should stay legible — e.g. a live-applied editor where
133
+ // you want to watch the content behind update.
134
+ // backdropBlur: blur radius in px; 0 disables the blur.
135
+ // backdropOpacity: dim darkness, 0 (clear) … 1 (opaque black).
136
+ this.backdropBlur = options.backdropBlur;
137
+ this.backdropOpacity = options.backdropOpacity;
138
+ // Modals are non-draggable by default
139
+ this.canDrag = options.canDrag ?? !this.modal;
140
+ this.onClose = options.onClose;
141
+ this.beforeClose = options.beforeClose || null;
142
+ this.onMinimize = options.onMinimize;
143
+
144
+ this.element = null;
145
+ this.backdropElement = null;
146
+ this.contentContainer = null;
147
+ this.isVisible = false;
148
+ this.isMinimized = false;
149
+ this.isMaximized = false;
150
+ this.zIndex = BASE_Z_INDEX;
151
+
152
+ // Position/size state - clamp to viewport bounds
153
+ this.x = 0;
154
+ this.y = 0;
155
+ const maxWidth = window.innerWidth;
156
+ const maxHeight = window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT;
157
+ this.width = Math.min(this.defaultWidth, maxWidth);
158
+ this.height = Math.min(this.defaultHeight, maxHeight);
159
+
160
+ // State before maximize (for restore)
161
+ this._preMaximizeState = null;
162
+
163
+ // Drag state
164
+ this._dragState = null;
165
+ this._resizeState = null;
166
+
167
+ // Bound handlers for cleanup
168
+ this._boundOnPointerMove = this._onPointerMove.bind(this);
169
+ this._boundOnPointerUp = this._onPointerUp.bind(this);
170
+ this._boundOnKeyDown = this._onKeyDown.bind(this);
171
+
172
+ // Register globally
173
+ _activeWindows.set(this.id, this);
174
+ }
175
+
176
+ /**
177
+ * Show the window.
178
+ */
179
+ show() {
180
+ if (this.isVisible && !this.isMinimized) {
181
+ this.bringToFront();
182
+ return;
183
+ }
184
+
185
+ if (!this.element) {
186
+ this._build();
187
+ this._restoreState();
188
+ }
189
+
190
+ if (this.isMinimized) {
191
+ this._restore();
192
+ } else {
193
+ this._applyPosition();
194
+ document.body.appendChild(this.element);
195
+ if (this.modal && this.backdropElement) {
196
+ document.body.appendChild(this.backdropElement);
197
+ }
198
+ this.isVisible = true;
199
+ }
200
+
201
+ this.bringToFront();
202
+ document.addEventListener('keydown', this._boundOnKeyDown);
203
+ }
204
+
205
+ /**
206
+ * Hide/close the window.
207
+ * @param {{ force?: boolean }} [options] - Pass force:true to bypass the beforeClose guard.
208
+ */
209
+ close({ force = false } = {}) {
210
+ if (!this.isVisible) return;
211
+
212
+ if (!force && this.beforeClose) {
213
+ const result = this.beforeClose();
214
+ if (result && typeof result.then === 'function') {
215
+ result.then(allowed => { if (allowed !== false) this._doClose(); });
216
+ return;
217
+ }
218
+ if (result === false) return;
219
+ }
220
+
221
+ this._doClose();
222
+ }
223
+
224
+ /** Internal close — always executes, no guard. */
225
+ _doClose() {
226
+ if (!this.isVisible) return;
227
+
228
+ this._saveCurrentState();
229
+
230
+ // Call onClose BEFORE removing elements so content can still read its state
231
+ // (e.g., expression modal needs to read the editor value before DOM is detached)
232
+ if (this.onClose) {
233
+ this.onClose();
234
+ }
235
+
236
+ if (this.element && this.element.parentNode) {
237
+ this.element.parentNode.removeChild(this.element);
238
+ }
239
+ if (this.backdropElement && this.backdropElement.parentNode) {
240
+ this.backdropElement.parentNode.removeChild(this.backdropElement);
241
+ }
242
+
243
+ this.isVisible = false;
244
+ this.isMinimized = false;
245
+ document.removeEventListener('keydown', this._boundOnKeyDown);
246
+
247
+ // Notify taskbar
248
+ window.dispatchEvent(new CustomEvent('managed-window-closed', { detail: { id: this.id } }));
249
+ }
250
+
251
+ /**
252
+ * Minimize to taskbar with animation.
253
+ */
254
+ minimize() {
255
+ if (!this.isVisible || this.isMinimized) return;
256
+
257
+ this._saveCurrentState();
258
+ this.isMinimized = true;
259
+
260
+ if (this.onMinimize) {
261
+ this.onMinimize();
262
+ }
263
+
264
+ if (this.backdropElement) {
265
+ this.backdropElement.style.display = 'none';
266
+ }
267
+
268
+ // Dispatch event first so the taskbar button is created synchronously
269
+ window.dispatchEvent(new CustomEvent('managed-window-minimized', {
270
+ detail: { id: this.id, title: this.title, icon: this.icon }
271
+ }));
272
+
273
+ if (!this.element) return;
274
+
275
+ if (!getSetting('window.animateMinimize', true)) {
276
+ this.element.style.display = 'none';
277
+ return;
278
+ }
279
+
280
+ // Calculate animation target toward the taskbar button
281
+ this._setMinimizeTargetProperties();
282
+ this.element.classList.add('twm-managed-window--minimizing');
283
+
284
+ setTimeout(() => {
285
+ if (this.element) {
286
+ this.element.style.display = 'none';
287
+ this.element.classList.remove('twm-managed-window--minimizing');
288
+ this._clearTargetProperties();
289
+ }
290
+ }, 200);
291
+ }
292
+
293
+ /**
294
+ * Restore from minimized state with animation.
295
+ */
296
+ _restore() {
297
+ if (!this.isMinimized) return;
298
+
299
+ // Capture taskbar item rect BEFORE dispatching the event (which removes it)
300
+ const taskbarRect = this._getTaskbarItemRect();
301
+
302
+ if (this.element) {
303
+ this.element.style.display = '';
304
+
305
+ const animate = getSetting('window.animateMinimize', true);
306
+ if (animate) {
307
+ this._setRestoreTargetProperties(taskbarRect);
308
+ this.element.classList.add('twm-managed-window--restoring');
309
+ setTimeout(() => {
310
+ if (this.element) {
311
+ this.element.classList.remove('twm-managed-window--restoring');
312
+ this._clearTargetProperties();
313
+ }
314
+ }, 250);
315
+ }
316
+ }
317
+ if (this.modal && this.backdropElement) {
318
+ this.backdropElement.style.display = '';
319
+ }
320
+
321
+ this.isMinimized = false;
322
+ this.bringToFront();
323
+
324
+ // Notify taskbar (removes the taskbar button)
325
+ window.dispatchEvent(new CustomEvent('managed-window-restored', { detail: { id: this.id } }));
326
+ }
327
+
328
+ /**
329
+ * Toggle maximize state.
330
+ */
331
+ toggleMaximize() {
332
+ if (!this.canMaximize) return;
333
+
334
+ if (this.isMaximized) {
335
+ // Restore
336
+ if (this._preMaximizeState) {
337
+ this.x = this._preMaximizeState.x;
338
+ this.y = this._preMaximizeState.y;
339
+ this.width = this._preMaximizeState.width;
340
+ this.height = this._preMaximizeState.height;
341
+ this._preMaximizeState = null;
342
+ }
343
+ this.isMaximized = false;
344
+ } else {
345
+ // Maximize - respect top and bottom bars
346
+ this._preMaximizeState = { x: this.x, y: this.y, width: this.width, height: this.height };
347
+ this.x = 0;
348
+ this.y = TOP_BAR_HEIGHT;
349
+ this.width = window.innerWidth;
350
+ this.height = window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT;
351
+ this.isMaximized = true;
352
+ }
353
+
354
+ this._applyPosition();
355
+ this._saveCurrentState();
356
+ }
357
+
358
+ /**
359
+ * Find the taskbar button for this window.
360
+ * @returns {DOMRect|null}
361
+ */
362
+ _getTaskbarItemRect() {
363
+ const btn = document.querySelector(`.twm-bar-windows__item[data-window-id="${this.id}"]`);
364
+ return btn ? btn.getBoundingClientRect() : null;
365
+ }
366
+
367
+ /**
368
+ * Set CSS custom properties to animate minimize toward the taskbar item.
369
+ */
370
+ _setMinimizeTargetProperties() {
371
+ const targetRect = this._getTaskbarItemRect();
372
+ if (!targetRect || !this.element) return;
373
+
374
+ const winRect = this.element.getBoundingClientRect();
375
+ const winCenterX = winRect.left + winRect.width / 2;
376
+ const winCenterY = winRect.top + winRect.height / 2;
377
+ const targetCenterX = targetRect.left + targetRect.width / 2;
378
+ const targetCenterY = targetRect.top + targetRect.height / 2;
379
+
380
+ const dx = targetCenterX - winCenterX;
381
+ const dy = targetCenterY - winCenterY;
382
+ const scale = Math.min(targetRect.width / winRect.width, targetRect.height / winRect.height, 0.15);
383
+
384
+ this.element.style.setProperty('--mw-target-x', `${dx}px`);
385
+ this.element.style.setProperty('--mw-target-y', `${dy}px`);
386
+ this.element.style.setProperty('--mw-target-scale', scale);
387
+ }
388
+
389
+ /**
390
+ * Set CSS custom properties to animate restore from the taskbar item position.
391
+ * @param {DOMRect|null} taskbarRect
392
+ */
393
+ _setRestoreTargetProperties(taskbarRect) {
394
+ if (!taskbarRect || !this.element) return;
395
+
396
+ const winRect = this.element.getBoundingClientRect();
397
+ const winCenterX = winRect.left + winRect.width / 2;
398
+ const winCenterY = winRect.top + winRect.height / 2;
399
+ const targetCenterX = taskbarRect.left + taskbarRect.width / 2;
400
+ const targetCenterY = taskbarRect.top + taskbarRect.height / 2;
401
+
402
+ const dx = targetCenterX - winCenterX;
403
+ const dy = targetCenterY - winCenterY;
404
+ const scale = Math.min(taskbarRect.width / winRect.width, taskbarRect.height / winRect.height, 0.15);
405
+
406
+ this.element.style.setProperty('--mw-target-x', `${dx}px`);
407
+ this.element.style.setProperty('--mw-target-y', `${dy}px`);
408
+ this.element.style.setProperty('--mw-target-scale', scale);
409
+ }
410
+
411
+ /**
412
+ * Clear animation CSS custom properties.
413
+ */
414
+ _clearTargetProperties() {
415
+ if (!this.element) return;
416
+ this.element.style.removeProperty('--mw-target-x');
417
+ this.element.style.removeProperty('--mw-target-y');
418
+ this.element.style.removeProperty('--mw-target-scale');
419
+ }
420
+
421
+ /**
422
+ * Bring window to front of z-order.
423
+ */
424
+ bringToFront() {
425
+ _zIndexCounter++;
426
+ this.zIndex = Math.min(BASE_Z_INDEX + _zIndexCounter, MAX_Z_INDEX);
427
+ if (this.element) {
428
+ this.element.style.zIndex = this.zIndex.toString();
429
+ }
430
+ if (this.backdropElement) {
431
+ this.backdropElement.style.zIndex = (this.zIndex - 1).toString();
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Update window title.
437
+ */
438
+ setTitle(title) {
439
+ this.title = title;
440
+ if (this.element) {
441
+ const titleEl = this.element.querySelector('.twm-managed-window__title');
442
+ if (titleEl) titleEl.textContent = title;
443
+ }
444
+ }
445
+
446
+ // ========== Private Methods ==========
447
+
448
+ _build() {
449
+ // Create backdrop for modal windows
450
+ if (this.modal) {
451
+ this.backdropElement = document.createElement('div');
452
+ this.backdropElement.className = 'twm-managed-window__backdrop';
453
+ // Apply backdrop overrides as inline styles only when the caller
454
+ // set them; otherwise the CSS default governs (single source of
455
+ // truth for the default look).
456
+ if (this.backdropBlur !== undefined && this.backdropBlur !== null) {
457
+ this.backdropElement.style.backdropFilter =
458
+ this.backdropBlur > 0 ? `blur(${this.backdropBlur}px)` : 'none';
459
+ }
460
+ if (this.backdropOpacity !== undefined && this.backdropOpacity !== null) {
461
+ this.backdropElement.style.background =
462
+ `rgba(0, 0, 0, ${this.backdropOpacity})`;
463
+ }
464
+ }
465
+
466
+ // Create window element
467
+ this.element = document.createElement('div');
468
+ this.element.className = 'twm-managed-window';
469
+ if (!this.canDrag) {
470
+ this.element.classList.add('twm-managed-window--no-drag');
471
+ }
472
+ if (this.modal) {
473
+ this.element.classList.add('twm-managed-window--modal');
474
+ }
475
+ this.element.setAttribute('data-window-id', this.id);
476
+
477
+ // Top bar
478
+ const topbar = document.createElement('div');
479
+ topbar.className = 'twm-managed-window__topbar';
480
+
481
+ // Icon
482
+ const icon = document.createElement('span');
483
+ icon.className = 'twm-managed-window__icon material-symbols-outlined';
484
+ icon.textContent = this.icon;
485
+
486
+ const title = document.createElement('div');
487
+ title.className = 'twm-managed-window__title';
488
+ title.textContent = this.title;
489
+
490
+ const buttons = document.createElement('div');
491
+ buttons.className = 'twm-managed-window__buttons';
492
+
493
+ // Minimize button (optional)
494
+ if (this.canMinimize) {
495
+ const minBtn = document.createElement('button');
496
+ minBtn.className = 'twm-managed-window__btn managed-window__btn--minimize';
497
+ minBtn.type = 'button';
498
+ minBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10"><path d="M1 5h8" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
499
+ minBtn.title = 'Minimize';
500
+ minBtn.addEventListener('click', (e) => { e.stopPropagation(); this.minimize(); });
501
+ buttons.appendChild(minBtn);
502
+ }
503
+
504
+ // Maximize button (optional)
505
+ if (this.canMaximize) {
506
+ const maxBtn = document.createElement('button');
507
+ maxBtn.className = 'twm-managed-window__btn managed-window__btn--maximize';
508
+ maxBtn.type = 'button';
509
+ maxBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10"><rect x="1" y="1" width="8" height="8" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
510
+ maxBtn.title = 'Maximize';
511
+ maxBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleMaximize(); });
512
+ buttons.appendChild(maxBtn);
513
+ }
514
+
515
+ // Close button
516
+ const closeBtn = document.createElement('button');
517
+ closeBtn.className = 'twm-managed-window__btn twm-managed-window__btn--close';
518
+ closeBtn.type = 'button';
519
+ closeBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10"><path d="M1 1l8 8M9 1l-8 8" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
520
+ closeBtn.title = 'Close';
521
+ closeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.close(); });
522
+ buttons.appendChild(closeBtn);
523
+
524
+ topbar.appendChild(icon);
525
+ topbar.appendChild(title);
526
+ topbar.appendChild(buttons);
527
+
528
+ // Content container
529
+ this.contentContainer = document.createElement('div');
530
+ this.contentContainer.className = 'twm-managed-window__content';
531
+
532
+ if (this.content instanceof HTMLElement) {
533
+ this.contentContainer.appendChild(this.content);
534
+ } else if (typeof this.content === 'function') {
535
+ const rendered = this.content();
536
+ if (rendered instanceof HTMLElement) {
537
+ this.contentContainer.appendChild(rendered);
538
+ }
539
+ }
540
+
541
+ this.element.appendChild(topbar);
542
+ this.element.appendChild(this.contentContainer);
543
+
544
+ // Resize handles (only if resizable)
545
+ if (this.canResize) {
546
+ this._addResizeHandles();
547
+ }
548
+
549
+ // Event listeners
550
+ topbar.addEventListener('pointerdown', (e) => this._onTopbarPointerDown(e));
551
+ if (this.canMaximize) {
552
+ topbar.addEventListener('dblclick', () => this.toggleMaximize());
553
+ }
554
+ this.element.addEventListener('pointerdown', () => this.bringToFront());
555
+ }
556
+
557
+ _addResizeHandles() {
558
+ const directions = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
559
+ for (const dir of directions) {
560
+ const handle = document.createElement('div');
561
+ handle.className = `twm-managed-window__resize managed-window__resize--${dir}`;
562
+ handle.addEventListener('pointerdown', (e) => this._onResizePointerDown(e, dir));
563
+ this.element.appendChild(handle);
564
+ }
565
+ }
566
+
567
+ _applyPosition() {
568
+ if (!this.element) return;
569
+
570
+ // Calculate max available dimensions
571
+ const maxWidth = window.innerWidth;
572
+ const maxHeight = window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT;
573
+
574
+ // Clamp width and height to viewport (but respect minWidth/minHeight)
575
+ this.width = Math.max(this.minWidth, Math.min(this.width, maxWidth));
576
+ this.height = Math.max(this.minHeight, Math.min(this.height, maxHeight));
577
+
578
+ // Ensure window is within viewport (respect top and bottom bars)
579
+ const maxX = Math.max(0, maxWidth - this.width);
580
+ const maxY = Math.max(TOP_BAR_HEIGHT, window.innerHeight - this.height - BOTTOM_BAR_HEIGHT);
581
+ this.x = Math.max(0, Math.min(this.x, maxX));
582
+ this.y = Math.max(TOP_BAR_HEIGHT, Math.min(this.y, maxY));
583
+
584
+ this.element.style.left = `${this.x}px`;
585
+ this.element.style.top = `${this.y}px`;
586
+ this.element.style.width = `${this.width}px`;
587
+ this.element.style.height = `${this.height}px`;
588
+ }
589
+
590
+ _restoreState() {
591
+ // Calculate max available dimensions
592
+ const maxWidth = window.innerWidth;
593
+ const maxHeight = window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT;
594
+
595
+ // Modal windows always center on screen - never restore saved position
596
+ const saved = this.modal ? null : _getWindowState(this.id);
597
+ if (saved) {
598
+ this.x = saved.x ?? this.x;
599
+ this.y = saved.y ?? this.y;
600
+ // Clamp restored dimensions to current viewport
601
+ this.width = Math.min(saved.width ?? this.width, maxWidth);
602
+ this.height = Math.min(saved.height ?? this.height, maxHeight);
603
+ this.isMaximized = saved.maximized ?? false;
604
+
605
+ if (this.isMaximized && this.canMaximize) {
606
+ this._preMaximizeState = { x: saved.x, y: saved.y, width: saved.width, height: saved.height };
607
+ this.x = 0;
608
+ this.y = TOP_BAR_HEIGHT;
609
+ this.width = maxWidth;
610
+ this.height = maxHeight;
611
+ }
612
+ } else {
613
+ // Center on screen (between top and bottom bars)
614
+ this.x = Math.max(0, (maxWidth - this.width) / 2);
615
+ this.y = Math.max(TOP_BAR_HEIGHT, TOP_BAR_HEIGHT + (maxHeight - this.height) / 2);
616
+ }
617
+ }
618
+
619
+ _saveCurrentState() {
620
+ // Modal windows always center - no need to persist position
621
+ if (this.modal) return;
622
+
623
+ _setWindowState(this.id, {
624
+ x: this._preMaximizeState?.x ?? this.x,
625
+ y: this._preMaximizeState?.y ?? this.y,
626
+ width: this._preMaximizeState?.width ?? this.width,
627
+ height: this._preMaximizeState?.height ?? this.height,
628
+ maximized: this.isMaximized,
629
+ });
630
+ }
631
+
632
+ // ========== Drag Handling ==========
633
+
634
+ _onTopbarPointerDown(e) {
635
+ // Don't start drag if clicking on buttons
636
+ if (e.target.closest('.twm-managed-window__buttons')) return;
637
+ if (this.isMaximized) return;
638
+ if (!this.canDrag) return;
639
+
640
+ e.preventDefault();
641
+ this._dragState = {
642
+ startX: e.clientX,
643
+ startY: e.clientY,
644
+ startWinX: this.x,
645
+ startWinY: this.y,
646
+ };
647
+
648
+ document.addEventListener('pointermove', this._boundOnPointerMove);
649
+ document.addEventListener('pointerup', this._boundOnPointerUp);
650
+ }
651
+
652
+ _onPointerMove(e) {
653
+ if (this._dragState) {
654
+ const dx = e.clientX - this._dragState.startX;
655
+ const dy = e.clientY - this._dragState.startY;
656
+ this.x = this._dragState.startWinX + dx;
657
+ this.y = this._dragState.startWinY + dy;
658
+ this._applyPosition();
659
+ } else if (this._resizeState) {
660
+ this._handleResize(e);
661
+ }
662
+ }
663
+
664
+ _onPointerUp() {
665
+ if (this._dragState || this._resizeState) {
666
+ this._saveCurrentState();
667
+ }
668
+ this._dragState = null;
669
+ this._resizeState = null;
670
+ document.removeEventListener('pointermove', this._boundOnPointerMove);
671
+ document.removeEventListener('pointerup', this._boundOnPointerUp);
672
+ }
673
+
674
+ // ========== Resize Handling ==========
675
+
676
+ _onResizePointerDown(e, direction) {
677
+ if (this.isMaximized) return;
678
+
679
+ e.preventDefault();
680
+ e.stopPropagation();
681
+
682
+ this._resizeState = {
683
+ direction,
684
+ startX: e.clientX,
685
+ startY: e.clientY,
686
+ startWinX: this.x,
687
+ startWinY: this.y,
688
+ startWidth: this.width,
689
+ startHeight: this.height,
690
+ };
691
+
692
+ document.addEventListener('pointermove', this._boundOnPointerMove);
693
+ document.addEventListener('pointerup', this._boundOnPointerUp);
694
+ }
695
+
696
+ _handleResize(e) {
697
+ const state = this._resizeState;
698
+ if (!state) return;
699
+
700
+ const dx = e.clientX - state.startX;
701
+ const dy = e.clientY - state.startY;
702
+ const dir = state.direction;
703
+
704
+ // Calculate max available dimensions
705
+ const maxWidth = window.innerWidth;
706
+ const maxHeight = window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT;
707
+
708
+ let newX = state.startWinX;
709
+ let newY = state.startWinY;
710
+ let newW = state.startWidth;
711
+ let newH = state.startHeight;
712
+
713
+ // Handle horizontal resize
714
+ if (dir.includes('e')) {
715
+ newW = Math.max(this.minWidth, Math.min(state.startWidth + dx, maxWidth - newX));
716
+ }
717
+ if (dir.includes('w')) {
718
+ const maxDx = state.startWidth - this.minWidth;
719
+ const actualDx = Math.min(dx, maxDx);
720
+ newX = Math.max(0, state.startWinX + actualDx);
721
+ newW = state.startWidth - (newX - state.startWinX);
722
+ }
723
+
724
+ // Handle vertical resize
725
+ if (dir.includes('s')) {
726
+ newH = Math.max(this.minHeight, Math.min(state.startHeight + dy, maxHeight - (newY - TOP_BAR_HEIGHT)));
727
+ }
728
+ if (dir.includes('n')) {
729
+ const maxDy = state.startHeight - this.minHeight;
730
+ const actualDy = Math.min(dy, maxDy);
731
+ newY = Math.max(TOP_BAR_HEIGHT, state.startWinY + actualDy);
732
+ newH = state.startHeight - (newY - state.startWinY);
733
+ }
734
+
735
+ this.x = newX;
736
+ this.y = newY;
737
+ this.width = newW;
738
+ this.height = newH;
739
+ this._applyPosition();
740
+ }
741
+
742
+ // ========== Keyboard Handling ==========
743
+
744
+ _onKeyDown(e) {
745
+ if (!this.isVisible || this.isMinimized) return;
746
+ // Only the top-most window owns global keydowns — otherwise a
747
+ // background window would steal focus from an open modal.
748
+ const topWindow = Array.from(_activeWindows.values())
749
+ .filter(w => w.isVisible && !w.isMinimized)
750
+ .sort((a, b) => b.zIndex - a.zIndex)[0];
751
+ if (topWindow !== this) return;
752
+
753
+ if (e.key === 'Escape') {
754
+ this.close();
755
+ return;
756
+ }
757
+ // Focus trap for modals: Tab and Shift+Tab cycle through the
758
+ // focusable elements inside the modal's frame instead of
759
+ // escaping to underlying tiles. Without this, Tab moves the
760
+ // active element into the background — bad UX, and a
761
+ // genuine WCAG focus-trap violation for accessibility tools.
762
+ if (e.key === 'Tab' && this.modal && this.element) {
763
+ const focusables = _collectFocusable(this.element);
764
+ if (focusables.length === 0) {
765
+ e.preventDefault();
766
+ return;
767
+ }
768
+ const first = focusables[0];
769
+ const last = focusables[focusables.length - 1];
770
+ const active = document.activeElement;
771
+ if (e.shiftKey) {
772
+ if (active === first || !this.element.contains(active)) {
773
+ e.preventDefault();
774
+ last.focus();
775
+ }
776
+ } else {
777
+ if (active === last || !this.element.contains(active)) {
778
+ e.preventDefault();
779
+ first.focus();
780
+ }
781
+ }
782
+ }
783
+ }
784
+
785
+ // ========== Static Methods ==========
786
+
787
+ /**
788
+ * Get a window by ID.
789
+ */
790
+ static get(id) {
791
+ return _activeWindows.get(id) || null;
792
+ }
793
+
794
+ /**
795
+ * Restore a minimized window by ID.
796
+ */
797
+ static restore(id) {
798
+ const win = _activeWindows.get(id);
799
+ if (win && win.isMinimized) {
800
+ win._restore();
801
+ win.bringToFront();
802
+ }
803
+ }
804
+ }