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,634 @@
1
+ /**
2
+ * Panel State Machine
3
+ *
4
+ * Reusable FSM for panel visibility management. Formalizes a four-state
5
+ * model used by the right panel and bottom panel:
6
+ *
7
+ * disabled – Panel hidden, toggle button disabled. Cannot be shown.
8
+ * hidden – Panel hidden, toggle button enabled. User can show it.
9
+ * on-demand – Panel hidden by default, auto-shown by external triggers
10
+ * (e.g. node selection). Toggle button enabled.
11
+ * pinned – Panel always visible. Toggle button shows pinned indicator.
12
+ *
13
+ * Different panels use different subsets of these states:
14
+ * Right panel: disabled ↔ on-demand ↔ pinned (trigger-based visibility)
15
+ * Bottom panel: disabled ↔ hidden ↔ pinned (simple show/hide toggle)
16
+ *
17
+ * Uses the composition pattern (like AiStateMachine): wraps a StateMachine
18
+ * instance in a private field and exposes a panel-domain public API.
19
+ *
20
+ * DOM manipulation is delegated to callback functions passed at construction
21
+ * time, keeping the FSM itself pure and testable.
22
+ *
23
+ * @module ui/controllers/panel_state_machine
24
+ */
25
+
26
+ import { StateMachine } from '../../core/state_machine.js';
27
+
28
+ // ─── Constants ──────────────────────────────────────────────────────────────
29
+
30
+ export const PANEL_STATES = Object.freeze({
31
+ DISABLED: 'disabled',
32
+ HIDDEN: 'hidden',
33
+ ON_DEMAND: 'on-demand',
34
+ PINNED: 'pinned',
35
+ });
36
+
37
+ export const PANEL_TRANSITIONS = Object.freeze({
38
+ ENABLE: 'panel:mode:enable',
39
+ ENABLE_PINNED: 'panel:mode:enable-pinned',
40
+ ENABLE_HIDDEN: 'panel:mode:enable-hidden',
41
+ DISABLE: 'panel:mode:disable',
42
+ PIN: 'panel:pin',
43
+ UNPIN: 'panel:unpin',
44
+ SHOW: 'panel:show',
45
+ HIDE: 'panel:hide',
46
+ });
47
+
48
+
49
+ // ─── State Machine ──────────────────────────────────────────────────────────
50
+
51
+ export class PanelStateMachine {
52
+
53
+ /** @type {StateMachine} */
54
+ #machine;
55
+
56
+ /** @type {import('../../core/event_bus.js').default} */
57
+ #eventBus;
58
+
59
+ /** @type {object|null} */
60
+ #logger;
61
+
62
+ // ── Identity ────────────────────────────────────────────────────────────
63
+ #name;
64
+
65
+ // ── DOM delegation ──────────────────────────────────────────────────────
66
+ #callbacks;
67
+
68
+ // ── Persistence / config ────────────────────────────────────────────────
69
+ #persistenceKey;
70
+ #supportsCollapse = false;
71
+
72
+ // ── Trigger-based visibility (on-demand state) ──────────────────────────
73
+ #triggerCount = 0;
74
+
75
+ // ── Orthogonal state ────────────────────────────────────────────────────
76
+ // null initial value ensures the first #applyVisibility call always fires
77
+ // onShow/onHide to synchronize DOM with FSM state (the DOM starts without
78
+ // any hidden class, so we must not assume it matches #visible = false).
79
+ #visible = null;
80
+ #collapsed = false;
81
+ #height = null;
82
+
83
+ // ── Overrides ───────────────────────────────────────────────────────────
84
+ #overrideHidden = false;
85
+ #widgetConfigActive = false;
86
+
87
+ // ── Persisted pin (read before first enable) ────────────────────────────
88
+ #persistedPinned = false;
89
+
90
+ // ── Config: which state to enter when enable() is called and not pinned ──
91
+ #unpinnedState;
92
+
93
+ /**
94
+ * @param {object} config
95
+ * @param {string} config.name Panel identity ('right', 'bottom', etc.)
96
+ * @param {import('../../core/event_bus.js').default} config.eventBus
97
+ * @param {object} [config.logger]
98
+ * @param {string} config.persistenceKey localStorage key
99
+ * @param {boolean} [config.defaultPinned] Default pin state when no persisted data exists
100
+ * @param {string} [config.unpinnedState] State for enable() when not pinned: 'on-demand' (default) or 'hidden'
101
+ * @param {object} config.callbacks DOM manipulation hooks:
102
+ * @param {Function} config.callbacks.onShow (reason) => void
103
+ * @param {Function} config.callbacks.onHide (reason) => void
104
+ * @param {Function} config.callbacks.onDisable () => void
105
+ * @param {Function} config.callbacks.onEnable () => void
106
+ * @param {Function} [config.callbacks.onPinChanged] (pinned) => void
107
+ * @param {Function} [config.callbacks.onVisibilityChanged] (visible) => void
108
+ * @param {Function} [config.callbacks.onCollapseChanged] (collapsed, height) => void
109
+ */
110
+ constructor(config) {
111
+ this.#name = config.name;
112
+ this.#eventBus = config.eventBus;
113
+ this.#logger = config.logger ?? null;
114
+ this.#callbacks = config.callbacks;
115
+ this.#persistenceKey = config.persistenceKey;
116
+ this.#persistedPinned = config.defaultPinned ?? false;
117
+ this.#unpinnedState = config.unpinnedState ?? PANEL_STATES.ON_DEMAND;
118
+
119
+ const self = this;
120
+
121
+ this.#machine = new StateMachine({
122
+ name: `panel-${config.name}`,
123
+ initialState: PANEL_STATES.DISABLED,
124
+ states: {
125
+ [PANEL_STATES.DISABLED]: {
126
+ onEnter() { self.#onEnterDisabled(); },
127
+ transitions: {
128
+ [PANEL_TRANSITIONS.ENABLE]: { target: PANEL_STATES.ON_DEMAND },
129
+ [PANEL_TRANSITIONS.ENABLE_PINNED]: { target: PANEL_STATES.PINNED },
130
+ [PANEL_TRANSITIONS.ENABLE_HIDDEN]: { target: PANEL_STATES.HIDDEN },
131
+ },
132
+ },
133
+ [PANEL_STATES.HIDDEN]: {
134
+ onEnter() { self.#onEnterHidden(); },
135
+ transitions: {
136
+ [PANEL_TRANSITIONS.DISABLE]: { target: PANEL_STATES.DISABLED },
137
+ [PANEL_TRANSITIONS.SHOW]: { target: PANEL_STATES.PINNED },
138
+ },
139
+ },
140
+ [PANEL_STATES.ON_DEMAND]: {
141
+ onEnter() { self.#onEnterOnDemand(); },
142
+ onExit() { self.#onExitOnDemand(); },
143
+ transitions: {
144
+ [PANEL_TRANSITIONS.DISABLE]: { target: PANEL_STATES.DISABLED },
145
+ [PANEL_TRANSITIONS.PIN]: { target: PANEL_STATES.PINNED },
146
+ },
147
+ },
148
+ [PANEL_STATES.PINNED]: {
149
+ onEnter() { self.#onEnterPinned(); },
150
+ transitions: {
151
+ [PANEL_TRANSITIONS.DISABLE]: { target: PANEL_STATES.DISABLED },
152
+ [PANEL_TRANSITIONS.UNPIN]: { target: PANEL_STATES.ON_DEMAND },
153
+ [PANEL_TRANSITIONS.HIDE]: { target: PANEL_STATES.HIDDEN },
154
+ },
155
+ },
156
+ },
157
+ eventBus: config.eventBus,
158
+ logger: config.logger,
159
+ });
160
+ }
161
+
162
+ // ═══════════════════════════════════════════════════════════════════════
163
+ // Queries
164
+ // ═══════════════════════════════════════════════════════════════════════
165
+
166
+ /** Current FSM state. */
167
+ getState() { return this.#machine.getState(); }
168
+
169
+ /** True when in pinned state. */
170
+ isPinned() { return this.getState() === PANEL_STATES.PINNED; }
171
+
172
+ /** True when in disabled state. */
173
+ isDisabled() { return this.getState() === PANEL_STATES.DISABLED; }
174
+
175
+ /** True when in hidden state (not visible, but toggle enabled). */
176
+ isHidden() { return this.getState() === PANEL_STATES.HIDDEN; }
177
+
178
+ /** True when the panel is currently visible to the user. */
179
+ isVisible() { return this.#visible; }
180
+
181
+ /** True when the panel is collapsed (bottom panel only). */
182
+ isCollapsed() { return this.#collapsed; }
183
+
184
+ /** Current stored height (for collapse restore). */
185
+ getHeight() { return this.#height; }
186
+
187
+ /** Whether widget config override is active. */
188
+ get widgetConfigActive() { return this.#widgetConfigActive; }
189
+
190
+ /** Whether collapse operations are supported (runtime-configurable per mode). */
191
+ get supportsCollapse() { return this.#supportsCollapse; }
192
+ set supportsCollapse(value) { this.#supportsCollapse = Boolean(value); }
193
+
194
+ // ═══════════════════════════════════════════════════════════════════════
195
+ // Mode transitions (called by ApplicationShell._switchMode)
196
+ // ═══════════════════════════════════════════════════════════════════════
197
+
198
+ /**
199
+ * Enable the panel for the current mode.
200
+ *
201
+ * With no argument, transitions from disabled → on-demand, hidden, or
202
+ * pinned based on persisted pin state and unpinnedState config.
203
+ *
204
+ * With an explicit `initialState`, forces that specific target state
205
+ * regardless of persistence. Useful when a mode always needs a specific
206
+ * starting state (e.g. ETL designer always starts as on-demand).
207
+ *
208
+ * If already enabled (hidden, on-demand, or pinned), this is a no-op.
209
+ *
210
+ * Note: getState() can return null during the StateMachine's async init
211
+ * microtask. We treat null the same as disabled — the transition() call
212
+ * internally awaits _ready, so the machine will be in 'disabled' by the
213
+ * time the transition executes.
214
+ *
215
+ * @param {string} [initialState] Force a specific target state: 'on-demand', 'hidden', or 'pinned'
216
+ */
217
+ enable(initialState) {
218
+ const state = this.getState();
219
+ if (state !== PANEL_STATES.DISABLED && state !== null) return;
220
+
221
+ if (initialState === PANEL_STATES.ON_DEMAND) {
222
+ this.#machine.transition(PANEL_TRANSITIONS.ENABLE);
223
+ } else if (initialState === PANEL_STATES.HIDDEN) {
224
+ this.#machine.transition(PANEL_TRANSITIONS.ENABLE_HIDDEN);
225
+ } else if (initialState === PANEL_STATES.PINNED) {
226
+ this.#machine.transition(PANEL_TRANSITIONS.ENABLE_PINNED);
227
+ } else if (this.#persistedPinned) {
228
+ this.#machine.transition(PANEL_TRANSITIONS.ENABLE_PINNED);
229
+ } else if (this.#unpinnedState === PANEL_STATES.HIDDEN) {
230
+ this.#machine.transition(PANEL_TRANSITIONS.ENABLE_HIDDEN);
231
+ } else {
232
+ this.#machine.transition(PANEL_TRANSITIONS.ENABLE);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Disable the panel for the current mode.
238
+ * Transitions from any enabled state → disabled.
239
+ * If already disabled (or null during async init), this is a no-op.
240
+ */
241
+ disable() {
242
+ const state = this.getState();
243
+ if (state === PANEL_STATES.DISABLED || state === null) return;
244
+ this.#machine.transition(PANEL_TRANSITIONS.DISABLE);
245
+ }
246
+
247
+ // ═══════════════════════════════════════════════════════════════════════
248
+ // Pin (called by toggle button click)
249
+ // ═══════════════════════════════════════════════════════════════════════
250
+
251
+ /** Toggle between on-demand and pinned. No-op if disabled. */
252
+ togglePin() {
253
+ const state = this.getState();
254
+ if (state === PANEL_STATES.ON_DEMAND) {
255
+ this.pin();
256
+ } else if (state === PANEL_STATES.PINNED) {
257
+ this.unpin();
258
+ }
259
+ }
260
+
261
+ /** on-demand → pinned. */
262
+ pin() {
263
+ if (this.getState() !== PANEL_STATES.ON_DEMAND) return;
264
+ this.#machine.transition(PANEL_TRANSITIONS.PIN);
265
+ }
266
+
267
+ /** pinned → on-demand. */
268
+ unpin() {
269
+ if (this.getState() !== PANEL_STATES.PINNED) return;
270
+ this.#machine.transition(PANEL_TRANSITIONS.UNPIN);
271
+ }
272
+
273
+ // ═══════════════════════════════════════════════════════════════════════
274
+ // Show / Hide (hidden ↔ pinned, for panels without trigger-based visibility)
275
+ // ═══════════════════════════════════════════════════════════════════════
276
+
277
+ /** hidden → pinned. No-op if not in hidden state. */
278
+ show() {
279
+ if (this.getState() !== PANEL_STATES.HIDDEN) return;
280
+ this.#machine.transition(PANEL_TRANSITIONS.SHOW);
281
+ }
282
+
283
+ /** pinned → hidden. No-op if not in pinned state. */
284
+ hide() {
285
+ if (this.getState() !== PANEL_STATES.PINNED) return;
286
+ this.#machine.transition(PANEL_TRANSITIONS.HIDE);
287
+ }
288
+
289
+ /** Toggle between hidden and pinned. No-op if disabled or on-demand. */
290
+ toggleShow() {
291
+ const state = this.getState();
292
+ if (state === PANEL_STATES.HIDDEN) {
293
+ this.show();
294
+ } else if (state === PANEL_STATES.PINNED) {
295
+ this.hide();
296
+ }
297
+ }
298
+
299
+ // ═══════════════════════════════════════════════════════════════════════
300
+ // Smart toggle (toolbar click handler)
301
+ // ═══════════════════════════════════════════════════════════════════════
302
+
303
+ /**
304
+ * The default action for a toolbar toggle click.
305
+ * Delegates to the appropriate operation based on FSM state and config:
306
+ * - Pinned + supportsCollapse → toggleCollapse
307
+ * - On-demand or pinned (no collapse) → togglePin
308
+ * - Hidden → show
309
+ */
310
+ toggle() {
311
+ const state = this.getState();
312
+ if (state === PANEL_STATES.DISABLED) return;
313
+ if (this.#supportsCollapse && state === PANEL_STATES.PINNED) {
314
+ this.toggleCollapse();
315
+ } else if (state === PANEL_STATES.HIDDEN) {
316
+ this.show();
317
+ } else {
318
+ this.togglePin();
319
+ }
320
+ }
321
+
322
+ // ═══════════════════════════════════════════════════════════════════════
323
+ // On-demand triggers
324
+ // ═══════════════════════════════════════════════════════════════════════
325
+
326
+ /**
327
+ * Increment or decrement the trigger count and sync visibility.
328
+ * In on-demand state, the panel is visible when triggerCount > 0.
329
+ * In pinned state, triggers are tracked but don't affect visibility.
330
+ * In disabled state, triggers are tracked but have no effect.
331
+ */
332
+ setTriggerActive(active) {
333
+ if (active) {
334
+ this.#triggerCount++;
335
+ } else {
336
+ this.#triggerCount = Math.max(0, this.#triggerCount - 1);
337
+ }
338
+ this.#syncVisibility('trigger');
339
+ }
340
+
341
+ /** Reset all triggers to 0 and sync visibility. */
342
+ clearTriggers() {
343
+ this.#triggerCount = 0;
344
+ this.#syncVisibility('triggers-cleared');
345
+ }
346
+
347
+ // ═══════════════════════════════════════════════════════════════════════
348
+ // Re-apply (mode switch support)
349
+ // ═══════════════════════════════════════════════════════════════════════
350
+
351
+ /**
352
+ * Force re-apply DOM callbacks from the current FSM state.
353
+ *
354
+ * Use after mode switches where external code changed the DOM (swapped
355
+ * panel content, cleared tabs) while the FSM remained in an enabled
356
+ * state. The cached `#visible` flag is invalidated so `#applyVisibility`
357
+ * is guaranteed to fire `onShow`/`onHide` + `onCollapseChanged`.
358
+ *
359
+ * No-op when the FSM is disabled — disable() handles its own cleanup.
360
+ */
361
+ reapply() {
362
+ if (this.isDisabled()) return;
363
+ this.#visible = !this.#visible;
364
+ this.#syncVisibility('reapply');
365
+ }
366
+
367
+ // ═══════════════════════════════════════════════════════════════════════
368
+ // Collapse (bottom panel only)
369
+ // ═══════════════════════════════════════════════════════════════════════
370
+
371
+ collapse() {
372
+ if (!this.#supportsCollapse || !this.isVisible() || this.#collapsed) return;
373
+ this.#collapsed = true;
374
+ this.#callbacks.onCollapseChanged?.(true, this.#height);
375
+ this.#emitBus(`panel:${this.#name}:collapse:changed`, { collapsed: true });
376
+ this.#persist();
377
+ }
378
+
379
+ expand() {
380
+ if (!this.#supportsCollapse || !this.isVisible() || !this.#collapsed) return;
381
+ this.#collapsed = false;
382
+ this.#callbacks.onCollapseChanged?.(false, this.#height);
383
+ this.#emitBus(`panel:${this.#name}:collapse:changed`, { collapsed: false });
384
+ this.#persist();
385
+ }
386
+
387
+ toggleCollapse() {
388
+ if (this.#collapsed) {
389
+ this.expand();
390
+ } else {
391
+ this.collapse();
392
+ }
393
+ }
394
+
395
+ /** Store the last expanded height (called during resize or before collapse). */
396
+ setHeight(px) {
397
+ if (typeof px === 'number' && px > 40) {
398
+ this.#height = px;
399
+ this.#persist();
400
+ }
401
+ }
402
+
403
+ // ═══════════════════════════════════════════════════════════════════════
404
+ // Overrides
405
+ // ═══════════════════════════════════════════════════════════════════════
406
+
407
+ /**
408
+ * Force-hide the panel regardless of FSM state (welcome screen).
409
+ * When lifted, visibility re-derives from current state + triggers.
410
+ */
411
+ setOverrideHidden(hidden) {
412
+ this.#overrideHidden = hidden;
413
+ if (hidden) {
414
+ this.#applyVisibility(false, 'override-hidden');
415
+ this.#callbacks.onDisable?.();
416
+ } else {
417
+ // Re-derive: if not disabled, re-enable toggle
418
+ if (!this.isDisabled()) {
419
+ this.#callbacks.onEnable?.();
420
+ }
421
+ this.#syncVisibility('override-lifted');
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Widget config special case (right panel).
427
+ * When active, the toggle button stays enabled even in disabled state
428
+ * so the user can click it to close the config panel.
429
+ */
430
+ setWidgetConfigActive(active) {
431
+ this.#widgetConfigActive = active;
432
+ // In disabled state, temporarily enable/disable the toggle
433
+ if (this.isDisabled()) {
434
+ if (active) {
435
+ this.#callbacks.onEnable?.();
436
+ } else {
437
+ this.#callbacks.onDisable?.();
438
+ }
439
+ }
440
+ this.#callbacks.onVisibilityChanged?.(this.#visible);
441
+ }
442
+
443
+ // ═══════════════════════════════════════════════════════════════════════
444
+ // Persistence
445
+ // ═══════════════════════════════════════════════════════════════════════
446
+
447
+ /**
448
+ * Read persisted state from localStorage and apply it.
449
+ * Must be called after construction, before the first enable().
450
+ */
451
+ hydrate() {
452
+ try {
453
+ if (typeof localStorage === 'undefined') return;
454
+ const raw = localStorage.getItem(this.#persistenceKey);
455
+ if (raw == null) return;
456
+ const parsed = JSON.parse(raw);
457
+ if (!parsed || typeof parsed !== 'object') return;
458
+
459
+ if (typeof parsed.pinned === 'boolean') {
460
+ this.#persistedPinned = parsed.pinned;
461
+ }
462
+ // Always restore collapse/height — supportsCollapse is set per mode
463
+ // and may not be configured yet at hydrate() time.
464
+ if (typeof parsed.collapsed === 'boolean') {
465
+ this.#collapsed = parsed.collapsed;
466
+ }
467
+ if (typeof parsed.height === 'number' && parsed.height > 40) {
468
+ this.#height = parsed.height;
469
+ }
470
+ this.#log('debug', 'Hydrated persisted state', {
471
+ pinned: this.#persistedPinned,
472
+ collapsed: this.#collapsed,
473
+ height: this.#height,
474
+ });
475
+ } catch (err) {
476
+ this.#log('warn', 'Failed to hydrate persisted state', { error: err });
477
+ }
478
+ }
479
+
480
+ // ═══════════════════════════════════════════════════════════════════════
481
+ // Lifecycle
482
+ // ═══════════════════════════════════════════════════════════════════════
483
+
484
+ dispose() {
485
+ // StateMachine doesn't hold subscriptions, but future-proof
486
+ this.#machine = null;
487
+ this.#eventBus = null;
488
+ this.#callbacks = null;
489
+ }
490
+
491
+ // ═══════════════════════════════════════════════════════════════════════
492
+ // Private: state entry/exit hooks
493
+ // ═══════════════════════════════════════════════════════════════════════
494
+
495
+ #onEnterDisabled() {
496
+ this.#applyVisibility(false, 'disabled');
497
+ this.#callbacks.onDisable?.();
498
+ this.#callbacks.onPinChanged?.(false);
499
+ this.#emitStateChanged();
500
+ }
501
+
502
+ #onEnterHidden() {
503
+ this.#callbacks.onEnable?.();
504
+ this.#callbacks.onPinChanged?.(false);
505
+ this.#applyVisibility(false, 'hidden');
506
+ this.#persistedPinned = false;
507
+ this.#persist();
508
+ this.#emitStateChanged();
509
+ }
510
+
511
+ #onEnterOnDemand() {
512
+ this.#callbacks.onEnable?.();
513
+ this.#callbacks.onPinChanged?.(false);
514
+ this.#persistedPinned = false;
515
+ this.#persist();
516
+ // Derive visibility from current trigger count
517
+ this.#syncVisibility('on-demand-entered');
518
+ this.#emitStateChanged();
519
+ }
520
+
521
+ #onExitOnDemand() {
522
+ // Nothing to clean up — triggers are preserved across states
523
+ }
524
+
525
+ #onEnterPinned() {
526
+ this.#callbacks.onEnable?.();
527
+ this.#callbacks.onPinChanged?.(true);
528
+ this.#persistedPinned = true;
529
+ this.#persist();
530
+ // Pinned = always visible (unless override).
531
+ // #applyVisibility handles onShow → onVisibilityChanged → onCollapseChanged.
532
+ this.#applyVisibility(true, 'pinned');
533
+ this.#emitStateChanged();
534
+ }
535
+
536
+ // ═══════════════════════════════════════════════════════════════════════
537
+ // Private: visibility logic
538
+ // ═══════════════════════════════════════════════════════════════════════
539
+
540
+ /**
541
+ * Derive whether the panel should be visible based on FSM state, triggers,
542
+ * and overrides. Call the appropriate show/hide callback if changed.
543
+ */
544
+ #syncVisibility(reason) {
545
+ const state = this.getState();
546
+ let shouldBeVisible;
547
+
548
+ if (state === PANEL_STATES.DISABLED || state === PANEL_STATES.HIDDEN) {
549
+ shouldBeVisible = false;
550
+ } else if (state === PANEL_STATES.PINNED) {
551
+ shouldBeVisible = true;
552
+ } else {
553
+ // on-demand: visible when triggers are active
554
+ shouldBeVisible = this.#triggerCount > 0;
555
+ }
556
+
557
+ // Override takes precedence
558
+ if (this.#overrideHidden) {
559
+ shouldBeVisible = false;
560
+ }
561
+
562
+ this.#applyVisibility(shouldBeVisible, reason);
563
+ }
564
+
565
+ /**
566
+ * Apply visibility change if it differs from current state.
567
+ *
568
+ * Callback ordering matters:
569
+ * 1. onShow/onHide — basic DOM setup/teardown
570
+ * 2. onVisibilityChanged — toggle button active state (active = visible)
571
+ * 3. onCollapseChanged — override toggle active if collapsed (active = !collapsed)
572
+ *
573
+ * This ensures collapse-aware panels (flow mode) get the correct toggle state
574
+ * while trigger-based panels (ETL) where supportsCollapse=false just use visibility.
575
+ */
576
+ #applyVisibility(visible, reason) {
577
+ if (visible === this.#visible) return;
578
+ this.#visible = visible;
579
+
580
+ if (visible) {
581
+ this.#callbacks.onShow?.(reason);
582
+ } else {
583
+ this.#callbacks.onHide?.(reason);
584
+ }
585
+
586
+ this.#callbacks.onVisibilityChanged?.(visible);
587
+
588
+ // Restore collapse state when showing (fires after onVisibilityChanged
589
+ // so it can override the toggle active state for collapsed panels).
590
+ if (visible && this.#supportsCollapse) {
591
+ this.#callbacks.onCollapseChanged?.(this.#collapsed, this.#height);
592
+ }
593
+
594
+ this.#emitBus(`panel:${this.#name}:visibility:changed`, { visible, reason });
595
+ this.#log('debug', `Panel ${visible ? 'shown' : 'hidden'}`, { reason });
596
+ }
597
+
598
+ // ═══════════════════════════════════════════════════════════════════════
599
+ // Private: persistence
600
+ // ═══════════════════════════════════════════════════════════════════════
601
+
602
+ #persist() {
603
+ try {
604
+ if (typeof localStorage === 'undefined') return;
605
+ const payload = {
606
+ pinned: this.#persistedPinned,
607
+ collapsed: this.#collapsed,
608
+ height: this.#height,
609
+ };
610
+ localStorage.setItem(this.#persistenceKey, JSON.stringify(payload));
611
+ } catch (err) {
612
+ this.#log('warn', 'Failed to persist panel state', { error: err });
613
+ }
614
+ }
615
+
616
+ // ═══════════════════════════════════════════════════════════════════════
617
+ // Private: EventBus + logging
618
+ // ═══════════════════════════════════════════════════════════════════════
619
+
620
+ #emitStateChanged() {
621
+ const state = this.getState();
622
+ const previous = this.#machine.previousState;
623
+ this.#emitBus(`panel:${this.#name}:state:changed`, { state, previousState: previous });
624
+ }
625
+
626
+ #emitBus(event, detail) {
627
+ try { this.#eventBus?.emit?.(event, detail); }
628
+ catch { /* swallow event handler errors */ }
629
+ }
630
+
631
+ #log(level, msg, data) {
632
+ this.#logger?.[level]?.('panel-fsm', `[${this.#name}] ${msg}`, data);
633
+ }
634
+ }