flexdesk 0.16.0 → 0.17.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 (251) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2261 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3373 -238
  7. package/css/overrides.css +16 -0
  8. package/css/reset.css +0 -0
  9. package/css/tokens.css +0 -0
  10. package/dist/canvas.js +6 -239
  11. package/dist/canvas.js.map +3 -3
  12. package/dist/charts.js +709 -18
  13. package/dist/charts.js.map +4 -4
  14. package/dist/chunk-3PHCPZHT.js +0 -0
  15. package/dist/chunk-3PHCPZHT.js.map +0 -0
  16. package/dist/chunk-4A7SJC3T.js +503 -0
  17. package/dist/chunk-4A7SJC3T.js.map +7 -0
  18. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  19. package/dist/chunk-CVMCZZX2.js.map +7 -0
  20. package/dist/chunk-D3COLZZ2.js +1311 -0
  21. package/dist/chunk-D3COLZZ2.js.map +7 -0
  22. package/dist/chunk-FOOS3T5L.js +0 -0
  23. package/dist/chunk-FOOS3T5L.js.map +0 -0
  24. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  25. package/dist/chunk-GOQLR5WR.js.map +7 -0
  26. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  27. package/dist/chunk-HNU6MM2B.js.map +7 -0
  28. package/dist/chunk-HYJYYEPK.js +375 -0
  29. package/dist/chunk-HYJYYEPK.js.map +7 -0
  30. package/dist/chunk-JKZLNKG6.js +2041 -0
  31. package/dist/chunk-JKZLNKG6.js.map +7 -0
  32. package/dist/chunk-JYWURG5T.js +0 -0
  33. package/dist/chunk-JYWURG5T.js.map +0 -0
  34. package/dist/{chunk-RQM5Z2MU.js → chunk-MCIXFNVF.js} +633 -311
  35. package/dist/chunk-MCIXFNVF.js.map +7 -0
  36. package/dist/chunk-N7HC3ADV.js +30 -0
  37. package/dist/chunk-N7HC3ADV.js.map +7 -0
  38. package/dist/{chunk-FBGP5KN5.js → chunk-Q36EPT2R.js} +3268 -203
  39. package/dist/chunk-Q36EPT2R.js.map +7 -0
  40. package/dist/chunk-QWQJUDPD.js +610 -0
  41. package/dist/chunk-QWQJUDPD.js.map +7 -0
  42. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  43. package/dist/chunk-UURZ37SD.js.map +7 -0
  44. package/dist/chunk-UWO43BZU.js +111 -0
  45. package/dist/chunk-UWO43BZU.js.map +7 -0
  46. package/dist/chunk-WVFGV5FT.js +0 -0
  47. package/dist/chunk-WVFGV5FT.js.map +0 -0
  48. package/dist/core.js +170 -118
  49. package/dist/core.js.map +2 -2
  50. package/dist/editor.js +156 -70
  51. package/dist/editor.js.map +2 -2
  52. package/dist/flexdesk.css +3373 -238
  53. package/dist/flow.js +2789 -306
  54. package/dist/flow.js.map +4 -4
  55. package/dist/host.js +109 -3
  56. package/dist/host.js.map +3 -3
  57. package/dist/reset.css +0 -0
  58. package/dist/tile_registry-6WZPTUZV.js +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  60. package/dist/tiles.js +1131 -253
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3254 -2112
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -345
  66. package/dist/wm.js.map +4 -4
  67. package/package.json +3 -1
  68. package/src/canvas/canvas_adapter.js +5 -0
  69. package/src/canvas/edge_router.js +0 -0
  70. package/src/canvas/graph/connector_router.js +0 -0
  71. package/src/canvas/graph/history_service.js +0 -0
  72. package/src/canvas/graph/node_platform.js +0 -0
  73. package/src/canvas/index.js +1 -0
  74. package/src/canvas/layered_layout.js +244 -0
  75. package/src/charts/chart_types.js +0 -0
  76. package/src/charts/data_series_plot_window.js +0 -0
  77. package/src/charts/downsample.js +2 -2
  78. package/src/charts/plot_config.js +53 -29
  79. package/src/charts/plot_config_editor.js +627 -0
  80. package/src/charts/plot_grid.js +449 -0
  81. package/src/charts/plot_popout_window.js +103 -317
  82. package/src/charts/plotly_wrapper.js +22 -2
  83. package/src/charts/series_transforms.js +206 -0
  84. package/src/core/event_bus.js +0 -0
  85. package/src/core/logging.js +1 -1
  86. package/src/core/settings.js +105 -19
  87. package/src/core/state_guard.js +93 -51
  88. package/src/core/state_guard_config.js +85 -74
  89. package/src/core/state_machine.js +0 -0
  90. package/src/editor/editor_pane.js +3 -3
  91. package/src/editor/monaco_editor_factory.js +124 -60
  92. package/src/editor/monaco_loader.js +103 -28
  93. package/src/editor/notebook_search.js +0 -0
  94. package/src/editor/notebook_tab_bar.js +38 -52
  95. package/src/editor/notebook_undo_manager.js +0 -0
  96. package/src/editor/split_pane_container.js +6 -3
  97. package/src/flow/canvas_editor/connections.js +90 -0
  98. package/src/flow/canvas_editor/editor.js +2351 -153
  99. package/src/flow/canvas_editor/index.js +2 -1
  100. package/src/flow/canvas_editor/layout.js +119 -12
  101. package/src/flow/canvas_editor/strings.js +80 -0
  102. package/src/flow/kit/always_before.js +0 -0
  103. package/src/flow/kit/catalogue.js +97 -3
  104. package/src/flow/kit/chip_input.js +0 -0
  105. package/src/flow/kit/dom.js +0 -0
  106. package/src/flow/kit/findings.js +0 -0
  107. package/src/flow/kit/history.js +0 -0
  108. package/src/flow/kit/index.js +3 -3
  109. package/src/flow/kit/keys.js +7 -1
  110. package/src/flow/kit/logic_graph.js +97 -15
  111. package/src/flow/kit/references.js +0 -0
  112. package/src/flow/kit/settings_panel.js +0 -0
  113. package/src/flow/kit/settings_schema.js +2 -1
  114. package/src/flow/kit/step_picker.js +0 -0
  115. package/src/flow/kit/strings.js +3 -1
  116. package/src/flow/kit/value_picker.js +0 -0
  117. package/src/flow/kit/widgets.js +86 -109
  118. package/src/flow/lanes/editor.js +136 -14
  119. package/src/flow/lanes/index.js +3 -1
  120. package/src/flow/lanes/layout.js +0 -0
  121. package/src/flow/lanes/pipeline.js +44 -2
  122. package/src/flow/lanes/preview.js +0 -0
  123. package/src/flow/lanes/strings.js +1 -0
  124. package/src/flow/outline/editor.js +0 -0
  125. package/src/flow/outline/index.js +0 -0
  126. package/src/flow/outline/mapping.js +0 -0
  127. package/src/flow/outline/menu.js +0 -0
  128. package/src/flow/outline/operations.js +0 -0
  129. package/src/flow/outline/recognise.js +0 -0
  130. package/src/flow/outline/strings.js +0 -0
  131. package/src/help/help_modal.js +168 -42
  132. package/src/help/help_registry.js +12 -1
  133. package/src/host/external_links.js +0 -0
  134. package/src/host/file_picker.js +117 -0
  135. package/src/host/host.js +10 -2
  136. package/src/host/photino_host.js +9 -0
  137. package/src/host/photino_host.md +21 -3
  138. package/src/host/pywebview_host.js +10 -0
  139. package/src/tiles/chart_tiles.js +53 -245
  140. package/src/tiles/config_schema.js +8 -0
  141. package/src/tiles/figure_tile.js +322 -0
  142. package/src/tiles/layout_persistence.js +3 -3
  143. package/src/tiles/list_tiles.js +422 -0
  144. package/src/tiles/scope_picker.js +96 -0
  145. package/src/tiles/tile_base.js +12 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +38 -1
  148. package/src/tiles/tile_registry.js +0 -0
  149. package/src/tiling/bottom_panel_tabs.js +67 -99
  150. package/src/tiling/command_palette.js +64 -19
  151. package/src/tiling/content_registry.js +4 -1
  152. package/src/tiling/desktops.js +11 -1
  153. package/src/tiling/entity_sources.js +0 -0
  154. package/src/tiling/keymap.js +403 -128
  155. package/src/tiling/kind_taxonomy.js +78 -0
  156. package/src/tiling/landing_table.js +0 -0
  157. package/src/tiling/loading_overlay.js +0 -0
  158. package/src/tiling/nav_panel.js +59 -10
  159. package/src/tiling/page_factory.js +5 -5
  160. package/src/tiling/panel_availability.js +49 -0
  161. package/src/tiling/panel_keys.js +0 -0
  162. package/src/tiling/shell.js +114 -12
  163. package/src/tiling/tab_strip.js +0 -0
  164. package/src/tiling/tile_breadcrumb.js +0 -0
  165. package/src/tiling/tile_renderer.js +105 -9
  166. package/src/tiling/tile_tab_menu.js +0 -0
  167. package/src/tiling/tile_tree.js +0 -0
  168. package/src/tiling/wm.js +562 -30
  169. package/src/tiling/zoom.js +0 -0
  170. package/src/ui/base/component_base.js +0 -0
  171. package/src/ui/base/controller_base.js +0 -0
  172. package/src/ui/base/page_base.js +0 -0
  173. package/src/ui/components/about_dialog.js +0 -0
  174. package/src/ui/components/action_dropdown.js +30 -2
  175. package/src/ui/components/alert_log.js +0 -0
  176. package/src/ui/components/alerts_pane.js +0 -0
  177. package/src/ui/components/attribute_list_editor.js +0 -0
  178. package/src/ui/components/autocomplete_field.js +0 -0
  179. package/src/ui/components/cell_editor_host.js +211 -0
  180. package/src/ui/components/cell_grid.js +648 -0
  181. package/src/ui/components/cell_selection.js +220 -0
  182. package/src/ui/components/choice_controls.js +351 -0
  183. package/src/ui/components/color_field.js +211 -0
  184. package/src/ui/components/color_list_field.js +210 -0
  185. package/src/ui/components/column_fit.js +0 -0
  186. package/src/ui/components/column_groups.js +314 -0
  187. package/src/ui/components/column_order.js +142 -0
  188. package/src/ui/components/computing_status_window.js +98 -25
  189. package/src/ui/components/confirm_dialog.js +81 -38
  190. package/src/ui/components/context_menu.js +2 -2
  191. package/src/ui/components/conversation_view.js +440 -0
  192. package/src/ui/components/crosstab.js +289 -0
  193. package/src/ui/components/data_table.js +601 -62
  194. package/src/ui/components/detail_header.js +22 -11
  195. package/src/ui/components/document_outline.js +397 -0
  196. package/src/ui/components/drag_reorder.js +0 -0
  197. package/src/ui/components/fact_table.js +0 -0
  198. package/src/ui/components/fill_handle.js +322 -0
  199. package/src/ui/components/gallery_picker.js +19 -8
  200. package/src/ui/components/header_actions.js +55 -0
  201. package/src/ui/components/inline_renamer.js +0 -0
  202. package/src/ui/components/job_progress.js +51 -0
  203. package/src/ui/components/managed_window.js +27 -4
  204. package/src/ui/components/menu_bar.js +745 -0
  205. package/src/ui/components/modal.js +449 -75
  206. package/src/ui/components/notification_history.js +3 -3
  207. package/src/ui/components/progress.js +483 -0
  208. package/src/ui/components/record_list.js +0 -0
  209. package/src/ui/components/row_activation.js +0 -0
  210. package/src/ui/components/section_stack.js +298 -0
  211. package/src/ui/components/series.js +173 -0
  212. package/src/ui/components/settings_view.js +634 -0
  213. package/src/ui/components/slide_out_panel.js +43 -6
  214. package/src/ui/components/slider_field.js +275 -18
  215. package/src/ui/components/sortable_list.js +26 -4
  216. package/src/ui/components/staged_edits.js +343 -0
  217. package/src/ui/components/staged_history.js +172 -0
  218. package/src/ui/components/table_filter_chips.js +0 -0
  219. package/src/ui/components/table_state_store.js +0 -0
  220. package/src/ui/components/table_structure.js +182 -0
  221. package/src/ui/components/table_values.js +0 -0
  222. package/src/ui/components/tabs.js +497 -0
  223. package/src/ui/components/tag_input.js +215 -0
  224. package/src/ui/components/toast.js +25 -8
  225. package/src/ui/components/toggle_field.js +175 -0
  226. package/src/ui/components/tree_view.js +55 -34
  227. package/src/ui/components/window_taskbar.js +0 -0
  228. package/src/ui/components/wizard.js +563 -0
  229. package/src/ui/controllers/window_chrome_controller.js +2 -2
  230. package/src/ui/notification_center.js +375 -119
  231. package/src/ui/utils/auto_scrollbars.js +0 -0
  232. package/src/ui/utils/markdown.js +709 -0
  233. package/src/ui/utils/native_context_menu.js +0 -0
  234. package/src/ui/utils/overlay_scrollbar.js +16 -12
  235. package/src/ui/utils/raf_resize_observer.js +0 -0
  236. package/src/ui/utils/scroll_lock.js +0 -0
  237. package/src/ui/utils/strip_scroll.js +55 -0
  238. package/src/ui/utils/tooltip_service.js +0 -8
  239. package/dist/chunk-6JSOVNID.js.map +0 -7
  240. package/dist/chunk-CWPMLX67.js +0 -475
  241. package/dist/chunk-CWPMLX67.js.map +0 -7
  242. package/dist/chunk-FBGP5KN5.js.map +0 -7
  243. package/dist/chunk-FL5KFNQH.js.map +0 -7
  244. package/dist/chunk-NLTOY57M.js.map +0 -7
  245. package/dist/chunk-NNFTETK3.js +0 -633
  246. package/dist/chunk-NNFTETK3.js.map +0 -7
  247. package/dist/chunk-O5OHMWBB.js +0 -284
  248. package/dist/chunk-O5OHMWBB.js.map +0 -7
  249. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  250. package/dist/chunk-SN4LSOAR.js.map +0 -7
  251. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -44,8 +44,28 @@
44
44
  * embedder's business, not the shell's.
45
45
  * labelOf? `(props) => string` — display label for ONE instance.
46
46
  * Defaults to `props.label`, then `props.id`.
47
+ * panels? `{ left?, right?, bottom? }` — which panels this kind
48
+ * offers while it is the primary tile. Each side is `true`,
49
+ * `false` or `{ reason }` (see panel_availability.js); a side
50
+ * not named is offered. A kind that names no side inherits
51
+ * that side from its top-nav kind, so declaring it on a page
52
+ * covers every entity opened under it. Withheld panels are
53
+ * not inserted and their toggles are disabled, showing
54
+ * `reason`.
55
+ *
56
+ * single? `true`, or the name of a prop. The kind exists ONCE: content
57
+ * that holds heavy app-wide state and must not be mounted
58
+ * twice. With `true` there is one instance of the kind; with a
59
+ * prop name there is one per value of that prop (`'id'`: one
60
+ * tile per record). The window manager makes every open path
61
+ * reveal the instance that exists, refuses the copy verbs with
62
+ * a reason, and keeps the first of any duplicates a restored
63
+ * layout holds. The root kind cannot be single: every new
64
+ * pane, tab and desktop is seeded with it.
47
65
  */
48
66
 
67
+ import { PANEL_OFFERED, PANEL_SIDES, readPanelOffer } from './panel_availability.js';
68
+
49
69
  /** Build a taxonomy from an embedder's kind map. Validated eagerly and
50
70
  * frozen — a typo in a `topNav` pointer is a boot error, not a chrome
51
71
  * surface that silently renders nothing six clicks later. */
@@ -68,6 +88,27 @@ export function createTaxonomy({ kinds, root } = {}) {
68
88
  throw new Error(
69
89
  `createTaxonomy: '${kind}' is both a top-nav kind and owned by one`);
70
90
  }
91
+ if (def.panels !== undefined) {
92
+ if (!def.panels || typeof def.panels !== 'object' || Array.isArray(def.panels)) {
93
+ throw new TypeError(`createTaxonomy: '${kind}'.panels must be an object`);
94
+ }
95
+ for (const [side, offer] of Object.entries(def.panels)) {
96
+ if (!PANEL_SIDES.includes(side)) {
97
+ throw new Error(`createTaxonomy: '${kind}'.panels names unknown side '${side}'`);
98
+ }
99
+ readPanelOffer(offer, `createTaxonomy: '${kind}'.panels.${side}`);
100
+ }
101
+ }
102
+ if (def.single != null && def.single !== false && def.single !== true
103
+ && !(typeof def.single === 'string' && def.single)) {
104
+ throw new TypeError(
105
+ `createTaxonomy: '${kind}'.single must be true or a prop name`);
106
+ }
107
+ }
108
+ if (kinds[root].single) {
109
+ throw new Error(
110
+ `createTaxonomy: the root kind '${root}' cannot be single — `
111
+ + 'every new pane, tab and desktop is seeded with it');
71
112
  }
72
113
  const _kinds = Object.freeze({ ...kinds });
73
114
 
@@ -143,6 +184,17 @@ export function createTaxonomy({ kinds, root } = {}) {
143
184
  return Array.isArray(s) ? s : [];
144
185
  };
145
186
 
187
+ /** Whether `kind` offers the panel on `side`: `{ available, reason }`.
188
+ * The kind's own declaration for that side, else its top-nav kind's,
189
+ * else offered. Unknown and absent kinds offer every panel. */
190
+ const panelOffer = (kind, side) => {
191
+ for (const k of [kind, _kinds[kind]?.topNav]) {
192
+ const offer = k ? _kinds[k]?.panels?.[side] : undefined;
193
+ if (offer !== undefined) return readPanelOffer(offer, `taxonomy '${k}'.panels.${side}`);
194
+ }
195
+ return PANEL_OFFERED;
196
+ };
197
+
146
198
  /** Top-nav entries in display order:
147
199
  * { kind, label (short), longLabel, icon }
148
200
  * `label` is the chip text; `longLabel` is what the palette shows. */
@@ -157,6 +209,28 @@ export function createTaxonomy({ kinds, root } = {}) {
157
209
  icon: m.icon,
158
210
  }));
159
211
 
212
+ /** Whether `kind` exists once (KindDef `single`). */
213
+ const isSingle = (kind) => {
214
+ const s = _kinds[kind]?.single;
215
+ return s === true || (typeof s === 'string' && s !== '');
216
+ };
217
+
218
+ /** The identity of one instance of a single kind: two tab specs with the
219
+ * same key are the same instance. `null` for a kind that is not single,
220
+ * which is how callers ask both questions at once. Keyed by the kind and,
221
+ * for `single: '<prop>'`, that prop's value as a string. */
222
+ const instanceKey = (kind, props) => {
223
+ if (!isSingle(kind)) return null;
224
+ const by = _kinds[kind].single;
225
+ if (by === true) return String(kind);
226
+ const v = props?.[by];
227
+ return `${kind}\u0000${v == null ? '' : String(v)}`;
228
+ };
229
+
230
+ /** The sentence a refused copy verb shows for a single kind. */
231
+ const singleReason = (kind) =>
232
+ `${_kinds[kind]?.label || kind} is open once: use the one that is open`;
233
+
160
234
  return Object.freeze({
161
235
  root,
162
236
  kinds: _kinds,
@@ -167,6 +241,10 @@ export function createTaxonomy({ kinds, root } = {}) {
167
241
  parentOf,
168
242
  labelOf,
169
243
  sourcesFor,
244
+ panelOffer,
170
245
  topNavEntries,
246
+ isSingle,
247
+ instanceKey,
248
+ singleReason,
171
249
  });
172
250
  }
File without changes
File without changes
@@ -30,6 +30,30 @@
30
30
  * `activate()` runs on every selection, INCLUDING the first — a pane that
31
31
  * has to rebuild on each visit (a git status list, say) does its work there.
32
32
  * The filter query is remembered per mode.
33
+ *
34
+ * A mode may also carry `filter: false`: a pane with controls of its own.
35
+ * While it is the active mode the shared filter box is hidden and its pane's
36
+ * `filter()` is not called; the next mode with a filter shows its own saved
37
+ * text again.
38
+ *
39
+ * Options beyond `modes`:
40
+ *
41
+ * showModeStrip (true) false renders no strip: the embedder switches
42
+ * modes through `select()` instead.
43
+ * showFilter (true) false renders no filter box at all.
44
+ * initialMode (first) the mode shown at mount; an unknown id falls back
45
+ * to the first mode.
46
+ *
47
+ * The handle:
48
+ *
49
+ * select(modeId) -> Promise the strip click's own path: mounts the mode
50
+ * on first use, runs its `activate()` and
51
+ * restores its filter; resolves once
52
+ * `activate()` has run. An unknown id is
53
+ * ignored (a resolved promise).
54
+ * active() -> modeId the mode currently shown.
55
+ * refresh() -> Promise re-runs the active mode's `activate()`.
56
+ * destroy()
33
57
  */
34
58
 
35
59
  export function mountNavPanel(hostEl, {
@@ -37,39 +61,54 @@ export function mountNavPanel(hostEl, {
37
61
  wm = null,
38
62
  eventBus = null,
39
63
  filterPlaceholder = 'Filter…',
64
+ showModeStrip = true,
65
+ showFilter = true,
66
+ initialMode = null,
40
67
  } = {}) {
41
68
  if (!Array.isArray(modes) || modes.length === 0) {
42
69
  throw new Error('mountNavPanel: at least one mode is required');
43
70
  }
44
71
 
45
- hostEl.classList.add('twm-nav');
46
- hostEl.innerHTML = `
47
- <div class="twm-nav__modes" data-role="modes"></div>
72
+ const stripHtml = showModeStrip
73
+ ? `
74
+ <div class="twm-nav__modes" data-role="modes"></div>`
75
+ : '';
76
+ const filterHtml = showFilter
77
+ ? `
48
78
  <div class="twm-sidebar-controls twm-nav__filter">
49
79
  <span class="material-symbols-outlined twm-sidebar-search-icon">search</span>
50
80
  <input class="twm-data-page__search twm-nav__filter-input"
51
81
  type="search" placeholder="${_esc(filterPlaceholder)}"
52
82
  autocomplete="off" />
53
- </div>
83
+ </div>`
84
+ : '';
85
+ hostEl.classList.add('twm-nav');
86
+ hostEl.innerHTML = `${stripHtml}${filterHtml}
54
87
  <div class="twm-nav__body" data-role="body"></div>
55
88
  `;
56
89
  const modesEl = hostEl.querySelector('[data-role="modes"]');
57
90
  const bodyEl = hostEl.querySelector('[data-role="body"]');
91
+ const filterBoxEl = hostEl.querySelector('.twm-nav__filter');
58
92
  const filterEl = hostEl.querySelector('.twm-nav__filter-input');
59
93
 
60
- modesEl.innerHTML = modes.map((m) => `
94
+ if (modesEl) {
95
+ modesEl.innerHTML = modes.map((m) => `
61
96
  <button class="twm-nav__mode twm-has-tooltip" data-mode="${_esc(m.id)}"
62
97
  data-tooltip="${_esc(m.label)}">
63
98
  <span class="material-symbols-outlined">${_esc(m.icon)}</span>
64
99
  <span>${_esc(m.label)}</span>
65
100
  </button>
66
101
  `).join('');
102
+ }
67
103
 
68
104
  const byId = new Map(modes.map((m) => [m.id, m]));
69
105
  const hosts = {}; // mode id -> pane host element
70
106
  const panes = {}; // mode id -> the object the mode's mount() returned
71
107
  const filterByMode = Object.fromEntries(modes.map((m) => [m.id, '']));
72
- let mode = modes[0].id;
108
+ let mode = byId.has(initialMode) ? initialMode : modes[0].id;
109
+
110
+ /** Whether the shared filter box serves this mode. */
111
+ const hasFilter = (id) => !!filterEl && byId.get(id)?.filter !== false;
73
112
 
74
113
  const ensureHost = (id) => {
75
114
  if (hosts[id]) return hosts[id];
@@ -88,9 +127,15 @@ export function mountNavPanel(hostEl, {
88
127
  for (const m of modes) {
89
128
  ensureHost(m.id).style.display = (m.id === next) ? 'flex' : 'none';
90
129
  }
91
- modesEl.querySelectorAll('[data-mode]').forEach((b) =>
130
+ modesEl?.querySelectorAll('[data-mode]').forEach((b) =>
92
131
  b.classList.toggle('twm-nav__mode--on', b.dataset.mode === next));
93
- filterEl.value = filterByMode[next] || '';
132
+ if (filterEl) {
133
+ // Touch the box's style only when a mode without a filter is
134
+ // involved, so a panel whose modes all filter renders as it always has.
135
+ if (!hasFilter(next)) filterBoxEl.style.display = 'none';
136
+ else if (filterBoxEl.style.display) filterBoxEl.style.removeProperty('display');
137
+ filterEl.value = hasFilter(next) ? (filterByMode[next] || '') : '';
138
+ }
94
139
 
95
140
  const paneEl = ensureHost(next);
96
141
  if (!panes[next]) {
@@ -108,17 +153,19 @@ export function mountNavPanel(hostEl, {
108
153
  };
109
154
 
110
155
  const _applyFilter = () => {
156
+ if (!hasFilter(mode)) return;
111
157
  const q = filterByMode[mode] || '';
112
158
  try { panes[mode]?.filter?.(q); }
113
159
  catch (err) { console.warn('[nav] filter failed', mode, err); }
114
160
  };
115
161
 
116
- filterEl.addEventListener('input', () => {
162
+ filterEl?.addEventListener('input', () => {
163
+ if (!hasFilter(mode)) return;
117
164
  filterByMode[mode] = filterEl.value;
118
165
  _applyFilter();
119
166
  });
120
167
 
121
- modesEl.addEventListener('click', (e) => {
168
+ modesEl?.addEventListener('click', (e) => {
122
169
  const btn = e.target.closest('[data-mode]');
123
170
  if (btn) activate(btn.dataset.mode);
124
171
  });
@@ -126,6 +173,8 @@ export function mountNavPanel(hostEl, {
126
173
  activate(mode);
127
174
 
128
175
  return {
176
+ select: (modeId) => activate(modeId),
177
+ active: () => mode,
129
178
  refresh: () => activate(mode),
130
179
  destroy: () => {
131
180
  for (const p of Object.values(panes)) {
@@ -111,20 +111,20 @@ export function createPageFactories({ eventBus = null, events = {}, refreshOn =
111
111
  console.error('[page] mount failed', err);
112
112
  hideLoading();
113
113
  }
114
- const listeners = {};
114
+ // Keep the handles on() returned.
115
+ const subscriptions = [];
115
116
  if (typeof tab?.refresh === 'function' && eventBus?.on) {
116
117
  for (const [busEvent, reason] of Object.entries(refreshOn || {})) {
117
118
  const fn = () => {
118
119
  try { tab.refresh(reason); }
119
120
  catch (err) { console.warn('[page] refresh failed', busEvent, err); }
120
121
  };
121
- listeners[busEvent] = fn;
122
- eventBus.on(busEvent, fn);
122
+ subscriptions.push(eventBus.on(busEvent, fn));
123
123
  }
124
124
  }
125
125
  return () => {
126
- for (const [n, fn] of Object.entries(listeners)) {
127
- try { eventBus?.off?.(n, fn); } catch {}
126
+ for (const handle of subscriptions) {
127
+ try { handle?.dispose?.(); } catch {}
128
128
  }
129
129
  try { tab?.dispose?.(); tab?.unmount?.(); } catch {}
130
130
  };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * panel_availability.js — what "this panel is offered here" means, said once.
3
+ *
4
+ * A panel side (`left`, `right`, `bottom`) is either offered or withheld. Two
5
+ * places may say so, and both speak the same small language:
6
+ *
7
+ * - a KindDef's `panels` (kind_taxonomy.js) — what a kind of content offers
8
+ * - `wm.setPanelAvailability(side, offer)` — content overriding at runtime
9
+ *
10
+ * An OFFER is one of:
11
+ *
12
+ * true offered (the same as saying nothing)
13
+ * false withheld, with no reason to show
14
+ * { reason } withheld, and `reason` is what the toggle's tooltip says
15
+ *
16
+ * Read into `{ available, reason }`. Anything else is a boot error rather than
17
+ * a panel that silently stays put, for the same reason a bad `topNav` pointer
18
+ * is one (createTaxonomy).
19
+ *
20
+ * Internal: neither export is part of the package surface.
21
+ */
22
+
23
+ export const PANEL_SIDES = Object.freeze(['left', 'right', 'bottom']);
24
+
25
+ /** What every side is when nobody declared anything: offered, as panels always
26
+ * were before availability existed. */
27
+ export const PANEL_OFFERED = Object.freeze({ available: true, reason: null });
28
+
29
+ /** Read one offer. `where` names the caller in the error. */
30
+ export function readPanelOffer(offer, where) {
31
+ if (offer === true) return PANEL_OFFERED;
32
+ if (offer === false) return Object.freeze({ available: false, reason: null });
33
+ if (offer && typeof offer === 'object' && !Array.isArray(offer)) {
34
+ const { reason = null } = offer;
35
+ if (reason !== null && typeof reason !== 'string') {
36
+ throw new TypeError(`${where}: a panel's \`reason\` must be a string`);
37
+ }
38
+ return Object.freeze({ available: false, reason: reason || null });
39
+ }
40
+ throw new TypeError(`${where}: a panel offer is true, false or { reason }`);
41
+ }
42
+
43
+ /** Throw unless `side` is one of PANEL_SIDES. */
44
+ export function assertPanelSide(side, where) {
45
+ if (!PANEL_SIDES.includes(side)) {
46
+ throw new TypeError(`${where}: unknown panel side '${side}' `
47
+ + `(expected ${PANEL_SIDES.join(', ')})`);
48
+ }
49
+ }
File without changes
@@ -42,10 +42,11 @@
42
42
  import { WindowManager } from './wm.js';
43
43
  import { createContentRegistry } from './content_registry.js';
44
44
  import { createCommandPalette } from './command_palette.js';
45
- import { installKeymap } from './keymap.js';
45
+ import { installKeymap, keymapShortcuts } from './keymap.js';
46
46
  import { openTileTabSwitcher } from './tile_tab_menu.js';
47
47
  import { mountZoomControl } from './zoom.js';
48
48
  import { showContextMenu } from '../ui/components/context_menu.js';
49
+ import { createMenuBar } from '../ui/components/menu_bar.js';
49
50
  import { openForm } from '../ui/components/modal.js';
50
51
 
51
52
  /**
@@ -125,13 +126,36 @@ import { openForm } from '../ui/components/modal.js';
125
126
  * desktops is said on the desktops
126
127
  * themselves (`group`, `fixed` — see
127
128
  * `DesktopManager.chips`).
129
+ * @param {object} [cfg.menuBar] STORY-0030. `{ menus, onAction?, label?,
130
+ * emptyLabel?, palette? }` — the model of
131
+ * the menu bar painted into
132
+ * `chrome.menuBar` (see menu_bar.js). Its
133
+ * enabled items are also the palette's
134
+ * commands, after `palette.commands`'
135
+ * own, unless `palette: false`. Each needs
136
+ * the other: either alone is a boot error.
128
137
  * @param {object} [cfg.chrome] { topNav?, paletteButton?, desktops?,
129
138
  * panelToggles?: { left?, right?, bottom? },
130
- * zoom? }
139
+ * zoom?, menuBar? }
131
140
  * `zoom` is C31: the element the content
132
141
  * zoom control is painted into — see
133
142
  * zoom.js. Absent, there is no control and
134
- * nothing is ever scaled.
143
+ * nothing is ever scaled. `menuBar` is the
144
+ * element that becomes the menu bar; it
145
+ * needs `cfg.menuBar`.
146
+ * @param {object} [cfg.keymap] `{ bindings?: { [chord]: action | false | fn } }`
147
+ * — the window manager's chords, changed:
148
+ * `false` hands a chord back to the page
149
+ * (unprevented), an action name such as
150
+ * `'nav:0'` rebinds it, a function runs
151
+ * instead. Chord ids and action names are
152
+ * listed in keymap.js; an unknown one is
153
+ * warned about once and ignored. The
154
+ * palette's *Keyboard shortcuts* lists the
155
+ * bindings in effect. Omitted, the default
156
+ * keymap, `?` for help included. Anything
157
+ * but an object whose only key is
158
+ * `bindings` is a TypeError.
135
159
  * @returns {Promise<object>} the frozen shell
136
160
  */
137
161
  export async function createShell({
@@ -155,11 +179,13 @@ export async function createShell({
155
179
  tabLayout = null,
156
180
  newTabButton = true,
157
181
  desktopBar = {},
182
+ menuBar: menuBarCfg = null,
158
183
  chrome = {},
159
184
  // An embedder that moved its sections out of the top bar — into an icon
160
185
  // rail, say — passes the selector its own buttons match, and F1..F8 keep
161
186
  // working. Omitted, the default top-bar selector applies.
162
187
  navSelector = null,
188
+ keymap = null,
163
189
  } = {}) {
164
190
  // A bad shell is a BOOT error, not a runtime surprise. Same doctrine as
165
191
  // createHost / createTaxonomy.
@@ -168,6 +194,25 @@ export async function createShell({
168
194
  }
169
195
  if (!taxonomy) throw new Error('createShell: a taxonomy is required');
170
196
  if (!entities) throw new Error('createShell: an entity catalog is required');
197
+ if (chrome.menuBar && !menuBarCfg?.menus) {
198
+ throw new TypeError('createShell: `chrome.menuBar` needs `menuBar: { menus }`');
199
+ }
200
+ if (menuBarCfg && !chrome.menuBar) {
201
+ throw new TypeError('createShell: `menuBar` needs `chrome.menuBar`, the element it is painted into');
202
+ }
203
+ // `keymap` is checked here, not left to the keymap: a typo such as
204
+ // `{ binding: … }` would otherwise be dropped without a word, and the chord
205
+ // the embedder meant to take back would stay bound.
206
+ if (keymap != null) {
207
+ if (typeof keymap !== 'object' || Array.isArray(keymap)) {
208
+ throw new TypeError('createShell: `keymap` must be an object of { bindings }');
209
+ }
210
+ const extra = Object.keys(keymap).filter((k) => k !== 'bindings');
211
+ if (extra.length) {
212
+ throw new TypeError(`createShell: unknown \`keymap\` key(s) ${extra.map((k) => `"${k}"`).join(', ')}`
213
+ + ' — the only key is `bindings`');
214
+ }
215
+ }
171
216
 
172
217
  const log = logger ?? { info(){}, warn(){}, error(){}, debug(){} };
173
218
  const registry = createContentRegistry(content); // throws on a non-function
@@ -213,13 +258,25 @@ export async function createShell({
213
258
  onChange: () => syncChrome(),
214
259
  });
215
260
 
261
+ // STORY-0030. The menu bar is built before the palette so that its model
262
+ // can be the palette's commands too: one list of verbs, two doors.
263
+ const menuBar = chrome.menuBar ? createMenuBar(chrome.menuBar, menuBarCfg) : null;
264
+ const ownCommands = paletteCfg.commands;
265
+ const commands = menuBar && menuBarCfg.palette !== false
266
+ ? () => [...(typeof ownCommands === 'function' ? ownCommands() || [] : []), ...menuBar.commands()]
267
+ : ownCommands;
268
+ const bindings = keymap?.bindings ?? null;
216
269
  const palette = createCommandPalette({
217
- wm, api, taxonomy, catalog: entities, ...paletteCfg,
270
+ wm, api, taxonomy, catalog: entities,
271
+ // What *Keyboard shortcuts* lists is what the keys do in THIS shell.
272
+ shortcuts: keymapShortcuts(bindings),
273
+ ...paletteCfg, commands,
218
274
  });
219
275
  // `navSelector` lets an embedder that moved its sections out of the top
220
276
  // bar keep F1..F8 working. Omitted, the default top-bar selector applies —
221
- // the behaviour every existing embedder has today.
222
- const disposeKeymap = installKeymap({ wm, palette, navSelector });
277
+ // the behaviour every existing embedder has today. The keymap also binds
278
+ // `?` to help, so `dispose()` takes the help shortcut off with it.
279
+ const disposeKeymap = installKeymap({ wm, palette, navSelector, bindings, logger });
223
280
 
224
281
  // Chrome — painted into the ELEMENTS the embedder handed over. An absent
225
282
  // key means absent chrome, not a crash.
@@ -240,7 +297,8 @@ export async function createShell({
240
297
  toggles = bindPanelToggles(chrome.panelToggles, wm);
241
298
 
242
299
  // Belt-and-braces: anything else that mutates the WM fans out to chrome.
243
- eventBus?.on?.('wm:changed', syncChrome);
300
+ // Keep the handle on() returned.
301
+ const chromeSubscription = eventBus?.on?.('wm:changed', syncChrome);
244
302
 
245
303
  await wm.load(); // ← FIRST MOUNT
246
304
  syncChrome();
@@ -262,6 +320,8 @@ export async function createShell({
262
320
  // `get`/`set` so an embedder can drive the zoom from its own settings
263
321
  // pane, or read it, without reaching into the control's DOM.
264
322
  zoom,
323
+ // STORY-0030. `setEnabled` / `setChecked` / `open` / `commands`.
324
+ menuBar,
265
325
  }),
266
326
  // A shell that can be built can be built TWICE — an embedder that
267
327
  // rebuilds on a context change (a different project, a different
@@ -269,7 +329,7 @@ export async function createShell({
269
329
  // outside `root` has to come off, or the second shell shares the page
270
330
  // with the first one's keyboard.
271
331
  dispose: () => {
272
- try { eventBus?.off?.('wm:changed', syncChrome); } catch { /* ignore */ }
332
+ try { chromeSubscription?.dispose?.(); } catch { /* ignore */ }
273
333
  try { disposeKeymap?.(); } catch { /* ignore */ }
274
334
  try { palette?.close?.(); } catch { /* ignore */ }
275
335
  // Every content factory gets its `destroy()`. Clearing the root
@@ -290,6 +350,8 @@ export async function createShell({
290
350
  // persist it under the host the old shell was given. The element
291
351
  // goes; the next shell paints its own.
292
352
  try { desktopsEl?.remove(); } catch { /* ignore */ }
353
+ // Its listeners are on the document; a rebuilt shell paints its own.
354
+ try { menuBar?.dispose(); } catch { /* ignore */ }
293
355
  },
294
356
  });
295
357
  }
@@ -368,13 +430,19 @@ function mountPaletteButton(hostEl, palette) {
368
430
  return btn;
369
431
  }
370
432
 
433
+ /** What each toggle said before a withheld panel made it say why, so the
434
+ * embedder's own tooltip comes back when the panel is offered again. Held
435
+ * here, keyed by the fresh node, never written back to the DOM. */
436
+ const OWN_TOOLTIPS = new WeakMap();
437
+
371
438
  /**
372
439
  * Take ownership of the embedder's three panel-toggle buttons.
373
440
  *
374
441
  * cloneNode copies any state the embedder's own machinery left on a button
375
442
  * (.active/.pinned/.disabled, the disabled attr, aria-pressed). We strip it so
376
- * the WM is the sole, clean source of truth — `syncPanelToggles` manages only
377
- * `.panel-toggle-btn--on` from here on. The clone keeps the id, so an embedder
443
+ * the WM is the sole, clean source of truth — `syncPanelToggles` manages
444
+ * `.twm-panel-toggle-btn--on` and a withheld panel's `aria-disabled` and
445
+ * tooltip from here on. The clone keeps the id, so an embedder
378
446
  * that drives a toggle by `getElementById(...).click()` still works.
379
447
  *
380
448
  * @returns {{left?:Element, right?:Element, bottom?:Element}} the FRESH nodes
@@ -391,16 +459,44 @@ function bindPanelToggles(map, wm) {
391
459
  el.removeAttribute('aria-pressed');
392
460
  btn.replaceWith(el);
393
461
  el.addEventListener('click', () => wm.togglePanel(side));
462
+ OWN_TOOLTIPS.set(el, {
463
+ tooltip: el.getAttribute('data-tooltip'),
464
+ title: el.getAttribute('title'),
465
+ });
394
466
  fresh[side] = el;
395
467
  }
396
468
  return fresh;
397
469
  }
398
470
 
471
+ /** Mirror the WM onto the toggles: `--on` while a panel shows, and, while the
472
+ * primary content withholds one, `aria-disabled` and the reason as the
473
+ * tooltip. `aria-disabled` rather than `disabled`: a disabled button gets no
474
+ * pointer events in every engine, and the reason is only worth giving if the
475
+ * pointer can find it. A click on it is answered by `togglePanel`, which
476
+ * refuses a withheld panel. */
399
477
  function syncPanelToggles(map, wm) {
400
478
  for (const side of ['left', 'right', 'bottom']) {
401
479
  const btn = map?.[side];
402
480
  if (!btn) continue;
403
481
  btn.classList.toggle('twm-panel-toggle-btn--on', wm.isPanelOpen(side));
482
+ const { available, reason } = wm.getPanelAvailability(side);
483
+ const own = OWN_TOOLTIPS.get(btn) || {};
484
+ const restore = (attr, value) => (value == null
485
+ ? btn.removeAttribute(attr) : btn.setAttribute(attr, value));
486
+ if (available) {
487
+ btn.removeAttribute('aria-disabled');
488
+ restore('data-tooltip', own.tooltip);
489
+ restore('title', own.title);
490
+ continue;
491
+ }
492
+ btn.setAttribute('aria-disabled', 'true');
493
+ // The framework's tooltip reads `data-tooltip` on a `twm-has-tooltip`
494
+ // button and strips `title` from it; any other button shows `title`.
495
+ if (btn.classList.contains('twm-has-tooltip')) {
496
+ restore('data-tooltip', reason ?? own.tooltip);
497
+ } else {
498
+ restore('title', reason ?? own.title);
499
+ }
404
500
  }
405
501
  }
406
502
 
@@ -560,13 +656,18 @@ function _tileContextMenu(wm, leafId, x, y) {
560
656
  const isPanel = String(leaf.content?.kind || '').startsWith('panel:');
561
657
  tree.focus(leafId);
562
658
  wm.renderer._updateFocusClasses();
659
+ // A SINGLE KIND HAS NO COPIES. The two copy verbs are greyed, and say why;
660
+ // the move verbs below (float, move to desktop) carry the one instance.
661
+ const single = !!leaf.content && wm.isSingleKind(leaf.content.kind);
662
+ const singleTitle = single ? wm.taxonomy.singleReason(leaf.content.kind) : undefined;
563
663
 
564
664
  const items = [
565
665
  { label: 'Split horizontally', icon: 'splitscreen_vertical_add', action: 'split-h' },
566
666
  { label: 'Split vertically', icon: 'splitscreen_add', action: 'split-v' },
567
667
  { separator: true },
568
668
  { label: 'Open in new tab', icon: 'tab',
569
- action: 'open-tab', disabled: isPanel || !leaf.content },
669
+ action: 'open-tab', disabled: isPanel || !leaf.content || single,
670
+ title: singleTitle },
570
671
  // TWO DIFFERENT GLYPHS FOR TWO DIFFERENT DESTINATIONS. `web_asset` is a
571
672
  // window INSIDE the application — the same glyph `ManagedWindow` uses
572
673
  // for itself — and an embedder that can also send content to a real
@@ -575,7 +676,8 @@ function _tileContextMenu(wm, leafId, x, y) {
575
676
  // two commands are the same command, and then loses a window looking
576
677
  // for it on the other screen.
577
678
  { label: 'Open a copy in a window', icon: 'web_asset',
578
- action: 'open-window', disabled: isPanel || !leaf.content },
679
+ action: 'open-window', disabled: isPanel || !leaf.content || single,
680
+ title: singleTitle },
579
681
  // C20, THE OTHER HALF — and it was missing while the `close` half
580
682
  // below carried a paragraph explaining why it could not be.
581
683
  //
File without changes
File without changes