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,652 @@
1
+ /**
2
+ * attribute_list_editor.js — generic AttributeSpec list editor.
3
+ *
4
+ * One reusable component for every entity that declares a typed-slot
5
+ * list: Asset.attributes, Archetype.attributes, SectorKind.requires,
6
+ * MarketKind.requires, Flow.params, KPI.params. Mounts a SortableList
7
+ * (drag-reorder + add + remove + empty-state) with typed cells:
8
+ *
9
+ * - scalar (number / integer / string / boolean): typed `<input>`
10
+ * - expression: free-text input — engine evaluates each tick
11
+ * - ref:<target>: typeahead-ish `<select>` populated by a caller-
12
+ * supplied resolver returning {id, label} lists
13
+ * - list[ref:<target>]: comma-separated input (UI primitive — chip
14
+ * editor can land later without changing the data shape)
15
+ * - select: dropdown of options (auto when `options[]` is set)
16
+ *
17
+ * Each row exposes the columns in `showColumns` (defaults to the full
18
+ * set); callers narrow it (e.g. SectorKind.requires only needs name +
19
+ * type + description, not default/value). Layout follows the column
20
+ * spec — header strip aligned via CSS grid using --cols.
21
+ *
22
+ * Usage:
23
+ *
24
+ * import { mountAttributeListEditor } from
25
+ * '../../ui/components/attribute_list_editor.js';
26
+ *
27
+ * const editor = mountAttributeListEditor(hostEl, {
28
+ * items: kind.attributes,
29
+ * showColumns: ['name', 'type', 'default', 'value', 'description'],
30
+ * refResolvers: {
31
+ * 'ref:asset': () => api.assets_list(),
32
+ * },
33
+ * onChange: (next) => { kind.attributes = next; save(); },
34
+ * });
35
+ * editor.refresh(); // call after `items` changes externally
36
+ * editor.dispose(); // teardown
37
+ */
38
+
39
+ import { SortableList } from './sortable_list.js';
40
+
41
+ // The types a generic attribute editor knows about, with no application loaded.
42
+ //
43
+ // This used to also list nine EcoAgent entity kinds — asset_kind, market,
44
+ // sub_sector, currency, sector, market_kind, asset_kind_template, archetype,
45
+ // kpi — and derive `ref:<kind>` and `list[ref:<kind>]` from them. `types` was
46
+ // ALREADY an injectable option; it simply had EcoAgent's ontology as its
47
+ // default, which meant every consumer who didn't override it inherited a type
48
+ // dropdown full of macroeconomics.
49
+ //
50
+ // Callers who want reference types build them with refTypesFor() and pass them
51
+ // in, alongside the `refResolvers` that make them resolvable.
52
+ const SCALAR_TYPES = ['number', 'integer', 'string', 'boolean'];
53
+
54
+ const DEFAULT_TYPES = [...SCALAR_TYPES, 'expression'];
55
+
56
+ /** `['agent']` -> `['ref:agent', 'list[ref:agent]']`. */
57
+ export function refTypesFor(kinds = []) {
58
+ return [
59
+ ...kinds.map((k) => `ref:${k}`),
60
+ ...kinds.map((k) => `list[ref:${k}]`),
61
+ ];
62
+ }
63
+
64
+ export { SCALAR_TYPES, DEFAULT_TYPES };
65
+
66
+ const DEFAULT_COLUMNS = ['name', 'type', 'default', 'value', 'description'];
67
+
68
+ const COLUMN_TEMPLATES = {
69
+ name: { label: 'name', width: 'minmax(140px, 1.2fr)' },
70
+ type: { label: 'type', width: '140px' },
71
+ default: { label: 'default', width: 'minmax(110px, 1fr)' },
72
+ value: { label: 'value', width: 'minmax(110px, 1fr)' },
73
+ description: { label: 'description', width: 'minmax(160px, 2fr)' },
74
+ required: { label: 'req', width: '60px' },
75
+ readonly: { label: 'r/o', width: '60px' },
76
+ expression: { label: 'expression', width: 'minmax(160px, 2fr)' },
77
+ // Layer N3 — extended AttributeSpec fields. Opt-in via showColumns
78
+ // so existing single-table layouts (asset_kind editor) stay tight;
79
+ // the agent params surface includes them.
80
+ min: { label: 'min', width: '90px' },
81
+ max: { label: 'max', width: '90px' },
82
+ step: { label: 'step', width: '70px' },
83
+ unit: { label: 'unit', width: '80px' },
84
+ distribution: { label: 'dist', width: '110px' },
85
+ // Distribution and "selection set" are two different concepts —
86
+ // numeric params sample from a continuous distribution (uniform /
87
+ // normal / lognormal / …), choice params pick from a discrete
88
+ // option set. Keep them in separate columns so the user can tell
89
+ // which is which at a glance.
90
+ choices: { label: 'choices', width: '130px' },
91
+ // Variation-override flags — R7 in
92
+ // docs/ARCHETYPE_RETHINK_REQUIREMENTS.md. Tight columns so they
93
+ // stay readable next to the scalar fields.
94
+ enabled: { label: 'on', width: '50px' },
95
+ force: { label: 'lock', width: '50px' },
96
+ optional: { label: 'opt', width: '50px' },
97
+ };
98
+
99
+ const DISTRIBUTION_OPTIONS = [
100
+ '', 'constant', 'uniform', 'normal', 'lognormal', 'choice',
101
+ ];
102
+
103
+
104
+ /** Classify one attribute against its parent-archetype counterpart.
105
+ * Layer 8.J1 inheritance display:
106
+ *
107
+ * - 'inherited' — entity's effective value matches the archetype's
108
+ * default for the same attribute name. UI paints the row blue.
109
+ * - 'overridden' — entity's value is set AND differs from the
110
+ * archetype's default. UI paints the row solarized orange + shows
111
+ * a revert button.
112
+ * - 'neutral' — neither side declares a value (or there's no parent).
113
+ * UI paints the row in default style.
114
+ */
115
+ function _classifyRow(row, parentByName) {
116
+ if (!parentByName) return 'neutral';
117
+ const p = parentByName.get(row.name);
118
+ if (!p) return 'neutral';
119
+ const ourValue = (row.value !== undefined && row.value !== null)
120
+ ? row.value : row.default;
121
+ const parentValue = (p.value !== undefined && p.value !== null)
122
+ ? p.value : p.default;
123
+ if (ourValue === undefined || ourValue === null) return 'neutral';
124
+ if (parentValue === undefined || parentValue === null) return 'overridden';
125
+ // Loose equality: handles number vs numeric-string from form inputs.
126
+ if (String(ourValue) === String(parentValue)) return 'inherited';
127
+ return 'overridden';
128
+ }
129
+
130
+
131
+ export function mountAttributeListEditor(hostEl, options = {}) {
132
+ const {
133
+ items = [],
134
+ types = DEFAULT_TYPES,
135
+ showColumns = DEFAULT_COLUMNS,
136
+ refResolvers = {},
137
+ onChange = () => {},
138
+ onFlush = () => {},
139
+ addButtonText = '',
140
+ emptyMessage = 'No entries yet.',
141
+ containerId = `attrs-${Math.random().toString(36).slice(2, 8)}`,
142
+ allowReorder = true,
143
+ allowRemove = true,
144
+ // Layer 8.J1: parent-archetype attributes (the inherited baseline).
145
+ // The editor uses this to classify each row as inherited / overridden /
146
+ // neutral and to provide a revert action. Pass [] when no archetype
147
+ // applies; the editor degrades gracefully (all rows render neutral).
148
+ parentAttributes = null,
149
+ createItem = () => ({
150
+ name: '', type: 'string', default: null,
151
+ value: null, description: '',
152
+ }),
153
+ // Layer N3 — per-column renderer overrides. Callers can supply
154
+ // `cellOverrides: {column_name: (item, callbacks, ctx) => HTMLElement | null}`
155
+ // to swap in a custom cell. Returning `null` falls through to
156
+ // the default renderer. The `ctx` object exposes:
157
+ // refreshRow() — force a re-render (the wider list refreshes
158
+ // when the row's items change, e.g. type swap).
159
+ // Use cases: agent params' distribution-dict button cell;
160
+ // structured-type modal openers on the default cell.
161
+ cellOverrides = null,
162
+ // R7 — when true, this editor is mounted as a *variation* of the
163
+ // parent archetype (not on the archetype itself). Rows where
164
+ // the parent has `force=true` lock their editable cells; the
165
+ // `enabled/force/optional` flag columns themselves stay
166
+ // read-only (variations don't author flags, only the
167
+ // archetype does).
168
+ variationMode = false,
169
+ } = options;
170
+
171
+ // Lookup map for inheritance classification.
172
+ const parentByName = parentAttributes
173
+ ? new Map(parentAttributes.map((p) => [p.name, p]))
174
+ : null;
175
+
176
+ if (!hostEl) {
177
+ return { refresh() {}, addItem() {}, dispose() {} };
178
+ }
179
+
180
+ hostEl.classList.add('twm-attr-editor');
181
+ hostEl.style.setProperty('--cols',
182
+ showColumns.map((c) => COLUMN_TEMPLATES[c]?.width || '1fr').join(' '));
183
+
184
+ const refsCache = new Map();
185
+ const resolveRefOptions = async (target) => {
186
+ if (refsCache.has(target)) return refsCache.get(target);
187
+ const resolver = refResolvers[`ref:${target}`]
188
+ || refResolvers[`list[ref:${target}]`];
189
+ let list = [];
190
+ if (typeof resolver === 'function') {
191
+ try { list = await resolver() || []; }
192
+ catch (e) { console.warn('ref resolver failed', target, e); }
193
+ }
194
+ const normalised = (list || []).map((o) => {
195
+ if (typeof o === 'string') return { id: o, label: o };
196
+ return { id: o.id ?? o.value ?? '', label: o.label ?? o.id ?? '' };
197
+ }).filter((o) => o.id);
198
+ refsCache.set(target, normalised);
199
+ return normalised;
200
+ };
201
+
202
+ // Header strip — mimics the SortableList row layout exactly so
203
+ // header cells align with body cells. Row shape is:
204
+ // [drag-handle] [content-with-cols-grid] [remove-button]
205
+ // The header reuses `.sortable-list__row` chrome with placeholder
206
+ // spans for the handle / remove gutters, then the inner content
207
+ // div carries the same `--cols` grid as each row's content div.
208
+ const header = document.createElement('div');
209
+ header.className = 'twm-sortable-list__row twm-attr-editor__header-row';
210
+ if (allowReorder) {
211
+ const handlePh = document.createElement('span');
212
+ handlePh.className = 'twm-sortable-list__handle twm-attr-editor__header-placeholder';
213
+ header.appendChild(handlePh);
214
+ }
215
+ const headerContent = document.createElement('div');
216
+ headerContent.className = 'twm-sortable-list__content twm-attr-editor__headers';
217
+ for (const col of showColumns) {
218
+ const span = document.createElement('span');
219
+ span.textContent = COLUMN_TEMPLATES[col]?.label ?? col;
220
+ headerContent.appendChild(span);
221
+ }
222
+ header.appendChild(headerContent);
223
+ if (allowRemove) {
224
+ const removePh = document.createElement('span');
225
+ removePh.className = 'twm-sortable-list__remove twm-attr-editor__header-placeholder';
226
+ header.appendChild(removePh);
227
+ }
228
+ hostEl.appendChild(header);
229
+
230
+ const listHost = document.createElement('div');
231
+ listHost.className = 'ea-attr-editor__rows';
232
+ hostEl.appendChild(listHost);
233
+
234
+ let list = null;
235
+
236
+ const renderRow = (contentEl, item, _idx, callbacks) => {
237
+ contentEl.classList.add('twm-attr-editor__row');
238
+ // Layer 8.J1 — paint inheritance state.
239
+ const cls = _classifyRow(item, parentByName);
240
+ contentEl.classList.remove(
241
+ 'twm-attr-editor__row--inherited',
242
+ 'twm-attr-editor__row--overridden',
243
+ 'twm-attr-editor__row--neutral',
244
+ );
245
+ contentEl.classList.add(`twm-attr-editor__row--${cls}`);
246
+
247
+ // R7 variation-override flags. `enabled` defaults true, so the
248
+ // disabled treatment only applies when explicitly false.
249
+ // `force` locks the row (variations can't override). `optional`
250
+ // signals the value is a suggestion — green border, no fill.
251
+ // The flag rules are applied additively so the inheritance
252
+ // classification's background still shows where applicable.
253
+ // In variation mode the relevant flags are the parent's; in
254
+ // archetype mode they're the row's own. (A variation row only
255
+ // has the flags it inherited; it doesn't author its own.)
256
+ const parentRow = parentByName?.get(item.name);
257
+ const flagSource = variationMode && parentRow ? parentRow : item;
258
+ contentEl.classList.toggle(
259
+ 'twm-attr-editor__row--disabled', flagSource.enabled === false);
260
+ contentEl.classList.toggle(
261
+ 'twm-attr-editor__row--forced', flagSource.force === true);
262
+ contentEl.classList.toggle(
263
+ 'twm-attr-editor__row--optional', flagSource.optional === true);
264
+
265
+ // Attention indicator on overridden rows + revert button.
266
+ if (cls === 'overridden') {
267
+ const attention = document.createElement('span');
268
+ attention.className = 'twm-attr-editor__attention material-symbols-outlined';
269
+ attention.textContent = 'fiber_manual_record';
270
+ attention.title = 'Value overrides the parent default';
271
+ contentEl.appendChild(attention);
272
+ }
273
+
274
+ for (const col of showColumns) {
275
+ const cell = renderCell(col, item, callbacks);
276
+ contentEl.appendChild(cell);
277
+ }
278
+ // R7 — lock the row's editable inputs when the parent forced it.
279
+ // The flag columns themselves stay enabled on the archetype
280
+ // (where the author sets them); in variation mode they're
281
+ // read-only because variations don't author flags.
282
+ const FLAG_COLS = new Set(['enabled', 'force', 'optional']);
283
+ if (variationMode && flagSource.force === true) {
284
+ contentEl.querySelectorAll(
285
+ 'input:not([type="checkbox"]), select, textarea'
286
+ ).forEach((el) => { el.disabled = true; });
287
+ }
288
+ if (variationMode) {
289
+ contentEl.querySelectorAll(
290
+ 'input[type="checkbox"].ea-attr-editor__cell--flag'
291
+ ).forEach((el) => { el.disabled = true; });
292
+ }
293
+ void FLAG_COLS; // referenced by future per-column handling
294
+
295
+ // Revert button — only rendered for overridden rows. Resets
296
+ // `value` back to the archetype's inherited default. The row
297
+ // re-classifies as 'inherited' on the next render pass.
298
+ if (cls === 'overridden' && parentByName) {
299
+ const p = parentByName.get(item.name);
300
+ const parentDefault = (p?.value !== undefined && p?.value !== null)
301
+ ? p.value : p?.default;
302
+ const revert = document.createElement('button');
303
+ revert.type = 'button';
304
+ revert.className = 'twm-attr-editor__revert material-symbols-outlined';
305
+ revert.textContent = 'undo';
306
+ revert.title =
307
+ `Revert to inherited default (${parentDefault ?? '—'})`;
308
+ revert.addEventListener('click', (e) => {
309
+ e.stopPropagation();
310
+ callbacks.update({ value: parentDefault });
311
+ onFlush();
312
+ // Force a re-render so the row re-classifies.
313
+ list?.setItems(currentItems());
314
+ });
315
+ contentEl.appendChild(revert);
316
+ }
317
+ };
318
+
319
+ const renderCell = (col, item, callbacks) => {
320
+ // Caller override wins — used by agent_tab to inject the
321
+ // distribution-dict button + structured-type modal openers.
322
+ if (cellOverrides && typeof cellOverrides[col] === 'function') {
323
+ const ctx = { refreshRow: () => list?.setItems(currentItems()) };
324
+ const custom = cellOverrides[col](item, callbacks, ctx);
325
+ if (custom) return custom;
326
+ }
327
+ if (col === 'name') return renderTextCell(item, 'name', callbacks, 'attr_name');
328
+ if (col === 'type') return renderTypeCell(item, callbacks);
329
+ if (col === 'description') return renderTextCell(item, 'description', callbacks, 'optional');
330
+ if (col === 'required') return renderBoolCell(item, 'required', callbacks);
331
+ if (col === 'readonly') return renderBoolCell(item, 'readonly', callbacks);
332
+ // R7 — three variation-override flags. `enabled` defaults true,
333
+ // so the checkbox renders checked when the field is undefined;
334
+ // the others default false.
335
+ if (col === 'enabled') return renderFlagCell(item, 'enabled', callbacks, true);
336
+ if (col === 'force') return renderFlagCell(item, 'force', callbacks, false);
337
+ if (col === 'optional') return renderFlagCell(item, 'optional', callbacks, false);
338
+ if (col === 'expression') return renderTextCell(item, 'expression', callbacks, 'expr');
339
+ if (col === 'min') return renderNumericCell(item, 'min', callbacks);
340
+ if (col === 'max') return renderNumericCell(item, 'max', callbacks);
341
+ if (col === 'step') return renderTextCell(item, 'step', callbacks, 'any');
342
+ if (col === 'unit') return renderTextCell(item, 'unit', callbacks, 'USD/%/…');
343
+ if (col === 'distribution') return renderDistributionCell(item, callbacks);
344
+ if (col === 'default' || col === 'value') return renderTypedValueCell(col, item, callbacks);
345
+ // Unknown column — render a stub.
346
+ const stub = document.createElement('span');
347
+ stub.textContent = '';
348
+ return stub;
349
+ };
350
+
351
+ const renderNumericCell = (item, field, callbacks) => {
352
+ const el = document.createElement('input');
353
+ el.type = 'number';
354
+ el.className = 'twm-sortable-list__input twm-attr-editor__cell';
355
+ el.step = 'any';
356
+ el.value = (item[field] === null || item[field] === undefined)
357
+ ? '' : String(item[field]);
358
+ el.placeholder = '—';
359
+ el.addEventListener('input', () => {
360
+ const v = el.value === '' ? null : Number(el.value);
361
+ callbacks.update({ [field]: v });
362
+ });
363
+ el.addEventListener('blur', () => onFlush());
364
+ return el;
365
+ };
366
+
367
+ const renderDistributionCell = (item, callbacks) => {
368
+ const el = document.createElement('select');
369
+ el.className = 'twm-attr-editor__cell';
370
+ for (const d of DISTRIBUTION_OPTIONS) {
371
+ const o = document.createElement('option');
372
+ o.value = d; o.textContent = d || '(none)';
373
+ if ((item.distribution || '') === d) o.selected = true;
374
+ el.appendChild(o);
375
+ }
376
+ el.addEventListener('change', () => {
377
+ callbacks.update({ distribution: el.value || null });
378
+ onFlush();
379
+ });
380
+ return el;
381
+ };
382
+
383
+ const renderTextCell = (item, field, callbacks, placeholder) => {
384
+ const el = document.createElement('input');
385
+ el.type = 'text';
386
+ el.className = 'twm-sortable-list__input twm-attr-editor__cell';
387
+ el.value = item[field] ?? '';
388
+ el.placeholder = placeholder;
389
+ el.addEventListener('input', () => {
390
+ callbacks.update({ [field]: el.value });
391
+ });
392
+ el.addEventListener('blur', () => onFlush());
393
+ return el;
394
+ };
395
+
396
+ const renderBoolCell = (item, field, callbacks) => {
397
+ const el = document.createElement('input');
398
+ el.type = 'checkbox';
399
+ el.className = 'twm-attr-editor__cell';
400
+ el.checked = !!item[field];
401
+ el.addEventListener('change', () => {
402
+ callbacks.update({ [field]: el.checked });
403
+ onFlush();
404
+ });
405
+ return el;
406
+ };
407
+
408
+ /** Boolean cell for R7 flags. Same as renderBoolCell but treats an
409
+ * undefined value as `defaultIfMissing`, so `enabled` (default
410
+ * true) renders checked on legacy rows that predate the flag. */
411
+ const renderFlagCell = (item, field, callbacks, defaultIfMissing) => {
412
+ const el = document.createElement('input');
413
+ el.type = 'checkbox';
414
+ el.className = 'twm-attr-editor__cell ea-attr-editor__cell--flag';
415
+ const cur = item[field];
416
+ el.checked = (cur === undefined || cur === null)
417
+ ? !!defaultIfMissing : !!cur;
418
+ el.addEventListener('change', () => {
419
+ callbacks.update({ [field]: el.checked });
420
+ onFlush();
421
+ });
422
+ return el;
423
+ };
424
+
425
+ const renderTypeCell = (item, callbacks) => {
426
+ const el = document.createElement('select');
427
+ el.className = 'twm-attr-editor__cell';
428
+ // Group scalar, expression, ref, list[ref] for readability.
429
+ // Any type the caller hands us that doesn't fit one of those
430
+ // buckets lands in `other` — that's how param-mode types
431
+ // (float / int / str / bool / choice / map / schedule /
432
+ // string_list) appear without breaking the dispatch.
433
+ const seen = new Set();
434
+ const addOpts = (label, optList) => {
435
+ if (optList.length === 0) return;
436
+ const grp = document.createElement('optgroup');
437
+ grp.label = label;
438
+ for (const t of optList) {
439
+ const o = document.createElement('option');
440
+ o.value = t; o.textContent = t;
441
+ if (t === item.type) o.selected = true;
442
+ grp.appendChild(o);
443
+ seen.add(t);
444
+ }
445
+ el.appendChild(grp);
446
+ };
447
+ addOpts('scalar', types.filter((t) => SCALAR_TYPES.includes(t)));
448
+ addOpts('expression', types.filter((t) => t === 'expression'));
449
+ addOpts('typed reference', types.filter((t) => t.startsWith('ref:')));
450
+ addOpts('list of references',
451
+ types.filter((t) => t.startsWith('list[ref:')));
452
+ addOpts('other', types.filter((t) => !seen.has(t)));
453
+ el.addEventListener('change', () => {
454
+ // On type change, leave default/value as-is — the user
455
+ // may want to keep e.g. a numeric value when switching
456
+ // from number to integer. The typed value cell renderer
457
+ // coerces on display.
458
+ callbacks.update({ type: el.value });
459
+ onFlush();
460
+ // Force re-render so the value cell picks up the new
461
+ // input type (number → select, etc.).
462
+ list?.setItems(currentItems());
463
+ });
464
+ return el;
465
+ };
466
+
467
+ const renderTypedValueCell = (field, item, callbacks) => {
468
+ const t = item.type || 'string';
469
+ if (t === 'boolean') {
470
+ return renderBoolCell(item, field, callbacks);
471
+ }
472
+ if (t === 'integer' || t === 'number') {
473
+ const el = document.createElement('input');
474
+ el.type = 'number';
475
+ el.className = 'twm-sortable-list__input twm-attr-editor__cell';
476
+ el.step = (t === 'integer') ? '1' : 'any';
477
+ el.value = (item[field] === null || item[field] === undefined)
478
+ ? '' : item[field];
479
+ el.placeholder = field;
480
+ el.addEventListener('input', () => {
481
+ const v = el.value === '' ? null
482
+ : (t === 'integer' ? parseInt(el.value, 10)
483
+ : Number(el.value));
484
+ callbacks.update({ [field]: v });
485
+ });
486
+ el.addEventListener('blur', () => onFlush());
487
+ return el;
488
+ }
489
+ if (t === 'expression') {
490
+ // For an expression-typed attribute, the value cell shows
491
+ // the expression itself (read-only by convention — the
492
+ // engine fills it). Stub: render the expression string;
493
+ // the actual expression authoring lives in the
494
+ // `expression` column when shown.
495
+ const el = document.createElement('span');
496
+ el.className = 'twm-attr-editor__cell twm-attr-editor__cell--readonly';
497
+ el.textContent = item.expression
498
+ ? `→ ${item.expression}` : '— (engine-evaluated)';
499
+ return el;
500
+ }
501
+ if (t.startsWith('ref:')) {
502
+ return renderRefCell(t.slice(4), field, item, callbacks);
503
+ }
504
+ if (t.startsWith('list[ref:') && t.endsWith(']')) {
505
+ return renderListRefCell(t.slice(9, -1), field, item, callbacks);
506
+ }
507
+ if (Array.isArray(item.options) && item.options.length > 0) {
508
+ return renderEnumCell(item.options, field, item, callbacks);
509
+ }
510
+ // String fallback.
511
+ const el = document.createElement('input');
512
+ el.type = 'text';
513
+ el.className = 'twm-sortable-list__input twm-attr-editor__cell';
514
+ el.value = item[field] ?? '';
515
+ el.placeholder = field;
516
+ el.addEventListener('input', () => {
517
+ callbacks.update({ [field]: el.value });
518
+ });
519
+ el.addEventListener('blur', () => onFlush());
520
+ return el;
521
+ };
522
+
523
+ const renderRefCell = (target, field, item, callbacks) => {
524
+ const el = document.createElement('select');
525
+ el.className = 'twm-attr-editor__cell';
526
+ const placeholder = document.createElement('option');
527
+ placeholder.value = '';
528
+ placeholder.textContent = `— pick ${target} —`;
529
+ el.appendChild(placeholder);
530
+ // Render once with the cached options if we have them; resolve
531
+ // async otherwise and patch.
532
+ const populate = (opts) => {
533
+ // Clear everything except the placeholder.
534
+ while (el.childNodes.length > 1) el.removeChild(el.lastChild);
535
+ for (const o of opts) {
536
+ const node = document.createElement('option');
537
+ node.value = o.id;
538
+ node.textContent = o.label;
539
+ if (o.id === item[field]) node.selected = true;
540
+ el.appendChild(node);
541
+ }
542
+ };
543
+ if (refsCache.has(target)) {
544
+ populate(refsCache.get(target));
545
+ } else {
546
+ resolveRefOptions(target).then(populate);
547
+ }
548
+ el.addEventListener('change', () => {
549
+ callbacks.update({ [field]: el.value || null });
550
+ onFlush();
551
+ });
552
+ return el;
553
+ };
554
+
555
+ const renderListRefCell = (target, field, item, callbacks) => {
556
+ // MVP: comma-separated text input over the underlying string-
557
+ // list. The chip-style picker can replace this without
558
+ // changing the on-disk shape.
559
+ const el = document.createElement('input');
560
+ el.type = 'text';
561
+ el.className = 'twm-sortable-list__input twm-attr-editor__cell';
562
+ const arr = Array.isArray(item[field]) ? item[field] : [];
563
+ el.value = arr.join(', ');
564
+ el.placeholder = `${target}, ${target}, …`;
565
+ el.addEventListener('input', () => {
566
+ const next = el.value.split(',')
567
+ .map((s) => s.trim()).filter(Boolean);
568
+ callbacks.update({ [field]: next });
569
+ });
570
+ el.addEventListener('blur', () => onFlush());
571
+ return el;
572
+ };
573
+
574
+ const renderEnumCell = (options, field, item, callbacks) => {
575
+ // `options` may be either a flat list of strings (legacy:
576
+ // `['fx', 'commodity', …]`) or a list of `{value, label}`
577
+ // pairs (newer callers that want display labels distinct from
578
+ // the stored value, e.g. market-rules ref fields where the
579
+ // value is a currency id and the label is the currency name).
580
+ const el = document.createElement('select');
581
+ el.className = 'twm-attr-editor__cell';
582
+ // Empty placeholder so the user can clear the selection.
583
+ const blank = document.createElement('option');
584
+ blank.value = '';
585
+ blank.textContent = '—';
586
+ if (item[field] === null || item[field] === undefined || item[field] === '') {
587
+ blank.selected = true;
588
+ }
589
+ el.appendChild(blank);
590
+ for (const opt of options) {
591
+ const o = document.createElement('option');
592
+ if (opt !== null && typeof opt === 'object') {
593
+ o.value = String(opt.value ?? opt.id ?? '');
594
+ o.textContent = String(opt.label ?? opt.value ?? opt.id ?? '');
595
+ } else {
596
+ o.value = String(opt);
597
+ o.textContent = String(opt);
598
+ }
599
+ if (o.value === String(item[field] ?? '')) o.selected = true;
600
+ el.appendChild(o);
601
+ }
602
+ el.addEventListener('change', () => {
603
+ callbacks.update({ [field]: el.value === '' ? null : el.value });
604
+ onFlush();
605
+ });
606
+ return el;
607
+ };
608
+
609
+ // ─── SortableList wiring ────────────────────────────────────────
610
+
611
+ const itemsCopy = (src) => (src || []).map((it) => ({ ...it }));
612
+ const currentItems = () => list?.getItems?.() || itemsCopy(items);
613
+
614
+ const buildList = (current) => {
615
+ try { list?.dispose?.(); } catch {}
616
+ listHost.innerHTML = '';
617
+ list = new SortableList({
618
+ containerId,
619
+ items: itemsCopy(current),
620
+ allowReorder,
621
+ allowRemove,
622
+ minItems: 0,
623
+ emptyMessage,
624
+ addButtonText,
625
+ createItem,
626
+ renderItem: renderRow,
627
+ onChange: (next) => onChange(next.map((it) => ({ ...it }))),
628
+ });
629
+ listHost.appendChild(list.render());
630
+ };
631
+
632
+ buildList(items);
633
+
634
+ return {
635
+ addItem() { list?.addItem(); },
636
+ refresh(nextItems) {
637
+ // External update: rebuild to pick up new type-cell shapes.
638
+ // Cheaper than setItems when types changed; setItems
639
+ // wouldn't re-render the row controls.
640
+ buildList(nextItems !== undefined ? nextItems : currentItems());
641
+ },
642
+ items() {
643
+ return currentItems();
644
+ },
645
+ dispose() {
646
+ try { list?.dispose?.(); } catch {}
647
+ list = null;
648
+ hostEl.innerHTML = '';
649
+ hostEl.classList.remove('twm-attr-editor');
650
+ },
651
+ };
652
+ }