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,662 @@
1
+ /**
2
+ * layout_persistence.js — saved tile layouts, keyed by whatever the app keys them by.
3
+ *
4
+ * Three things used to make this un-shippable, and all three are now injected:
5
+ *
6
+ * 1. It wrote to `localStorage` under 'ecosim.results.layouts' and
7
+ * 'ecosim.results.configs' — a LIBRARY scribbling on one application's storage
8
+ * keys. The keys are the consumer's now, and EcoAgent passes its existing ones,
9
+ * so not a single saved layout is orphaned.
10
+ *
11
+ * 2. It imported EcoAgent's TemplateRegistry to pick a starting layout. That is a
12
+ * `resolveTemplate` callback now: hand it your context, get a layout back, or
13
+ * return null and get an empty grid.
14
+ *
15
+ * 3. Everything was called `scenarioId`, 85 times. A tile layout is keyed by
16
+ * SOMETHING — a scenario, a document, a user, a run — and the library does not
17
+ * get an opinion about which. It is `contextId` now. This is a rename of
18
+ * IDENTIFIERS only: the old name was never a stored string, just a key into the
19
+ * stored object, so no persisted data changes shape.
20
+ *
21
+ * `storage` is any synchronous Storage-shaped object (getItem/setItem/removeItem).
22
+ * It defaults to localStorage because that is what a browser has; it is not the Host,
23
+ * because the Host is async and every caller of this class is not.
24
+ */
25
+
26
+ const LAYOUT_VERSION = 3;
27
+
28
+ const DEFAULT_LAYOUTS_KEY = 'twm.tiles.layouts';
29
+ const DEFAULT_CONFIGS_KEY = 'twm.tiles.configs';
30
+
31
+ export class LayoutPersistence {
32
+ constructor({
33
+ storage = (typeof localStorage !== 'undefined' ? localStorage : null),
34
+ layoutsKey = DEFAULT_LAYOUTS_KEY,
35
+ configsKey = DEFAULT_CONFIGS_KEY,
36
+ resolveTemplate = null,
37
+ logger = null,
38
+ } = {}) {
39
+ this._cache = null;
40
+ this._storage = storage;
41
+ this._logger = logger;
42
+ this._layoutsKey = layoutsKey;
43
+ this._configsKey = configsKey;
44
+ // (context) => layout | null. No resolver => no starting template.
45
+ this._resolveTemplate = typeof resolveTemplate === 'function' ? resolveTemplate : null;
46
+ }
47
+
48
+ /**
49
+ * Get layout for a specific context.
50
+ * Falls back to template resolution when no saved layout exists.
51
+ * @param {string} contextId - Whatever the app keys layouts by.
52
+ * @param {Object} [context] - Passed opaquely to `resolveTemplate`.
53
+ * @returns {Object} Layout object with tiles array
54
+ */
55
+ getLayout(contextId, context) {
56
+ const all = this._loadAll();
57
+ const layout = all[contextId];
58
+
59
+ if (layout && layout.version === LAYOUT_VERSION) {
60
+ return layout;
61
+ }
62
+
63
+ // Resolve a template based on context metadata
64
+ const instantiated = this._resolveTemplate?.(context) ?? null;
65
+ if (instantiated) {
66
+ return {
67
+ tiles: instantiated.tiles,
68
+ version: LAYOUT_VERSION,
69
+ templateId: instantiated.templateId,
70
+ templateModified: false,
71
+ };
72
+ }
73
+
74
+ // Final fallback: empty layout (should not happen — resolve always returns a default)
75
+ return { tiles: [], version: LAYOUT_VERSION };
76
+ }
77
+
78
+ /**
79
+ * Save layout for a specific context.
80
+ * @param {string} contextId - Whatever the app keys layouts by.
81
+ * @param {Array|Object} layout - Layout array or object with tiles property
82
+ */
83
+ saveLayout(contextId, layout) {
84
+ const all = this._loadAll();
85
+
86
+ // Handle both array (from TileGrid.getLayout) and object (with tiles property) formats
87
+ const tilesArray = Array.isArray(layout) ? layout : (layout?.tiles || []);
88
+
89
+ all[contextId] = {
90
+ tiles: tilesArray,
91
+ version: LAYOUT_VERSION,
92
+ updatedAt: Date.now(),
93
+ templateId: layout?.templateId || all[contextId]?.templateId || null,
94
+ templateModified: layout?.templateModified ?? true,
95
+ };
96
+
97
+ this._saveAll(all);
98
+ this._logger?.info?.(`[LayoutPersistence] Saved layout for context ${contextId}`);
99
+ }
100
+
101
+ /**
102
+ * Delete layout for a specific context.
103
+ * @param {string} contextId - Whatever the app keys layouts by.
104
+ */
105
+ deleteLayout(contextId) {
106
+ const all = this._loadAll();
107
+
108
+ if (all[contextId]) {
109
+ delete all[contextId];
110
+ this._saveAll(all);
111
+ this._logger?.info?.(`[LayoutPersistence] Deleted layout for context ${contextId}`);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Reset a layout by re-resolving the best-matching template.
117
+ *
118
+ * With no `resolveTemplate` supplied, "reset" means "empty grid" — which is the
119
+ * honest answer for a library that has no templates. The previous code called
120
+ * `TemplateRegistry.instantiate()` unconditionally and read `.tiles` off the
121
+ * result, so a null resolver here would have been a TypeError.
122
+ *
123
+ * @param {string} contextId - Whatever the app keys layouts by.
124
+ * @param {Object} [context] - Passed opaquely to `resolveTemplate`.
125
+ * @returns {Object} The resolved layout (empty if nothing resolves).
126
+ */
127
+ resetLayout(contextId, context) {
128
+ const instantiated = this._resolveTemplate?.(context) ?? null;
129
+ const layout = {
130
+ tiles: instantiated?.tiles ?? [],
131
+ version: LAYOUT_VERSION,
132
+ templateId: instantiated?.templateId ?? null,
133
+ templateModified: false,
134
+ };
135
+ this.saveLayout(contextId, layout);
136
+ return layout;
137
+ }
138
+
139
+ /**
140
+ * Check if a context has a saved layout.
141
+ * @param {string} contextId - Whatever the app keys layouts by.
142
+ * @returns {boolean}
143
+ */
144
+ hasLayout(contextId) {
145
+ const all = this._loadAll();
146
+ return !!all[contextId];
147
+ }
148
+
149
+ /**
150
+ * Get all context IDs with saved layouts.
151
+ * @returns {Array<string>}
152
+ */
153
+ getAllContextIds() {
154
+ const all = this._loadAll();
155
+ return Object.keys(all);
156
+ }
157
+
158
+ /**
159
+ * Export all layouts as JSON string.
160
+ * @returns {string}
161
+ */
162
+ exportAll() {
163
+ const all = this._loadAll();
164
+ return JSON.stringify(all, null, 2);
165
+ }
166
+
167
+ /**
168
+ * Import layouts from JSON string.
169
+ * @param {string} json - JSON string of layouts
170
+ * @param {boolean} [merge=false] - Whether to merge with existing or replace
171
+ */
172
+ importAll(json, merge = false) {
173
+ try {
174
+ const imported = JSON.parse(json);
175
+
176
+ if (merge) {
177
+ const existing = this._loadAll();
178
+ this._saveAll({ ...existing, ...imported });
179
+ } else {
180
+ this._saveAll(imported);
181
+ }
182
+
183
+ this._cache = null;
184
+ this._logger?.info?.('[LayoutPersistence] Imported layouts');
185
+ } catch (err) {
186
+ console.error('[LayoutPersistence] Failed to import layouts:', err);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Clear all saved layouts.
192
+ */
193
+ clearAll() {
194
+ try {
195
+ this._storage?.removeItem(this._layoutsKey);
196
+ this._cache = null;
197
+ this._logger?.info?.('[LayoutPersistence] Cleared all layouts');
198
+ } catch (err) {
199
+ console.error('[LayoutPersistence] Failed to clear layouts:', err);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Load all layouts from storage.
205
+ * @returns {Object}
206
+ * @private
207
+ */
208
+ _loadAll() {
209
+ if (this._cache) {
210
+ return this._cache;
211
+ }
212
+
213
+ try {
214
+ const raw = this._storage?.getItem(this._layoutsKey);
215
+ if (raw) {
216
+ this._cache = JSON.parse(raw);
217
+ return this._cache;
218
+ }
219
+ } catch (err) {
220
+ console.error('[LayoutPersistence] Failed to load layouts:', err);
221
+ }
222
+
223
+ this._cache = {};
224
+ return this._cache;
225
+ }
226
+
227
+ /**
228
+ * Save all layouts to storage.
229
+ * @param {Object} data
230
+ * @private
231
+ */
232
+ _saveAll(data) {
233
+ try {
234
+ this._storage?.setItem(this._layoutsKey, JSON.stringify(data));
235
+ this._cache = data;
236
+ } catch (err) {
237
+ console.error('[LayoutPersistence] Failed to save layouts:', err);
238
+
239
+ // If quota exceeded, try to clear old entries
240
+ if (err.name === 'QuotaExceededError') {
241
+ this._pruneOldLayouts(data);
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Remove oldest layouts to free up space.
248
+ * @param {Object} data
249
+ * @private
250
+ */
251
+ _pruneOldLayouts(data) {
252
+ const entries = Object.entries(data);
253
+
254
+ // Sort by updatedAt, oldest first
255
+ entries.sort((a, b) => (a[1].updatedAt || 0) - (b[1].updatedAt || 0));
256
+
257
+ // Remove oldest 25%
258
+ const removeCount = Math.ceil(entries.length * 0.25);
259
+ for (let i = 0; i < removeCount; i++) {
260
+ delete data[entries[i][0]];
261
+ }
262
+
263
+ // Try saving again
264
+ try {
265
+ this._storage?.setItem(this._layoutsKey, JSON.stringify(data));
266
+ this._cache = data;
267
+ this._logger?.warn?.('[LayoutPersistence] Pruned old layouts due to quota');
268
+ } catch (err) {
269
+ console.error('[LayoutPersistence] Still cannot save after pruning:', err);
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Get the template ID associated with a saved layout.
275
+ * @param {string} contextId - Whatever the app keys layouts by.
276
+ * @returns {string|null} Template ID or null
277
+ */
278
+ getTemplateId(contextId) {
279
+ const all = this._loadAll();
280
+ return all[contextId]?.templateId || null;
281
+ }
282
+
283
+ /**
284
+ * Check if the user has modified the layout since the template was applied.
285
+ * @param {string} contextId - Whatever the app keys layouts by.
286
+ * @returns {boolean}
287
+ */
288
+ isTemplateModified(contextId) {
289
+ const all = this._loadAll();
290
+ return all[contextId]?.templateModified ?? false;
291
+ }
292
+
293
+ // =========================================================================
294
+ // NAMED CONFIGURATION MANAGEMENT
295
+ // =========================================================================
296
+
297
+ /**
298
+ * Get all saved configurations for a context.
299
+ * @param {string} contextId - Whatever the app keys layouts by.
300
+ * @returns {Object} { activeId: string, configs: { id: { name, tiles, createdAt } } }
301
+ */
302
+ getConfigurations(contextId) {
303
+ const all = this._loadConfigs();
304
+ const contextConfigs = all[contextId];
305
+
306
+ if (contextConfigs && Object.keys(contextConfigs.configs || {}).length > 0) {
307
+ return contextConfigs;
308
+ }
309
+
310
+ // Return empty structure
311
+ return {
312
+ activeId: null,
313
+ configs: {}
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Save the current layout as a named configuration.
319
+ * Names are enforced to be unique - duplicates get "(2)", "(3)", etc. suffix.
320
+ * @param {string} contextId - Whatever the app keys layouts by.
321
+ * @param {string} name - Configuration name
322
+ * @param {Array|Object} layout - Layout to save
323
+ * @param {Object} [options] - Additional options
324
+ * @param {string} [options.templateId] - Template ID that generated this layout
325
+ * @returns {string} The new configuration ID
326
+ */
327
+ saveConfiguration(contextId, name, layout, options) {
328
+ const all = this._loadConfigs();
329
+
330
+ if (!all[contextId]) {
331
+ all[contextId] = { activeId: null, configs: {} };
332
+ }
333
+
334
+ // Ensure unique name
335
+ const uniqueName = this._getUniqueName(all[contextId].configs, name);
336
+
337
+ const configId = `config-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
338
+ const tilesArray = Array.isArray(layout) ? layout : (layout?.tiles || []);
339
+
340
+ all[contextId].configs[configId] = {
341
+ name: uniqueName,
342
+ tiles: tilesArray,
343
+ version: LAYOUT_VERSION,
344
+ createdAt: Date.now(),
345
+ templateId: options?.templateId || null,
346
+ };
347
+
348
+ // Set as active if it's the first one
349
+ if (!all[contextId].activeId) {
350
+ all[contextId].activeId = configId;
351
+ }
352
+
353
+ this._saveConfigs(all);
354
+ this._logger?.info?.(`[LayoutPersistence] Saved configuration "${uniqueName}" for context ${contextId}`);
355
+
356
+ return configId;
357
+ }
358
+
359
+ /**
360
+ * Generate a unique name by appending (2), (3), etc. if name already exists.
361
+ * @param {Object} configs - Existing configurations object
362
+ * @param {string} name - Desired name
363
+ * @returns {string} Unique name
364
+ * @private
365
+ */
366
+ _getUniqueName(configs, name) {
367
+ if (!configs) return name;
368
+
369
+ const existingNames = new Set(Object.values(configs).map(c => c.name));
370
+
371
+ if (!existingNames.has(name)) {
372
+ return name;
373
+ }
374
+
375
+ // Find next available number
376
+ let counter = 2;
377
+ let candidate = `${name} (${counter})`;
378
+ while (existingNames.has(candidate)) {
379
+ counter++;
380
+ candidate = `${name} (${counter})`;
381
+ }
382
+
383
+ return candidate;
384
+ }
385
+
386
+ /**
387
+ * Load a specific configuration.
388
+ * @param {string} contextId - Whatever the app keys layouts by.
389
+ * @param {string} configId - Configuration ID
390
+ * @returns {Object|null} Configuration layout or null if not found
391
+ */
392
+ loadConfiguration(contextId, configId) {
393
+ const all = this._loadConfigs();
394
+ const config = all[contextId]?.configs?.[configId];
395
+
396
+ if (config) {
397
+ // Update active config
398
+ all[contextId].activeId = configId;
399
+ this._saveConfigs(all);
400
+ return config;
401
+ }
402
+
403
+ return null;
404
+ }
405
+
406
+ /**
407
+ * Delete a saved configuration.
408
+ * @param {string} contextId - Whatever the app keys layouts by.
409
+ * @param {string} configId - Configuration ID
410
+ * @returns {boolean} True if deleted, false if not found
411
+ */
412
+ deleteConfiguration(contextId, configId) {
413
+ const all = this._loadConfigs();
414
+
415
+ if (!all[contextId]?.configs?.[configId]) {
416
+ return false;
417
+ }
418
+
419
+ delete all[contextId].configs[configId];
420
+
421
+ // Update active if deleted
422
+ if (all[contextId].activeId === configId) {
423
+ const remaining = Object.keys(all[contextId].configs);
424
+ all[contextId].activeId = remaining.length > 0 ? remaining[0] : null;
425
+ }
426
+
427
+ this._saveConfigs(all);
428
+ this._logger?.info?.(`[LayoutPersistence] Deleted configuration ${configId} for context ${contextId}`);
429
+
430
+ return true;
431
+ }
432
+
433
+ /**
434
+ * Update an existing configuration's layout (without creating a new ID).
435
+ * @param {string} contextId - Whatever the app keys layouts by.
436
+ * @param {string} configId - Configuration ID
437
+ * @param {Array|Object} layout - New layout to save
438
+ * @returns {boolean} True if updated, false if not found
439
+ */
440
+ updateConfiguration(contextId, configId, layout) {
441
+ const all = this._loadConfigs();
442
+
443
+ if (!all[contextId]?.configs?.[configId]) {
444
+ return false;
445
+ }
446
+
447
+ const tilesArray = Array.isArray(layout) ? layout : (layout?.tiles || []);
448
+
449
+ all[contextId].configs[configId].tiles = tilesArray;
450
+ all[contextId].configs[configId].version = LAYOUT_VERSION;
451
+ all[contextId].configs[configId].updatedAt = Date.now();
452
+
453
+ this._saveConfigs(all);
454
+ return true;
455
+ }
456
+
457
+ /**
458
+ * Update the templateId on an existing configuration.
459
+ * @param {string} contextId - Whatever the app keys layouts by.
460
+ * @param {string} configId - Configuration ID
461
+ * @param {string} templateId - New template ID
462
+ * @returns {boolean} True if updated, false if not found
463
+ */
464
+ updateConfigurationTemplateId(contextId, configId, templateId) {
465
+ const all = this._loadConfigs();
466
+
467
+ if (!all[contextId]?.configs?.[configId]) {
468
+ return false;
469
+ }
470
+
471
+ all[contextId].configs[configId].templateId = templateId;
472
+ this._saveConfigs(all);
473
+ return true;
474
+ }
475
+
476
+ /**
477
+ * Rename a configuration.
478
+ * Names are enforced to be unique - duplicates get "(2)", "(3)", etc. suffix.
479
+ * @param {string} contextId - Whatever the app keys layouts by.
480
+ * @param {string} configId - Configuration ID
481
+ * @param {string} newName - New name
482
+ * @returns {string|false} The actual name used (may differ if duplicate), or false if not found
483
+ */
484
+ renameConfiguration(contextId, configId, newName) {
485
+ const all = this._loadConfigs();
486
+
487
+ if (!all[contextId]?.configs?.[configId]) {
488
+ return false;
489
+ }
490
+
491
+ // Get configs excluding the one being renamed for uniqueness check
492
+ const otherConfigs = {};
493
+ Object.entries(all[contextId].configs).forEach(([id, config]) => {
494
+ if (id !== configId) {
495
+ otherConfigs[id] = config;
496
+ }
497
+ });
498
+
499
+ const uniqueName = this._getUniqueName(otherConfigs, newName);
500
+ all[contextId].configs[configId].name = uniqueName;
501
+ this._saveConfigs(all);
502
+
503
+ return uniqueName;
504
+ }
505
+
506
+ /**
507
+ * Get the active configuration for a context.
508
+ * @param {string} contextId - Whatever the app keys layouts by.
509
+ * @returns {Object|null} { id, name, tiles } or null
510
+ */
511
+ getActiveConfiguration(contextId) {
512
+ const all = this._loadConfigs();
513
+ const contextConfigs = all[contextId];
514
+
515
+ if (!contextConfigs?.activeId) {
516
+ return null;
517
+ }
518
+
519
+ const activeConfig = contextConfigs.configs[contextConfigs.activeId];
520
+ if (activeConfig) {
521
+ return {
522
+ id: contextConfigs.activeId,
523
+ ...activeConfig
524
+ };
525
+ }
526
+
527
+ return null;
528
+ }
529
+
530
+ /**
531
+ * Set the active configuration.
532
+ * @param {string} contextId - Whatever the app keys layouts by.
533
+ * @param {string} configId - Configuration ID
534
+ */
535
+ setActiveConfiguration(contextId, configId) {
536
+ const all = this._loadConfigs();
537
+
538
+ if (all[contextId]?.configs?.[configId]) {
539
+ all[contextId].activeId = configId;
540
+ this._saveConfigs(all);
541
+ }
542
+ }
543
+
544
+ /**
545
+ * List all configurations for a context.
546
+ * @param {string} contextId - Whatever the app keys layouts by.
547
+ * @returns {Array} Array of { id, name, createdAt, isActive }
548
+ */
549
+ listConfigurations(contextId) {
550
+ const all = this._loadConfigs();
551
+ const contextConfigs = all[contextId];
552
+
553
+ if (!contextConfigs?.configs) {
554
+ return [];
555
+ }
556
+
557
+ return Object.entries(contextConfigs.configs).map(([id, config]) => ({
558
+ id,
559
+ name: config.name,
560
+ createdAt: config.createdAt,
561
+ isActive: id === contextConfigs.activeId
562
+ })).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
563
+ }
564
+
565
+ /**
566
+ * Get the template set fingerprint for a context.
567
+ * Used to detect when templates have changed and configs need regeneration.
568
+ * @param {string} contextId
569
+ * @returns {string|null}
570
+ */
571
+ getTemplateSetId(contextId) {
572
+ const all = this._loadConfigs();
573
+ return all[contextId]?.templateSetId || null;
574
+ }
575
+
576
+ /**
577
+ * Store the template set fingerprint for a context.
578
+ * @param {string} contextId
579
+ * @param {string} templateSetId
580
+ */
581
+ setTemplateSetId(contextId, templateSetId) {
582
+ const all = this._loadConfigs();
583
+ if (!all[contextId]) {
584
+ all[contextId] = { activeId: null, configs: {} };
585
+ }
586
+ all[contextId].templateSetId = templateSetId;
587
+ this._saveConfigs(all);
588
+ }
589
+
590
+ /**
591
+ * Delete ALL configurations for a context.
592
+ * @param {string} contextId
593
+ */
594
+ clearConfigurations(contextId) {
595
+ const all = this._loadConfigs();
596
+ delete all[contextId];
597
+ this._saveConfigs(all);
598
+ }
599
+
600
+ /**
601
+ * Load all configurations from storage.
602
+ * @returns {Object}
603
+ * @private
604
+ */
605
+ _loadConfigs() {
606
+ try {
607
+ const raw = this._storage?.getItem(this._configsKey);
608
+ if (raw) {
609
+ return JSON.parse(raw);
610
+ }
611
+ } catch (err) {
612
+ console.error('[LayoutPersistence] Failed to load configurations:', err);
613
+ }
614
+ return {};
615
+ }
616
+
617
+ /**
618
+ * Save all configurations to storage.
619
+ * @param {Object} data
620
+ * @private
621
+ */
622
+ _saveConfigs(data) {
623
+ try {
624
+ this._storage?.setItem(this._configsKey, JSON.stringify(data));
625
+ } catch (err) {
626
+ console.error('[LayoutPersistence] Failed to save configurations:', err);
627
+ }
628
+ }
629
+ }
630
+
631
+ // Singleton instance
632
+ let instance = null;
633
+ let _options = {};
634
+
635
+ /**
636
+ * Configure the singleton BEFORE anything reads it. Call once, at boot.
637
+ *
638
+ * The first cut let `getLayoutPersistence(options)` configure it lazily — whoever
639
+ * called first won. That is a trap with teeth: if ANY code path had reached the
640
+ * store before the dashboard did, the singleton would have been built with the
641
+ * library's generic defaults, and the app would have read
642
+ * 'twm.tiles.layouts' instead of 'ecosim.results.layouts' — an empty store. Every
643
+ * saved dashboard a user had ever built would have silently vanished, with no
644
+ * error, and no captured view would show it because the pixel gate runs on a
645
+ * fresh template that has no saved layouts to lose.
646
+ *
647
+ * Configure once, at boot, where the ordering is not in question.
648
+ */
649
+ export function configureLayoutPersistence(options = {}) {
650
+ _options = options;
651
+ instance = null; // rebuild on next get
652
+ }
653
+
654
+ /** @returns {LayoutPersistence} */
655
+ export function getLayoutPersistence() {
656
+ if (!instance) {
657
+ instance = new LayoutPersistence(_options);
658
+ }
659
+ return instance;
660
+ }
661
+
662
+ export default LayoutPersistence;