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,813 @@
1
+ /**
2
+ * Application Settings — a generic, schema-driven value store.
3
+ *
4
+ * Purpose
5
+ * -------
6
+ * A persisted key/value store whose UI is driven entirely by a declarative
7
+ * SCHEMA. The store knows nothing about any particular application's settings:
8
+ * it ships the *shell's* own namespaces (workspace, window, logging, data,
9
+ * notebook, ai, …) and an embedder PUSHES its own namespace in at boot with
10
+ * `registerSettings()`. The Settings page renders whatever is in the schema.
11
+ *
12
+ * Two doors, on purpose
13
+ * ---------------------
14
+ * createSettingsStore({ defaults, schema, categories, storageKey })
15
+ * The primitive. A private store with zero global state — what the
16
+ * library's demo and the unit tests use.
17
+ *
18
+ * getSetting() / setSetting() / registerSettings() / …
19
+ * A thin binding over ONE instance of that primitive for THIS document.
20
+ * The cardinality is correct, not merely convenient: the store has a
21
+ * single physical backing key (`localStorage['ecosim.settings.v1']`), so
22
+ * two live stores in one document would be two writers to one key.
23
+ *
24
+ * Usage
25
+ * -----
26
+ * import { getSetting, setSetting } from '../core/settings.js';
27
+ *
28
+ * // Read a setting
29
+ * if (getSetting('workspace.save.showToast')) { ... }
30
+ *
31
+ * // Write a setting (persists to localStorage, emits events)
32
+ * setSetting('window.macShadows', true);
33
+ *
34
+ * // React to changes (after registerSettingsEventBus is called)
35
+ * eventBus.on('settings:window.macShadows:changed', ({ value }) => { ... });
36
+ *
37
+ * Adding New Settings
38
+ * -------------------
39
+ * Shell-owned setting: add the default under the right namespace in DEFAULTS,
40
+ * add a SCHEMA entry (type, category, group, label, description), done — the
41
+ * Settings page picks it up.
42
+ *
43
+ * Embedder-owned setting: do NOT add it here. Put it in your own slice and
44
+ * `registerSettings({ defaults, schema, categories })` at bootstrap.
45
+ */
46
+
47
+ // ─── Defaults (shell-owned namespaces only) ──────────────────────────────────
48
+
49
+ const DEFAULTS = Object.freeze({
50
+ workspace: Object.freeze({
51
+ save: Object.freeze({
52
+ showToast: false,
53
+ showErrorToast: true,
54
+ }),
55
+ import: Object.freeze({
56
+ showToast: false,
57
+ showErrorToast: true,
58
+ }),
59
+ autosave: Object.freeze({
60
+ enabled: true,
61
+ intervalSeconds: 60,
62
+ }),
63
+ undoHistoryLimit: 200,
64
+ }),
65
+
66
+ host: Object.freeze({
67
+ showConnectedToast: false,
68
+ }),
69
+
70
+ window: Object.freeze({
71
+ animateMinimize: true,
72
+ macShadows: true,
73
+ }),
74
+
75
+ notifications: Object.freeze({
76
+ durationMs: 3500,
77
+ }),
78
+
79
+ data: Object.freeze({
80
+ tablePageSize: 100,
81
+ defaultResampleMethod: 'mean',
82
+ }),
83
+
84
+ etl: Object.freeze({
85
+ parallelWorkers: 1,
86
+ }),
87
+
88
+ logging: Object.freeze({
89
+ minLevel: 'info',
90
+ historyLimit: 500,
91
+ enableConsole: false,
92
+ }),
93
+
94
+ editor: Object.freeze({
95
+ autosaveDelayMs: 5000,
96
+ }),
97
+
98
+ notebook: Object.freeze({
99
+ cellWidthMode: 'fixed',
100
+ codeCellMaxHeight: false,
101
+ defaultCellView: 'config',
102
+ }),
103
+
104
+ debug: Object.freeze({
105
+ logSettingsAccess: false,
106
+ }),
107
+
108
+ projects: Object.freeze({
109
+ directory: null,
110
+ }),
111
+
112
+ // Only auto-create-defaults survives; the modules directory / addon /
113
+ // ETL-plugin loaders were the node-graph plugin system.
114
+ ai: Object.freeze({
115
+ provider: '',
116
+ cloud: Object.freeze({
117
+ providerId: 'anthropic',
118
+ providers: Object.freeze({}),
119
+ }),
120
+ local: Object.freeze({
121
+ serverType: 'llamacpp',
122
+ modelPath: '',
123
+ baseUrl: '',
124
+ model: '',
125
+ port: 8080,
126
+ gpuLayers: -1,
127
+ contextLength: 32768,
128
+ flashAttention: true,
129
+ evalBatchSize: 512,
130
+ kvCacheOnGpu: true,
131
+ }),
132
+ defaultMode: 'ask',
133
+ maxToolCalls: 25,
134
+ }),
135
+ });
136
+
137
+ // ─── Schema (drives the Settings page UI) ────────────────────────────────────
138
+
139
+ /**
140
+ * @typedef {Object} SettingDef
141
+ * @property {'boolean'|'select'|'number'|'text'|'colorList'} type
142
+ * @property {string} category - Category ID (matches CATEGORIES[].id)
143
+ * @property {string} group - Visual group within the category
144
+ * @property {string} label - Human-readable label
145
+ * @property {string} description - Explanation shown below the label
146
+ * @property {*} defaultValue - Must match DEFAULTS
147
+ * @property {Array<{value:string,label:string}>} [options] - For 'select' type
148
+ * @property {number} [min] - For 'number' type
149
+ * @property {number} [max] - For 'number' type
150
+ * @property {number|string} [step] - For 'number' type
151
+ * @property {string} [placeholder] - For 'text' type
152
+ */
153
+
154
+ /**
155
+ * @typedef {Object} SettingsSlice
156
+ * @property {object} [defaults] - Nested default values for the namespace.
157
+ * @property {Object<string, SettingDef>} [schema] - Dot-path -> SettingDef.
158
+ * @property {Array<CategoryDef>} [categories] - Categories the slice adds.
159
+ */
160
+
161
+ /**
162
+ * @typedef {Object} CategoryDef
163
+ * @property {string} id
164
+ * @property {string} label
165
+ * @property {string} icon
166
+ * @property {string} description
167
+ * @property {number} [order] - Sidebar sort key. Lower sorts first; ties keep
168
+ * registration order. Defaults to 1000.
169
+ */
170
+
171
+ const SCHEMA = Object.freeze({
172
+ // ── General ──────────────────────────────────────────────────────────────
173
+ 'workspace.save.showToast': {
174
+ type: 'boolean', category: 'general', group: 'Workspace Notifications',
175
+ label: 'Save success notification',
176
+ description: 'Show a toast notification when a workspace is saved successfully.',
177
+ defaultValue: false,
178
+ },
179
+ 'workspace.save.showErrorToast': {
180
+ type: 'boolean', category: 'general', group: 'Workspace Notifications',
181
+ label: 'Save error notification',
182
+ description: 'Show a toast notification when a workspace save fails.',
183
+ defaultValue: true,
184
+ },
185
+ 'workspace.import.showToast': {
186
+ type: 'boolean', category: 'general', group: 'Workspace Notifications',
187
+ label: 'Import success notification',
188
+ description: 'Show a toast notification when a workspace is imported successfully.',
189
+ defaultValue: false,
190
+ },
191
+ 'workspace.import.showErrorToast': {
192
+ type: 'boolean', category: 'general', group: 'Workspace Notifications',
193
+ label: 'Import error notification',
194
+ description: 'Show a toast notification when a workspace import fails.',
195
+ defaultValue: true,
196
+ },
197
+ 'host.showConnectedToast': {
198
+ type: 'boolean', category: 'general', group: 'Host Bridge',
199
+ label: 'Host connected notification',
200
+ description: 'Show a toast notification when the desktop host bridge connects.',
201
+ defaultValue: false,
202
+ },
203
+ 'notifications.durationMs': {
204
+ type: 'number', category: 'general', group: 'Notifications',
205
+ label: 'Toast notification duration',
206
+ description: 'How long toast notifications stay visible (milliseconds).',
207
+ defaultValue: 3500, min: 1000, max: 15000, step: 500,
208
+ },
209
+ 'workspace.autosave.enabled': {
210
+ type: 'boolean', category: 'general', group: 'Auto-Save',
211
+ label: 'Enable auto-save',
212
+ description: 'Automatically save the workspace at regular intervals.',
213
+ defaultValue: true,
214
+ },
215
+ 'workspace.autosave.intervalSeconds': {
216
+ type: 'number', category: 'general', group: 'Auto-Save',
217
+ label: 'Auto-save interval (seconds)',
218
+ description: 'Time between automatic saves.',
219
+ defaultValue: 60, min: 10, max: 600, step: 10,
220
+ },
221
+ 'workspace.undoHistoryLimit': {
222
+ type: 'number', category: 'general', group: 'History',
223
+ label: 'Undo/redo history depth',
224
+ description: 'Maximum number of undo/redo steps retained in memory.',
225
+ defaultValue: 200, min: 10, max: 1000, step: 10,
226
+ },
227
+
228
+ // ── Windows ──────────────────────────────────────────────────────────────
229
+ 'window.animateMinimize': {
230
+ type: 'boolean', category: 'window', group: 'Appearance',
231
+ label: 'Animate minimize/restore',
232
+ description: 'Animate managed windows toward/from the taskbar when minimizing and restoring.',
233
+ defaultValue: true,
234
+ },
235
+ 'window.macShadows': {
236
+ type: 'boolean', category: 'window', group: 'Appearance',
237
+ label: 'macOS-style shadows',
238
+ description: 'Use multi-layered soft shadows on managed windows.',
239
+ defaultValue: true,
240
+ },
241
+
242
+ // ── Data ─────────────────────────────────────────────────────────────────
243
+ 'data.tablePageSize': {
244
+ type: 'number', category: 'data', group: 'Tables',
245
+ label: 'Table page size',
246
+ description: 'Number of rows displayed per page in data tables.',
247
+ defaultValue: 100, min: 25, max: 1000, step: 25,
248
+ },
249
+ 'data.defaultResampleMethod': {
250
+ type: 'select', category: 'data', group: 'Import',
251
+ label: 'Default resample method',
252
+ description: 'Aggregation method used when resampling imported time series.',
253
+ defaultValue: 'mean',
254
+ options: [
255
+ { value: 'mean', label: 'Mean' },
256
+ { value: 'sum', label: 'Sum' },
257
+ { value: 'last', label: 'Last' },
258
+ { value: 'first', label: 'First' },
259
+ { value: 'linear', label: 'Linear interpolation' },
260
+ ],
261
+ },
262
+ 'etl.parallelWorkers': {
263
+ type: 'number', category: 'data', group: 'ETL Pipelines',
264
+ label: 'Parallel pipeline workers',
265
+ description: 'Number of pipelines to execute simultaneously in orchestrations. 1 = sequential.',
266
+ defaultValue: 1, min: 1, max: 8, step: 1,
267
+ },
268
+
269
+ // ── Logging ──────────────────────────────────────────────────────────────
270
+ 'logging.minLevel': {
271
+ type: 'select', category: 'logging', group: 'Output',
272
+ label: 'Minimum log level',
273
+ description: 'Only messages at this level or above are recorded.',
274
+ defaultValue: 'info',
275
+ options: [
276
+ { value: 'trace', label: 'Trace' },
277
+ { value: 'debug', label: 'Debug' },
278
+ { value: 'info', label: 'Info' },
279
+ { value: 'warn', label: 'Warning' },
280
+ { value: 'error', label: 'Error' },
281
+ { value: 'fatal', label: 'Fatal' },
282
+ ],
283
+ },
284
+ 'logging.enableConsole': {
285
+ type: 'boolean', category: 'logging', group: 'Output',
286
+ label: 'Enable console output',
287
+ description: 'Mirror log messages to the browser console.',
288
+ defaultValue: false,
289
+ },
290
+ 'logging.historyLimit': {
291
+ type: 'number', category: 'logging', group: 'History',
292
+ label: 'Log history limit',
293
+ description: 'Maximum number of log entries retained in memory.',
294
+ defaultValue: 500, min: 50, max: 10000, step: 50,
295
+ },
296
+
297
+ // ── Projects ─────────────────────────────────────────────────────────────
298
+ 'projects.directory': {
299
+ type: 'text', category: 'general', group: 'Projects',
300
+ label: 'Projects directory',
301
+ description: 'Default directory for new projects and bundled demos. Leave empty for OS default.',
302
+ defaultValue: null,
303
+ placeholder: 'OS default (%APPDATA%/EcoSim/projects)',
304
+ },
305
+
306
+ // ── AI Assistant ────────────────────────────────────────────────────────
307
+ // Provider, auth, model, and server settings are managed from the chat UI
308
+ // modals. Only behavior settings appear here.
309
+ 'ai.defaultMode': {
310
+ type: 'select', category: 'ai', group: 'Behavior',
311
+ label: 'Default mode',
312
+ description: 'Default interaction mode for the AI assistant.',
313
+ defaultValue: 'ask',
314
+ options: [
315
+ { value: 'ask', label: 'Ask (preview before applying)' },
316
+ { value: 'edit', label: 'Edit (apply immediately)' },
317
+ ],
318
+ },
319
+ 'ai.maxToolCalls': {
320
+ type: 'number', category: 'ai', group: 'Behavior',
321
+ label: 'Max tool calls',
322
+ description: 'Maximum number of tool calls per AI turn.',
323
+ defaultValue: 25, min: 1, max: 100, step: 1,
324
+ },
325
+
326
+ // ── Notebook ─────────────────────────────────────────────────────────────
327
+ 'notebook.cellWidthMode': {
328
+ type: 'select', category: 'notebook', group: 'Layout',
329
+ label: 'Cell width mode',
330
+ description: 'Controls how wide all cells appear. "Fixed" constrains cells to a max-width; "Full" stretches all cells.',
331
+ defaultValue: 'fixed',
332
+ options: [
333
+ { value: 'fixed', label: 'Fixed width (900px)' },
334
+ { value: 'full', label: 'Full width' },
335
+ ],
336
+ },
337
+ 'notebook.codeCellMaxHeight': {
338
+ type: 'boolean', category: 'notebook', group: 'Layout',
339
+ label: 'Limit code cell height',
340
+ description: 'When enabled, code cells have a maximum height and scroll internally instead of expanding to show all content.',
341
+ defaultValue: false,
342
+ },
343
+ 'notebook.paramCellMaxHeight': {
344
+ type: 'boolean', category: 'notebook', group: 'Layout',
345
+ label: 'Limit parameter cell height',
346
+ description: 'When enabled, parameter cells have a maximum height and scroll internally instead of expanding to show all content.',
347
+ defaultValue: false,
348
+ },
349
+ 'notebook.defaultCellView': {
350
+ type: 'select', category: 'notebook', group: 'Layout',
351
+ label: 'Default cell view',
352
+ description: 'Which tab to show by default on all cells: Config (edit) or EcoLang (generated code).',
353
+ defaultValue: 'config',
354
+ options: [
355
+ { value: 'config', label: 'Config' },
356
+ { value: 'dsl', label: 'EcoLang' },
357
+ ],
358
+ },
359
+
360
+ // ── Advanced ─────────────────────────────────────────────────────────────
361
+ 'editor.autosaveDelayMs': {
362
+ type: 'number', category: 'advanced', group: 'Performance',
363
+ label: 'Editor autosave delay (ms)',
364
+ description: 'Delay before the function editor auto-saves changes.',
365
+ defaultValue: 5000, min: 1000, max: 30000, step: 1000,
366
+ },
367
+ 'debug.logSettingsAccess': {
368
+ type: 'boolean', category: 'advanced', group: 'Debug',
369
+ label: 'Log settings access',
370
+ description: 'Log all getSetting/setSetting calls to the console for debugging.',
371
+ defaultValue: false,
372
+ },
373
+ });
374
+
375
+ /**
376
+ * Shell-owned categories. `order` is the sidebar sort key — it exists so a
377
+ * registered slice can slot its category *between* two shell categories
378
+ * instead of being stuck at the end.
379
+ */
380
+ const CATEGORIES = Object.freeze([
381
+ { id: 'general', label: 'General', icon: 'tune', description: 'Workspace behavior and notifications', order: 10 },
382
+ { id: 'ai', label: 'AI Assistant', icon: 'smart_toy', description: 'AI assistant behavior', order: 30 },
383
+ { id: 'window', label: 'Windows', icon: 'web_asset', description: 'Managed window animation and appearance', order: 40 },
384
+ { id: 'data', label: 'Data', icon: 'database', description: 'Table display, import, and pipeline defaults', order: 50 },
385
+ { id: 'logging', label: 'Logging', icon: 'terminal', description: 'Log level, console output, and history', order: 60 },
386
+ { id: 'notebook', label: 'Notebook', icon: 'menu_book', description: 'Notebook editor layout and behavior', order: 70 },
387
+ { id: 'advanced', label: 'Advanced', icon: 'code', description: 'Debug and developer settings', order: 80 },
388
+ ]);
389
+
390
+ const STORAGE_KEY = 'ecosim.settings.v1';
391
+
392
+ // ─── Path / value helpers ────────────────────────────────────────────────────
393
+
394
+ /**
395
+ * Traverse an object by dot-path parts and return the value.
396
+ * @param {object} obj
397
+ * @param {string[]} parts
398
+ * @returns {*}
399
+ */
400
+ function _getNestedValue(obj, parts) {
401
+ let current = obj;
402
+ for (const part of parts) {
403
+ if (current === null || current === undefined || typeof current !== 'object') {
404
+ return undefined;
405
+ }
406
+ current = current[part];
407
+ }
408
+ return current;
409
+ }
410
+
411
+ /**
412
+ * Set a value in a nested object by dot-path parts, creating intermediates.
413
+ * @param {object} obj
414
+ * @param {string[]} parts
415
+ * @param {*} value
416
+ */
417
+ function _setNestedValue(obj, parts, value) {
418
+ let current = obj;
419
+ for (let i = 0; i < parts.length - 1; i++) {
420
+ if (current[parts[i]] === undefined || current[parts[i]] === null || typeof current[parts[i]] !== 'object') {
421
+ current[parts[i]] = {};
422
+ }
423
+ current = current[parts[i]];
424
+ }
425
+ current[parts[parts.length - 1]] = value;
426
+ }
427
+
428
+ /**
429
+ * Deep equality check for primitives, arrays, and plain objects.
430
+ */
431
+ function _deepEqual(a, b) {
432
+ if (a === b) return true;
433
+ if (a == null || b == null) return a === b;
434
+ if (typeof a !== typeof b) return false;
435
+ if (Array.isArray(a)) {
436
+ if (!Array.isArray(b) || a.length !== b.length) return false;
437
+ return a.every((v, i) => _deepEqual(v, b[i]));
438
+ }
439
+ if (typeof a === 'object') {
440
+ const keysA = Object.keys(a);
441
+ const keysB = Object.keys(b);
442
+ if (keysA.length !== keysB.length) return false;
443
+ return keysA.every(k => _deepEqual(a[k], b[k]));
444
+ }
445
+ return false;
446
+ }
447
+
448
+ /** Deep clone via JSON — the value tree is JSON by construction (it persists). */
449
+ function _clone(value) {
450
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
451
+ }
452
+
453
+ /** Recursively merge `src` into `target` (plain objects only; arrays replace). */
454
+ function _deepMerge(target, src) {
455
+ for (const [key, value] of Object.entries(src || {})) {
456
+ const isPlain = value !== null && typeof value === 'object' && !Array.isArray(value);
457
+ if (isPlain) {
458
+ if (target[key] === null || typeof target[key] !== 'object' || Array.isArray(target[key])) {
459
+ target[key] = {};
460
+ }
461
+ _deepMerge(target[key], value);
462
+ } else {
463
+ target[key] = _clone(value);
464
+ }
465
+ }
466
+ return target;
467
+ }
468
+
469
+ // ─── The store (the primitive) ───────────────────────────────────────────────
470
+
471
+ /**
472
+ * Create an isolated, schema-driven settings store.
473
+ *
474
+ * The store owns no domain knowledge: everything it can hold, validate,
475
+ * persist and render is described by the `defaults` / `schema` / `categories`
476
+ * it is handed. Pass none and you get an empty store that an embedder fills
477
+ * entirely via `extend()`.
478
+ *
479
+ * @param {object} [opts]
480
+ * @param {object} [opts.defaults] Nested default values.
481
+ * @param {Object<string, SettingDef>} [opts.schema] Dot-path -> definition.
482
+ * @param {CategoryDef[]} [opts.categories]
483
+ * @param {string} [opts.storageKey] localStorage key. Omit -> no persistence.
484
+ * @param {object} [opts.eventBus] Optional; may be attached later.
485
+ * @returns {object} Frozen store handle.
486
+ */
487
+ export function createSettingsStore({
488
+ defaults = {},
489
+ schema = {},
490
+ categories = [],
491
+ storageKey = null,
492
+ eventBus = null,
493
+ } = {}) {
494
+ const _defaults = _clone(defaults) ?? {};
495
+ const _schema = { ...schema };
496
+ let _categories = categories.map(c => ({ ...c }));
497
+ const _values = _clone(defaults) ?? {};
498
+ let _bus = eventBus;
499
+
500
+ const _sortCategories = () => {
501
+ // Array#sort is stable, so equal `order` keeps registration order.
502
+ _categories.sort((a, b) => (a.order ?? 1000) - (b.order ?? 1000));
503
+ };
504
+ _sortCategories();
505
+
506
+ const _debug = () => _values.debug?.logSettingsAccess;
507
+
508
+ /**
509
+ * Load user-persisted values from localStorage (sparse merge).
510
+ * Only paths currently present in the schema are restored; anything else in
511
+ * storage is left untouched in memory — that is what makes `extend()` able
512
+ * to pick up a late-registered namespace's saved values.
513
+ */
514
+ const _load = () => {
515
+ try {
516
+ if (!storageKey || typeof localStorage === 'undefined') return;
517
+ const raw = localStorage.getItem(storageKey);
518
+ if (!raw) return;
519
+ const saved = JSON.parse(raw);
520
+ for (const path of Object.keys(_schema)) {
521
+ const parts = path.split('.');
522
+ const value = _getNestedValue(saved, parts);
523
+ if (value !== undefined) {
524
+ _setNestedValue(_values, parts, value);
525
+ }
526
+ }
527
+ } catch (err) {
528
+ console.warn('[Settings] Failed to load persisted settings', err);
529
+ }
530
+ };
531
+
532
+ /** Persist values that differ from their defaults (sparse storage). */
533
+ const _save = () => {
534
+ try {
535
+ if (!storageKey || typeof localStorage === 'undefined') return;
536
+ const sparse = {};
537
+ for (const path of Object.keys(_schema)) {
538
+ const parts = path.split('.');
539
+ const current = _getNestedValue(_values, parts);
540
+ const def = _schema[path].defaultValue;
541
+ if (!_deepEqual(current, def)) {
542
+ _setNestedValue(sparse, parts, current);
543
+ }
544
+ }
545
+ if (Object.keys(sparse).length === 0) {
546
+ localStorage.removeItem(storageKey);
547
+ } else {
548
+ localStorage.setItem(storageKey, JSON.stringify(sparse));
549
+ }
550
+ } catch (err) {
551
+ console.warn('[Settings] Failed to persist settings', err);
552
+ }
553
+ };
554
+
555
+ const _emit = (path, value) => {
556
+ if (!_bus) return;
557
+ _bus.emit(`settings:${path}:changed`, { path, value });
558
+ _bus.emit('settings:changed', { path, value });
559
+ };
560
+
561
+ _load();
562
+
563
+ /**
564
+ * Merge an additional slice in — an embedder's own namespace — then re-apply
565
+ * persisted overrides for the newly-known paths.
566
+ *
567
+ * ORDERING RULE: call this before the first `set()`. `_save()` rebuilds
568
+ * storage from `Object.keys(_schema)`, so a write that lands *before* a
569
+ * namespace is registered would garbage-collect that namespace's saved
570
+ * values. Registering at the top of bootstrap satisfies this trivially.
571
+ *
572
+ * @param {SettingsSlice} slice
573
+ */
574
+ const extend = ({ defaults: d = {}, schema: s = {}, categories: c = [] } = {}) => {
575
+ _deepMerge(_defaults, d);
576
+ _deepMerge(_values, d);
577
+ Object.assign(_schema, s);
578
+ for (const cat of c) {
579
+ if (!cat?.id) continue;
580
+ if (_categories.some(x => x.id === cat.id)) continue;
581
+ _categories.push({ ...cat });
582
+ }
583
+ _sortCategories();
584
+ _load(); // idempotent: overlays only paths it finds in storage
585
+ };
586
+
587
+ const get = (path, defaultValue = undefined) => {
588
+ const parts = path.split('.');
589
+ let current = _values;
590
+ for (const part of parts) {
591
+ if (current === null || current === undefined || typeof current !== 'object') {
592
+ if (_debug()) console.warn(`[Settings] Path not found: ${path}`);
593
+ return defaultValue;
594
+ }
595
+ current = current[part];
596
+ }
597
+ if (current === undefined) return defaultValue;
598
+ if (_debug()) console.log(`[Settings] getSetting('${path}') =>`, current);
599
+ return current;
600
+ };
601
+
602
+ const set = (path, value) => {
603
+ const parts = path.split('.');
604
+ const lastPart = parts.pop();
605
+ let current = _values;
606
+ for (const part of parts) {
607
+ if (current[part] === undefined || current[part] === null) {
608
+ current[part] = {};
609
+ }
610
+ if (typeof current[part] !== 'object') {
611
+ console.warn(`[Settings] Cannot set '${path}': intermediate path is not an object`);
612
+ return false;
613
+ }
614
+ current = current[part];
615
+ }
616
+ current[lastPart] = value;
617
+ if (_debug()) console.log(`[Settings] setSetting('${path}', ${JSON.stringify(value)})`);
618
+ _save();
619
+ _emit(path, value);
620
+ return true;
621
+ };
622
+
623
+ const getDefault = (path) => {
624
+ const def = _schema[path];
625
+ if (!def) return _clone(_getNestedValue(_defaults, path.split('.')));
626
+ return _clone(def.defaultValue);
627
+ };
628
+
629
+ const resetOne = (path) => {
630
+ const defaultVal = getDefault(path);
631
+ if (defaultVal === undefined) {
632
+ console.warn(`[Settings] No default found for '${path}'`);
633
+ return false;
634
+ }
635
+ return set(path, defaultVal);
636
+ };
637
+
638
+ const resetCategoryFn = (categoryId) => {
639
+ for (const [path, def] of Object.entries(_schema)) {
640
+ if (def.category !== categoryId) continue;
641
+ _setNestedValue(_values, path.split('.'), _clone(def.defaultValue));
642
+ }
643
+ _save();
644
+ if (_bus) {
645
+ _bus.emit('settings:category:reset', { category: categoryId });
646
+ _bus.emit('settings:changed', { path: null, category: categoryId });
647
+ }
648
+ };
649
+
650
+ const resetAll = () => {
651
+ for (const [path, def] of Object.entries(_schema)) {
652
+ _setNestedValue(_values, path.split('.'), _clone(def.defaultValue));
653
+ }
654
+ _save();
655
+ if (_bus) {
656
+ _bus.emit('settings:reset', {});
657
+ _bus.emit('settings:changed', { path: null });
658
+ }
659
+ };
660
+
661
+ const getByCategory = (categoryId) => {
662
+ const result = [];
663
+ for (const [path, def] of Object.entries(_schema)) {
664
+ if (def.category === categoryId) result.push({ path, ...def });
665
+ }
666
+ return result;
667
+ };
668
+
669
+ return Object.freeze({
670
+ get,
671
+ set,
672
+ extend,
673
+ getDefault,
674
+ resetOne,
675
+ resetCategory: resetCategoryFn,
676
+ resetAll,
677
+ getAll: () => _clone(_values),
678
+ getSchema: () => _schema,
679
+ // Frozen snapshot: the old module-level CATEGORIES was a frozen const, and
680
+ // a caller that sorted or spliced the live array in place would silently
681
+ // reorder the Settings sidebar. Same objects, same order — just not ours
682
+ // to wreck.
683
+ getCategories: () => Object.freeze(_categories.slice()),
684
+ getByCategory,
685
+ setEventBus: (bus) => { _bus = bus; },
686
+ });
687
+ }
688
+
689
+ // ─── The app-global binding over ONE store ───────────────────────────────────
690
+ //
691
+ // A capability *port* (the host bridge) may have many implementations at once,
692
+ // so a singleton there is a category error. A *value store* with a single
693
+ // physical backing key has cardinality one by construction: two live stores in
694
+ // one document would be two writers to `localStorage[STORAGE_KEY]`. The
695
+ // singleton is the correct cardinality, not a shortcut — and the factory above
696
+ // is the primitive, so tests and a standalone demo never touch this binding.
697
+
698
+ const _store = createSettingsStore({
699
+ defaults: DEFAULTS,
700
+ schema: SCHEMA,
701
+ categories: CATEGORIES,
702
+ storageKey: STORAGE_KEY,
703
+ });
704
+
705
+ /**
706
+ * Push an embedder's settings namespace into the app-global store.
707
+ *
708
+ * This is the whole of "push, don't pull": the framework ships the shell's own
709
+ * settings and the *embedder* hands over its own — the store never imports a
710
+ * schema it does not own.
711
+ *
712
+ * MUST run before any setSetting() — see `extend()`.
713
+ *
714
+ * @param {SettingsSlice} slice
715
+ */
716
+ export function registerSettings(slice) {
717
+ _store.extend(slice);
718
+ }
719
+
720
+ /**
721
+ * Register the EventBus instance for settings change notifications.
722
+ * Called once during app bootstrap.
723
+ * @param {object} eventBus
724
+ */
725
+ export function registerSettingsEventBus(eventBus) {
726
+ _store.setEventBus(eventBus);
727
+ }
728
+
729
+ /**
730
+ * Get a setting value by dot-notation path.
731
+ * @param {string} path - Dot-separated path (e.g., 'workspace.save.showToast')
732
+ * @param {*} [defaultValue] - Fallback if path not found
733
+ * @returns {*} The setting value or defaultValue
734
+ */
735
+ export function getSetting(path, defaultValue = undefined) {
736
+ return _store.get(path, defaultValue);
737
+ }
738
+
739
+ /**
740
+ * Set a setting value. Persists to localStorage and emits change events.
741
+ * @param {string} path - Dot-separated path
742
+ * @param {*} value - Value to set
743
+ * @returns {boolean} True if set successfully
744
+ */
745
+ export function setSetting(path, value) {
746
+ return _store.set(path, value);
747
+ }
748
+
749
+ /**
750
+ * Get the default value for a setting path.
751
+ * @param {string} path - Dot-separated path
752
+ * @returns {*} The default value, or undefined if the path is unknown
753
+ */
754
+ export function getDefaultValue(path) {
755
+ return _store.getDefault(path);
756
+ }
757
+
758
+ /**
759
+ * Reset a setting to its default value.
760
+ * @param {string} path - Dot-separated path
761
+ * @returns {boolean} True if reset successfully
762
+ */
763
+ export function resetSetting(path) {
764
+ return _store.resetOne(path);
765
+ }
766
+
767
+ /**
768
+ * Reset all settings in a category to their defaults.
769
+ * @param {string} categoryId
770
+ */
771
+ export function resetCategory(categoryId) {
772
+ _store.resetCategory(categoryId);
773
+ }
774
+
775
+ /** Reset all settings to their defaults. */
776
+ export function resetAllSettings() {
777
+ _store.resetAll();
778
+ }
779
+
780
+ /**
781
+ * Get all settings as a plain object (for serialization or debugging).
782
+ * @returns {object} Deep copy of current settings
783
+ */
784
+ export function getAllSettings() {
785
+ return _store.getAll();
786
+ }
787
+
788
+ // ─── Schema / Category API (for Settings page UI) ───────────────────────────
789
+
790
+ /**
791
+ * Get the full settings schema (shell + every registered slice).
792
+ * @returns {Object<string, SettingDef>}
793
+ */
794
+ export function getSchema() {
795
+ return _store.getSchema();
796
+ }
797
+
798
+ /**
799
+ * Get the categories list, in sidebar order.
800
+ * @returns {CategoryDef[]}
801
+ */
802
+ export function getCategories() {
803
+ return _store.getCategories();
804
+ }
805
+
806
+ /**
807
+ * Get all settings definitions for a given category, in schema order.
808
+ * @param {string} categoryId
809
+ * @returns {Array<{path:string} & SettingDef>}
810
+ */
811
+ export function getSettingsByCategory(categoryId) {
812
+ return _store.getByCategory(categoryId);
813
+ }