flexdesk 0.2.0 → 0.4.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 (58) hide show
  1. package/README.md +26 -0
  2. package/css/base.css +2243 -872
  3. package/css/flexdesk.css +1375 -14
  4. package/css/overrides.css +44 -0
  5. package/css/tokens.css +45 -0
  6. package/dist/charts.js +5 -3
  7. package/dist/charts.js.map +1 -1
  8. package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
  9. package/dist/chunk-ELXVW542.js.map +7 -0
  10. package/dist/chunk-LH5TSOZW.js +1237 -0
  11. package/dist/chunk-LH5TSOZW.js.map +7 -0
  12. package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
  13. package/dist/chunk-O5OHMWBB.js.map +7 -0
  14. package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
  15. package/dist/chunk-QIU5S2RU.js.map +7 -0
  16. package/dist/chunk-QNQHQ24V.js +408 -0
  17. package/dist/chunk-QNQHQ24V.js.map +7 -0
  18. package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
  19. package/dist/chunk-XKDTIT4Q.js.map +7 -0
  20. package/dist/editor.js +3 -380
  21. package/dist/editor.js.map +3 -3
  22. package/dist/flexdesk.css +1375 -14
  23. package/dist/tiles.js +168 -41
  24. package/dist/tiles.js.map +2 -2
  25. package/dist/tokens.css +45 -0
  26. package/dist/widgets.js +44 -14
  27. package/dist/widgets.js.map +2 -2
  28. package/dist/wm.js +3140 -157
  29. package/dist/wm.js.map +4 -4
  30. package/package.json +3 -2
  31. package/src/charts/chart_types.js +167 -0
  32. package/src/charts/plotly_wrapper.js +178 -10
  33. package/src/editor/notebook_tab_bar.js +39 -3
  34. package/src/tiles/tile_base.js +143 -35
  35. package/src/tiles/tile_grid.js +52 -1
  36. package/src/tiling/command_palette.js +71 -18
  37. package/src/tiling/desktops.js +36 -12
  38. package/src/tiling/keymap.js +24 -4
  39. package/src/tiling/shell.js +156 -25
  40. package/src/tiling/tab_strip.js +184 -0
  41. package/src/tiling/tile_breadcrumb.js +34 -2
  42. package/src/tiling/tile_renderer.js +1386 -21
  43. package/src/tiling/tile_tab_menu.js +101 -0
  44. package/src/tiling/tile_tree.js +115 -11
  45. package/src/tiling/wm.js +2375 -84
  46. package/src/tiling/zoom.js +248 -0
  47. package/src/ui/components/action_dropdown.js +34 -3
  48. package/src/ui/components/autocomplete_field.js +65 -13
  49. package/src/ui/components/context_menu.js +79 -8
  50. package/src/ui/components/data_table.js +508 -84
  51. package/src/ui/components/managed_window.js +928 -36
  52. package/src/ui/components/modal.js +214 -8
  53. package/dist/chunk-CT4YXXLP.js.map +0 -7
  54. package/dist/chunk-DRYCDMEG.js.map +0 -7
  55. package/dist/chunk-DVU44T77.js.map +0 -7
  56. package/dist/chunk-TLZUUFOE.js.map +0 -7
  57. package/dist/chunk-UCJ2WD4D.js +0 -625
  58. package/dist/chunk-UCJ2WD4D.js.map +0 -7
@@ -42,10 +42,25 @@ export class TileBase {
42
42
  * @param {Object} [options.eventBus] - Event bus for cross-component communication
43
43
  * @param {Object} [options.config] - Widget-specific configuration
44
44
  * @param {boolean} [options.headless] - If true, mount without tile chrome (header, controls)
45
+ * @param {{resolve: Function}} [options.dataSource] - Where this tile asks for its
46
+ * own rows. Handed down by `TileGrid`; absent for a grid whose data is
47
+ * broadcast with `setData()`, and then nothing ever calls `loadData()`.
45
48
  */
46
49
  constructor({ id, grid, eventBus = null, config = {}, readonly = false,
47
- headless = false, host = null, stateGuard = null }) {
50
+ headless = false, host = null, stateGuard = null,
51
+ dataSource = null }) {
48
52
  this.host = host;
53
+ // THE TILE PULLS; THE GRID DOES NOT PUSH.
54
+ //
55
+ // `TileGrid.setData()` broadcasts one dataset to every tile, which is the
56
+ // right shape when every tile reads the same simulation run and the wrong
57
+ // one when each tile is bound to a different query — the second tile would
58
+ // then have to find its own rows inside a payload it never asked for. A
59
+ // data source is anything answering `resolve(tileId, binding)`; the base
60
+ // class only holds it and hands it to `loadData()`, so a grid constructed
61
+ // without one behaves exactly as it did before this line existed, and the
62
+ // broadcast path is untouched.
63
+ this.dataSource = dataSource;
49
64
  // `window.__ECOSIM_JS_NEW__?.stateGuard` — an application's global, reached for
50
65
  // by a tile. The StateGuard is a FRAMEWORK service (@flexdesk/core); the INSTANCE is
51
66
  // the application's, and it is handed down through the grid. No guard => the
@@ -64,6 +79,12 @@ export class TileBase {
64
79
  /** @type {Object|null} Unfiltered analytics data for cross-namespace variable resolution */
65
80
  this.fullData = null;
66
81
  this._disposed = false;
82
+ // Written by whatever implements `loadData()`. `_loadSeq` is the guard
83
+ // against a slow first request landing after a fast second one and
84
+ // painting stale rows over fresh ones — a tile is re-resolved on every
85
+ // filter change, so that race is the normal case rather than the edge.
86
+ this._lastLoadedAt = null;
87
+ this._loadSeq = 0;
67
88
  /** @type {Map<string, {name: string, analytics: Object, color: string}>|null} */
68
89
  this.comparisonData = null;
69
90
  }
@@ -308,6 +329,33 @@ export class TileBase {
308
329
  }
309
330
  }
310
331
 
332
+ /**
333
+ * Load this tile's own data from `this.dataSource` and render it.
334
+ *
335
+ * A NO-OP HERE ON PURPOSE. The base class knows nothing about what a binding
336
+ * is or what a dataset looks like — those belong to the consumer, and the
337
+ * consumer that has them (Tables' `TablesTile`) implements this in ~30 lines
338
+ * over `showLoading()` / `render()` / `showError()`. What upstream owns is the
339
+ * CALL SITES: `TileGrid` invokes this after mount, after a resize settles and
340
+ * after a config save, but only when a `dataSource` was injected. Making it a
341
+ * hook rather than an implementation is what keeps EcoAgent's broadcast path
342
+ * (`setData` -> `update`) bit-identical: with no data source nothing here ever
343
+ * runs.
344
+ *
345
+ * @param {{force?: boolean}} [_opts] - `force` bypasses the source's cache.
346
+ * @returns {Promise<void>}
347
+ */
348
+ async loadData(_opts = {}) {}
349
+
350
+ /**
351
+ * Called once after a drag-resize settles, for widgets whose content does not
352
+ * reflow on its own — a Plotly figure sizes itself at draw time and stays that
353
+ * size until something calls `Plotly.Plots.resize`. Deliberately NOT wired to a
354
+ * ResizeObserver: the grid already knows when a resize ended, and an observer
355
+ * per tile fires during the drag, which is a relayout per pointer-move.
356
+ */
357
+ onResize() {}
358
+
311
359
  /** Add, update, or remove the info (ⓘ) button in the header to match current docs state. */
312
360
  _syncInfoButton() {
313
361
  const header = this.element?.querySelector('.tile-header');
@@ -367,6 +415,60 @@ export class TileBase {
367
415
  this.fullData = null;
368
416
  }
369
417
 
418
+ /**
419
+ * The entries in the tile's ⋯ menu, as `{action, label, icon, hidden}`.
420
+ *
421
+ * This existed as a LITERAL inside `_buildChrome`, and the literal was
422
+ * EcoAgent's: "Add to Documentation" and "Show in Documentation". Both are
423
+ * meaningful only for a tile whose `config.sourceCellId` names a notebook
424
+ * cell, and the first one's hide condition is `sourceCellId && already-added` —
425
+ * so on a tile that has no `sourceCellId` at all it renders VISIBLE, and
426
+ * choosing it emits `tile:add-to-documentation` at an application that has no
427
+ * documentation. Every consumer other than EcoAgent therefore shipped a menu
428
+ * item that did nothing, and could not remove it without editing this file.
429
+ *
430
+ * Returning `[]` drops the whole wrapper — button, dropdown and all — because
431
+ * a ⋯ button that opens an empty menu reads as a broken feature rather than
432
+ * an absent one. The default is the two entries above, so EcoAgent's chrome
433
+ * is unchanged.
434
+ *
435
+ * Selection is routed through `_onMenuAction()`; override both together.
436
+ * @returns {Array<{action: string, label: string, icon?: string, hidden?: boolean}>}
437
+ */
438
+ menuItems() {
439
+ const linked = !!(this.config?.sourceCellId
440
+ && this.grid?.documentationCellIds?.has(this.config.sourceCellId));
441
+ return [
442
+ { action: 'add-to-documentation',
443
+ label: 'Add to Documentation', icon: 'post_add', hidden: linked },
444
+ { action: 'show-in-documentation',
445
+ label: 'Show in Documentation', icon: 'description', hidden: !linked },
446
+ ];
447
+ }
448
+
449
+ /**
450
+ * Act on a ⋯ menu selection. One dispatch point for every entry, so an
451
+ * override of `menuItems()` cannot add a row that has nothing behind it, and
452
+ * an unrecognised action is silently ignored rather than throwing into a
453
+ * click handler.
454
+ * @param {string} action
455
+ * @protected
456
+ */
457
+ _onMenuAction(action) {
458
+ if (action === 'add-to-documentation') {
459
+ this.eventBus?.emit?.('tile:add-to-documentation', {
460
+ tileType: this.constructor.TYPE,
461
+ config: { ...this.config },
462
+ });
463
+ } else if (action === 'show-in-documentation') {
464
+ if (this.config?.sourceCellId) {
465
+ this.eventBus?.emit?.('tile:show-in-documentation', {
466
+ sourceCellId: this.config.sourceCellId,
467
+ });
468
+ }
469
+ }
470
+ }
471
+
370
472
  /**
371
473
  * Build the tile chrome (header, content area, resize handles).
372
474
  * @private
@@ -397,6 +499,24 @@ export class TileBase {
397
499
  </button>`
398
500
  : '';
399
501
 
502
+ // The ⋯ menu, from `menuItems()` rather than from a literal. Read-only
503
+ // chrome never had one, so the hook is not even asked in that mode.
504
+ const menuEntries = (this.readonly ? [] : this.menuItems()) ?? [];
505
+ const menuHtml = menuEntries.length
506
+ ? `<div class="tile-menu-wrapper">
507
+ <button class="tile-menu-btn twm-has-tooltip" data-tooltip="More actions">
508
+ <span class="material-symbols-outlined">more_vert</span>
509
+ </button>
510
+ <div class="tile-menu-dropdown" hidden>
511
+ ${menuEntries.map((item) => `
512
+ <button class="tile-menu-item" data-action="${this.#escapeAttr(item.action)}"${item.hidden ? ' style="display:none"' : ''}>
513
+ ${item.icon ? `<span class="material-symbols-outlined">${this.#escapeAttr(item.icon)}</span>` : ''}
514
+ <span>${this.#escapeAttr(item.label ?? '')}</span>
515
+ </button>`).join('')}
516
+ </div>
517
+ </div>`
518
+ : '';
519
+
400
520
  if (this.readonly) {
401
521
  // Read-only mode: no drag handle, no menu, no remove button
402
522
  header.innerHTML = `
@@ -416,21 +536,7 @@ export class TileBase {
416
536
  <button class="tile-config-btn twm-has-tooltip" data-tooltip="Configure">
417
537
  <span class="material-symbols-outlined">settings</span>
418
538
  </button>
419
- <div class="tile-menu-wrapper">
420
- <button class="tile-menu-btn twm-has-tooltip" data-tooltip="More actions">
421
- <span class="material-symbols-outlined">more_vert</span>
422
- </button>
423
- <div class="tile-menu-dropdown" hidden>
424
- <button class="tile-menu-item" data-action="add-to-documentation" style="${this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId) ? 'display:none' : ''}">
425
- <span class="material-symbols-outlined">post_add</span>
426
- <span>Add to Documentation</span>
427
- </button>
428
- <button class="tile-menu-item" data-action="show-in-documentation" style="${this.config?.sourceCellId && this.grid?.documentationCellIds?.has(this.config.sourceCellId) ? '' : 'display:none'}">
429
- <span class="material-symbols-outlined">description</span>
430
- <span>Show in Documentation</span>
431
- </button>
432
- </div>
433
- </div>
539
+ ${menuHtml}
434
540
  ${infoBtnHtml}
435
541
  ${expandBtnHtml}
436
542
  <button class="tile-remove-btn twm-has-tooltip" data-tooltip="Remove widget">
@@ -485,23 +591,17 @@ export class TileBase {
485
591
  });
486
592
  }
487
593
  });
488
- menuDropdown.querySelector('[data-action="add-to-documentation"]')?.addEventListener('click', (e) => {
489
- e.stopPropagation();
490
- menuDropdown.hidden = true;
491
- this.eventBus?.emit?.('tile:add-to-documentation', {
492
- tileType: this.constructor.TYPE,
493
- config: { ...this.config },
594
+ // One listener per rendered entry, dispatching by `data-action`
595
+ // into `_onMenuAction`. Wiring each action by name here is what
596
+ // made the menu un-overridable: a subclass could add a row and
597
+ // could not add the handler behind it.
598
+ menuDropdown.querySelectorAll('.tile-menu-item').forEach((itemEl) => {
599
+ itemEl.addEventListener('click', (e) => {
600
+ e.stopPropagation();
601
+ menuDropdown.hidden = true;
602
+ this._onMenuAction(itemEl.dataset.action);
494
603
  });
495
604
  });
496
- menuDropdown.querySelector('[data-action="show-in-documentation"]')?.addEventListener('click', (e) => {
497
- e.stopPropagation();
498
- menuDropdown.hidden = true;
499
- if (this.config?.sourceCellId) {
500
- this.eventBus?.emit?.('tile:show-in-documentation', {
501
- sourceCellId: this.config.sourceCellId,
502
- });
503
- }
504
- });
505
605
  }
506
606
 
507
607
  const removeBtn = header.querySelector('.tile-remove-btn');
@@ -619,6 +719,13 @@ export class TileBase {
619
719
 
620
720
  /**
621
721
  * Show empty state in content area.
722
+ *
723
+ * The message is ESCAPED. Both this and `showError` interpolated straight
724
+ * into `innerHTML`, and the string reaching `showError` is the one place a
725
+ * caller has least control over — a server's error text, echoed back with a
726
+ * column name or a filter value in it. Callers pass plain text; this decides
727
+ * what that means in HTML.
728
+ *
622
729
  * @param {string} [message] - Custom message
623
730
  */
624
731
  showEmpty(message = 'No data available') {
@@ -626,14 +733,14 @@ export class TileBase {
626
733
  this.contentElement.innerHTML = `
627
734
  <div class="tile-empty">
628
735
  <span class="material-symbols-outlined">inbox</span>
629
- <span>${message}</span>
736
+ <span>${this.#escapeAttr(message)}</span>
630
737
  </div>
631
738
  `;
632
739
  }
633
740
  }
634
741
 
635
742
  /**
636
- * Show error state in content area.
743
+ * Show error state in content area. The message is escaped — see `showEmpty`.
637
744
  * @param {string} [message] - Error message
638
745
  */
639
746
  showError(message = 'Failed to load data') {
@@ -641,7 +748,7 @@ export class TileBase {
641
748
  this.contentElement.innerHTML = `
642
749
  <div class="twm-tile-error">
643
750
  <span class="material-symbols-outlined">error</span>
644
- <span>${message}</span>
751
+ <span>${this.#escapeAttr(message)}</span>
645
752
  </div>
646
753
  `;
647
754
  }
@@ -760,7 +867,8 @@ export class TileBase {
760
867
  }
761
868
 
762
869
  /**
763
- * Escape a string for safe insertion into an HTML attribute.
870
+ * Escape a string for safe insertion into HTML — an attribute value or a text
871
+ * node, since the five characters that matter are the same five in both.
764
872
  * @private
765
873
  */
766
874
  #escapeAttr(s) {
@@ -21,6 +21,9 @@ export class TileGrid {
21
21
  * @param {Function} [options.onAddWidget] - Callback for add widget action
22
22
  * @param {Function} [options.onResetLayout] - Callback for reset layout action
23
23
  * @param {Function} [options.onExport] - Callback for export action
24
+ * @param {{resolve: Function}} [options.dataSource] - Per-tile data provider,
25
+ * handed to every tile. With none, the grid is the broadcast-only grid
26
+ * it has always been and no tile is ever asked to load anything.
24
27
  */
25
28
  constructor({ container, columns = 12, rowHeight = 80, gap = 16, eventBus = null,
26
29
  showToolbar = true, readonly = false, onLayoutChange, onBeforeLayoutChange,
@@ -29,9 +32,13 @@ export class TileGrid {
29
32
  // ui/js/ecoagent/ to back Expand -> Export CSV/PNG; a grid that has
30
33
  // no host simply gets the Blob-download fallback the host contract
31
34
  // promises. It is not an error to have none.
32
- host = null, stateGuard = null }) {
35
+ host = null, stateGuard = null,
36
+ // Injected, never constructed here: the grid does not know what
37
+ // a binding is, only that a tile may have somewhere to ask.
38
+ dataSource = null }) {
33
39
  this.host = host;
34
40
  this.stateGuard = stateGuard;
41
+ this.dataSource = dataSource;
35
42
  this.container = container;
36
43
  this.columns = columns;
37
44
  this.rowHeight = rowHeight;
@@ -181,6 +188,7 @@ export class TileGrid {
181
188
  readonly: this.readonly,
182
189
  host: this.host,
183
190
  stateGuard: this.stateGuard,
191
+ dataSource: this.dataSource,
184
192
  });
185
193
 
186
194
  if (!tile) return null;
@@ -211,6 +219,14 @@ export class TileGrid {
211
219
  if (this.data) {
212
220
  tile.fullData = this.fullData;
213
221
  tile.update(this.data);
222
+ } else if (this.dataSource) {
223
+ // A tile that fetches its own rows is never handed any, so the branch
224
+ // above cannot fire for it and it would mount empty and stay empty
225
+ // until something else happened to it. NOT awaited: `setLayout` calls
226
+ // `addTile` in a loop, and awaiting here would serialise a board into
227
+ // one request per tile in sequence — the opposite of what a batching
228
+ // data source exists to do.
229
+ tile.loadData?.();
214
230
  }
215
231
 
216
232
  // Emit layout changed
@@ -248,6 +264,31 @@ export class TileGrid {
248
264
  });
249
265
  }
250
266
 
267
+ /**
268
+ * Ask every self-fetching tile to load again, bypassing whatever the data
269
+ * source has cached.
270
+ *
271
+ * The counterpart of `setData` for the pull model: `setData` is how a caller
272
+ * with one dataset for the whole board pushes it, and this is how a caller
273
+ * whose tiles each own a query says "that underlying data changed". Passing
274
+ * `ids` narrows it to the tiles a change actually touched, which is what an
275
+ * invalidation carrying a table id can work out and a broadcast cannot.
276
+ *
277
+ * Not awaited and returns nothing: the tiles render themselves as their own
278
+ * requests land, and a caller that waited for all of them would be waiting on
279
+ * the slowest tile to show the fastest one.
280
+ *
281
+ * @param {string[]|Set<string>|null} [ids] - Tile ids, or null for all.
282
+ */
283
+ reloadAll(ids = null) {
284
+ if (!this.dataSource) return;
285
+ const wanted = ids ? new Set(ids) : null;
286
+ this.tiles.forEach((tile, tileId) => {
287
+ if (wanted && !wanted.has(tileId)) return;
288
+ tile.loadData?.({ force: true });
289
+ });
290
+ }
291
+
251
292
  /**
252
293
  * Get current layout.
253
294
  * @returns {Array} Layout array
@@ -560,6 +601,12 @@ export class TileGrid {
560
601
  if (this.data) {
561
602
  state.tile.fullData = this.fullData;
562
603
  state.tile.update(this.data);
604
+ } else if (this.dataSource) {
605
+ // Re-rendering from a dataset already in hand is free; asking
606
+ // the server again because a tile got 40 px wider is not. A
607
+ // self-fetching tile is told the geometry settled and decides
608
+ // what that costs it — for a chart, one `Plotly.Plots.resize`.
609
+ state.tile.onResize?.();
563
610
  }
564
611
  }
565
612
  }
@@ -771,6 +818,10 @@ export class TileGrid {
771
818
  if (tile) {
772
819
  tile.fullData = this.fullData;
773
820
  tile.update(this.data, newConfig);
821
+ // A saved config is usually a changed BINDING, and the data
822
+ // source's cache is keyed by the old one — so this is the one
823
+ // reload that must not be served from it.
824
+ if (this.dataSource) tile.loadData?.({ force: true });
774
825
  // Update layout config
775
826
  const layoutItem = this.layout.find(l => l.id === tileId);
776
827
  if (layoutItem) {
@@ -33,8 +33,13 @@ const STATIC_COMMANDS = [
33
33
  /** @param taxonomy injected ontology — supplies the chip strip + row icons
34
34
  * @param catalog injected entity catalog — supplies the searchable rows
35
35
  * @param placeholder input placeholder. It names the embedder's entity types
36
- * ("try foo:bar"), so the embedder owns the string. */
37
- export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = 'Search…' }) {
36
+ * ("try foo:bar"), so the embedder owns the string.
37
+ * @param onPick C30. `(pick) => truthy` CLAIMS the open, exactly the way
38
+ * `ManagedWindow.onMaximize` claims the maximise gesture.
39
+ * See `commit` for what it is for; omitted, the palette
40
+ * behaves as it always has. */
41
+ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = 'Search…',
42
+ onPick = null }) {
38
43
  if (!taxonomy) throw new Error('createCommandPalette: a taxonomy is required');
39
44
  if (!catalog) throw new Error('createCommandPalette: an entity catalog is required');
40
45
  let overlay = null;
@@ -53,7 +58,7 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
53
58
  overlay = document.createElement('div');
54
59
  overlay.id = ROOT_ID;
55
60
  overlay.className = 'twm-cmdpal-overlay';
56
- overlay.innerHTML = _markup(taxonomy, placeholder);
61
+ overlay.innerHTML = _markup(taxonomy, placeholder, wm);
57
62
  document.body.appendChild(overlay);
58
63
  overlay.addEventListener('click', (e) => {
59
64
  if (e.target === overlay) close();
@@ -143,11 +148,50 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
143
148
  el.addEventListener('click', () => commit(Number(el.dataset.idx)));
144
149
  });
145
150
  };
151
+ /**
152
+ * ══ C30. THE PALETTE IS A DOOR, NOT A PLACEMENT POLICY ══════════
153
+ *
154
+ * `openInPrimary` is the wrong verb for a picked ENTITY and it was the
155
+ * only one here. It RESETS the primary leaf to a single fresh tab —
156
+ * its own doc says so, and says why: navigation from outside the tile
157
+ * is a page change, and a page change replaces the page. So picking a
158
+ * table from Ctrl+K threw away every other tab in that leaf, ignored
159
+ * whatever rule the embedder has about where its entities open (a tab
160
+ * beside its siblings, a floating window on a canvas pane, a split),
161
+ * and bypassed any per-mount-site bookkeeping the embedder keeps —
162
+ * for Tables, the grid registry keyed on `(mount site, table)`, which
163
+ * is what holds staged edits across a re-render.
164
+ *
165
+ * The result was a door that did something different from every other
166
+ * door onto the same object: the rail opened a table one way, the
167
+ * palette another, and only the palette destroyed work. Reported as
168
+ * "open table via Ctrl+K does not work" — which is exactly what it
169
+ * looks like from the outside when the tab you were in is replaced.
170
+ *
171
+ * The library cannot decide this. It does not know what a `table` is,
172
+ * which is the whole point of the injected taxonomy and catalogue. So
173
+ * the embedder is offered the pick and may CLAIM it, on the same
174
+ * contract as `ManagedWindow.onMaximize` (`managed_window.js:230`):
175
+ * return truthy and nothing else happens. Guarded, because a throwing
176
+ * embedder must not leave the palette closed over a half-done open —
177
+ * and unclaimed picks still land in the primary tile, so no existing
178
+ * embedder changes behaviour by upgrading.
179
+ *
180
+ * `pick` is the catalogue row verbatim (`{kind, id, label, hint,
181
+ * props?}`) rather than a re-shaped argument: the embedder wrote the
182
+ * `shape` that produced it, so it is the one party that can read it.
183
+ */
146
184
  const commit = (i) => {
147
185
  const pick = filtered[i];
148
186
  if (!pick) return;
149
187
  close();
150
188
  if (pick.action) { try { pick.action(); } catch (err) { console.error(err); } return; }
189
+ if (onPick) {
190
+ let handled = false;
191
+ try { handled = onPick(pick) ?? false; }
192
+ catch (err) { console.error('[cmdpal] onPick threw', err); }
193
+ if (handled) return;
194
+ }
151
195
  wm.openInPrimary(pick.kind, pick.props || { id: pick.id, label: pick.label });
152
196
  };
153
197
 
@@ -240,7 +284,29 @@ export function createCommandPalette({ wm, api, taxonomy, catalog, placeholder =
240
284
  return { open, close, toggle, isOpen };
241
285
  }
242
286
 
243
- function _markup(taxonomy, placeholder) {
287
+ function _markup(taxonomy, placeholder, wm) {
288
+ // ONLY THE PANELS THIS SHELL CAN ACTUALLY SHOW.
289
+ //
290
+ // The three toggles were hardcoded, so the palette offered `panel:left` to
291
+ // an embedder that has no factory for it — and `togglePanel` then
292
+ // canonicalized a leaf whose only possible rendering is "no factory
293
+ // registered yet", permanently, because the renderer caches tile DOM per
294
+ // (kind, props). C14 closed that path at boot; this closes the other end of
295
+ // it. A panel with no factory is not a panel this shell can show, and the
296
+ // registry is the thing that knows.
297
+ const PANEL_CHIPS = [
298
+ { side: 'left', icon: 'menu', label: 'Left nav' },
299
+ { side: 'right', icon: 'dock_to_left', label: 'Right panel' },
300
+ { side: 'bottom', icon: 'dock_to_bottom', label: 'Bottom panel' },
301
+ ];
302
+ const panelToggles = PANEL_CHIPS
303
+ .filter((p) => wm.content?.has?.(`panel:${p.side}`) !== false)
304
+ .map((p) => `
305
+ <button class="twm-chip" data-toggle="${p.side}">
306
+ <span class="material-symbols-outlined">${p.icon}</span>
307
+ ${p.label}
308
+ </button>`).join('');
309
+
244
310
  const chips = taxonomy.topNavEntries().map((k) => `
245
311
  <button class="twm-chip" data-shortcut="${k.kind}">
246
312
  <span class="material-symbols-outlined">${k.icon}</span>
@@ -256,20 +322,7 @@ function _markup(taxonomy, placeholder) {
256
322
  autocomplete="off" />
257
323
  </div>
258
324
  <div class="twm-cmdpal__chips">${chips}</div>
259
- <div class="twm-cmdpal__toggles">
260
- <button class="twm-chip" data-toggle="left">
261
- <span class="material-symbols-outlined">menu</span>
262
- Left nav
263
- </button>
264
- <button class="twm-chip" data-toggle="right">
265
- <span class="material-symbols-outlined">dock_to_left</span>
266
- Right panel
267
- </button>
268
- <button class="twm-chip" data-toggle="bottom">
269
- <span class="material-symbols-outlined">dock_to_bottom</span>
270
- Bottom panel
271
- </button>
272
- </div>
325
+ <div class="twm-cmdpal__toggles">${panelToggles}</div>
273
326
  <div class="twm-cmdpal__list" data-role="list"></div>
274
327
  <div class="twm-cmdpal__footer">
275
328
  <span><kbd>↑</kbd><kbd>↓</kbd> navigate</span>
@@ -10,6 +10,16 @@
10
10
 
11
11
  import { TileTree, makeLeaf } from './tile_tree.js';
12
12
 
13
+ /** What a FRESH desktop's panel tiles start as, when the embedder says nothing.
14
+ *
15
+ * C14. It is a DEFAULT, not a constant. An embedder whose navigator and
16
+ * inspector are its own chrome OUTSIDE the WM root — an icon rail, say — has
17
+ * no `panel:left` factory to mount, and every desktop it creates would open
18
+ * with two "no factory registered yet" placeholders wedged either side of its
19
+ * content. It cannot fix that after the fact: `wm.load()` canonicalizes the
20
+ * panels in before it returns, and the renderer caches tile DOM per
21
+ * (kind, props). The only place the answer can be given is here, before the
22
+ * first desktop exists — which is what `panelDefaults` is for. */
13
23
  const DEFAULT_PANEL_STATE = {
14
24
  left: true,
15
25
  right: true,
@@ -25,7 +35,7 @@ const DEFAULT_PANEL_STATE = {
25
35
  * deliberately NO default: a silent 'home' fallback is the exact bug this
26
36
  * parameter exists to remove.
27
37
  */
28
- function _makeDesktop(label, seed) {
38
+ function _makeDesktop(label, seed, panelDefaults = DEFAULT_PANEL_STATE) {
29
39
  const tree = new TileTree();
30
40
  tree.setRoot(makeLeaf(seed()));
31
41
  return {
@@ -33,20 +43,28 @@ function _makeDesktop(label, seed) {
33
43
  label,
34
44
  tree,
35
45
  windows: [],
36
- // boot default: left + right + bottom all open. Names are
37
- // assigned by wm._canonicalize via PANEL_TITLES.
38
- panels: { ...DEFAULT_PANEL_STATE },
46
+ // Boot default: whatever the embedder asked for, left + right + bottom
47
+ // when it asked for nothing. Names are assigned by wm._canonicalize via
48
+ // PANEL_TITLES.
49
+ panels: { ...panelDefaults },
39
50
  };
40
51
  }
41
52
 
42
53
  export class DesktopManager {
43
- /** @param {{seed: () => object}} opts `seed` builds the root leaf. Required. */
44
- constructor({ seed } = {}) {
54
+ /**
55
+ * @param {object} opts
56
+ * @param {function} opts.seed builds the root leaf. Required.
57
+ * @param {object} [opts.panelDefaults] C14. Which panel tiles a fresh
58
+ * desktop opens with, merged over `DEFAULT_PANEL_STATE`. Omitted, every
59
+ * desktop opens with all three — today's behaviour, unchanged.
60
+ */
61
+ constructor({ seed, panelDefaults = null } = {}) {
45
62
  if (typeof seed !== 'function') {
46
63
  throw new Error('DesktopManager: a `seed` function is required (the taxonomy root leaf)');
47
64
  }
48
65
  this.seed = seed;
49
- this.desktops = [_makeDesktop('1', seed)];
66
+ this.panelDefaults = { ...DEFAULT_PANEL_STATE, ...(panelDefaults || {}) };
67
+ this.desktops = [_makeDesktop('1', seed, this.panelDefaults)];
50
68
  this.activeIdx = 0;
51
69
  }
52
70
 
@@ -61,12 +79,14 @@ export class DesktopManager {
61
79
 
62
80
  ensureCount(n) {
63
81
  while (this.desktops.length < n) {
64
- this.desktops.push(_makeDesktop(String(this.desktops.length + 1), this.seed));
82
+ this.desktops.push(_makeDesktop(
83
+ String(this.desktops.length + 1), this.seed, this.panelDefaults));
65
84
  }
66
85
  }
67
86
 
68
87
  addDesktop(label = null) {
69
- const d = _makeDesktop(label || String(this.desktops.length + 1), this.seed);
88
+ const d = _makeDesktop(
89
+ label || String(this.desktops.length + 1), this.seed, this.panelDefaults);
70
90
  this.desktops.push(d);
71
91
  return d;
72
92
  }
@@ -84,15 +104,19 @@ export class DesktopManager {
84
104
  };
85
105
  }
86
106
 
87
- static deserialize(blob, { seed } = {}) {
88
- const m = new DesktopManager({ seed });
107
+ static deserialize(blob, { seed, panelDefaults = null } = {}) {
108
+ const m = new DesktopManager({ seed, panelDefaults });
89
109
  if (!blob || !Array.isArray(blob.desktops) || blob.desktops.length === 0) return m;
90
110
  m.desktops = blob.desktops.map((raw) => ({
91
111
  id: raw.id || `desk-${Math.random().toString(36).slice(2,8)}`,
92
112
  label: raw.label || '?',
93
113
  tree: raw.tree ? TileTree.deserialize(raw.tree) : new TileTree(),
94
114
  windows: [],
95
- panels: { ...DEFAULT_PANEL_STATE, ...(raw.panels || {}) },
115
+ // A RESTORED desktop's own answer wins over the default: the user
116
+ // closed that panel, and re-opening it on every reload is the bug
117
+ // this merge order avoids. The default only fills a key the stored
118
+ // blob predates.
119
+ panels: { ...m.panelDefaults, ...(raw.panels || {}) },
96
120
  }));
97
121
  // Empty tree → seed with the taxonomy root so the desktop is usable.
98
122
  for (const d of m.desktops) {
@@ -28,10 +28,20 @@
28
28
  *
29
29
  * The handler refuses to act when focus is in an editable field, unless
30
30
  * the chord uses the Alt modifier (which is never typed into a field).
31
+ *
32
+ * ── It returns a disposer, and that is not decoration ──────────────────
33
+ * The listener is on `document` and is bound to ONE shell's `wm` and
34
+ * `palette`. An embedder that can build a second shell in the same page —
35
+ * Tables rebuilds its whole shell when you switch project, because every open
36
+ * tab names a table by id — would otherwise leave the first one's handler
37
+ * attached for ever. Two handlers is not "twice as responsive": `Ctrl+K`
38
+ * toggles the dead palette open and the live one closed in the same keystroke,
39
+ * and `Alt+W` closes a tile in a tree nobody can see. `createShell().dispose()`
40
+ * calls this.
31
41
  */
32
42
 
33
- export function installKeymap({ wm, palette }) {
34
- document.addEventListener('keydown', (e) => {
43
+ export function installKeymap({ wm, palette, ...opts } = {}) {
44
+ const onKeyDown = (e) => {
35
45
  const inField = e.target?.closest?.(
36
46
  'input, textarea, select, [contenteditable="true"]');
37
47
 
@@ -66,8 +76,15 @@ export function installKeymap({ wm, palette }) {
66
76
  // flows (query editor, filters) keep keyboard focus.
67
77
  const fMatch = /^F([1-9]|1[0-2])$/.exec(e.key);
68
78
  if (fMatch && !inField && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
79
+ // The selector is a CONFIG option, not a constant. An embedder that
80
+ // moves its sections out of the top bar — into an icon rail, say —
81
+ // would otherwise find F1..F8 silently doing nothing: the query
82
+ // returns an empty NodeList, `idx < btns.length` is false, and the
83
+ // handler falls through without a hint that anything is bound.
84
+ // Passing `navSelector` is how such an embedder keeps the keys.
69
85
  const btns = document.querySelectorAll(
70
- '.twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn');
86
+ opts.navSelector
87
+ || '.twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn');
71
88
  const idx = Number(fMatch[1]) - 1;
72
89
  if (idx < btns.length) {
73
90
  e.preventDefault();
@@ -152,5 +169,8 @@ export function installKeymap({ wm, palette }) {
152
169
  if (key.length === 1 && /[a-z]/.test(key) && !inField) {
153
170
  e.preventDefault();
154
171
  }
155
- });
172
+ };
173
+
174
+ document.addEventListener('keydown', onKeyDown);
175
+ return () => document.removeEventListener('keydown', onKeyDown);
156
176
  }