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,576 @@
1
+ /**
2
+ * Notification Center (js_new)
3
+ *
4
+ * Purpose
5
+ * -------
6
+ * Unified notifications/toasts/status-line controller consuming events from the rest of the app.
7
+ *
8
+ * Responsibilities
9
+ * - Render queued notifications with namespace context and severity.
10
+ * - Ingest `log:*` and domain-specific events to present user-facing feedback.
11
+ * - Replace scattered `window.notify` / `status_view.js` hooks.
12
+ *
13
+ * Source Material
14
+ * - html/js/status_view.js and ui_manager.js toast helpers.
15
+ * - html/js/ui_utilities.js notification utilities.
16
+ */
17
+ import { ComponentBase } from './base/component_base.js';
18
+ import { getSetting } from '../core/settings.js';
19
+
20
+ const SEVERITIES = ['info', 'success', 'warn', 'error', 'fatal'];
21
+ const LOG_SEVERITY_MAP = {
22
+ trace: 'info',
23
+ debug: 'info',
24
+ info: 'info',
25
+ warn: 'warn',
26
+ error: 'error',
27
+ fatal: 'fatal',
28
+ };
29
+
30
+ const DEFAULT_LOG_LEVELS = new Set(['warn', 'error', 'fatal']);
31
+
32
+ // Internal namespace identifiers that should not be shown as toast titles.
33
+ // Messages from these namespaces are self-descriptive and need no category header.
34
+ const SUPPRESSED_NAMESPACE_TITLES = new Set(['bootstrap', 'app']);
35
+
36
+ export class NotificationCenter extends ComponentBase {
37
+ constructor({
38
+ eventBus,
39
+ logger,
40
+ maxVisible = 3,
41
+ historyLimit = 50,
42
+ defaultDurationMs = 6000,
43
+ logLevels = DEFAULT_LOG_LEVELS,
44
+ } = {}) {
45
+ super({ eventBus, logger });
46
+
47
+ this.maxVisible = maxVisible;
48
+ this.historyLimit = historyLimit;
49
+ this.defaultDurationMs = defaultDurationMs;
50
+ this.logLevels = new Set(logLevels ?? DEFAULT_LOG_LEVELS);
51
+
52
+ this.notifications = [];
53
+ this.history = [];
54
+ this.sequence = 0;
55
+ this.activeTimers = new Map();
56
+ this.eventDisposers = [];
57
+
58
+ this.container = null;
59
+ this.rootEl = null;
60
+ this.listEl = null;
61
+ this.liveRegionEl = null;
62
+ }
63
+
64
+ mount(container, props = {}) {
65
+ // Always mount to document.body for reliable visibility
66
+ // The passed container is ignored - we create our own fixed-position container
67
+ const existingContainer = document.getElementById('notification-center-root');
68
+ if (existingContainer) {
69
+ this.container = existingContainer;
70
+ this.rootEl = existingContainer.querySelector('.twm-notification-center');
71
+ this.listEl = existingContainer.querySelector('.twm-notification-center__list');
72
+ this.liveRegionEl = existingContainer.querySelector('.notification-center__live-region');
73
+ this._mounted = true;
74
+ this.#subscribeEvents();
75
+ return this;
76
+ }
77
+
78
+ // Create a new fixed-position container directly on body
79
+ this.container = document.createElement('div');
80
+ this.container.id = 'notification-center-root';
81
+ this.container.style.cssText = 'position: fixed; right: 16px; bottom: 24px; z-index: 10000; pointer-events: none;';
82
+
83
+ this.rootEl = document.createElement('div');
84
+ this.rootEl.className = 'twm-notification-center';
85
+
86
+ this.listEl = document.createElement('div');
87
+ this.listEl.className = 'twm-notification-center__list';
88
+
89
+ this.liveRegionEl = document.createElement('div');
90
+ this.liveRegionEl.className = 'notification-center__live-region';
91
+ this.liveRegionEl.setAttribute('aria-live', 'polite');
92
+ this.liveRegionEl.setAttribute('aria-atomic', 'true');
93
+ this.liveRegionEl.style.position = 'absolute';
94
+ this.liveRegionEl.style.width = '1px';
95
+ this.liveRegionEl.style.height = '1px';
96
+ this.liveRegionEl.style.overflow = 'hidden';
97
+ this.liveRegionEl.style.clip = 'rect(1px, 1px, 1px, 1px)';
98
+
99
+ this.rootEl.appendChild(this.listEl);
100
+ this.rootEl.appendChild(this.liveRegionEl);
101
+ this.container.appendChild(this.rootEl);
102
+ document.body.appendChild(this.container);
103
+
104
+ this._mounted = true;
105
+ this.#subscribeEvents();
106
+
107
+ if (Array.isArray(props.initialNotifications)) {
108
+ props.initialNotifications.forEach((notification) => this.show(notification));
109
+ }
110
+ return this;
111
+ }
112
+
113
+ dispose() {
114
+ this.#clearTimers();
115
+ this.#unsubscribeEvents();
116
+ if (this.rootEl?.parentNode) {
117
+ this.rootEl.parentNode.removeChild(this.rootEl);
118
+ }
119
+ this.container = null;
120
+ this.rootEl = null;
121
+ this.listEl = null;
122
+ this.liveRegionEl = null;
123
+ super.dispose();
124
+ }
125
+
126
+ show(notification) {
127
+ const normalized = this.#normalizeNotification(notification);
128
+ this.#consoleLog(normalized);
129
+ this.notifications.push(normalized);
130
+ if (this.notifications.length > this.maxVisible) {
131
+ const removed = this.notifications.shift();
132
+ this.#stopTimer(removed.id);
133
+ this.#recordHistory({ ...removed, dismissed: true, reason: 'overflow' });
134
+ }
135
+
136
+ this.#recordHistory(normalized);
137
+ this.#scheduleAutoDismiss(normalized);
138
+ this.#render();
139
+ this.#announce(normalized);
140
+ return normalized.id;
141
+ }
142
+
143
+ dismiss(notificationId, reason = 'dismissed') {
144
+ const idx = this.notifications.findIndex((item) => item.id === notificationId);
145
+ if (idx === -1) {
146
+ return false;
147
+ }
148
+ const [removed] = this.notifications.splice(idx, 1);
149
+ this.#stopTimer(notificationId);
150
+ this.#recordHistory({ ...removed, dismissed: true, reason });
151
+ this.#render();
152
+ return true;
153
+ }
154
+
155
+ /**
156
+ * Update an existing notification's message in place without re-rendering.
157
+ * This prevents the flicker caused by dismiss+show.
158
+ * @param {string} notificationId - The ID of the notification to update
159
+ * @param {Object} updates - Object with fields to update (message, title, etc.)
160
+ * @returns {boolean} True if the notification was found and updated
161
+ */
162
+ update(notificationId, updates = {}) {
163
+ const notification = this.notifications.find((item) => item.id === notificationId);
164
+ if (!notification) {
165
+ return false;
166
+ }
167
+
168
+ // Update allowed fields
169
+ if (updates.message !== undefined) notification.message = updates.message;
170
+ if (updates.title !== undefined) notification.title = updates.title;
171
+ if (updates.severity !== undefined) notification.severity = this.#normalizeSeverity(updates.severity);
172
+ if (updates.meta !== undefined) notification.meta = updates.meta;
173
+
174
+ // Update the DOM directly without full re-render
175
+ const notificationEl = this.listEl?.querySelector(`[data-notification-id="${notificationId}"]`);
176
+ if (notificationEl) {
177
+ const bodyEl = notificationEl.querySelector('.twm-notification__body');
178
+ if (bodyEl && updates.message !== undefined) {
179
+ bodyEl.textContent = updates.message;
180
+ }
181
+ const titleEl = notificationEl.querySelector('.twm-notification__title');
182
+ if (titleEl && updates.title !== undefined) {
183
+ titleEl.textContent = updates.title;
184
+ }
185
+ } else {
186
+ // Fallback to full re-render if DOM element not found
187
+ this.#render();
188
+ }
189
+
190
+ return true;
191
+ }
192
+
193
+ clearAll(reason = 'cleared') {
194
+ [...this.notifications].forEach((notification) => this.dismiss(notification.id, reason));
195
+ }
196
+
197
+ ingestLogEntry(entry) {
198
+ if (!entry || !this.logLevels.has(entry.level)) {
199
+ return;
200
+ }
201
+
202
+ // Suppress state-guard warnings - these are internal and would cause a cascade
203
+ // (NotificationCenter renders -> triggers state-guard -> logs warning -> creates notification -> repeat)
204
+ if (entry.namespace === 'state-guard') {
205
+ return;
206
+ }
207
+
208
+ const severity = LOG_SEVERITY_MAP[entry.level] ?? 'info';
209
+ const suppressTitle = SUPPRESSED_NAMESPACE_TITLES.has(entry.namespace);
210
+ const title = suppressTitle
211
+ ? null
212
+ : NotificationCenter.#formatNamespaceTitle(entry.namespace);
213
+ // Info-level log entries from internal namespaces are noise in history
214
+ const skipHistory = severity === 'info' && suppressTitle;
215
+ this.show({
216
+ title,
217
+ message: entry.message,
218
+ severity,
219
+ namespace: entry.namespace,
220
+ meta: entry.data,
221
+ skipHistory,
222
+ });
223
+ }
224
+
225
+ #consoleLog(notification) {
226
+ if (notification.severity !== 'warn' && notification.severity !== 'error' && notification.severity !== 'fatal') {
227
+ return;
228
+ }
229
+ const method = notification.severity === 'warn' ? console.warn : console.error; // eslint-disable-line no-console
230
+ const prefix = notification.title ? `[${notification.title}]` : '[Notification]';
231
+ const details = notification.meta ?? notification.description ?? '';
232
+ method(`${prefix} ${notification.message}`, details); // eslint-disable-line no-console
233
+ }
234
+
235
+ #normalizeNotification(notification) {
236
+ if (!notification) {
237
+ throw new TypeError('Notification payload is required');
238
+ }
239
+ const id = notification.id ?? `notification-${++this.sequence}`;
240
+ const severity = this.#normalizeSeverity(notification.severity ?? 'info');
241
+ return {
242
+ id,
243
+ title: notification.title ?? null,
244
+ message: notification.message ?? '',
245
+ description: notification.description ?? null,
246
+ timestamp: notification.timestamp ?? Date.now(),
247
+ severity,
248
+ namespace: notification.namespace ?? null,
249
+ durationMs: this.#coerceDuration(notification.durationMs),
250
+ persistent: Boolean(notification.persistent),
251
+ skipHistory: Boolean(notification.skipHistory),
252
+ actions: this.#normalizeActions(notification.actions),
253
+ meta: notification.meta ?? null,
254
+ };
255
+ }
256
+
257
+ #normalizeSeverity(severity) {
258
+ const value = typeof severity === 'string' ? severity.toLowerCase() : 'info';
259
+ if (!SEVERITIES.includes(value)) {
260
+ return 'info';
261
+ }
262
+ return value;
263
+ }
264
+
265
+ #coerceDuration(duration) {
266
+ if (duration === null || duration === undefined) {
267
+ return this.defaultDurationMs;
268
+ }
269
+ const value = Number(duration);
270
+ if (!Number.isFinite(value) || value < 0) {
271
+ return this.defaultDurationMs;
272
+ }
273
+ return value;
274
+ }
275
+
276
+ toast(title, message, type = 'info') {
277
+ // Legacy compatibility method
278
+ this.show({
279
+ title,
280
+ message,
281
+ severity: type,
282
+ persistent: type === 'error' || type === 'fatal',
283
+ });
284
+ }
285
+
286
+ error(titleOrMessage, message) {
287
+ const hasTitle = message !== undefined;
288
+ return this.show({
289
+ title: hasTitle ? titleOrMessage : null,
290
+ message: hasTitle ? message : titleOrMessage,
291
+ severity: 'error',
292
+ persistent: true,
293
+ });
294
+ }
295
+
296
+ warn(titleOrMessage, message) {
297
+ const hasTitle = message !== undefined;
298
+ return this.show({
299
+ title: hasTitle ? titleOrMessage : null,
300
+ message: hasTitle ? message : titleOrMessage,
301
+ severity: 'warn',
302
+ });
303
+ }
304
+
305
+ info(titleOrMessage, message) {
306
+ const hasTitle = message !== undefined;
307
+ return this.show({
308
+ title: hasTitle ? titleOrMessage : null,
309
+ message: hasTitle ? message : titleOrMessage,
310
+ severity: 'info',
311
+ });
312
+ }
313
+
314
+ success(titleOrMessage, message) {
315
+ const hasTitle = message !== undefined;
316
+ return this.show({
317
+ title: hasTitle ? titleOrMessage : null,
318
+ message: hasTitle ? message : titleOrMessage,
319
+ severity: 'success',
320
+ });
321
+ }
322
+
323
+ #normalizeActions(actions) {
324
+ if (!Array.isArray(actions) || actions.length === 0) {
325
+ return [];
326
+ }
327
+ return actions.map((action, idx) => ({
328
+ id: action.id ?? `action-${idx}`,
329
+ label: action.label ?? 'Action',
330
+ handler: typeof action.handler === 'function' ? action.handler : null,
331
+ eventName: action.eventName ?? null,
332
+ payload: action.payload ?? null,
333
+ }));
334
+ }
335
+
336
+ #scheduleAutoDismiss(notification) {
337
+ if (notification.persistent || notification.durationMs === 0) {
338
+ return;
339
+ }
340
+ const timer = setTimeout(() => {
341
+ this.dismiss(notification.id, 'timeout');
342
+ }, notification.durationMs);
343
+ this.activeTimers.set(notification.id, timer);
344
+ }
345
+
346
+ #stopTimer(notificationId) {
347
+ const timer = this.activeTimers.get(notificationId);
348
+ if (timer) {
349
+ clearTimeout(timer);
350
+ this.activeTimers.delete(notificationId);
351
+ }
352
+ }
353
+
354
+ #clearTimers() {
355
+ this.activeTimers.forEach((timer) => clearTimeout(timer));
356
+ this.activeTimers.clear();
357
+ }
358
+
359
+ #recordHistory(entry) {
360
+ if (entry.skipHistory) return;
361
+ this.history.push(entry);
362
+ if (this.history.length > this.historyLimit) {
363
+ this.history.shift();
364
+ }
365
+ }
366
+
367
+ #render() {
368
+ if (!this._mounted || !this.listEl) {
369
+ return;
370
+ }
371
+ this.listEl.innerHTML = '';
372
+ const fragment = document.createDocumentFragment();
373
+
374
+ this.notifications.forEach((notification) => {
375
+ const item = document.createElement('div');
376
+ item.className = `notification notification--${notification.severity}`;
377
+ // Use setAttribute to avoid StateGuard blocking dataset mutations
378
+ item.setAttribute('data-notification-id', notification.id);
379
+
380
+ const header = document.createElement('div');
381
+ header.className = 'twm-notification__header';
382
+ if (notification.title) {
383
+ const titleEl = document.createElement('span');
384
+ titleEl.className = 'twm-notification__title';
385
+ titleEl.textContent = notification.title;
386
+ header.appendChild(titleEl);
387
+ }
388
+
389
+ const timeEl = document.createElement('span');
390
+ timeEl.className = 'twm-notification__timestamp';
391
+ timeEl.textContent = this.#formatTimestamp(notification.timestamp);
392
+ header.appendChild(timeEl);
393
+
394
+ const closeBtn = document.createElement('button');
395
+ closeBtn.className = 'twm-notification__close';
396
+ closeBtn.type = 'button';
397
+ closeBtn.setAttribute('aria-label', 'Dismiss notification');
398
+ closeBtn.innerHTML = '<span class="material-symbols-outlined">close</span>';
399
+ closeBtn.addEventListener('click', () => this.dismiss(notification.id, 'manual'));
400
+ header.appendChild(closeBtn);
401
+ item.appendChild(header);
402
+
403
+ const body = document.createElement('div');
404
+ body.className = 'twm-notification__body';
405
+ body.textContent = notification.message;
406
+ item.appendChild(body);
407
+
408
+ if (notification.description) {
409
+ const descriptionEl = document.createElement('div');
410
+ descriptionEl.className = 'twm-notification__description';
411
+ descriptionEl.textContent = notification.description;
412
+ item.appendChild(descriptionEl);
413
+ }
414
+
415
+ if (notification.actions.length > 0) {
416
+ const actionsEl = document.createElement('div');
417
+ actionsEl.className = 'twm-notification__actions';
418
+ notification.actions.forEach((action) => {
419
+ const button = document.createElement('button');
420
+ button.type = 'button';
421
+ button.className = 'twm-notification__action-btn';
422
+ button.textContent = action.label;
423
+ button.addEventListener('click', () => {
424
+ try {
425
+ if (action.handler) {
426
+ action.handler(notification, action);
427
+ } else if (action.eventName && this.eventBus) {
428
+ this.eventBus.emit(action.eventName, action.payload ?? { notificationId: notification.id });
429
+ }
430
+ } catch (err) {
431
+ this.logger?.error?.('twm-notification-center', 'Notification action failed', { err });
432
+ } finally {
433
+ // Don't auto-dismiss error/fatal notifications after action clicks
434
+ if (notification.severity !== 'error' && notification.severity !== 'fatal') {
435
+ this.dismiss(notification.id, 'action');
436
+ }
437
+ }
438
+ });
439
+ actionsEl.appendChild(button);
440
+ });
441
+ item.appendChild(actionsEl);
442
+ }
443
+
444
+ item.addEventListener('click', (event) => {
445
+ if (event.target.closest('button')) {
446
+ return;
447
+ }
448
+ this.dismiss(notification.id, 'click');
449
+ });
450
+
451
+ fragment.appendChild(item);
452
+ });
453
+
454
+ this.listEl.appendChild(fragment);
455
+ }
456
+
457
+ #formatTimestamp(timestamp) {
458
+ const date = new Date(timestamp);
459
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
460
+ }
461
+
462
+ #announce(notification) {
463
+ if (!this.liveRegionEl) {
464
+ return;
465
+ }
466
+ const parts = [notification.title, notification.message].filter(Boolean);
467
+ this.liveRegionEl.textContent = parts.join(': ');
468
+ }
469
+
470
+ #handleToast(payload = {}) {
471
+ const message = payload.text ?? payload.message ?? (typeof payload === 'string' ? payload : '');
472
+ if (!message) {
473
+ return;
474
+ }
475
+
476
+ const persistent = payload.persistent === true || payload.autoHide === false;
477
+ const durationMs = persistent ? 0 : (payload.durationMs ?? payload.duration ?? payload.timeout);
478
+ this.show({
479
+ title: payload.title ?? null,
480
+ message,
481
+ severity: payload.type ?? payload.severity ?? 'info',
482
+ durationMs,
483
+ persistent,
484
+ namespace: payload.namespace ?? null,
485
+ actions: payload.actions,
486
+ meta: payload.meta ?? null,
487
+ });
488
+ }
489
+
490
+ #subscribeEvents() {
491
+ if (!this.eventBus) {
492
+ return;
493
+ }
494
+ this.eventDisposers.push(
495
+ this.eventBus.on('toast:show', (payload) => this.#handleToast(payload)),
496
+ this.eventBus.on('notification:show', (payload) => this.show(payload)),
497
+ this.eventBus.on('notification:update', (payload) => {
498
+ if (payload?.id) {
499
+ this.update(payload.id, payload);
500
+ }
501
+ }),
502
+ this.eventBus.on('log:entry', (entry) => this.ingestLogEntry(entry)),
503
+ this.eventBus.on('workspace:save:failed', (payload) => {
504
+ // Error toasts are shown by default unless explicitly disabled
505
+ if (getSetting('workspace.save.showErrorToast', true)) {
506
+ this.show({
507
+ title: 'Workspace Save Failed',
508
+ message: payload?.reason ? `Save failed: ${payload.reason}` : 'Workspace save failed',
509
+ severity: 'error',
510
+ meta: payload,
511
+ persistent: true,
512
+ });
513
+ }
514
+ }),
515
+ this.eventBus.on('workspace:save:completed', (payload) => {
516
+ // Only show toast if enabled in settings
517
+ if (getSetting('workspace.save.showToast', false)) {
518
+ this.show({
519
+ title: 'Workspace Saved',
520
+ message: 'Changes saved successfully',
521
+ severity: 'success',
522
+ durationMs: 3000,
523
+ meta: payload,
524
+ skipHistory: true,
525
+ });
526
+ }
527
+ }),
528
+ this.eventBus.on('workspace:import:failed', (payload) => {
529
+ // Error toasts are shown by default unless explicitly disabled
530
+ if (getSetting('workspace.import.showErrorToast', true)) {
531
+ this.show({
532
+ title: 'Workspace Import Failed',
533
+ message: payload?.error?.message || 'Import failed',
534
+ severity: 'error',
535
+ meta: payload,
536
+ persistent: true,
537
+ });
538
+ }
539
+ }),
540
+ this.eventBus.on('workspace:import:completed', (payload) => {
541
+ // Show import completion toast if enabled in settings
542
+ if (getSetting('workspace.import.showToast', true)) {
543
+ this.show({
544
+ title: 'Workspace Ready',
545
+ message: 'Workspace import completed',
546
+ severity: 'success',
547
+ durationMs: 3000,
548
+ meta: payload,
549
+ skipHistory: true,
550
+ });
551
+ }
552
+ }),
553
+ );
554
+ }
555
+
556
+ #unsubscribeEvents() {
557
+ this.eventDisposers.forEach((disposer) => {
558
+ try {
559
+ disposer?.dispose?.();
560
+ } catch (err) {
561
+ this.logger?.warn?.('twm-notification-center', 'Failed to remove event listener', { err });
562
+ }
563
+ });
564
+ this.eventDisposers = [];
565
+ }
566
+
567
+ static #formatNamespaceTitle(namespace) {
568
+ if (!namespace) return null;
569
+ return namespace
570
+ .split(/[-_]/)
571
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
572
+ .join(' ');
573
+ }
574
+ }
575
+
576
+ NotificationCenter.SEVERITIES = SEVERITIES;