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,69 @@
1
+ /**
2
+ * host.js — the host port.
3
+ *
4
+ * The framework declares what it needs from its embedder. A Host is a bag of
5
+ * INDEPENDENTLY OPTIONAL capability objects:
6
+ *
7
+ * - absent capability => absent key (never a stub)
8
+ * - present capability => must be complete (validated at boot)
9
+ *
10
+ * There is NO module-level default host and NO getHost(). Hosts are injected —
11
+ * a module singleton here would just be `window.pywebview` wearing a hat, and
12
+ * would make a second host (mock, browser tab, VS Code webview) impossible.
13
+ *
14
+ * Zero pywebview. Zero EcoAgent. See pywebview_host.js for the reference
15
+ * adapter that implements this port against the pywebview / HTTP transports.
16
+ */
17
+
18
+ export const HOST_CONTRACT = Object.freeze({
19
+ window: Object.freeze({
20
+ required: Object.freeze(['chrome', 'minimize', 'close', 'isMaximized', 'setMaximized',
21
+ 'isFullscreen', 'toggleFullscreen', 'getBounds', 'setBounds']),
22
+ optional: Object.freeze(['startNativeDrag']),
23
+ }),
24
+ dialogs: Object.freeze({ required: Object.freeze(['saveFile']), optional: Object.freeze([]) }),
25
+ state: Object.freeze({ required: Object.freeze(['read', 'write']), optional: Object.freeze([]) }),
26
+ });
27
+
28
+ /**
29
+ * Validate ONE capability object. We validate the capability the adapter
30
+ * authored — a plain object literal with real closures — not the raw bridge:
31
+ * `index.html`'s browser-mode polyfill installs a `new Proxy({}, {get})` that
32
+ * returns a function for EVERY property name, so duck-typing the bridge is
33
+ * vacuously true and validates nothing. A Proxy cannot forge a missing key in
34
+ * an object literal.
35
+ *
36
+ * @returns {{ ok: boolean, missing: string[] }}
37
+ */
38
+ export function validateCapability(name, cap) {
39
+ const spec = HOST_CONTRACT[name];
40
+ if (!spec) return { ok: false, missing: [`unknown capability '${name}'`] };
41
+ if (!cap || typeof cap !== 'object') return { ok: false, missing: [...spec.required] };
42
+ const missing = spec.required.filter((m) => typeof cap[m] !== 'function');
43
+ return { ok: missing.length === 0, missing };
44
+ }
45
+
46
+ /**
47
+ * Validate + freeze a set of capabilities into a Host.
48
+ * Throws at boot on an incomplete capability — a half-wired host is a bug,
49
+ * and a bug at boot beats a silent degrade at click time.
50
+ *
51
+ * @param {{ window?: object, dialogs?: object, state?: object }} caps
52
+ * @returns {Readonly<object>} the Host
53
+ */
54
+ export function createHost(caps = {}) {
55
+ const host = {};
56
+ for (const name of Object.keys(HOST_CONTRACT)) {
57
+ const cap = caps[name];
58
+ if (cap == null) continue; // legitimately absent
59
+ const v = validateCapability(name, cap);
60
+ if (!v.ok) {
61
+ throw new Error(`Host capability '${name}' is incomplete: missing ${v.missing.join(', ')}`);
62
+ }
63
+ host[name] = Object.freeze(cap);
64
+ }
65
+ return Object.freeze(host);
66
+ }
67
+
68
+ /** The zero-capability host. Every framework consumer MUST survive it. */
69
+ export const NULL_HOST = createHost({});
@@ -0,0 +1,133 @@
1
+ /**
2
+ * pywebview_host.js — the REFERENCE host adapter.
3
+ *
4
+ * Knows pywebview. Knows NOTHING about EcoAgent: every domain fact (which
5
+ * bridge ref to use, where a state key lands on disk) arrives as a parameter.
6
+ * This is the adapter a standalone consumer copies; EcoAgent is just its first
7
+ * caller (see ui/js/bootstrap/app_bootstrap.js).
8
+ *
9
+ * It speaks to whatever `window.pywebview.api` currently is — the real
10
+ * pywebview bridge in the desktop app, or the `POST /api/<method>` Proxy
11
+ * polyfill that ui/index.html installs in --browser mode. Both transports are
12
+ * positional-on-the-wire, which is why setBounds() spreads its object back out
13
+ * into positional args.
14
+ */
15
+
16
+ import { createHost } from './host.js';
17
+
18
+ const FRAMELESS_BACKENDS = new Set(['edgechromium', 'mshtml']);
19
+
20
+ /**
21
+ * Custom (frameless) window chrome — the min/max/close buttons, edge resize
22
+ * handles and drag-to-move top bar — only makes sense when our top bar IS the
23
+ * window's title bar. That holds on a native pywebview window the OS draws
24
+ * *without* decorations of its own: Windows (frameless). On Linux/macOS the
25
+ * window manager already draws a native title bar above our top bar, and in
26
+ * `--browser` mode the browser supplies its own chrome — in both cases the
27
+ * custom controls are a redundant second set, so we neither render nor wire
28
+ * them (which also stops us swallowing native F11 fullscreen and showing dead
29
+ * resize cursors at the window edges).
30
+ *
31
+ * Detection keys off the pywebview backend string, which is unambiguous: the
32
+ * browser-mode polyfill in index.html injects only `.api`, never `.platform`,
33
+ * so any `.platform` at all ⇒ running as a native pywebview app. The frameless
34
+ * (custom-title-bar) backends are the Windows WebViews — 'edgechromium' /
35
+ * 'mshtml'. Linux ('gtkwebkit2' / 'qtwebengine') and macOS ('cocoa') draw
36
+ * native decorations, so they return false. Verified live: WebKitGTK reports
37
+ * platform 'gtkwebkit2'.
38
+ */
39
+ export function usesCustomWindowChrome() {
40
+ // Authoritative signal from the Python host: run_pywebview_mode() appends
41
+ // `?frameless=1` to the window URL exactly when it created a frameless native
42
+ // window (Windows). Prefer it over the backend sniff below because it's
43
+ // present at document parse — before pywebview finishes injecting
44
+ // `window.pywebview` (and before the browser-mode polyfill, which sets `.api`
45
+ // but no `.platform`, can clobber it). Reading the sniff at shell-build time
46
+ // otherwise races and the whole custom chrome silently fails to render.
47
+ try {
48
+ if (new URLSearchParams(window.location.search).get('frameless') === '1') {
49
+ return true;
50
+ }
51
+ } catch (_) { /* location unavailable (non-browser env) — fall through */ }
52
+ const platform = window.pywebview && window.pywebview.platform;
53
+ return !!platform && FRAMELESS_BACKENDS.has(platform);
54
+ }
55
+
56
+ /**
57
+ * Build a Host backed by the pywebview bridge.
58
+ *
59
+ * @param {object} opts
60
+ * @param {{current: object|null}} [opts.bridgeRef] LIVE ref to the bridge.
61
+ * MUST be a ref, never a snapshot: `setupHostBridgeListener` mutates
62
+ * `.current` when a late bridge arrives, and `resolveHostBridge` can
63
+ * resolve `null` after its timeout. Snapshotting the bridge here would
64
+ * freeze that null and reproduce as an intermittent boot failure.
65
+ * @param {(key: string) => string} [opts.resolvePath] logical state key ->
66
+ * storage path. THE domain seam: the framework only ever names keys
67
+ * ('desktops', 'datatable_state'); the embedder decides where they land.
68
+ * @param {object} [opts.logger]
69
+ */
70
+ export function createPywebviewHost({ bridgeRef = null, resolvePath = (k) => `${k}.json`, logger = console } = {}) {
71
+ const api = () => bridgeRef?.current ?? window.pywebview?.api ?? null;
72
+
73
+ const call = async (name, args = []) => {
74
+ const a = api();
75
+ if (!a || typeof a[name] !== 'function') return null;
76
+ try { return await a[name](...args); }
77
+ catch (e) { logger.warn?.('[host] call failed', name, e); return null; }
78
+ };
79
+
80
+ // Envelope unwrapping, in ONE place. Transcribed from the call sites this
81
+ // replaces, so app.py's catch-all `{ok: true}` reply (which carries no
82
+ // `maximized` key) still reads as false, exactly as it does today.
83
+ const okMax = (r) => Boolean(r && r.ok && r.maximized === true);
84
+ const okFs = (r) => Boolean(r && r.ok && r.fullscreen);
85
+
86
+ // ALL THREE capabilities are ALWAYS advertised. Do NOT gate them on api()
87
+ // being non-null at construction time: the methods late-bind through the
88
+ // ref, and gating here would permanently strand a late-arriving bridge on
89
+ // the fallback paths (Blob download instead of the native save dialog).
90
+ return createHost({
91
+ window: {
92
+ chrome: () => (usesCustomWindowChrome() ? 'custom' : 'native'),
93
+ minimize: () => { void call('window_minimize'); },
94
+ close: () => { void call('window_close'); },
95
+ isMaximized: async () => okMax(await call('window_is_maximized')),
96
+ setMaximized: async (on) => okMax(await call('window_set_maximized', [on])),
97
+ isFullscreen: async () => okFs(await call('window_is_fullscreen')),
98
+ toggleFullscreen: async () => okFs(await call('window_toggle_fullscreen')),
99
+ async getBounds() {
100
+ const r = await call('window_get_bounds');
101
+ if (!r || !r.ok) return null;
102
+ return { x: r.x, y: r.y, width: r.width, height: r.height };
103
+ },
104
+ // POSITIONAL on the wire — both transports depend on it. Not async:
105
+ // this is a rAF hot path during a window drag/resize; nobody awaits.
106
+ setBounds({ x = null, y = null, width = null, height = null } = {}) {
107
+ void call('window_set_bounds', [x, y, width, height]);
108
+ },
109
+ startNativeDrag: async () => Boolean((await call('window_start_native_drag'))?.ok),
110
+ },
111
+ dialogs: {
112
+ // Resolves to null when the transport has no save_file_dialog behind it.
113
+ // Per host.js that is the contract for "unavailable", and every caller
114
+ // treats it exactly like an absent `dialogs` capability: fall back to a
115
+ // Blob download, never report a failed save. Covered by
116
+ // tests/ui/test_host_save_fallback.mjs.
117
+ saveFile: ({ data, filename, kind }) => call('save_file_dialog', [data, filename, kind]),
118
+ },
119
+ state: {
120
+ async read(key) {
121
+ const blob = await call('workspace_state_read', [{ path: resolvePath(key) }]);
122
+ if (!blob) return null;
123
+ try { return typeof blob === 'string' ? JSON.parse(blob) : blob; }
124
+ catch (e) { logger.warn?.('[host] bad JSON for state key', key, e); return null; }
125
+ },
126
+ async write(key, value) {
127
+ const r = await call('workspace_state_write',
128
+ [{ path: resolvePath(key), data: JSON.stringify(value) }]);
129
+ return r !== null;
130
+ },
131
+ },
132
+ });
133
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * config_schema.js
3
+ *
4
+ * Configuration schema definitions for widget types.
5
+ * Defines field types, validation rules, and default values.
6
+ */
7
+
8
+ /**
9
+ * Field type definitions for widget configuration.
10
+ */
11
+ export const FieldTypes = {
12
+ TEXT: 'text',
13
+ NUMBER: 'number',
14
+ CHECKBOX: 'checkbox',
15
+ SELECT: 'select',
16
+ MULTISELECT: 'multiselect',
17
+ COLOR: 'color',
18
+ RANGE: 'range'
19
+ };
20
+
21
+ /**
22
+ * Validate a configuration value against its field schema.
23
+ * @param {any} value - Value to validate
24
+ * @param {Object} field - Field schema
25
+ * @returns {{valid: boolean, error?: string}} Validation result
26
+ */
27
+ export function validateField(value, field) {
28
+ // Check required
29
+ if (field.required && (value == null || value === '')) {
30
+ return { valid: false, error: `${field.label} is required` };
31
+ }
32
+
33
+ // Type-specific validation
34
+ switch (field.type) {
35
+ case FieldTypes.NUMBER:
36
+ case FieldTypes.RANGE:
37
+ if (value != null && value !== '') {
38
+ const num = parseFloat(value);
39
+ if (isNaN(num)) {
40
+ return { valid: false, error: `${field.label} must be a number` };
41
+ }
42
+ if (field.min != null && num < field.min) {
43
+ return { valid: false, error: `${field.label} must be at least ${field.min}` };
44
+ }
45
+ if (field.max != null && num > field.max) {
46
+ return { valid: false, error: `${field.label} must be at most ${field.max}` };
47
+ }
48
+ }
49
+ break;
50
+
51
+ case FieldTypes.SELECT:
52
+ if (field.required && !value) {
53
+ return { valid: false, error: `${field.label} must be selected` };
54
+ }
55
+ break;
56
+
57
+ case FieldTypes.MULTISELECT:
58
+ if (field.required && (!Array.isArray(value) || value.length === 0)) {
59
+ return { valid: false, error: `${field.label} must have at least one selection` };
60
+ }
61
+ break;
62
+ }
63
+
64
+ return { valid: true };
65
+ }
66
+
67
+ /**
68
+ * Validate an entire configuration object against a schema.
69
+ * @param {Object} config - Configuration object
70
+ * @param {Object} schema - Schema with fields array
71
+ * @returns {{valid: boolean, errors: Object}} Validation result
72
+ */
73
+ export function validateConfig(config, schema) {
74
+ const errors = {};
75
+ let valid = true;
76
+
77
+ if (!schema?.fields) {
78
+ return { valid: true, errors: {} };
79
+ }
80
+
81
+ schema.fields.forEach(field => {
82
+ const result = validateField(config[field.key], field);
83
+ if (!result.valid) {
84
+ errors[field.key] = result.error;
85
+ valid = false;
86
+ }
87
+ });
88
+
89
+ return { valid, errors };
90
+ }
91
+
92
+ /**
93
+ * Merge user config with defaults from schema.
94
+ * @param {Object} config - User configuration
95
+ * @param {Object} schema - Schema with fields array
96
+ * @returns {Object} Merged configuration
97
+ */
98
+ export function mergeWithDefaults(config, schema) {
99
+ const result = { ...config };
100
+
101
+ if (schema?.fields) {
102
+ schema.fields.forEach(field => {
103
+ if (result[field.key] === undefined && field.default !== undefined) {
104
+ result[field.key] = field.default;
105
+ }
106
+ });
107
+ }
108
+
109
+ return result;
110
+ }
111
+
112
+ /**
113
+ * Get options for a select/multiselect field.
114
+ * Handles dynamic options (e.g., 'variables' which gets populated from data).
115
+ * @param {Object} field - Field schema
116
+ * @param {Object} data - Current data context
117
+ * @returns {Array<{value: string, label: string}>} Options array
118
+ */
119
+ export function getFieldOptions(field, data) {
120
+ if (Array.isArray(field.options)) {
121
+ return field.options;
122
+ }
123
+
124
+ // Dynamic options based on string identifier
125
+ if (field.options === 'variables') {
126
+ return getVariableOptions(data);
127
+ }
128
+
129
+ if (field.options === 'namespaces') {
130
+ return getNamespaceOptions(data);
131
+ }
132
+
133
+ return [];
134
+ }
135
+
136
+ /**
137
+ * Format a raw analytics variable key into a user-friendly display label.
138
+ * Strips namespace prefixes, internal Godley patterns, and stock type suffixes.
139
+ *
140
+ * Examples:
141
+ * "Agriculture.__SimpleStocks__::arable_land[State]" → "arable_land"
142
+ * "Population.population" → "population"
143
+ * "Banks::Assets[Cash]" → "Assets / Cash"
144
+ * "my_variable" → "my_variable"
145
+ *
146
+ * @param {string} name - Raw variable key from analytics
147
+ * @returns {string} Friendly display label
148
+ */
149
+ export function formatVariableLabel(name) {
150
+ if (!name) return '';
151
+ let label = name;
152
+
153
+ // Strip namespace prefix: "Namespace.rest" → "rest"
154
+ const dotIdx = label.indexOf('.');
155
+ if (dotIdx > 0) {
156
+ label = label.slice(dotIdx + 1);
157
+ }
158
+
159
+ // Strip __SimpleStocks__:: or similar double-underscore sector prefixes
160
+ label = label.replace(/^__\w+__::/, '');
161
+
162
+ // Handle Godley stock pattern: "Sector::AccountType[Account]" → "AccountType / Account"
163
+ // But first strip [State] / [Rate] suffixes that are internal
164
+ label = label.replace(/\[State\]$/i, '');
165
+ label = label.replace(/\[Rate\]$/i, '');
166
+
167
+ // Clean up trailing empty brackets or colons from stripping
168
+ label = label.replace(/\[\]$/, '');
169
+
170
+ // Replace :: with readable separator
171
+ label = label.replace(/::/g, ' / ');
172
+
173
+ // Replace single colons used as separators with underscore
174
+ label = label.replace(/:/g, '_');
175
+
176
+ // Clean up any double spaces or leading/trailing whitespace
177
+ return label.replace(/\s{2,}/g, ' ').trim();
178
+ }
179
+
180
+ /**
181
+ * Get variable options from data.
182
+ * @param {Object} data - Analytics data
183
+ * @returns {Array<{value: string, label: string, category?: string}>} Variable options
184
+ */
185
+ export function getVariableOptions(data) {
186
+ const options = [];
187
+
188
+ if (!data) return options;
189
+
190
+ // Add stocks
191
+ if (data.stocks) {
192
+ Object.keys(data.stocks).forEach(name => {
193
+ options.push({
194
+ value: name,
195
+ label: formatVariableLabel(name),
196
+ category: 'Stocks'
197
+ });
198
+ });
199
+ }
200
+
201
+ // Add flows
202
+ if (data.flows) {
203
+ Object.keys(data.flows).forEach(name => {
204
+ options.push({
205
+ value: name,
206
+ label: formatVariableLabel(name),
207
+ category: 'Flows'
208
+ });
209
+ });
210
+ }
211
+
212
+ // Add indicators
213
+ if (data.indicators) {
214
+ Object.keys(data.indicators).forEach(name => {
215
+ options.push({
216
+ value: name,
217
+ label: formatVariableLabel(name),
218
+ category: 'Indicators'
219
+ });
220
+ });
221
+ }
222
+
223
+ return options;
224
+ }
225
+
226
+ /**
227
+ * Extract unique namespace names from variable keys in data.
228
+ * Stock keys follow the pattern "Namespace.Sector::Type[Account]".
229
+ * @param {Object} data - Analytics data
230
+ * @returns {Array<{value: string, label: string}>}
231
+ */
232
+ export function getNamespaceOptions(data) {
233
+ const namespaces = new Set();
234
+ const sources = [data?.stocks, data?.flows, data?.indicators];
235
+ for (const source of sources) {
236
+ if (!source) continue;
237
+ for (const key of Object.keys(source)) {
238
+ const dotIdx = key.indexOf('.');
239
+ if (dotIdx > 0) {
240
+ namespaces.add(key.slice(0, dotIdx));
241
+ }
242
+ }
243
+ }
244
+ if (namespaces.size === 0) namespaces.add('Main');
245
+ return Array.from(namespaces).sort().map(ns => ({ value: ns, label: ns }));
246
+ }
247
+
248
+ export default {
249
+ FieldTypes,
250
+ validateField,
251
+ validateConfig,
252
+ mergeWithDefaults,
253
+ getFieldOptions,
254
+ getVariableOptions,
255
+ formatVariableLabel
256
+ };