flexdesk 0.16.1 → 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 (252) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2217 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/css/base.css +1834 -75
  6. package/css/flexdesk.css +3146 -164
  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-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  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 +3146 -164
  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 +657 -234
  61. package/dist/tiles.js.map +4 -4
  62. package/dist/tokens.css +0 -0
  63. package/dist/widgets.js +3252 -2124
  64. package/dist/widgets.js.map +4 -4
  65. package/dist/wm.js +1227 -347
  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 +7 -7
  144. package/src/tiles/scope_picker.js +0 -0
  145. package/src/tiles/tile_base.js +1 -1
  146. package/src/tiles/tile_config_form.js +442 -0
  147. package/src/tiles/tile_grid.js +2 -0
  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 +0 -0
  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-B5Q53LAJ.js.map +0 -7
  241. package/dist/chunk-FBGP5KN5.js.map +0 -7
  242. package/dist/chunk-FL5KFNQH.js.map +0 -7
  243. package/dist/chunk-NLTOY57M.js.map +0 -7
  244. package/dist/chunk-NNFTETK3.js +0 -633
  245. package/dist/chunk-NNFTETK3.js.map +0 -7
  246. package/dist/chunk-O5OHMWBB.js +0 -284
  247. package/dist/chunk-O5OHMWBB.js.map +0 -7
  248. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  249. package/dist/chunk-SN4LSOAR.js.map +0 -7
  250. package/dist/chunk-W6FBJLPW.js +0 -367
  251. package/dist/chunk-W6FBJLPW.js.map +0 -7
  252. package/src/ui/controllers/panel_state_machine.js +0 -634
package/dist/wm.js CHANGED
@@ -1,25 +1,31 @@
1
1
  import {
2
- HelpModal
3
- } from "./chunk-W6FBJLPW.js";
2
+ HelpModal,
3
+ createMenuBar
4
+ } from "./chunk-JKZLNKG6.js";
5
+ import {
6
+ TAB_LEVELS,
7
+ createTabs
8
+ } from "./chunk-HYJYYEPK.js";
4
9
  import {
5
10
  showContextMenu
6
- } from "./chunk-B5Q53LAJ.js";
11
+ } from "./chunk-UWO43BZU.js";
7
12
  import {
8
13
  openForm
9
- } from "./chunk-NNFTETK3.js";
14
+ } from "./chunk-D3COLZZ2.js";
10
15
  import {
11
16
  ManagedWindow,
12
17
  isRowActivation
13
- } from "./chunk-6JSOVNID.js";
14
- import "./chunk-FL5KFNQH.js";
18
+ } from "./chunk-CVMCZZX2.js";
19
+ import "./chunk-HNU6MM2B.js";
15
20
  import {
16
21
  NotebookTabBar
17
- } from "./chunk-NLTOY57M.js";
22
+ } from "./chunk-UURZ37SD.js";
23
+ import "./chunk-N7HC3ADV.js";
18
24
  import "./chunk-WVFGV5FT.js";
19
25
  import "./chunk-JYWURG5T.js";
20
26
 
21
27
  // src/tiling/bottom_panel_tabs.js
22
- var BADGE_LEVELS = Object.freeze(["info", "warning", "alert"]);
28
+ var BADGE_LEVELS = TAB_LEVELS;
23
29
  function createBottomPanelTabs(container, {
24
30
  panes = [],
25
31
  remember = null,
@@ -28,103 +34,38 @@ function createBottomPanelTabs(container, {
28
34
  onSelect = null,
29
35
  initial = null
30
36
  } = {}) {
31
- const doc = container.ownerDocument;
32
- const root = doc.createElement("div");
33
- root.className = "twm-bp";
34
- const strip = doc.createElement("div");
35
- strip.className = "twm-bp__tabs";
36
- strip.setAttribute("role", "tablist");
37
- const body = doc.createElement("div");
38
- body.className = "twm-bp__body";
39
- root.append(strip, body);
40
- container.appendChild(root);
41
37
  const byId = new Map(panes.map((pane) => [pane.id, pane]));
42
- const tabs = /* @__PURE__ */ new Map();
43
- const mounted = /* @__PURE__ */ new Map();
44
- let active = null;
38
+ let restoring = false;
45
39
  let chosen = false;
46
40
  let destroyed = false;
47
- for (const pane of panes) {
48
- const tab = doc.createElement("button");
49
- tab.type = "button";
50
- tab.className = "twm-bp__tab";
51
- tab.dataset.pane = pane.id;
52
- tab.setAttribute("role", "tab");
53
- tab.setAttribute("aria-selected", "false");
54
- const label = doc.createElement("span");
55
- label.className = "twm-bp__label";
56
- label.textContent = pane.label;
57
- const badge = doc.createElement("span");
58
- badge.className = "twm-bp__badge";
59
- badge.hidden = true;
60
- tab.append(label, badge);
61
- tab.addEventListener("click", () => api.select(pane.id));
62
- strip.appendChild(tab);
63
- tabs.set(pane.id, tab);
64
- }
65
- function mount(id) {
66
- if (mounted.has(id)) return mounted.get(id);
67
- const pane = byId.get(id);
68
- const el = doc.createElement("div");
69
- el.className = "twm-bp-pane";
70
- el.dataset.pane = id;
71
- el.setAttribute("role", "tabpanel");
72
- el.hidden = id !== active;
73
- body.appendChild(el);
74
- const entry = { el, instance: null };
75
- mounted.set(id, entry);
76
- try {
77
- entry.instance = pane.mount?.(el, store ? store(pane) : null, api) ?? null;
78
- } catch (err) {
79
- console.error(`[bottom-panel] pane "${id}" failed to mount`, err);
80
- }
81
- return entry;
82
- }
83
- function show(id) {
84
- if (destroyed || !byId.has(id)) return false;
85
- active = id;
86
- for (const [key, tab] of tabs) {
87
- const on = key === id;
88
- tab.classList.toggle("twm-bp__tab--on", on);
89
- tab.setAttribute("aria-selected", String(on));
90
- }
91
- for (const [key, entry] of mounted) entry.el.hidden = key !== id;
92
- mount(id).instance?.onShow?.();
93
- onSelect?.(id, byId.get(id));
94
- return true;
95
- }
41
+ let strip = null;
96
42
  const api = {
97
- root,
43
+ root: null,
98
44
  /** Bring a tab to the front, and remember it. */
99
45
  select(id) {
100
- if (!show(id)) return;
46
+ if (destroyed || !byId.has(id)) return;
101
47
  chosen = true;
102
- try {
103
- remember?.write?.(id);
104
- } catch {
48
+ if (strip.active() !== id) {
49
+ strip.select(id);
50
+ return;
105
51
  }
52
+ strip.instance(id)?.onShow?.();
53
+ remembered(id);
106
54
  },
107
55
  /** The tab in front, or null. */
108
- active: () => active,
56
+ active: () => strip.active(),
109
57
  /** `count` on a tab, coloured by `level`; nothing at zero. */
110
- setBadge(id, count, level = null) {
111
- const badge = tabs.get(id)?.querySelector(".twm-bp__badge");
112
- if (!badge) return;
113
- const n = Number(count) || 0;
114
- badge.hidden = n <= 0;
115
- badge.textContent = n > 99 ? "99+" : String(Math.max(n, 0));
116
- badge.className = "twm-bp__badge" + (BADGE_LEVELS.includes(level) ? ` twm-bp__badge--${level}` : "");
117
- },
58
+ setBadge: (id, count, level = null) => strip.setBadge(id, count, level),
118
59
  /** A mounted pane's live instance, or null — how one tab hands work to
119
60
  * another. `select` it first, which mounts it. */
120
- instance: (id) => mounted.get(id)?.instance ?? null,
61
+ instance: (id) => strip.instance(id),
121
62
  /** The panel is on screen again (its tile re-mounted): tell the pane in front. */
122
63
  reshow() {
123
- if (active) mounted.get(active)?.instance?.onShow?.();
64
+ strip.instance(strip.active())?.onShow?.();
124
65
  },
125
66
  /** Before the panel is torn down: every mounted pane saves. */
126
67
  async persist() {
127
- for (const entry of mounted.values()) await entry.instance?.persist?.();
68
+ for (const instance of strip.instances()) await instance.persist?.();
128
69
  },
129
70
  /** Gone for good: panes save, then are destroyed; the bus is let go. */
130
71
  async destroy() {
@@ -135,47 +76,410 @@ function createBottomPanelTabs(container, {
135
76
  subscription?.dispose?.();
136
77
  } catch {
137
78
  }
138
- for (const entry of mounted.values()) {
139
- try {
140
- entry.instance?.destroy?.();
141
- } catch (err) {
142
- console.warn("[bottom-panel] pane destroy failed", err);
143
- }
144
- }
145
- mounted.clear();
146
- root.remove();
79
+ strip.destroy();
147
80
  }
148
81
  };
149
- for (const pane of panes) if (pane.eager) mount(pane.id);
82
+ function remembered(id) {
83
+ onSelect?.(id, byId.get(id));
84
+ try {
85
+ remember?.write?.(id);
86
+ } catch {
87
+ }
88
+ }
89
+ strip = createTabs(container, {
90
+ block: "twm-bp",
91
+ dataKey: "pane",
92
+ items: panes.map(({ id, label, eager }) => ({ id, label, eager: !!eager })),
93
+ active: byId.has(initial) ? initial : panes[0]?.id,
94
+ mountPane: (item, el, tabs) => {
95
+ strip ??= tabs;
96
+ api.root ??= tabs.root;
97
+ const pane = byId.get(item.id);
98
+ return pane.mount?.(el, store ? store(pane) : null, api) ?? null;
99
+ },
100
+ onSelect: (id) => {
101
+ if (restoring) {
102
+ onSelect?.(id, byId.get(id));
103
+ return;
104
+ }
105
+ chosen = true;
106
+ remembered(id);
107
+ },
108
+ // A click on the tab already in front is a select here, as it always
109
+ // was: the pane hears onShow again and the choice is remembered.
110
+ onReselect: (id) => api.select(id)
111
+ });
112
+ api.root ??= strip.root;
113
+ if (strip.active() != null) onSelect?.(strip.active(), byId.get(strip.active()));
150
114
  const subscription = eventBus?.on?.(
151
115
  "bottompanel:show",
152
116
  (payload) => api.select(payload?.tab ?? panes[0]?.id)
153
117
  ) ?? null;
154
- show(byId.has(initial) ? initial : panes[0]?.id);
155
118
  if (remember?.read) {
156
119
  Promise.resolve().then(() => remember.read()).then((saved) => {
157
- if (chosen || destroyed) return;
158
- if (byId.has(saved)) show(saved);
120
+ if (chosen || destroyed || !byId.has(saved)) return;
121
+ if (strip.active() === saved) {
122
+ strip.instance(saved)?.onShow?.();
123
+ onSelect?.(saved, byId.get(saved));
124
+ return;
125
+ }
126
+ restoring = true;
127
+ try {
128
+ strip.select(saved);
129
+ } finally {
130
+ restoring = false;
131
+ }
159
132
  }).catch(() => {
160
133
  });
161
134
  }
162
135
  return api;
163
136
  }
164
137
 
165
- // src/tiling/command_palette.js
166
- var ROOT_ID = "twm-cmdpal";
167
- var STATIC_COMMANDS = [
168
- {
169
- kind: "command",
170
- id: "shortcuts",
171
- label: "Keyboard shortcuts",
172
- hint: "Show all key bindings (?)",
173
- icon: "keyboard",
174
- shortcut: "?",
175
- verb: "run",
176
- action: () => HelpModal.open("keyboard-shortcuts")
138
+ // src/tiling/keymap.js
139
+ var KEYMAP_SHORTCUTS = Object.freeze([
140
+ { keys: "Ctrl+K", action: "Open the command palette" },
141
+ { keys: "?", action: "Open help" },
142
+ { keys: "Esc", action: "Close the palette or a modal" },
143
+ { keys: "F1..F8", action: "Jump to the Nth top-nav page" },
144
+ { keys: "Backspace", action: "Go back one step up the breadcrumb" },
145
+ { keys: "Alt+H / Alt+V", action: "Split the focused tile horizontally / vertically (empty pane)" },
146
+ { keys: "Alt+Shift+H / Alt+Shift+V", action: "Open the focused tile's content in a horizontal / vertical split" },
147
+ { keys: "Alt+T", action: "Open the focused tile's content in a new tab" },
148
+ { keys: "Alt+N", action: "Open the focused tile's content in a new window" },
149
+ { keys: "Alt+W", action: "Close the focused tile" },
150
+ { keys: "Alt+F", action: "Float the focused tile into a window, or dock it back" },
151
+ { keys: "Alt+Arrow", action: "Move focus to the neighbouring tile" },
152
+ { keys: "Alt+Shift+Arrow", action: "Swap the focused tile with its neighbour" },
153
+ { keys: "Alt+1..9", action: "Switch to desktop N" },
154
+ { keys: "Alt+Shift+1..9", action: "Move the focused tile to desktop N" },
155
+ { keys: "Ctrl+Left / Ctrl+Right", action: "Previous / next desktop" },
156
+ { keys: "Shift+Left / Shift+Right", action: "Previous / next bottom-panel tab" },
157
+ { keys: "Ctrl+Tab / Ctrl+Shift+Tab", action: "Next / previous tab in the focused tile" }
158
+ ].map((r) => Object.freeze(r)));
159
+ var KEYBOARD_SHORTCUTS_TOPIC_ID = "keyboard-shortcuts";
160
+ var _esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
161
+ function keyboardShortcutsTopic(shortcuts = KEYMAP_SHORTCUTS) {
162
+ const rows = shortcuts.map((r) => `<tr><td><kbd>${_esc(r.keys)}</kbd></td><td>${_esc(r.action)}</td></tr>`).join("");
163
+ return {
164
+ id: KEYBOARD_SHORTCUTS_TOPIC_ID,
165
+ title: "Keyboard shortcuts",
166
+ html: `<table class="help-shortcuts"><thead><tr><th>Keys</th><th>Action</th></tr></thead><tbody>${rows}</tbody></table>`
167
+ };
168
+ }
169
+ var DEFAULT_NAV_SELECTOR = ".twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn";
170
+ var DIRS = ["left", "right", "up", "down"];
171
+ var _where = (d) => d === "up" || d === "down" ? d : `on the ${d}`;
172
+ var ACTIONS = {
173
+ "palette": { label: "Open the command palette", run: ({ palette }) => {
174
+ palette.toggle();
175
+ } },
176
+ "palette:close": { label: "Close the palette", run: ({ palette }) => {
177
+ if (palette.isOpen()) palette.close();
178
+ return false;
179
+ } },
180
+ "help": { label: "Open help", run: () => {
181
+ HelpModal.toggle();
182
+ } },
183
+ "back": { label: "Go back one step up the breadcrumb", run: ({ wm }) => {
184
+ wm.navigateBack?.();
185
+ } },
186
+ "tab:next": { label: "Next tab in the focused tile", run: ({ wm }) => {
187
+ wm.cycleFocusedTab(1);
188
+ } },
189
+ "tab:prev": { label: "Previous tab in the focused tile", run: ({ wm }) => {
190
+ wm.cycleFocusedTab(-1);
191
+ } },
192
+ "desktop:next": { label: "Next desktop", run: ({ wm }) => {
193
+ wm.cycleDesktop(1);
194
+ } },
195
+ "desktop:prev": { label: "Previous desktop", run: ({ wm }) => {
196
+ wm.cycleDesktop(-1);
197
+ } },
198
+ "panelTab:next": { label: "Next bottom-panel tab", run: () => _cyclePanelTab(1) },
199
+ "panelTab:prev": { label: "Previous bottom-panel tab", run: () => _cyclePanelTab(-1) },
200
+ "split:h": { label: "Split the focused tile horizontally (empty pane)", run: ({ wm }) => {
201
+ wm.split("h");
202
+ } },
203
+ "split:v": { label: "Split the focused tile vertically (empty pane)", run: ({ wm }) => {
204
+ wm.split("v");
205
+ } },
206
+ "splitWith:h": { label: "Open the focused tile's content in a horizontal split", run: ({ wm }) => {
207
+ wm.splitFocusedWith("h");
208
+ } },
209
+ "splitWith:v": { label: "Open the focused tile's content in a vertical split", run: ({ wm }) => {
210
+ wm.splitFocusedWith("v");
211
+ } },
212
+ "openInTab": { label: "Open the focused tile's content in a new tab", run: ({ wm }) => {
213
+ wm.openFocusedInTab();
214
+ } },
215
+ "openInWindow": { label: "Open the focused tile's content in a new window", run: ({ wm }) => {
216
+ wm.openFocusedInWindow();
217
+ } },
218
+ "close": { label: "Close the focused tile", run: ({ wm }) => {
219
+ wm.closeFocused();
220
+ } },
221
+ "float": { label: "Float the focused tile into a window, or dock it back", run: ({ wm }) => {
222
+ wm.toggleManagedFocused();
223
+ } },
224
+ // Bare Alt+letter: browsers open their menu bar on it, mid-flow.
225
+ "suppress": { label: null, run: () => {
226
+ } },
227
+ ...Object.fromEntries(DIRS.flatMap((d) => [
228
+ [`focus:${d}`, { label: `Move focus to the tile ${_where(d)}`, run: ({ wm }) => {
229
+ wm.focusDir(d);
230
+ } }],
231
+ [`move:${d}`, { label: `Swap the focused tile with its neighbour ${_where(d)}`, run: ({ wm }) => {
232
+ wm.moveFocused(d);
233
+ } }]
234
+ ]))
235
+ };
236
+ function _action(name) {
237
+ if (typeof name !== "string") return null;
238
+ if (Object.hasOwn(ACTIONS, name)) return ACTIONS[name];
239
+ const m = /^(nav|desktop|toDesktop):(\d+)$/.exec(name);
240
+ if (!m) return null;
241
+ const n = Number(m[2]);
242
+ if (m[1] === "nav") {
243
+ return {
244
+ label: `Jump to top-nav page ${n + 1}`,
245
+ // The selector is a CONFIG option, not a constant. An embedder that
246
+ // moves its sections out of the top bar — into an icon rail, say —
247
+ // would otherwise find F1..F8 silently doing nothing. Passing
248
+ // `navSelector` is how such an embedder keeps the keys.
249
+ run: ({ navSelector }) => {
250
+ const btns = document.querySelectorAll(navSelector || DEFAULT_NAV_SELECTOR);
251
+ if (n >= btns.length) return false;
252
+ btns[n].click();
253
+ return void 0;
254
+ }
255
+ };
177
256
  }
257
+ if (m[1] === "desktop") {
258
+ return { label: `Switch to desktop ${n + 1}`, run: ({ wm }) => {
259
+ wm.switchDesktop(n);
260
+ } };
261
+ }
262
+ return { label: `Move the focused tile to desktop ${n + 1}`, run: ({ wm }) => {
263
+ wm.moveFocusedToDesktop(n);
264
+ } };
265
+ }
266
+ function _cyclePanelTab(dir) {
267
+ const tabs = [...document.querySelectorAll(".twm-bp__tabs .twm-bp__tab")];
268
+ if (!tabs.length) return false;
269
+ const cur = tabs.findIndex((t) => t.classList.contains("twm-bp__tab--on"));
270
+ tabs[((cur < 0 ? 0 : cur) + dir + tabs.length) % tabs.length].click();
271
+ return void 0;
272
+ }
273
+ var ARROWS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"];
274
+ var DIGITS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
275
+ var F_KEYS = Array.from({ length: 12 }, (_, i) => `F${i + 1}`);
276
+ var DEFAULT_CHORDS = [
277
+ ["Ctrl+K", "palette", true],
278
+ ["Meta+K", "palette", true],
279
+ ["?", "help", false],
280
+ ["Escape", "palette:close", true],
281
+ ...F_KEYS.map((f, i) => [f, `nav:${i}`, false]),
282
+ ["Backspace", "back", false],
283
+ ["Alt+H", "split:h", true],
284
+ ["Alt+V", "split:v", true],
285
+ ["Alt+Shift+H", "splitWith:h", true],
286
+ ["Alt+Shift+V", "splitWith:v", true],
287
+ ["Alt+T", "openInTab", true],
288
+ ["Alt+N", "openInWindow", true],
289
+ ["Alt+W", "close", true],
290
+ ["Alt+F", "float", true],
291
+ ...ARROWS.map((k, i) => [`Alt+${k}`, `focus:${DIRS[i]}`, true]),
292
+ ...ARROWS.map((k, i) => [`Alt+Shift+${k}`, `move:${DIRS[i]}`, true]),
293
+ ...DIGITS.map((d) => [`Alt+${d}`, `desktop:${d - 1}`, true]),
294
+ ...DIGITS.map((d) => [`Alt+Shift+${d}`, `toDesktop:${d - 1}`, true]),
295
+ ["Ctrl+ArrowLeft", "desktop:prev", false],
296
+ ["Ctrl+ArrowRight", "desktop:next", false],
297
+ ["Shift+ArrowLeft", "panelTab:prev", false],
298
+ ["Shift+ArrowRight", "panelTab:next", false],
299
+ ["Ctrl+Tab", "tab:next", true],
300
+ ["Meta+Tab", "tab:next", true],
301
+ ["Ctrl+Shift+Tab", "tab:prev", true],
302
+ ["Meta+Shift+Tab", "tab:prev", true],
303
+ ["Alt+Letter", "suppress", false]
304
+ ];
305
+ var SHORTCUT_CHORDS = [
306
+ ["Ctrl+K", "Meta+K"],
307
+ ["?"],
308
+ ["Escape"],
309
+ F_KEYS,
310
+ ["Backspace"],
311
+ ["Alt+H", "Alt+V"],
312
+ ["Alt+Shift+H", "Alt+Shift+V"],
313
+ ["Alt+T"],
314
+ ["Alt+N"],
315
+ ["Alt+W"],
316
+ ["Alt+F"],
317
+ ARROWS.map((k) => `Alt+${k}`),
318
+ ARROWS.map((k) => `Alt+Shift+${k}`),
319
+ DIGITS.map((d) => `Alt+${d}`),
320
+ DIGITS.map((d) => `Alt+Shift+${d}`),
321
+ ["Ctrl+ArrowLeft", "Ctrl+ArrowRight"],
322
+ ["Shift+ArrowLeft", "Shift+ArrowRight"],
323
+ ["Ctrl+Tab", "Meta+Tab", "Ctrl+Shift+Tab", "Meta+Shift+Tab"]
178
324
  ];
325
+ var DEFAULTS = new Map(DEFAULT_CHORDS.map(([id, action, inField]) => [id, { action, inField }]));
326
+ var BY_LOWER = new Map([...DEFAULTS.keys()].map((id) => [id.toLowerCase(), id]));
327
+ var MODIFIER_ORDER = ["Ctrl", "Meta", "Alt", "Shift"];
328
+ var MODIFIER_NAMES = {
329
+ ctrl: "Ctrl",
330
+ control: "Ctrl",
331
+ meta: "Meta",
332
+ cmd: "Meta",
333
+ command: "Meta",
334
+ alt: "Alt",
335
+ option: "Alt",
336
+ shift: "Shift"
337
+ };
338
+ var KEY_ALIASES = { esc: "Escape", left: "ArrowLeft", right: "ArrowRight", up: "ArrowUp", down: "ArrowDown" };
339
+ var _shiftCounts = (key) => key.length !== 1 || /^[\p{L}\p{N}]$/u.test(key);
340
+ function _compose(mods, key) {
341
+ const k = key.length === 1 ? key.toUpperCase() : key;
342
+ const used = MODIFIER_ORDER.filter((m) => mods.has(m) && (m !== "Shift" || _shiftCounts(key)));
343
+ return [...used, k].join("+");
344
+ }
345
+ function _chordOfEvent(e) {
346
+ const key = e.key;
347
+ if (!key || key === "Control" || key === "Shift" || key === "Alt" || key === "Meta") return null;
348
+ const { ctrlKey: ctrl, metaKey: meta, altKey: alt, shiftKey: shift } = e;
349
+ const lower = key.toLowerCase();
350
+ const cm = ctrl ? "Ctrl" : "Meta";
351
+ if ((ctrl || meta) && lower === "k" && !shift) return `${cm}+K`;
352
+ if ((ctrl || meta) && !alt && key === "Tab") return `${cm}+${shift ? "Shift+" : ""}Tab`;
353
+ if (key === "Escape") return "Escape";
354
+ if (key === "?") return "?";
355
+ if (key === "Backspace") return ctrl || meta ? null : "Backspace";
356
+ if (!alt) {
357
+ const only = (m) => ctrl === (m === "Ctrl") && shift === (m === "Shift") && !meta;
358
+ if (/^F([1-9]|1[0-2])$/.test(key)) return !ctrl && !meta && !shift ? key : null;
359
+ if (key === "ArrowLeft" || key === "ArrowRight") {
360
+ if (only("Shift")) return `Shift+${key}`;
361
+ if (only("Ctrl")) return `Ctrl+${key}`;
362
+ }
363
+ return null;
364
+ }
365
+ const sh = shift ? "Shift+" : "";
366
+ if (/^[1-9]$/.test(key)) return `Alt+${sh}${key}`;
367
+ if (lower === "h" || lower === "v") return `Alt+${sh}${lower.toUpperCase()}`;
368
+ if (lower === "t" || lower === "n" || lower === "w" || lower === "f") return `Alt+${lower.toUpperCase()}`;
369
+ if (ARROWS.includes(key)) return `Alt+${sh}${key}`;
370
+ if (/^[a-z]$/.test(lower)) return "Alt+Letter";
371
+ return null;
372
+ }
373
+ function _canonicalChord(id) {
374
+ if (typeof id !== "string" || !id) return null;
375
+ const parts = id.split("+").map((p) => p.trim());
376
+ const keyRaw = parts.pop();
377
+ if (!keyRaw) return null;
378
+ const mods = /* @__PURE__ */ new Set();
379
+ for (const p of parts) {
380
+ const m = MODIFIER_NAMES[p.toLowerCase()];
381
+ if (!m) return null;
382
+ mods.add(m);
383
+ }
384
+ const key = KEY_ALIASES[keyRaw.toLowerCase()] ?? keyRaw;
385
+ return BY_LOWER.get(_compose(mods, key).toLowerCase()) ?? null;
386
+ }
387
+ function _resolve(bindings) {
388
+ const table = /* @__PURE__ */ new Map();
389
+ for (const [id, { action, inField }] of DEFAULTS) {
390
+ const a = _action(action);
391
+ table.set(id, { run: a.run, label: a.label, inField, isDefault: true });
392
+ }
393
+ const problems = [];
394
+ if (bindings == null) return { table, problems };
395
+ if (typeof bindings !== "object") {
396
+ problems.push("`bindings` must be an object of { chord: action | false | function } \u2014 ignored");
397
+ return { table, problems };
398
+ }
399
+ for (const [rawId, value] of Object.entries(bindings)) {
400
+ const id = _canonicalChord(rawId);
401
+ if (!id) {
402
+ problems.push(`unknown chord "${rawId}" in bindings \u2014 ignored`);
403
+ continue;
404
+ }
405
+ const { action: defaultAction, inField } = DEFAULTS.get(id);
406
+ if (value === false) {
407
+ table.set(id, { disabled: true });
408
+ } else if (typeof value === "function") {
409
+ table.set(id, {
410
+ run: (ctx, e) => value(e, { wm: ctx.wm, palette: ctx.palette }),
411
+ label: "Defined by the application",
412
+ inField,
413
+ isDefault: false
414
+ });
415
+ } else {
416
+ const a = _action(value);
417
+ if (!a) {
418
+ problems.push(`unknown action ${JSON.stringify(value)} for chord "${rawId}" \u2014 ignored`);
419
+ continue;
420
+ }
421
+ table.set(id, { run: a.run, label: a.label, inField, isDefault: value === defaultAction });
422
+ }
423
+ }
424
+ return { table, problems };
425
+ }
426
+ function keymapShortcuts(bindings = null) {
427
+ const { table } = _resolve(bindings);
428
+ const rows = [];
429
+ SHORTCUT_CHORDS.forEach((chords, i) => {
430
+ if (chords.every((id) => table.get(id).isDefault)) {
431
+ rows.push(KEYMAP_SHORTCUTS[i]);
432
+ return;
433
+ }
434
+ for (const id of chords) {
435
+ const b = table.get(id);
436
+ if (b.disabled || !b.label) continue;
437
+ rows.push(Object.freeze({ keys: id, action: b.label }));
438
+ }
439
+ });
440
+ return Object.freeze(rows);
441
+ }
442
+ function installKeymap({ wm, palette, navSelector = null, bindings = null, logger = null } = {}) {
443
+ const { table, problems } = _resolve(bindings);
444
+ const log = logger ?? console;
445
+ for (const p of problems) log.warn?.(`[keymap] ${p}`);
446
+ const ctx = { wm, palette, navSelector };
447
+ const onKeyDown = (e) => {
448
+ if (e.defaultPrevented) return;
449
+ const chord = _chordOfEvent(e);
450
+ if (!chord) return;
451
+ const b = table.get(chord);
452
+ if (!b || b.disabled) return;
453
+ if (!b.inField && e.target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
454
+ if (b.run(ctx, e) !== false) e.preventDefault();
455
+ };
456
+ document.addEventListener("keydown", onKeyDown);
457
+ return () => document.removeEventListener("keydown", onKeyDown);
458
+ }
459
+
460
+ // src/tiling/command_palette.js
461
+ var ROOT_ID = "twm-cmdpal";
462
+ var HELP_ACTION = KEYMAP_SHORTCUTS.find((r) => r.keys === "?").action;
463
+ function _staticCommands(shortcuts) {
464
+ const helpKeys = shortcuts.filter((r) => r.action === HELP_ACTION).map((r) => r.keys).join(" / ");
465
+ return [
466
+ {
467
+ kind: "command",
468
+ id: "shortcuts",
469
+ label: "Keyboard shortcuts",
470
+ hint: helpKeys ? `Show all key bindings (${helpKeys})` : "Show all key bindings",
471
+ icon: "keyboard",
472
+ ...helpKeys ? { shortcut: helpKeys } : {},
473
+ verb: "run",
474
+ // The application's help may define the topic; when it does not, the
475
+ // window manager's own bindings are shown rather than an error.
476
+ action: () => HelpModal.open(
477
+ KEYBOARD_SHORTCUTS_TOPIC_ID,
478
+ { fallback: keyboardShortcutsTopic(shortcuts) }
479
+ )
480
+ }
481
+ ];
482
+ }
179
483
  var DEFAULT_GROUP_CAP = 4;
180
484
  var DEFAULT_COMMAND_MIN_CHARS = 2;
181
485
  var REMOTE_DEBOUNCE_MS = 150;
@@ -197,7 +501,8 @@ function createCommandPalette({
197
501
  drill = null,
198
502
  cache = false,
199
503
  tips = null,
200
- footerNote = ""
504
+ footerNote = "",
505
+ shortcuts = KEYMAP_SHORTCUTS
201
506
  }) {
202
507
  if (!taxonomy) throw new Error("createCommandPalette: a taxonomy is required");
203
508
  if (!catalog) throw new Error("createCommandPalette: an entity catalog is required");
@@ -206,6 +511,7 @@ function createCommandPalette({
206
511
  let entities = [];
207
512
  let cached = null;
208
513
  let onClose = null;
514
+ const isSingle = (row) => !!row && taxonomy.isSingle(row.kind);
209
515
  const isOpen = () => !!overlay;
210
516
  const close = () => {
211
517
  if (!overlay) return;
@@ -223,6 +529,7 @@ function createCommandPalette({
223
529
  const invalidate = () => {
224
530
  cached = null;
225
531
  };
532
+ const staticCommands = _staticCommands(shortcuts);
226
533
  const open = async () => {
227
534
  if (overlay) return;
228
535
  overlay = document.createElement("div");
@@ -240,19 +547,28 @@ function createCommandPalette({
240
547
  const chipSlot = overlay.querySelector('[data-role="type-chip"]');
241
548
  const chips = overlay.querySelectorAll("[data-shortcut]");
242
549
  const toggles = overlay.querySelectorAll("[data-toggle]");
243
- for (const btn of toggles) {
550
+ const syncToggle = (btn) => {
244
551
  const which = btn.dataset.toggle;
552
+ const { available, reason } = wm.getPanelAvailability(which);
245
553
  btn.classList.toggle("twm-chip--on", wm.isPanelOpen(which));
246
- }
554
+ if (available) {
555
+ btn.removeAttribute("aria-disabled");
556
+ btn.removeAttribute("title");
557
+ } else {
558
+ btn.setAttribute("aria-disabled", "true");
559
+ if (reason) btn.setAttribute("title", reason);
560
+ else btn.removeAttribute("title");
561
+ }
562
+ };
563
+ toggles.forEach(syncToggle);
247
564
  chips.forEach((btn) => btn.addEventListener("click", () => {
248
565
  const kind = btn.dataset.shortcut;
249
566
  wm.openInPrimary(kind);
250
567
  close();
251
568
  }));
252
569
  toggles.forEach((btn) => btn.addEventListener("click", () => {
253
- const which = btn.dataset.toggle;
254
- wm.togglePanel(which);
255
- btn.classList.toggle("twm-chip--on", wm.isPanelOpen(which));
570
+ wm.togglePanel(btn.dataset.toggle);
571
+ syncToggle(btn);
256
572
  }));
257
573
  let filtered = [];
258
574
  let active = 0;
@@ -273,12 +589,12 @@ function createCommandPalette({
273
589
  const renderChip = () => {
274
590
  if (scope) {
275
591
  chipSlot.innerHTML = `
276
- <span class="twm-cmdpal__type-chip" title="${_esc(scope.label)}">
277
- <span class="material-symbols-outlined twm-cmdpal__type-chip-icon">${_esc(scope.icon || "arrow_right")}</span>
278
- <span class="twm-cmdpal__type-chip-label twm-cmdpal__type-chip-label--scope">${_esc(scope.label)}</span>
592
+ <span class="twm-cmdpal__type-chip" title="${_esc2(scope.label)}">
593
+ <span class="material-symbols-outlined twm-cmdpal__type-chip-icon">${_esc2(scope.icon || "arrow_right")}</span>
594
+ <span class="twm-cmdpal__type-chip-label twm-cmdpal__type-chip-label--scope">${_esc2(scope.label)}</span>
279
595
  <button type="button" class="twm-cmdpal__type-chip-x"
280
596
  data-action="clear-type"
281
- aria-label="Leave ${_esc(scope.label)}">\xD7</button>
597
+ aria-label="Leave ${_esc2(scope.label)}">\xD7</button>
282
598
  </span>`;
283
599
  chipSlot.querySelector('[data-action="clear-type"]')?.addEventListener("click", (ev) => {
284
600
  ev.preventDefault();
@@ -295,9 +611,9 @@ function createCommandPalette({
295
611
  const meta = taxonomy.meta(head);
296
612
  const label = typeFilterLabel || meta?.label || head;
297
613
  chipSlot.innerHTML = `
298
- <span class="twm-cmdpal__type-chip" title="Filtering by ${_esc(label)} (${typeFilter.length} kind${typeFilter.length === 1 ? "" : "s"})">
614
+ <span class="twm-cmdpal__type-chip" title="Filtering by ${_esc2(label)} (${typeFilter.length} kind${typeFilter.length === 1 ? "" : "s"})">
299
615
  <span class="material-symbols-outlined twm-cmdpal__type-chip-icon">${meta?.icon || "arrow_right"}</span>
300
- <span class="twm-cmdpal__type-chip-label">${_esc(label)}</span>
616
+ <span class="twm-cmdpal__type-chip-label">${_esc2(label)}</span>
301
617
  <button type="button" class="twm-cmdpal__type-chip-x"
302
618
  data-action="clear-type"
303
619
  aria-label="Clear type filter">\xD7</button>
@@ -321,9 +637,9 @@ function createCommandPalette({
321
637
  <div class="twm-cmdpal__item${i === active ? " twm-cmdpal__item--active" : ""}"
322
638
  data-idx="${i}">
323
639
  <span class="material-symbols-outlined twm-cmdpal__icon">${e.icon || "arrow_right"}</span>
324
- <span class="twm-cmdpal__label">${_esc(e.label || e.id)}</span>
640
+ <span class="twm-cmdpal__label">${_esc2(e.label || e.id)}</span>
325
641
  <span class="twm-cmdpal__kind">${e.kind}</span>
326
- <span class="twm-cmdpal__hint">${_esc(e.hint || "")}</span>
642
+ <span class="twm-cmdpal__hint">${_esc2(e.hint || "")}</span>
327
643
  </div>
328
644
  `).join("") || "";
329
645
  list.querySelectorAll("[data-idx]").forEach((el) => {
@@ -348,21 +664,22 @@ function createCommandPalette({
348
664
  if (r.more) {
349
665
  return `<div class="twm-cmdpal__item twm-cmdpal__item--more${on}" data-idx="${i}" role="option">
350
666
  <span class="material-symbols-outlined twm-cmdpal__icon">expand_more</span>
351
- <span class="twm-cmdpal__main"><span class="twm-cmdpal__label">${_esc(r.label)}</span></span>
667
+ <span class="twm-cmdpal__main"><span class="twm-cmdpal__label">${_esc2(r.label)}</span></span>
352
668
  <span class="twm-cmdpal__side"><span class="twm-cmdpal__acts"><span><kbd>Enter</kbd> show all</span></span></span>
353
669
  </div>`;
354
670
  }
355
671
  const drawn = _drawn(r);
356
- const ctx = r.cells?.length ? `<span class="twm-cmdpal__cells">${r.cells.map(_esc).join(" \xB7 ")}</span>` : drawn ? `<span class="twm-cmdpal__context">${_esc(drawn)}</span>` : "";
672
+ const ctx = r.cells?.length ? `<span class="twm-cmdpal__cells">${r.cells.map(_esc2).join(" \xB7 ")}</span>` : drawn ? `<span class="twm-cmdpal__context">${_esc2(drawn)}</span>` : "";
357
673
  const verb = r.verb || (r.action || r.kind === "command" ? "run" : "open");
358
674
  const modes = Array.isArray(r.modes) ? r.modes : [];
359
- const acts = [`<span><kbd>Enter</kbd> ${_esc(verb)}</span>`];
360
- if (r.drillLabel && drill) acts.push(`<span><kbd>Tab</kbd> ${_esc(r.drillLabel)}</span>`);
361
- if (modes.includes("split")) acts.push("<span><kbd>Ctrl Enter</kbd> split</span>");
362
- if (modes.includes("window")) acts.push("<span><kbd>Shift Enter</kbd> window</span>");
363
- const side = r.shortcut ? `<kbd class="twm-cmdpal__shortcut">${_esc(r.shortcut)}</kbd>` : `<span class="twm-cmdpal__kind">${_esc(kindLabel(r))}</span>`;
675
+ const acts = [`<span><kbd>Enter</kbd> ${_esc2(verb)}</span>`];
676
+ if (r.drillLabel && drill) acts.push(`<span><kbd>Tab</kbd> ${_esc2(r.drillLabel)}</span>`);
677
+ const refused = isSingle(r) ? ` class="twm-cmdpal__act--disabled" aria-disabled="true" title="${_esc2(taxonomy.singleReason(r.kind))}"` : "";
678
+ if (modes.includes("split")) acts.push(`<span${refused}><kbd>Ctrl Enter</kbd> split</span>`);
679
+ if (modes.includes("window")) acts.push(`<span${refused}><kbd>Shift Enter</kbd> window</span>`);
680
+ const side = r.shortcut ? `<kbd class="twm-cmdpal__shortcut">${_esc2(r.shortcut)}</kbd>` : `<span class="twm-cmdpal__kind">${_esc2(kindLabel(r))}</span>`;
364
681
  return `<div class="twm-cmdpal__item${on}" data-idx="${i}" role="option">
365
- <span class="material-symbols-outlined twm-cmdpal__icon">${_esc(r.icon || "arrow_right")}</span>
682
+ <span class="material-symbols-outlined twm-cmdpal__icon">${_esc2(r.icon || "arrow_right")}</span>
366
683
  <span class="twm-cmdpal__main"><span class="twm-cmdpal__label">${_mark(r.label || r.id, r._hits)}</span>${ctx}</span>
367
684
  <span class="twm-cmdpal__side">${side}<span class="twm-cmdpal__acts">${acts.join("")}</span></span>
368
685
  </div>`;
@@ -371,22 +688,22 @@ function createCommandPalette({
371
688
  let html = "";
372
689
  for (const s of sections) {
373
690
  if (!s.count) continue;
374
- if (s.title) html += `<div class="twm-cmdpal__group" role="presentation">${_esc(s.title)}</div>`;
691
+ if (s.title) html += `<div class="twm-cmdpal__group" role="presentation">${_esc2(s.title)}</div>`;
375
692
  for (let i = s.start; i < s.start + s.count; i++) html += rowHtml(filtered[i], i);
376
693
  }
377
694
  const q = input.value.trim();
378
695
  if (!filtered.length) {
379
696
  if (scope) {
380
697
  const msg = remote.error ? remote.error : remote.pending ? "Searching\u2026" : q ? `Nothing in ${scope.label} matches \u201C${q}\u201D.` : scope.emptyText || `Type to search ${scope.label}.`;
381
- html += `<div class="twm-cmdpal__empty">${_esc(msg)}</div>`;
698
+ html += `<div class="twm-cmdpal__empty">${_esc2(msg)}</div>`;
382
699
  } else if (q && q !== ">") {
383
- html += `<div class="twm-cmdpal__empty">Nothing matches \u201C${_esc(q)}\u201D.</div>`;
700
+ html += `<div class="twm-cmdpal__empty">Nothing matches \u201C${_esc2(q)}\u201D.</div>`;
384
701
  }
385
702
  } else if (scope && remote.pending) {
386
703
  html += '<div class="twm-cmdpal__empty twm-cmdpal__empty--quiet">Searching\u2026</div>';
387
704
  }
388
705
  if (!q && !scope && !typeFilter && Array.isArray(tips) && tips.length) {
389
- html += `<div class="twm-cmdpal__tips">${tips.map((t) => `<span>${_esc(t)}</span>`).join("")}</div>`;
706
+ html += `<div class="twm-cmdpal__tips">${tips.map((t) => `<span>${_esc2(t)}</span>`).join("")}</div>`;
390
707
  }
391
708
  list.innerHTML = html;
392
709
  renderFooter();
@@ -405,7 +722,7 @@ function createCommandPalette({
405
722
  if (scope || typeFilter) parts.push("<span><kbd>Backspace</kbd> leave</span>");
406
723
  parts.push("<span><kbd>Esc</kbd> close</span>");
407
724
  const note = scope?.note || footerNote;
408
- foot.innerHTML = parts.join("") + (note ? `<span class="twm-cmdpal__footnote">${_esc(note)}</span>` : "");
725
+ foot.innerHTML = parts.join("") + (note ? `<span class="twm-cmdpal__footnote">${_esc2(note)}</span>` : "");
409
726
  };
410
727
  if (grouped) {
411
728
  list.addEventListener("mousemove", (e) => {
@@ -430,6 +747,7 @@ function createCommandPalette({
430
747
  showAll(picked.more);
431
748
  return;
432
749
  }
750
+ if (mode !== "open" && isSingle(picked)) return;
433
751
  const pick = _public(picked);
434
752
  const how = { mode, scope: scope?.id ?? null };
435
753
  close();
@@ -675,7 +993,7 @@ function createCommandPalette({
675
993
  console.warn("[cmdpal] commands() threw", err);
676
994
  }
677
995
  }
678
- return [...STATIC_COMMANDS, ...extra];
996
+ return [...staticCommands, ...extra];
679
997
  };
680
998
  const withCommands = (rows) => grouped ? [...rows, ...commandRows()] : [...commandRows(), ...rows];
681
999
  entities = _decorateIcons(withCommands(cache && cached ? cached : []), taxonomy);
@@ -730,7 +1048,7 @@ function _markup(taxonomy, placeholder, wm, { chrome = {}, grouped = false } = {
730
1048
  <span class="material-symbols-outlined twm-cmdpal__search-icon">search</span>
731
1049
  <span class="twm-cmdpal__type-chip-slot" data-role="type-chip"></span>
732
1050
  <input type="text" class="twm-cmdpal__input"
733
- placeholder="${_esc(placeholder)}"
1051
+ placeholder="${_esc2(placeholder)}"
734
1052
  autocomplete="off" spellcheck="false" role="combobox"
735
1053
  aria-expanded="true" aria-controls="twm-cmdpal-list" />
736
1054
  <kbd class="twm-cmdpal__esc">Esc</kbd>
@@ -746,7 +1064,7 @@ function _markup(taxonomy, placeholder, wm, { chrome = {}, grouped = false } = {
746
1064
  <div class="twm-cmdpal__inputrow">
747
1065
  <span class="twm-cmdpal__type-chip-slot" data-role="type-chip"></span>
748
1066
  <input type="text" class="twm-cmdpal__input"
749
- placeholder="${_esc(placeholder)}"
1067
+ placeholder="${_esc2(placeholder)}"
750
1068
  autocomplete="off" />
751
1069
  </div>
752
1070
  <div class="twm-cmdpal__chips">${chips}</div>
@@ -864,7 +1182,7 @@ function _match(text, query) {
864
1182
  }
865
1183
  function _mark(text, hits) {
866
1184
  const s = String(text ?? "");
867
- if (!hits?.length) return _esc(s);
1185
+ if (!hits?.length) return _esc2(s);
868
1186
  const set = new Set(hits);
869
1187
  let out = "";
870
1188
  let open = false;
@@ -878,12 +1196,12 @@ function _mark(text, hits) {
878
1196
  out += "</mark>";
879
1197
  open = false;
880
1198
  }
881
- out += _esc(s[i]);
1199
+ out += _esc2(s[i]);
882
1200
  }
883
1201
  if (open) out += "</mark>";
884
1202
  return out;
885
1203
  }
886
- function _esc(s) {
1204
+ function _esc2(s) {
887
1205
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({
888
1206
  "&": "&amp;",
889
1207
  "<": "&lt;",
@@ -904,7 +1222,7 @@ function windowPlaceholderFactory(hostEl, props, ctx) {
904
1222
  hostEl.innerHTML = `
905
1223
  <div class="twm-window-placeholder">
906
1224
  <span class="material-symbols-outlined twm-window-placeholder__icon">open_in_new</span>
907
- <div class="twm-window-placeholder__title">${_esc2(props.originalTitle || props.originalKind || "content")}</div>
1225
+ <div class="twm-window-placeholder__title">${_esc3(props.originalTitle || props.originalKind || "content")}</div>
908
1226
  <div class="twm-window-placeholder__hint">is open in a managed window</div>
909
1227
  <button class="twm-window-placeholder__btn" data-action="bring-back">
910
1228
  Bring back to this tile
@@ -956,7 +1274,7 @@ function _placeholder(kind) {
956
1274
  return { title: kind };
957
1275
  };
958
1276
  }
959
- function _esc2(s) {
1277
+ function _esc3(s) {
960
1278
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
961
1279
  }
962
1280
 
@@ -1806,6 +2124,11 @@ function _makeDesktop(label, seed, panelDefaults = DEFAULT_PANEL_STATE) {
1806
2124
  // when it asked for nothing. Names are assigned by wm._canonicalize via
1807
2125
  // PANEL_TITLES.
1808
2126
  panels: { ...panelDefaults },
2127
+ // Each panel's last share of the WM area along its axis, recorded by
2128
+ // the WM when a splitter drag ends (and, for a panel not yet
2129
+ // measured, when it rebuilds the panels). A side with none opens at
2130
+ // the default ratio.
2131
+ panelShares: {},
1809
2132
  // C40. Both unset is a desktop exactly as it always was.
1810
2133
  group: null,
1811
2134
  fixed: false
@@ -1929,6 +2252,9 @@ var DesktopManager = class _DesktopManager {
1929
2252
  label: d.label,
1930
2253
  tree: d.tree.serialize(),
1931
2254
  panels: { ...d.panels },
2255
+ // Written only when a panel has been measured, like the C40
2256
+ // keys below.
2257
+ ...Object.keys(d.panelShares).length ? { panelShares: { ...d.panelShares } } : {},
1932
2258
  // C40. Written only when set, so a blob without them is
1933
2259
  // byte-for-byte the blob it always was.
1934
2260
  ...d.group ? { group: d.group } : {},
@@ -1951,6 +2277,7 @@ var DesktopManager = class _DesktopManager {
1951
2277
  // this merge order avoids. The default only fills a key the stored
1952
2278
  // blob predates.
1953
2279
  panels: { ...m.panelDefaults, ...raw.panels || {} },
2280
+ panelShares: { ...raw.panelShares || {} },
1954
2281
  group: raw.group || null,
1955
2282
  fixed: !!raw.fixed
1956
2283
  }));
@@ -2093,120 +2420,25 @@ function createEntityCatalog({ sources, aliases = {}, aggregate = null } = {}) {
2093
2420
  });
2094
2421
  }
2095
2422
 
2096
- // src/tiling/keymap.js
2097
- function installKeymap({ wm, palette, ...opts } = {}) {
2098
- const onKeyDown = (e) => {
2099
- const inField = e.target?.closest?.(
2100
- 'input, textarea, select, [contenteditable="true"]'
2101
- );
2102
- if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k" && !e.shiftKey) {
2103
- e.preventDefault();
2104
- palette.toggle();
2105
- return;
2106
- }
2107
- if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key === "Tab") {
2108
- e.preventDefault();
2109
- wm.cycleFocusedTab(e.shiftKey ? -1 : 1);
2110
- return;
2111
- }
2112
- if (e.key === "Escape") {
2113
- if (palette.isOpen()) {
2114
- palette.close();
2115
- return;
2116
- }
2117
- }
2118
- if (e.key === "Backspace" && !inField && !e.metaKey && !e.ctrlKey) {
2119
- e.preventDefault();
2120
- wm.navigateBack?.();
2121
- return;
2122
- }
2123
- const fMatch = /^F([1-9]|1[0-2])$/.exec(e.key);
2124
- if (fMatch && !inField && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
2125
- const btns = document.querySelectorAll(
2126
- opts.navSelector || ".twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn"
2127
- );
2128
- const idx = Number(fMatch[1]) - 1;
2129
- if (idx < btns.length) {
2130
- e.preventDefault();
2131
- btns[idx].click();
2132
- return;
2133
- }
2134
- }
2135
- if (e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && !inField && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
2136
- const tabs = [...document.querySelectorAll(".twm-bp__tabs .twm-bp__tab")];
2137
- if (tabs.length) {
2138
- const cur = tabs.findIndex((t) => t.classList.contains("twm-bp__tab--on"));
2139
- const dir = e.key === "ArrowRight" ? 1 : -1;
2140
- const next = ((cur < 0 ? 0 : cur) + dir + tabs.length) % tabs.length;
2141
- e.preventDefault();
2142
- tabs[next].click();
2143
- return;
2144
- }
2145
- }
2146
- if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && !inField && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
2147
- e.preventDefault();
2148
- wm.cycleDesktop(e.key === "ArrowRight" ? 1 : -1);
2149
- return;
2150
- }
2151
- if (!e.altKey) return;
2152
- const key = e.key.toLowerCase();
2153
- if (/^[1-9]$/.test(e.key)) {
2154
- e.preventDefault();
2155
- const idx = Number(e.key) - 1;
2156
- if (e.shiftKey) wm.moveFocusedToDesktop(idx);
2157
- else wm.switchDesktop(idx);
2158
- return;
2159
- }
2160
- switch (key) {
2161
- // Plain Alt+H/V split into an empty pane; with Shift they open
2162
- // the focused tile's own content in the new split.
2163
- case "h":
2164
- e.preventDefault();
2165
- e.shiftKey ? wm.splitFocusedWith("h") : wm.split("h");
2166
- return;
2167
- case "v":
2168
- e.preventDefault();
2169
- e.shiftKey ? wm.splitFocusedWith("v") : wm.split("v");
2170
- return;
2171
- case "t":
2172
- e.preventDefault();
2173
- wm.openFocusedInTab();
2174
- return;
2175
- case "n":
2176
- e.preventDefault();
2177
- wm.openFocusedInWindow();
2178
- return;
2179
- case "w":
2180
- e.preventDefault();
2181
- wm.closeFocused();
2182
- return;
2183
- case "f":
2184
- e.preventDefault();
2185
- wm.toggleManagedFocused();
2186
- return;
2187
- case "arrowleft":
2188
- e.preventDefault();
2189
- e.shiftKey ? wm.moveFocused("left") : wm.focusDir("left");
2190
- return;
2191
- case "arrowright":
2192
- e.preventDefault();
2193
- e.shiftKey ? wm.moveFocused("right") : wm.focusDir("right");
2194
- return;
2195
- case "arrowup":
2196
- e.preventDefault();
2197
- e.shiftKey ? wm.moveFocused("up") : wm.focusDir("up");
2198
- return;
2199
- case "arrowdown":
2200
- e.preventDefault();
2201
- e.shiftKey ? wm.moveFocused("down") : wm.focusDir("down");
2202
- return;
2203
- }
2204
- if (key.length === 1 && /[a-z]/.test(key) && !inField) {
2205
- e.preventDefault();
2206
- }
2207
- };
2208
- document.addEventListener("keydown", onKeyDown);
2209
- return () => document.removeEventListener("keydown", onKeyDown);
2423
+ // src/tiling/panel_availability.js
2424
+ var PANEL_SIDES = Object.freeze(["left", "right", "bottom"]);
2425
+ var PANEL_OFFERED = Object.freeze({ available: true, reason: null });
2426
+ function readPanelOffer(offer, where) {
2427
+ if (offer === true) return PANEL_OFFERED;
2428
+ if (offer === false) return Object.freeze({ available: false, reason: null });
2429
+ if (offer && typeof offer === "object" && !Array.isArray(offer)) {
2430
+ const { reason = null } = offer;
2431
+ if (reason !== null && typeof reason !== "string") {
2432
+ throw new TypeError(`${where}: a panel's \`reason\` must be a string`);
2433
+ }
2434
+ return Object.freeze({ available: false, reason: reason || null });
2435
+ }
2436
+ throw new TypeError(`${where}: a panel offer is true, false or { reason }`);
2437
+ }
2438
+ function assertPanelSide(side, where) {
2439
+ if (!PANEL_SIDES.includes(side)) {
2440
+ throw new TypeError(`${where}: unknown panel side '${side}' (expected ${PANEL_SIDES.join(", ")})`);
2441
+ }
2210
2442
  }
2211
2443
 
2212
2444
  // src/tiling/kind_taxonomy.js
@@ -2231,6 +2463,27 @@ function createTaxonomy({ kinds, root } = {}) {
2231
2463
  `createTaxonomy: '${kind}' is both a top-nav kind and owned by one`
2232
2464
  );
2233
2465
  }
2466
+ if (def.panels !== void 0) {
2467
+ if (!def.panels || typeof def.panels !== "object" || Array.isArray(def.panels)) {
2468
+ throw new TypeError(`createTaxonomy: '${kind}'.panels must be an object`);
2469
+ }
2470
+ for (const [side, offer] of Object.entries(def.panels)) {
2471
+ if (!PANEL_SIDES.includes(side)) {
2472
+ throw new Error(`createTaxonomy: '${kind}'.panels names unknown side '${side}'`);
2473
+ }
2474
+ readPanelOffer(offer, `createTaxonomy: '${kind}'.panels.${side}`);
2475
+ }
2476
+ }
2477
+ if (def.single != null && def.single !== false && def.single !== true && !(typeof def.single === "string" && def.single)) {
2478
+ throw new TypeError(
2479
+ `createTaxonomy: '${kind}'.single must be true or a prop name`
2480
+ );
2481
+ }
2482
+ }
2483
+ if (kinds[root].single) {
2484
+ throw new Error(
2485
+ `createTaxonomy: the root kind '${root}' cannot be single \u2014 every new pane, tab and desktop is seeded with it`
2486
+ );
2234
2487
  }
2235
2488
  const _kinds = Object.freeze({ ...kinds });
2236
2489
  const meta = (kind) => _kinds[kind];
@@ -2280,12 +2533,31 @@ function createTaxonomy({ kinds, root } = {}) {
2280
2533
  const s = _kinds[kind]?.sources ?? _kinds[root]?.sources;
2281
2534
  return Array.isArray(s) ? s : [];
2282
2535
  };
2536
+ const panelOffer = (kind, side) => {
2537
+ for (const k of [kind, _kinds[kind]?.topNav]) {
2538
+ const offer = k ? _kinds[k]?.panels?.[side] : void 0;
2539
+ if (offer !== void 0) return readPanelOffer(offer, `taxonomy '${k}'.panels.${side}`);
2540
+ }
2541
+ return PANEL_OFFERED;
2542
+ };
2283
2543
  const topNavEntries = () => Object.entries(_kinds).filter(([, m]) => m.isTopNav).sort((a, b) => (a[1].order ?? 1e3) - (b[1].order ?? 1e3)).map(([kind, m]) => ({
2284
2544
  kind,
2285
2545
  label: m.shortLabel || m.label,
2286
2546
  longLabel: m.label,
2287
2547
  icon: m.icon
2288
2548
  }));
2549
+ const isSingle = (kind) => {
2550
+ const s = _kinds[kind]?.single;
2551
+ return s === true || typeof s === "string" && s !== "";
2552
+ };
2553
+ const instanceKey = (kind, props) => {
2554
+ if (!isSingle(kind)) return null;
2555
+ const by = _kinds[kind].single;
2556
+ if (by === true) return String(kind);
2557
+ const v = props?.[by];
2558
+ return `${kind}\0${v == null ? "" : String(v)}`;
2559
+ };
2560
+ const singleReason = (kind) => `${_kinds[kind]?.label || kind} is open once: use the one that is open`;
2289
2561
  return Object.freeze({
2290
2562
  root,
2291
2563
  kinds: _kinds,
@@ -2296,7 +2568,11 @@ function createTaxonomy({ kinds, root } = {}) {
2296
2568
  parentOf,
2297
2569
  labelOf,
2298
2570
  sourcesFor,
2299
- topNavEntries
2571
+ panelOffer,
2572
+ topNavEntries,
2573
+ isSingle,
2574
+ instanceKey,
2575
+ singleReason
2300
2576
  });
2301
2577
  }
2302
2578
 
@@ -2762,7 +3038,7 @@ function mountLandingShell(hostEl, opts) {
2762
3038
  pane = { key: "rows", title: "", hint: "" }
2763
3039
  } = opts || {};
2764
3040
  const paneKey = String(pane.key || "rows");
2765
- const _esc8 = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({
3041
+ const _esc9 = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({
2766
3042
  "&": "&amp;",
2767
3043
  "<": "&lt;",
2768
3044
  ">": "&gt;",
@@ -2771,24 +3047,24 @@ function mountLandingShell(hostEl, opts) {
2771
3047
  })[c]);
2772
3048
  const statsHtml = (stats || []).map((s) => `
2773
3049
  <span class="twm-landing__stat twm-entity-header__field">
2774
- <span>${_esc8(s.label || "")}</span>
2775
- <strong data-role="${_esc8(s.key)}">${_esc8(s.initial != null ? s.initial : 0)}</strong>
3050
+ <span>${_esc9(s.label || "")}</span>
3051
+ <strong data-role="${_esc9(s.key)}">${_esc9(s.initial != null ? s.initial : 0)}</strong>
2776
3052
  </span>
2777
3053
  `).join("");
2778
3054
  hostEl.classList.add("twm-landing");
2779
3055
  hostEl.innerHTML = `
2780
3056
  <header class="twm-entity-header">
2781
- <h2>${_esc8(title)}</h2>
3057
+ <h2>${_esc9(title)}</h2>
2782
3058
  ${statsHtml}
2783
3059
  <span class="twm-entity-header__spacer"></span>
2784
3060
  </header>
2785
3061
  <div class="twm-landing__split twm-landing__split--single">
2786
- <section class="twm-landing__pane" data-pane="${_esc8(paneKey)}">
3062
+ <section class="twm-landing__pane" data-pane="${_esc9(paneKey)}">
2787
3063
  <div class="twm-landing__pane-head">
2788
- <span class="twm-landing__pane-title">${_esc8(pane.title || "")}</span>
2789
- <span class="twm-landing__pane-hint">${_esc8(pane.hint || "")}</span>
3064
+ <span class="twm-landing__pane-title">${_esc9(pane.title || "")}</span>
3065
+ <span class="twm-landing__pane-hint">${_esc9(pane.hint || "")}</span>
2790
3066
  </div>
2791
- <div class="twm-landing__table" data-role="${_esc8(paneKey)}-host"></div>
3067
+ <div class="twm-landing__table" data-role="${_esc9(paneKey)}-host"></div>
2792
3068
  </section>
2793
3069
  </div>
2794
3070
  <footer class="twm-landing__actions" data-role="actions"></footer>
@@ -2817,9 +3093,9 @@ function mountLandingActions(footerEl, actions, hostEl) {
2817
3093
  btn.className = "twm-action-btn";
2818
3094
  if (a.id) btn.dataset.action = a.id;
2819
3095
  if (a.title) btn.title = a.title;
2820
- const icon = a.icon ? `<span class="material-symbols-outlined">${_esc3(a.icon)}</span>` : "";
2821
- const sc = a.shortcut ? ` <span class="twm-action-btn__kbd">[${_esc3(a.shortcut.toUpperCase())}]</span>` : "";
2822
- btn.innerHTML = `${icon}<span>${_esc3(a.label || "")}</span>${sc}`;
3096
+ const icon = a.icon ? `<span class="material-symbols-outlined">${_esc4(a.icon)}</span>` : "";
3097
+ const sc = a.shortcut ? ` <span class="twm-action-btn__kbd">[${_esc4(a.shortcut.toUpperCase())}]</span>` : "";
3098
+ btn.innerHTML = `${icon}<span>${_esc4(a.label || "")}</span>${sc}`;
2823
3099
  btn.addEventListener("click", (ev) => {
2824
3100
  ev.preventDefault();
2825
3101
  try {
@@ -2880,7 +3156,7 @@ function actionsCellRenderer(actionsColIdx, options = { edit: true, delete: true
2880
3156
  return true;
2881
3157
  };
2882
3158
  }
2883
- function _esc3(s) {
3159
+ function _esc4(s) {
2884
3160
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({
2885
3161
  "&": "&amp;",
2886
3162
  "<": "&lt;",
@@ -2910,37 +3186,46 @@ function mountNavPanel(hostEl, {
2910
3186
  modes = [],
2911
3187
  wm = null,
2912
3188
  eventBus = null,
2913
- filterPlaceholder = "Filter\u2026"
3189
+ filterPlaceholder = "Filter\u2026",
3190
+ showModeStrip = true,
3191
+ showFilter = true,
3192
+ initialMode = null
2914
3193
  } = {}) {
2915
3194
  if (!Array.isArray(modes) || modes.length === 0) {
2916
3195
  throw new Error("mountNavPanel: at least one mode is required");
2917
3196
  }
2918
- hostEl.classList.add("twm-nav");
2919
- hostEl.innerHTML = `
2920
- <div class="twm-nav__modes" data-role="modes"></div>
3197
+ const stripHtml = showModeStrip ? `
3198
+ <div class="twm-nav__modes" data-role="modes"></div>` : "";
3199
+ const filterHtml = showFilter ? `
2921
3200
  <div class="twm-sidebar-controls twm-nav__filter">
2922
3201
  <span class="material-symbols-outlined twm-sidebar-search-icon">search</span>
2923
3202
  <input class="twm-data-page__search twm-nav__filter-input"
2924
- type="search" placeholder="${_esc4(filterPlaceholder)}"
3203
+ type="search" placeholder="${_esc5(filterPlaceholder)}"
2925
3204
  autocomplete="off" />
2926
- </div>
3205
+ </div>` : "";
3206
+ hostEl.classList.add("twm-nav");
3207
+ hostEl.innerHTML = `${stripHtml}${filterHtml}
2927
3208
  <div class="twm-nav__body" data-role="body"></div>
2928
3209
  `;
2929
3210
  const modesEl = hostEl.querySelector('[data-role="modes"]');
2930
3211
  const bodyEl = hostEl.querySelector('[data-role="body"]');
3212
+ const filterBoxEl = hostEl.querySelector(".twm-nav__filter");
2931
3213
  const filterEl = hostEl.querySelector(".twm-nav__filter-input");
2932
- modesEl.innerHTML = modes.map((m) => `
2933
- <button class="twm-nav__mode twm-has-tooltip" data-mode="${_esc4(m.id)}"
2934
- data-tooltip="${_esc4(m.label)}">
2935
- <span class="material-symbols-outlined">${_esc4(m.icon)}</span>
2936
- <span>${_esc4(m.label)}</span>
3214
+ if (modesEl) {
3215
+ modesEl.innerHTML = modes.map((m) => `
3216
+ <button class="twm-nav__mode twm-has-tooltip" data-mode="${_esc5(m.id)}"
3217
+ data-tooltip="${_esc5(m.label)}">
3218
+ <span class="material-symbols-outlined">${_esc5(m.icon)}</span>
3219
+ <span>${_esc5(m.label)}</span>
2937
3220
  </button>
2938
3221
  `).join("");
3222
+ }
2939
3223
  const byId = new Map(modes.map((m) => [m.id, m]));
2940
3224
  const hosts = {};
2941
3225
  const panes = {};
2942
3226
  const filterByMode = Object.fromEntries(modes.map((m) => [m.id, ""]));
2943
- let mode = modes[0].id;
3227
+ let mode = byId.has(initialMode) ? initialMode : modes[0].id;
3228
+ const hasFilter = (id) => !!filterEl && byId.get(id)?.filter !== false;
2944
3229
  const ensureHost = (id) => {
2945
3230
  if (hosts[id]) return hosts[id];
2946
3231
  const el = document.createElement("div");
@@ -2957,8 +3242,12 @@ function mountNavPanel(hostEl, {
2957
3242
  for (const m of modes) {
2958
3243
  ensureHost(m.id).style.display = m.id === next ? "flex" : "none";
2959
3244
  }
2960
- modesEl.querySelectorAll("[data-mode]").forEach((b) => b.classList.toggle("twm-nav__mode--on", b.dataset.mode === next));
2961
- filterEl.value = filterByMode[next] || "";
3245
+ modesEl?.querySelectorAll("[data-mode]").forEach((b) => b.classList.toggle("twm-nav__mode--on", b.dataset.mode === next));
3246
+ if (filterEl) {
3247
+ if (!hasFilter(next)) filterBoxEl.style.display = "none";
3248
+ else if (filterBoxEl.style.display) filterBoxEl.style.removeProperty("display");
3249
+ filterEl.value = hasFilter(next) ? filterByMode[next] || "" : "";
3250
+ }
2962
3251
  const paneEl = ensureHost(next);
2963
3252
  if (!panes[next]) {
2964
3253
  try {
@@ -2976,6 +3265,7 @@ function mountNavPanel(hostEl, {
2976
3265
  _applyFilter();
2977
3266
  };
2978
3267
  const _applyFilter = () => {
3268
+ if (!hasFilter(mode)) return;
2979
3269
  const q = filterByMode[mode] || "";
2980
3270
  try {
2981
3271
  panes[mode]?.filter?.(q);
@@ -2983,16 +3273,19 @@ function mountNavPanel(hostEl, {
2983
3273
  console.warn("[nav] filter failed", mode, err);
2984
3274
  }
2985
3275
  };
2986
- filterEl.addEventListener("input", () => {
3276
+ filterEl?.addEventListener("input", () => {
3277
+ if (!hasFilter(mode)) return;
2987
3278
  filterByMode[mode] = filterEl.value;
2988
3279
  _applyFilter();
2989
3280
  });
2990
- modesEl.addEventListener("click", (e) => {
3281
+ modesEl?.addEventListener("click", (e) => {
2991
3282
  const btn = e.target.closest("[data-mode]");
2992
3283
  if (btn) activate(btn.dataset.mode);
2993
3284
  });
2994
3285
  activate(mode);
2995
3286
  return {
3287
+ select: (modeId) => activate(modeId),
3288
+ active: () => mode,
2996
3289
  refresh: () => activate(mode),
2997
3290
  destroy: () => {
2998
3291
  for (const p of Object.values(panes)) {
@@ -3004,7 +3297,7 @@ function mountNavPanel(hostEl, {
3004
3297
  }
3005
3298
  };
3006
3299
  }
3007
- function _esc4(s) {
3300
+ function _esc5(s) {
3008
3301
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
3009
3302
  }
3010
3303
 
@@ -3158,8 +3451,8 @@ function _renderInto(container, segments) {
3158
3451
  li.classList.add("twm-topbar-breadcrumb__item--current");
3159
3452
  li.setAttribute("aria-current", "page");
3160
3453
  li.innerHTML = `
3161
- <span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc5(seg.icon)}</span>
3162
- <span class="twm-topbar-breadcrumb__current" title="${_esc5(seg.label)}">${_esc5(seg.label)}</span>
3454
+ <span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc6(seg.icon)}</span>
3455
+ <span class="twm-topbar-breadcrumb__current" title="${_esc6(seg.label)}">${_esc6(seg.label)}</span>
3163
3456
  `;
3164
3457
  } else {
3165
3458
  const btn = document.createElement("button");
@@ -3167,8 +3460,8 @@ function _renderInto(container, segments) {
3167
3460
  btn.type = "button";
3168
3461
  btn.title = String(seg.label ?? "");
3169
3462
  btn.innerHTML = `
3170
- <span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc5(seg.icon)}</span>
3171
- <span>${_esc5(seg.label)}</span>
3463
+ <span class="material-symbols-outlined twm-topbar-breadcrumb__icon">${_esc6(seg.icon)}</span>
3464
+ <span>${_esc6(seg.label)}</span>
3172
3465
  `;
3173
3466
  btn.addEventListener("click", () => {
3174
3467
  btn.classList.add("twm-tile-breadcrumb__link--flash");
@@ -3191,7 +3484,7 @@ function _renderInto(container, segments) {
3191
3484
  });
3192
3485
  container.appendChild(ol);
3193
3486
  }
3194
- function _esc5(s) {
3487
+ function _esc6(s) {
3195
3488
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({
3196
3489
  "&": "&amp;",
3197
3490
  "<": "&lt;",
@@ -3276,7 +3569,7 @@ function createPageFactories({ eventBus = null, events = {}, refreshOn = {} } =
3276
3569
  console.error("[page] mount failed", err);
3277
3570
  hideLoading();
3278
3571
  }
3279
- const listeners = {};
3572
+ const subscriptions = [];
3280
3573
  if (typeof tab?.refresh === "function" && eventBus?.on) {
3281
3574
  for (const [busEvent, reason] of Object.entries(refreshOn || {})) {
3282
3575
  const fn = () => {
@@ -3286,14 +3579,13 @@ function createPageFactories({ eventBus = null, events = {}, refreshOn = {} } =
3286
3579
  console.warn("[page] refresh failed", busEvent, err);
3287
3580
  }
3288
3581
  };
3289
- listeners[busEvent] = fn;
3290
- eventBus.on(busEvent, fn);
3582
+ subscriptions.push(eventBus.on(busEvent, fn));
3291
3583
  }
3292
3584
  }
3293
3585
  return () => {
3294
- for (const [n, fn] of Object.entries(listeners)) {
3586
+ for (const handle of subscriptions) {
3295
3587
  try {
3296
- eventBus?.off?.(n, fn);
3588
+ handle?.dispose?.();
3297
3589
  } catch {
3298
3590
  }
3299
3591
  }
@@ -3380,6 +3672,7 @@ function createPageFactories({ eventBus = null, events = {}, refreshOn = {} } =
3380
3672
 
3381
3673
  // src/tiling/tile_renderer.js
3382
3674
  var SPLITTER_PX = 4;
3675
+ var MIN_SLOT_FRACTION = 0.05;
3383
3676
  var TAB_LAYOUTS = ["bottom", "top", "header"];
3384
3677
  var DEFAULT_TAB_LAYOUT = "bottom";
3385
3678
  function _normalizeTabLayout(value) {
@@ -3412,6 +3705,12 @@ var TileRenderer = class {
3412
3705
  static get TAB_MIME() {
3413
3706
  return TILE_TAB_MIME;
3414
3707
  }
3708
+ /** The smallest share of its split a splitter drag leaves a slot. Readable
3709
+ * so a caller that sets split sizes itself (the WM rebuilding its panel
3710
+ * splits) never builds one a drag could not have produced. */
3711
+ static get MIN_SLOT_FRACTION() {
3712
+ return MIN_SLOT_FRACTION;
3713
+ }
3415
3714
  constructor({
3416
3715
  root,
3417
3716
  tree,
@@ -3419,6 +3718,7 @@ var TileRenderer = class {
3419
3718
  ctx,
3420
3719
  onFocusChange,
3421
3720
  onAfterRender,
3721
+ onSplitResized,
3422
3722
  tabLayout = null,
3423
3723
  newTabButton = true
3424
3724
  }) {
@@ -3433,6 +3733,8 @@ var TileRenderer = class {
3433
3733
  });
3434
3734
  this.onAfterRender = onAfterRender || (() => {
3435
3735
  });
3736
+ this.onSplitResized = onSplitResized || (() => {
3737
+ });
3436
3738
  this.newTabButton = newTabButton !== false;
3437
3739
  this._leafCache = /* @__PURE__ */ new Map();
3438
3740
  this._drag = null;
@@ -3695,6 +3997,7 @@ var TileRenderer = class {
3695
3997
  let content = {};
3696
3998
  let liveTitle = null;
3697
3999
  let liveEntry = null;
4000
+ let liveActionsSet = false;
3698
4001
  if (leaf.content) {
3699
4002
  const setTitle = (text) => {
3700
4003
  const next = text == null || text === "" ? null : String(text);
@@ -3708,13 +4011,22 @@ var TileRenderer = class {
3708
4011
  const now = this.tree?.get?.(leaf.id) || leaf;
3709
4012
  liveEntry.titleEl.textContent = next || now.title || (now.content ? now.content.kind : "empty");
3710
4013
  };
3711
- const leafCtx = { ...this.ctx, leafId: leaf.id, setTitle };
4014
+ const setChromeActions = (specs) => {
4015
+ if (!liveEntry) {
4016
+ liveActionsSet = true;
4017
+ _paintContentActions(contentActions, specs);
4018
+ return;
4019
+ }
4020
+ if (this._leafCache.get(leaf.id) !== liveEntry) return;
4021
+ _paintContentActions(liveEntry.contentActionsEl, specs);
4022
+ };
4023
+ const leafCtx = { ...this.ctx, leafId: leaf.id, setTitle, setChromeActions };
3712
4024
  const activeProps = activeTab?.props ?? leaf.content.props;
3713
4025
  content = this.content.mount(leaf.content.kind, body, activeProps, leafCtx);
3714
4026
  if (content.title) title.textContent = content.title;
3715
4027
  if (liveTitle) title.textContent = liveTitle;
3716
4028
  _paintLeafIcon(icon, leaf, content, this.ctx);
3717
- _paintContentActions(contentActions, content.chromeActions);
4029
+ if (!liveActionsSet) _paintContentActions(contentActions, content.chromeActions);
3718
4030
  _vetoStructuralActions(actions, content.chrome);
3719
4031
  } else {
3720
4032
  body.innerHTML = `<div class="tile-placeholder"><div class="tile-placeholder__hint">empty tile</div></div>`;
@@ -3726,6 +4038,10 @@ var TileRenderer = class {
3726
4038
  titleEl: title,
3727
4039
  iconEl: icon,
3728
4040
  content,
4041
+ // C42. The strip `ctx.setChromeActions` repaints after the
4042
+ // mount. A cached repaint never rebuilds it, which is what
4043
+ // makes a list set later survive one.
4044
+ contentActionsEl: contentActions,
3729
4045
  // DERIVED HERE, NOT REUSED FROM ABOVE. `kindKey` was computed
3730
4046
  // before the mount; content that records its sub-tab or its
3731
4047
  // scroll offset WHILE mounting has already written to the
@@ -4128,7 +4444,7 @@ var TileRenderer = class {
4128
4444
  _topTabCallbacks(leafId) {
4129
4445
  return {
4130
4446
  // C33. `DragReorder` calls this through `NotebookTabBar`, which
4131
- // sets its own `application/x-ecosim-tab` first and unchanged — so
4447
+ // sets its own tab MIME (getTabDragMime()) first and unchanged — so
4132
4448
  // an editor pane sharing the page cannot notice that tiles are
4133
4449
  // dragging tabs too.
4134
4450
  onDragStart: (ev, key, item) => {
@@ -4199,9 +4515,9 @@ var TileRenderer = class {
4199
4515
  ${tabs.map((t, i) => `
4200
4516
  <li class="twm-leaf__tab${i === activeIdx ? " twm-leaf__tab--on" : ""}"
4201
4517
  role="tab" data-tab-idx="${i}"
4202
- title="${_esc6(t.title || t.kind)}"
4518
+ title="${_esc7(t.title || t.kind)}"
4203
4519
  draggable="true">
4204
- <span class="twm-leaf__tab-label">${_esc6(t.title || t.kind)}</span>
4520
+ <span class="twm-leaf__tab-label">${_esc7(t.title || t.kind)}</span>
4205
4521
  <button type="button" class="twm-leaf__tab-close"
4206
4522
  data-action="tab-close" data-tab-idx="${i}"
4207
4523
  title="Close this tab"
@@ -4552,7 +4868,7 @@ var TileRenderer = class {
4552
4868
  const px = totalPx - SPLITTER_PX * (split.children.length - 1);
4553
4869
  if (px <= 0) return;
4554
4870
  const deltaFrac = delta / px * totalSize;
4555
- const minFrac = totalSize * 0.05;
4871
+ const minFrac = totalSize * MIN_SLOT_FRACTION;
4556
4872
  const newSizes = startSizes.slice();
4557
4873
  newSizes[slotIdx] = Math.max(minFrac, startSizes[slotIdx] + deltaFrac);
4558
4874
  newSizes[slotIdx + 1] = Math.max(minFrac, startSizes[slotIdx + 1] - deltaFrac);
@@ -4569,11 +4885,20 @@ var TileRenderer = class {
4569
4885
  });
4570
4886
  }
4571
4887
  _onMouseUp() {
4888
+ const drag = this._drag;
4572
4889
  this._drag = null;
4573
4890
  document.body.classList.remove("twm-dragging");
4891
+ if (!drag) return;
4892
+ const sizes = this.tree.get(drag.splitId)?.sizes;
4893
+ if (!sizes || sizes.every((v, i) => v === drag.startSizes[i])) return;
4894
+ try {
4895
+ this.onSplitResized(drag.splitId);
4896
+ } catch (err) {
4897
+ console.error("[tile-renderer] onSplitResized threw", err);
4898
+ }
4574
4899
  }
4575
4900
  };
4576
- function _esc6(s) {
4901
+ function _esc7(s) {
4577
4902
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({
4578
4903
  "&": "&amp;",
4579
4904
  "<": "&lt;",
@@ -4597,15 +4922,41 @@ function _paintContentActions(hostEl, specs) {
4597
4922
  }
4598
4923
  hostEl.hidden = false;
4599
4924
  for (const spec of specs) {
4600
- if (!spec || !spec.icon) continue;
4925
+ if (!spec) continue;
4926
+ if (spec.separator) {
4927
+ const sep = document.createElement("span");
4928
+ sep.className = "twm-leaf__sep";
4929
+ sep.setAttribute("role", "separator");
4930
+ hostEl.appendChild(sep);
4931
+ continue;
4932
+ }
4933
+ const label = spec.label == null || spec.label === "" ? "" : String(spec.label);
4934
+ if (!spec.icon && !label) continue;
4601
4935
  const btn = document.createElement("button");
4602
4936
  btn.type = "button";
4603
4937
  btn.className = "twm-leaf__btn";
4604
4938
  btn.title = spec.title || "";
4605
- btn.setAttribute("aria-label", spec.title || "");
4606
- btn.innerHTML = `<span class="material-symbols-outlined">${spec.icon}</span>`;
4939
+ btn.setAttribute("aria-label", spec.title || label);
4940
+ btn.innerHTML = spec.icon ? `<span class="material-symbols-outlined">${spec.icon}</span>` : "";
4941
+ if (label) {
4942
+ const text = document.createElement("span");
4943
+ text.className = "twm-leaf__btn-label";
4944
+ text.textContent = label;
4945
+ btn.appendChild(text);
4946
+ btn.classList.add("twm-leaf__btn--labelled");
4947
+ }
4948
+ if (spec.pressed != null) {
4949
+ btn.setAttribute("aria-pressed", spec.pressed ? "true" : "false");
4950
+ if (spec.pressed) btn.classList.add("twm-leaf__btn--pressed");
4951
+ }
4952
+ const disabled = !!spec.disabled;
4953
+ if (disabled) {
4954
+ btn.setAttribute("aria-disabled", "true");
4955
+ btn.classList.add("twm-leaf__btn--disabled");
4956
+ }
4607
4957
  btn.addEventListener("click", (e) => {
4608
4958
  e.stopPropagation();
4959
+ if (disabled) return;
4609
4960
  try {
4610
4961
  spec.onClick?.();
4611
4962
  } catch (err) {
@@ -4779,6 +5130,11 @@ var WindowManager = class _WindowManager {
4779
5130
  this.eventBus = eventBus || null;
4780
5131
  this.onChange = onChange || (() => {
4781
5132
  });
5133
+ this._panelOverrides = {};
5134
+ this._panelAvailability = Object.fromEntries(PANEL_SIDES.map((s) => [s, PANEL_OFFERED]));
5135
+ this._panelAvailabilityListeners = /* @__PURE__ */ new Set();
5136
+ this._reconcilingPanels = false;
5137
+ this._quietDepth = 0;
4782
5138
  this.floatActiveTab = !!floatActiveTab;
4783
5139
  this._rootLeaf = () => {
4784
5140
  const kind = this.taxonomy.root;
@@ -4823,7 +5179,8 @@ var WindowManager = class _WindowManager {
4823
5179
  onLeafTabAction: (leafId, action, data) => this._leafTabAction(leafId, action, data)
4824
5180
  },
4825
5181
  onFocusChange: () => this._notifyChange(),
4826
- onAfterRender: () => this._rehomeContainedWindows()
5182
+ onAfterRender: () => this._rehomeContainedWindows(),
5183
+ onSplitResized: (splitId) => this._onSplitResized(splitId)
4827
5184
  });
4828
5185
  this._persistTimer = null;
4829
5186
  this._windowToLeaf = /* @__PURE__ */ new Map();
@@ -4876,6 +5233,8 @@ var WindowManager = class _WindowManager {
4876
5233
  * so other surfaces (palette, top-bar toggles, page shortcuts) can
4877
5234
  * subscribe through the existing event system. */
4878
5235
  _notifyChange(reason = null) {
5236
+ if (this._quietDepth > 0) return;
5237
+ this._reconcilePanels();
4879
5238
  try {
4880
5239
  this.onChange(reason);
4881
5240
  } catch (err) {
@@ -4925,10 +5284,12 @@ var WindowManager = class _WindowManager {
4925
5284
  }
4926
5285
  }
4927
5286
  }
4928
- this._canonicalize(d.tree, d);
4929
5287
  }
5288
+ const deduped = this._dropDuplicateSingles();
5289
+ for (const d of this.desktops.desktops) this._canonicalize(d.tree, d);
4930
5290
  this.renderer.tree = this.desktops.active().tree;
4931
5291
  this.renderer.render();
5292
+ if (deduped) this._persist();
4932
5293
  this._notifyChange();
4933
5294
  }
4934
5295
  _persist() {
@@ -4938,6 +5299,276 @@ var WindowManager = class _WindowManager {
4938
5299
  await saveDesktops(this.host?.state, this.desktops.serialize());
4939
5300
  }, 500);
4940
5301
  }
5302
+ // ── Single-instance kinds (KindDef `single`) ────────────────────
5303
+ //
5304
+ // Some content holds heavy app-wide state and must exist once. The
5305
+ // taxonomy says which kinds (`single: true`, or `single: '<prop>'` for one
5306
+ // per value of that prop); the WM keeps three promises about them:
5307
+ //
5308
+ // - every OPEN path reveals the instance that exists — it switches to
5309
+ // its desktop, activates its tab (restoring an archived page if that is
5310
+ // where it is) and focuses its tile, or raises its window — instead of
5311
+ // mounting a second one;
5312
+ // - the COPY verbs (Alt+T / Alt+N / Alt+Shift+H / Alt+Shift+V, the tile
5313
+ // menu's "Open in new tab" and "Open a copy in a window", the palette's
5314
+ // split and window modes) are refused, the menus greyed with a reason;
5315
+ // - a restored layout holding duplicates keeps the FIRST (desktop order,
5316
+ // then tile order; live tabs before archived pages).
5317
+ //
5318
+ // MOVE verbs (float, dock, drag a tab, move to desktop) carry the one
5319
+ // instance somewhere else and are untouched.
5320
+ /** The instance key of a tab spec, or null when its kind is not single. */
5321
+ _instanceKey(kind, props) {
5322
+ return this.taxonomy.instanceKey(kind, props);
5323
+ }
5324
+ /** Whether `kind` exists once, and so refuses the copy verbs. */
5325
+ isSingleKind(kind) {
5326
+ return this.taxonomy.isSingle(kind);
5327
+ }
5328
+ /** Where the one instance of a single kind is open, or null — also null
5329
+ * for a kind that is not single. One of:
5330
+ * `{ where: 'window', windowId, idx, desktopIdx }`
5331
+ * `{ where: 'tab', desktopIdx, leafId, idx }`
5332
+ * `{ where: 'archive', desktopIdx, leafId, page, idx }` — a tab of a
5333
+ * top-nav page the tile has switched away from (`pageTabs`). */
5334
+ findInstance(kind, props = {}) {
5335
+ const key = this._instanceKey(kind, props);
5336
+ if (key == null) return null;
5337
+ const same = (t) => !!t && this._instanceKey(t.kind, t.props) === key;
5338
+ for (const [windowId, rec] of this._windowToLeaf) {
5339
+ const idx = _windowTabSpecs(rec).findIndex(same);
5340
+ if (idx >= 0) return { where: "window", windowId, idx, desktopIdx: rec.desktopIdx };
5341
+ }
5342
+ const desktops = this.desktops.desktops;
5343
+ for (const [desktopIdx, d] of desktops.entries()) {
5344
+ for (const leaf of d.tree.leaves()) {
5345
+ const idx = (leaf.tabs || []).findIndex(same);
5346
+ if (idx >= 0) return { where: "tab", desktopIdx, leafId: leaf.id, idx };
5347
+ }
5348
+ }
5349
+ for (const [desktopIdx, d] of desktops.entries()) {
5350
+ for (const leaf of d.tree.leaves()) {
5351
+ for (const [page, arc] of this._archivedPages(leaf)) {
5352
+ const idx = arc.tabs.findIndex(same);
5353
+ if (idx >= 0) return { where: "archive", desktopIdx, leafId: leaf.id, page, idx };
5354
+ }
5355
+ }
5356
+ }
5357
+ return null;
5358
+ }
5359
+ /** Reveal the instance of a single kind that is open: answers whether
5360
+ * there was one. Every open path asks this first. */
5361
+ revealInstance(kind, props = {}) {
5362
+ const loc = this.findInstance(kind, props);
5363
+ return loc ? this._revealAt(loc) : false;
5364
+ }
5365
+ /** A leaf's archived pages that can come back — every `pageTabs` entry
5366
+ * but the CURRENT page's. `swapToPage` restores an archive without
5367
+ * deleting it, so the current page's entry is a stale copy of the live
5368
+ * tabs, overwritten the next time the tile leaves the page; counting it
5369
+ * would find every live instance twice. */
5370
+ _archivedPages(leaf) {
5371
+ const kind = leaf?.content?.kind;
5372
+ const current = kind ? this.taxonomy.topNavFor(kind) || null : null;
5373
+ return Object.entries(leaf?.pageTabs || {}).filter(([page, arc]) => page !== current && Array.isArray(arc?.tabs));
5374
+ }
5375
+ /** Reveal `loc` with ONE change notification. The verbs a reveal is made
5376
+ * of — `showWindowTab`, `switchDesktop` (directly or via `revealWindow`)
5377
+ * — each notify on their own; inside a reveal they are quiet, and the
5378
+ * reveal notifies once, as every other verb does. */
5379
+ _revealAt(loc) {
5380
+ this._quietDepth += 1;
5381
+ let shown = false;
5382
+ try {
5383
+ shown = this._revealAtQuietly(loc);
5384
+ } finally {
5385
+ this._quietDepth -= 1;
5386
+ }
5387
+ if (shown) this._notifyChange("single-reveal");
5388
+ return shown;
5389
+ }
5390
+ _revealAtQuietly(loc) {
5391
+ if (loc.where === "window") {
5392
+ const rec = this._windowToLeaf.get(loc.windowId);
5393
+ if (!rec) return false;
5394
+ if (Array.isArray(rec.tabs) && rec.tabs.length) this.showWindowTab(loc.windowId, loc.idx);
5395
+ this.revealWindow(rec.window);
5396
+ try {
5397
+ rec.window.show();
5398
+ } catch (err) {
5399
+ console.warn("[wm] reveal: show failed", err);
5400
+ }
5401
+ return true;
5402
+ }
5403
+ const tree = this.desktops.desktops[loc.desktopIdx]?.tree;
5404
+ const leaf = tree?.get(loc.leafId);
5405
+ if (!leaf || leaf.kind !== "leaf") return false;
5406
+ if (loc.where === "archive") {
5407
+ const tab = leaf.pageTabs[loc.page].tabs[loc.idx];
5408
+ const key = this._instanceKey(tab.kind, tab.props);
5409
+ const curKind = leaf.content?.kind;
5410
+ const current = curKind ? this.taxonomy.topNavFor(curKind) || null : null;
5411
+ this._swapToPage(
5412
+ tree,
5413
+ loc.leafId,
5414
+ { kind: tab.kind, props: tab.props || {}, title: tab.title },
5415
+ current,
5416
+ loc.page,
5417
+ { recordHistory: false }
5418
+ );
5419
+ const at = (leaf.tabs || []).findIndex((t) => this._instanceKey(t.kind, t.props) === key);
5420
+ if (at >= 0) tree.setActiveLeafTab(loc.leafId, at);
5421
+ } else {
5422
+ tree.setActiveLeafTab(loc.leafId, loc.idx);
5423
+ }
5424
+ tree.focus(loc.leafId);
5425
+ if (loc.desktopIdx !== this.desktops.activeIdx) {
5426
+ this.switchDesktop(loc.desktopIdx);
5427
+ } else {
5428
+ this.renderer.render();
5429
+ this._persist();
5430
+ }
5431
+ return true;
5432
+ }
5433
+ /** `TileTree.swapToPage`, and then no restored tab is a second copy of a
5434
+ * single kind. Every page restore in the WM goes through here.
5435
+ *
5436
+ * A page archive (`pageTabs`) is a snapshot: the instance it lists can
5437
+ * have moved to another tile or a window since — and the current page's
5438
+ * own entry is a stale copy of the live tabs that comes back into play as
5439
+ * soon as the tile leaves the page by a path other than `swapToPage`
5440
+ * (`navigateActiveTab`). So a restored tab whose instance is live
5441
+ * anywhere else is dropped from the tile and from the restored archive;
5442
+ * the instance stays where it is. */
5443
+ _swapToPage(tree, leafId, target, currentTopNav, targetTopNav, opts) {
5444
+ const done = tree.swapToPage(leafId, target, currentTopNav, targetTopNav, opts);
5445
+ if (done) this._dropRestoredCopies(tree, leafId, targetTopNav, target);
5446
+ return done;
5447
+ }
5448
+ _dropRestoredCopies(tree, leafId, page, target) {
5449
+ const leaf = tree.get(leafId);
5450
+ const tabs = Array.isArray(leaf?.tabs) ? leaf.tabs : null;
5451
+ if (!tabs) return;
5452
+ const elsewhere = this._liveInstanceKeys(tree, leafId);
5453
+ const keeper = () => {
5454
+ const seen = /* @__PURE__ */ new Set();
5455
+ return (t) => {
5456
+ const k = t ? this._instanceKey(t.kind, t.props) : null;
5457
+ if (k == null) return true;
5458
+ if (elsewhere.has(k) || seen.has(k)) return false;
5459
+ seen.add(k);
5460
+ return true;
5461
+ };
5462
+ };
5463
+ const arc = leaf.pageTabs?.[page];
5464
+ if (arc && Array.isArray(arc.tabs)) {
5465
+ const kept = arc.tabs.filter(keeper());
5466
+ if (kept.length !== arc.tabs.length) {
5467
+ arc.tabs = kept;
5468
+ arc.activeTabIdx = Math.max(0, Math.min(kept.length - 1, arc.activeTabIdx || 0));
5469
+ }
5470
+ }
5471
+ const keep = keeper();
5472
+ let active = leaf.activeTabIdx || 0;
5473
+ let changed = false;
5474
+ for (let i = 0; i < tabs.length; ) {
5475
+ if (keep(tabs[i])) {
5476
+ i++;
5477
+ continue;
5478
+ }
5479
+ tabs.splice(i, 1);
5480
+ if (i < active) active -= 1;
5481
+ changed = true;
5482
+ }
5483
+ if (!changed) return;
5484
+ if (!tabs.length) {
5485
+ tabs.push({
5486
+ kind: target.kind,
5487
+ props: target.props || {},
5488
+ title: target.title || _tabTitle(target.kind, target.props),
5489
+ history: []
5490
+ });
5491
+ active = 0;
5492
+ }
5493
+ tree.setActiveLeafTab(leafId, Math.max(0, Math.min(tabs.length - 1, active)));
5494
+ }
5495
+ /** The instance keys of every LIVE tab — windows, and every leaf of every
5496
+ * desktop but `leafId` of `tree`. Archived pages are not live. */
5497
+ _liveInstanceKeys(tree, leafId) {
5498
+ const keys = /* @__PURE__ */ new Set();
5499
+ const add = (t) => {
5500
+ const k = t ? this._instanceKey(t.kind, t.props) : null;
5501
+ if (k != null) keys.add(k);
5502
+ };
5503
+ for (const [, rec] of this._windowToLeaf) _windowTabSpecs(rec).forEach(add);
5504
+ for (const d of this.desktops.desktops) {
5505
+ for (const leaf of d.tree.leaves()) {
5506
+ if (d.tree === tree && leaf.id === leafId) continue;
5507
+ (leaf.tabs || []).forEach(add);
5508
+ }
5509
+ }
5510
+ return keys;
5511
+ }
5512
+ /** A restored layout keeps the FIRST instance of each single kind: later
5513
+ * live tabs are dropped (the tile shows the tab that took the dropped
5514
+ * one's place, or closes when it held nothing else), and so are later
5515
+ * archived ones. Answers whether anything was dropped. */
5516
+ _dropDuplicateSingles() {
5517
+ const seen = /* @__PURE__ */ new Set();
5518
+ let dropped = false;
5519
+ const keep = (t) => {
5520
+ const k = t ? this._instanceKey(t.kind, t.props) : null;
5521
+ if (k == null) return true;
5522
+ if (seen.has(k)) return false;
5523
+ seen.add(k);
5524
+ return true;
5525
+ };
5526
+ for (const d of this.desktops.desktops) {
5527
+ const tree = d.tree;
5528
+ for (const leaf of tree.leaves()) {
5529
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
5530
+ let active = leaf.activeTabIdx || 0;
5531
+ let changed = false;
5532
+ for (let i = 0; i < tabs.length; ) {
5533
+ if (keep(tabs[i])) {
5534
+ i++;
5535
+ continue;
5536
+ }
5537
+ tabs.splice(i, 1);
5538
+ if (i < active) active -= 1;
5539
+ changed = true;
5540
+ }
5541
+ if (!changed) continue;
5542
+ dropped = true;
5543
+ if (tabs.length) {
5544
+ tree.setActiveLeafTab(leaf.id, Math.max(0, Math.min(tabs.length - 1, active)));
5545
+ continue;
5546
+ }
5547
+ tree.close(leaf.id);
5548
+ if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
5549
+ if (!tree.leaves().some((l) => !String(l.content?.kind || "").startsWith("panel:"))) {
5550
+ const spawned = this._spawnContentLeaf(tree);
5551
+ if (spawned) this._seedHome(tree, spawned);
5552
+ }
5553
+ }
5554
+ }
5555
+ for (const d of this.desktops.desktops) {
5556
+ for (const leaf of d.tree.leaves()) {
5557
+ for (const [page, arc] of this._archivedPages(leaf)) {
5558
+ const kept = arc.tabs.filter(keep);
5559
+ if (kept.length === arc.tabs.length) continue;
5560
+ dropped = true;
5561
+ if (!kept.length) {
5562
+ delete leaf.pageTabs[page];
5563
+ continue;
5564
+ }
5565
+ arc.tabs = kept;
5566
+ arc.activeTabIdx = Math.max(0, Math.min(kept.length - 1, arc.activeTabIdx || 0));
5567
+ }
5568
+ }
5569
+ }
5570
+ return dropped;
5571
+ }
4941
5572
  // ── Content loading ─────────────────────────────────────────────
4942
5573
  /** Load content into the primary tile (last-active non-panel leaf).
4943
5574
  /** Walk back one step. Bound to Backspace.
@@ -5131,11 +5762,13 @@ var WindowManager = class _WindowManager {
5131
5762
  const tree = this._tree();
5132
5763
  const prior = tree.popActiveTabHistory?.(id);
5133
5764
  if (!prior || !prior.kind) return false;
5765
+ if (this.revealInstance(prior.kind, prior.props || {})) return true;
5134
5766
  const curKind = tree.get(id)?.content?.kind;
5135
5767
  const curTopNav = curKind ? this.taxonomy.topNavFor(curKind) : null;
5136
5768
  const title = prior.title || _tabTitle(prior.kind, prior.props);
5137
5769
  if (prior.topNav && curTopNav && prior.topNav !== curTopNav) {
5138
- tree.swapToPage?.(
5770
+ this._swapToPage(
5771
+ tree,
5139
5772
  id,
5140
5773
  { kind: prior.kind, props: prior.props || {}, title },
5141
5774
  curTopNav,
@@ -5245,6 +5878,7 @@ var WindowManager = class _WindowManager {
5245
5878
  * the leaf. Used by Backspace + breadcrumb segments + anything
5246
5879
  * else that should walk WITHIN the tile rather than reset it. */
5247
5880
  navigateActiveTab(leafId, kind, props = {}) {
5881
+ if (this.revealInstance(kind, props)) return;
5248
5882
  const tree = this._tree();
5249
5883
  const leaf = tree.get(leafId);
5250
5884
  if (!leaf || leaf.kind !== "leaf") {
@@ -5298,6 +5932,7 @@ var WindowManager = class _WindowManager {
5298
5932
  * tab feature shipped), the behavior is identical to the old
5299
5933
  * setLeafContent: one tab in, one tab out. */
5300
5934
  openInLeaf(leafId, kind, props = {}) {
5935
+ if (this.revealInstance(kind, props)) return;
5301
5936
  const tree = this._tree();
5302
5937
  const leaf = tree.get(leafId);
5303
5938
  if (!leaf || leaf.kind !== "leaf") {
@@ -5314,6 +5949,7 @@ var WindowManager = class _WindowManager {
5314
5949
  * previous mount, mounts the new kind into the same body element,
5315
5950
  * and updates the window's title. */
5316
5951
  openInWindow(winId, kind, props = {}) {
5952
+ if (this.revealInstance(kind, props)) return;
5317
5953
  const rec = this._windowToLeaf.get(winId);
5318
5954
  if (!rec) {
5319
5955
  this.openInPrimary(kind, props);
@@ -5352,6 +5988,7 @@ var WindowManager = class _WindowManager {
5352
5988
  * If only panel tiles exist (or the tree is empty), spawn a new
5353
5989
  * content leaf and canonicalize so the panels wrap it. */
5354
5990
  openInPrimary(kind, props = {}) {
5991
+ if (this.revealInstance(kind, props)) return;
5355
5992
  const tree = this._tree();
5356
5993
  let leafId = tree.primaryLeafId();
5357
5994
  let spawned = false;
@@ -5364,7 +6001,8 @@ var WindowManager = class _WindowManager {
5364
6001
  const currentTopNav = leaf?.content?.kind ? this.taxonomy.topNavFor(leaf.content.kind) || null : null;
5365
6002
  const targetTopNav = this.taxonomy.topNavFor(kind) || kind;
5366
6003
  const title = _tabTitle(kind, props);
5367
- tree.swapToPage(
6004
+ this._swapToPage(
6005
+ tree,
5368
6006
  leafId,
5369
6007
  { kind, props, title },
5370
6008
  currentTopNav,
@@ -5410,6 +6048,7 @@ var WindowManager = class _WindowManager {
5410
6048
  * pane in a split never dismounts the original). Returns the new
5411
6049
  * leaf id, or null if the leaf can't be split. */
5412
6050
  splitLeafWith(leafId, dir, kind, props = {}, title = "") {
6051
+ if (this.revealInstance(kind, props)) return null;
5413
6052
  const tree = this._tree();
5414
6053
  leafId = this._contentLeafId(leafId);
5415
6054
  const src = leafId ? tree.get(leafId) : null;
@@ -5445,17 +6084,18 @@ var WindowManager = class _WindowManager {
5445
6084
  if (!kind || kind === PLACEHOLDER_KIND || kind.startsWith("panel:")) return null;
5446
6085
  return { leafId: id, kind, props: leaf.content.props || {}, title: leaf.title };
5447
6086
  }
5448
- /** Alt+T — open the focused tile's content in a new tab on that tile. */
6087
+ /** Alt+T — open the focused tile's content in a new tab on that tile.
6088
+ * A copy verb, so a single kind refuses it (and Alt+N, Alt+Shift+H/V). */
5449
6089
  openFocusedInTab() {
5450
6090
  const c = this._focusedContent();
5451
- if (!c) return;
6091
+ if (!c || this.isSingleKind(c.kind)) return;
5452
6092
  this.openInTabFromContext({ leafId: c.leafId }, c.kind, c.props);
5453
6093
  }
5454
6094
  /** Alt+N — open the focused tile's content in a fresh managed window.
5455
6095
  * Unlike Alt+F (promote) this DUPLICATES: the source tile stays put. */
5456
6096
  openFocusedInWindow() {
5457
6097
  const c = this._focusedContent();
5458
- if (!c) return;
6098
+ if (!c || this.isSingleKind(c.kind)) return;
5459
6099
  this._navigateWindow(c.kind, c.props);
5460
6100
  }
5461
6101
  /** Alt+Shift+H / Alt+Shift+V — split the focused tile and open a fresh
@@ -5467,6 +6107,7 @@ var WindowManager = class _WindowManager {
5467
6107
  this.split(dir);
5468
6108
  return;
5469
6109
  }
6110
+ if (this.isSingleKind(c.kind)) return;
5470
6111
  this.splitLeafWith(c.leafId, dir, c.kind, c.props, c.title);
5471
6112
  }
5472
6113
  closeFocused() {
@@ -7227,15 +7868,94 @@ var WindowManager = class _WindowManager {
7227
7868
  * edge (a recursive split) or closes that leaf. */
7228
7869
  isPanelOpen(side) {
7229
7870
  const d = this.desktops.active();
7230
- return !!d.panels[side];
7871
+ return !!d.panels[side] && this.isPanelAvailable(side);
7231
7872
  }
7873
+ /** Flip a panel. A withheld panel is not flipped: answers `false` and
7874
+ * touches nothing — not the remembered open state, not the layout, not
7875
+ * what is persisted. Answers `true` when it flipped. */
7232
7876
  togglePanel(side) {
7877
+ if (!this.isPanelAvailable(side)) return false;
7233
7878
  const d = this.desktops.active();
7234
7879
  d.panels[side] = !d.panels[side];
7235
7880
  this._canonicalize(this._tree(), d);
7236
7881
  this.renderer.render();
7237
7882
  this._persist();
7238
7883
  this._notifyChange();
7884
+ return true;
7885
+ }
7886
+ // ── Panel availability ──────────────────────────────────────────
7887
+ /** Whether the panel on `side` is offered on the active desktop right now:
7888
+ * `{ available, reason }`. A runtime override wins; else the primary
7889
+ * tile's kind says (`KindDef.panels`, see kind_taxonomy.js); else it is
7890
+ * offered. `desktops.panels` is untouched by any of this — it remembers
7891
+ * what the user OPENED, so a panel withheld while it was open comes back
7892
+ * open once it is offered again. */
7893
+ getPanelAvailability(side, tree = this._tree()) {
7894
+ assertPanelSide(side, "getPanelAvailability");
7895
+ const override = this._panelOverrides[side];
7896
+ if (override) return override;
7897
+ const id = tree.primaryLeafId();
7898
+ const content = id ? tree.get(id)?.content : null;
7899
+ const kind = content?.kind === PLACEHOLDER_KIND ? content.props?.originalKind : content?.kind;
7900
+ return this.taxonomy.panelOffer(kind, side);
7901
+ }
7902
+ isPanelAvailable(side) {
7903
+ return this.getPanelAvailability(side).available;
7904
+ }
7905
+ /** Content's say, at runtime: `offer` is `true`, `false` or `{ reason }`
7906
+ * and beats the kind's declaration until it is cleared with `null`. The
7907
+ * override belongs to the shell, not to a tile — content that sets one
7908
+ * clears it when it goes (its `destroy`). */
7909
+ setPanelAvailability(side, offer) {
7910
+ assertPanelSide(side, "setPanelAvailability");
7911
+ if (offer === null || offer === void 0) delete this._panelOverrides[side];
7912
+ else this._panelOverrides[side] = readPanelOffer(offer, "setPanelAvailability");
7913
+ this._notifyChange("panel-availability");
7914
+ }
7915
+ /** Subscribe to availability changes. `fn({ panels, changed })` runs ONCE
7916
+ * per change, however many sides changed in it: `panels` is every side's
7917
+ * `{ available, reason }`, `changed` the sides that differ from the last
7918
+ * call. The same payload goes out on the bus as `wm:panels:availability`.
7919
+ * Returns the unsubscribe. */
7920
+ onPanelAvailabilityChange(fn) {
7921
+ this._panelAvailabilityListeners.add(fn);
7922
+ return () => this._panelAvailabilityListeners.delete(fn);
7923
+ }
7924
+ /** Bring the active desktop's panel leaves in line with what is offered,
7925
+ * and announce a change of availability. Called from `_notifyChange`, so
7926
+ * every path that can change the primary kind passes through it. */
7927
+ _reconcilePanels() {
7928
+ if (this._reconcilingPanels || !this.desktops) return;
7929
+ this._reconcilingPanels = true;
7930
+ try {
7931
+ const d = this.desktops.active();
7932
+ const panels = {};
7933
+ for (const side of PANEL_SIDES) panels[side] = this.getPanelAvailability(side, d.tree);
7934
+ const shown = (side) => !!d.panels[side] && panels[side].available;
7935
+ if (d.tree.rootId && PANEL_SIDES.some((s) => shown(s) !== !!this._findPanelLeaf(s))) {
7936
+ this._canonicalize(d.tree, d);
7937
+ this.renderer.render();
7938
+ }
7939
+ const last = this._panelAvailability;
7940
+ const changed = PANEL_SIDES.filter((s) => last[s].available !== panels[s].available || last[s].reason !== panels[s].reason);
7941
+ if (!changed.length) return;
7942
+ this._panelAvailability = panels;
7943
+ const payload = Object.freeze({ panels: Object.freeze(panels), changed, wm: this });
7944
+ for (const fn of [...this._panelAvailabilityListeners]) {
7945
+ try {
7946
+ fn(payload);
7947
+ } catch (err) {
7948
+ console.error("[wm] panel availability listener threw", err);
7949
+ }
7950
+ }
7951
+ try {
7952
+ this.eventBus?.emit?.("wm:panels:availability", payload);
7953
+ } catch (err) {
7954
+ console.warn("[wm] event emit failed", err);
7955
+ }
7956
+ } finally {
7957
+ this._reconcilingPanels = false;
7958
+ }
7239
7959
  }
7240
7960
  /** Rebuild the tree so panel:* leaves sit in the canonical positions:
7241
7961
  *
@@ -7249,13 +7969,13 @@ var WindowManager = class _WindowManager {
7249
7969
  * Existing panel leaves are detached + re-attached (same id), so the
7250
7970
  * renderer cache keeps the mounted content (nav filter state, etc.). */
7251
7971
  _canonicalize(tree, desktop) {
7252
- const panel = {};
7253
- for (const leaf of tree.leaves()) {
7254
- const kind = leaf.content?.kind;
7255
- if (kind === "panel:left") panel.left = leaf;
7256
- else if (kind === "panel:right") panel.right = leaf;
7257
- else if (kind === "panel:bottom") panel.bottom = leaf;
7972
+ const open = {};
7973
+ for (const side of PANEL_SIDES) {
7974
+ open[side] = !!desktop.panels[side] && this.getPanelAvailability(side, tree).available;
7258
7975
  }
7976
+ const panel = _panelLeaves(tree);
7977
+ this._recordPanelShares(tree, desktop);
7978
+ const share = desktop.panelShares;
7259
7979
  const detach = (leaf) => {
7260
7980
  if (!leaf) return;
7261
7981
  const pid = leaf.parentId;
@@ -7279,7 +7999,7 @@ var WindowManager = class _WindowManager {
7279
7999
  detach(panel.bottom);
7280
8000
  let seededSide = null;
7281
8001
  if (!tree.rootId) {
7282
- seededSide = desktop.panels.left ? "left" : desktop.panels.bottom ? "bottom" : desktop.panels.right ? "right" : null;
8002
+ seededSide = open.left ? "left" : open.bottom ? "bottom" : open.right ? "right" : null;
7283
8003
  if (seededSide) {
7284
8004
  const seed = panel[seededSide] || makeLeaf({
7285
8005
  content: { kind: `panel:${seededSide}`, props: {} },
@@ -7294,6 +8014,15 @@ var WindowManager = class _WindowManager {
7294
8014
  tree.rootId = seed.id;
7295
8015
  }
7296
8016
  }
8017
+ const willWrap = (side) => open[side] && seededSide !== side && !!tree.rootId;
8018
+ const DEFAULT_SHARE = 1 / 5;
8019
+ const slotShare = (side) => {
8020
+ const own = share[side];
8021
+ if (own == null) return null;
8022
+ if (side !== "left" || !willWrap("right")) return _clampSlotShare(own);
8023
+ const right = _clampSlotShare(share.right ?? DEFAULT_SHARE);
8024
+ return _clampSlotShare(own / (1 - right));
8025
+ };
7297
8026
  const wrap = (panelLeaf, side) => {
7298
8027
  if (panelLeaf && !tree.nodes.has(panelLeaf.id)) {
7299
8028
  tree.nodes.set(panelLeaf.id, panelLeaf);
@@ -7309,7 +8038,8 @@ var WindowManager = class _WindowManager {
7309
8038
  const before = side === "left";
7310
8039
  const dir = side === "bottom" ? "v" : "h";
7311
8040
  const children = before ? [leaf.id, tree.rootId] : [tree.rootId, leaf.id];
7312
- const sizes = side === "bottom" ? [4, 1] : side === "left" ? [1, 4] : [4, 1];
8041
+ const kept = slotShare(side);
8042
+ const sizes = kept != null ? before ? [kept, 1 - kept] : [1 - kept, kept] : before ? [1, 4] : [4, 1];
7313
8043
  const split = {
7314
8044
  id: `split-panel-${side}-${Math.random().toString(36).slice(2, 7)}`,
7315
8045
  kind: "split",
@@ -7324,9 +8054,9 @@ var WindowManager = class _WindowManager {
7324
8054
  leaf.parentId = split.id;
7325
8055
  tree.rootId = split.id;
7326
8056
  };
7327
- if (desktop.panels.left && seededSide !== "left" && tree.rootId) wrap(panel.left, "left");
7328
- if (desktop.panels.right && seededSide !== "right" && tree.rootId) wrap(panel.right, "right");
7329
- if (desktop.panels.bottom && seededSide !== "bottom" && tree.rootId) wrap(panel.bottom, "bottom");
8057
+ if (willWrap("left")) wrap(panel.left, "left");
8058
+ if (willWrap("right")) wrap(panel.right, "right");
8059
+ if (willWrap("bottom")) wrap(panel.bottom, "bottom");
7330
8060
  const reachable = /* @__PURE__ */ new Set();
7331
8061
  const walk = (id) => {
7332
8062
  if (!id || reachable.has(id)) return;
@@ -7339,6 +8069,68 @@ var WindowManager = class _WindowManager {
7339
8069
  if (!reachable.has(id)) tree.nodes.delete(id);
7340
8070
  }
7341
8071
  }
8072
+ /** A finished splitter drag of split `splitId` is a layout change like
8073
+ * any other: record the shares of the panels that split sizes (the ones
8074
+ * inside it; the Navigator sits inside the right panel's split) while
8075
+ * the tree is still canonical (a drag never changes its structure), then
8076
+ * save, so the dragged size is what the next load, or the next
8077
+ * structural change, gives back. */
8078
+ _onSplitResized(splitId) {
8079
+ this._recordPanelShares(this._tree(), this.desktops.active(), splitId);
8080
+ this._persist();
8081
+ this._notifyChange("split-resized");
8082
+ }
8083
+ /** Keep `desktop.panelShares` current: each panel's share of the WM area
8084
+ * along its own axis (width for left/right, height for bottom), kept on
8085
+ * the desktop record and serialized with it, so a panel the user closes
8086
+ * comes back at the size it had, even across a reload while it was
8087
+ * closed. With `resizedSplitId`, the panels inside that split are
8088
+ * measured (a drag); without, the panels that have no share yet are.
8089
+ * A panel that measures nothing (not on the tree, the root, or under a
8090
+ * split that is not one of its canonical panel splits) keeps the share
8091
+ * it last had. A share is recorded as measured, never clamped: the drag
8092
+ * range belongs to the slot of the split being built (step 5 of
8093
+ * _canonicalize), not to an area share. A stored value that is not a
8094
+ * share at all (not a finite number strictly between 0 and 1) is
8095
+ * dropped, so that panel opens at the default again. */
8096
+ _recordPanelShares(tree, desktop, resizedSplitId = null) {
8097
+ const panel = _panelLeaves(tree);
8098
+ for (const side of PANEL_SIDES) {
8099
+ const kept = _validShare(desktop.panelShares[side]);
8100
+ const measure = resizedSplitId == null ? kept == null : _isInside(tree, panel[side], resizedSplitId);
8101
+ const v = (measure ? this._panelShare(tree, panel[side], side) : null) ?? kept;
8102
+ if (v == null) delete desktop.panelShares[side];
8103
+ else desktop.panelShares[side] = v;
8104
+ }
8105
+ }
8106
+ /** The share (0..1) of the WM area the `side` panel leaf takes along its
8107
+ * axis: the product of its shares in every ancestor split running that
8108
+ * way (splits across the axis do not change it). Only a CANONICAL
8109
+ * placement measures: the leaf's own parent is its `split-panel-<side>`
8110
+ * split and every ancestor is a panel split. Any other tree is one a
8111
+ * structural change has left in passing (the last content tile closed,
8112
+ * or a spawned content leaf wrapping the whole root), whose sizes are
8113
+ * not the user's; that, a missing or root leaf, or a degenerate split
8114
+ * gives null. */
8115
+ _panelShare(tree, leaf, side) {
8116
+ if (!leaf?.parentId) return null;
8117
+ if (!String(leaf.parentId).startsWith(`split-panel-${side}-`)) return null;
8118
+ const axis = side === "bottom" ? "v" : "h";
8119
+ let frac = 1;
8120
+ let node = leaf;
8121
+ while (node.parentId) {
8122
+ const parent = tree.get(node.parentId);
8123
+ if (!parent || !String(parent.id).startsWith("split-panel-")) return null;
8124
+ if (parent.dir === axis) {
8125
+ const idx = parent.children.indexOf(node.id);
8126
+ const total = parent.sizes.reduce((a, b) => a + b, 0);
8127
+ if (idx < 0 || !(total > 0) || !(parent.sizes[idx] > 0)) return null;
8128
+ frac *= parent.sizes[idx] / total;
8129
+ }
8130
+ node = parent;
8131
+ }
8132
+ return frac > 0 && frac < 1 ? frac : null;
8133
+ }
7342
8134
  _findPanelLeaf(side) {
7343
8135
  const kind = `panel:${side}`;
7344
8136
  for (const leaf of this._tree().leaves()) {
@@ -7684,6 +8476,7 @@ var WindowManager = class _WindowManager {
7684
8476
  /** Spawn a fresh ManagedWindow with the requested content. No
7685
8477
  * source leaf — closing the window just disposes the content. */
7686
8478
  _navigateWindow(kind, props) {
8479
+ if (this.revealInstance(kind, props)) return;
7687
8480
  const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
7688
8481
  const contentEl = document.createElement("div");
7689
8482
  contentEl.className = "twm-window-content";
@@ -7801,6 +8594,7 @@ var WindowManager = class _WindowManager {
7801
8594
  * routing) but uses `appendLeafTab` so the existing content
7802
8595
  * stays in place as a tab. */
7803
8596
  openInTabFromContext(ctx, kind, props = {}, transient = false, background = false) {
8597
+ if (this.revealInstance(kind, props)) return;
7804
8598
  if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
7805
8599
  this.openInWindow(ctx.windowId, kind, props);
7806
8600
  return;
@@ -7833,6 +8627,7 @@ var WindowManager = class _WindowManager {
7833
8627
  * "Add row" button, which passes no ctx) reliably lands as a sibling
7834
8628
  * tab in the main tile rather than swapping its content. */
7835
8629
  openInTabInPrimary(kind, props = {}, transient = false, background = false) {
8630
+ if (this.revealInstance(kind, props)) return;
7836
8631
  const tree = this._tree();
7837
8632
  const leafId = tree.primaryLeafId();
7838
8633
  if (!leafId) {
@@ -7851,11 +8646,38 @@ var WindowManager = class _WindowManager {
7851
8646
  this._notifyChange("tab-open");
7852
8647
  }
7853
8648
  };
8649
+ function _panelLeaves(tree) {
8650
+ const panel = {};
8651
+ for (const leaf of tree.leaves()) {
8652
+ const kind = leaf.content?.kind;
8653
+ if (kind === "panel:left") panel.left = leaf;
8654
+ else if (kind === "panel:right") panel.right = leaf;
8655
+ else if (kind === "panel:bottom") panel.bottom = leaf;
8656
+ }
8657
+ return panel;
8658
+ }
8659
+ function _isInside(tree, node, splitId) {
8660
+ for (let id = node?.parentId; id; id = tree.get(id)?.parentId) {
8661
+ if (id === splitId) return true;
8662
+ }
8663
+ return false;
8664
+ }
8665
+ function _validShare(v) {
8666
+ return typeof v === "number" && Number.isFinite(v) && v > 0 && v < 1 ? v : null;
8667
+ }
8668
+ function _clampSlotShare(v) {
8669
+ const min = TileRenderer.MIN_SLOT_FRACTION;
8670
+ return Math.min(Math.max(v, min), 1 - min);
8671
+ }
7854
8672
  function _tabTitle(kind, props) {
7855
8673
  if (props && typeof props.label === "string" && props.label) return props.label;
7856
8674
  if (props && (typeof props.id === "string" || typeof props.id === "number") && String(props.id)) return String(props.id);
7857
8675
  return kind || "";
7858
8676
  }
8677
+ function _windowTabSpecs(rec) {
8678
+ if (Array.isArray(rec?.tabs) && rec.tabs.length) return rec.tabs;
8679
+ return rec?.original ? [rec.original] : [];
8680
+ }
7859
8681
  function _leafTabSpecs(leaf) {
7860
8682
  const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
7861
8683
  if (tabs.length) {
@@ -8116,17 +8938,17 @@ function openTileTabMenu({
8116
8938
  return `
8117
8939
  <li class="twm-tile-tabmenu__item${idx === _cursor ? " twm-tile-tabmenu__item--cursor" : ""}"
8118
8940
  data-flat-idx="${idx}">
8119
- <span class="twm-tile-tabmenu__item-label">${_esc7(label)}</span>
8120
- ${hint ? `<span class="twm-tile-tabmenu__item-hint">${_esc7(hint)}</span>` : ""}
8941
+ <span class="twm-tile-tabmenu__item-label">${_esc8(label)}</span>
8942
+ ${hint ? `<span class="twm-tile-tabmenu__item-hint">${_esc8(hint)}</span>` : ""}
8121
8943
  </li>
8122
8944
  `;
8123
8945
  }).join("");
8124
8946
  return `
8125
8947
  <section class="twm-tile-tabmenu__section"
8126
- data-section="${_esc7(s.src.navKind)}">
8948
+ data-section="${_esc8(s.src.navKind)}">
8127
8949
  <header class="twm-tile-tabmenu__section-head">
8128
- <span class="material-symbols-outlined">${_esc7(s.meta?.icon || "arrow_right")}</span>
8129
- <span class="twm-tile-tabmenu__section-label">${_esc7(s.meta?.label || s.src.navKind)}</span>
8950
+ <span class="material-symbols-outlined">${_esc8(s.meta?.icon || "arrow_right")}</span>
8951
+ <span class="twm-tile-tabmenu__section-label">${_esc8(s.meta?.label || s.src.navKind)}</span>
8130
8952
  <span class="twm-tile-tabmenu__section-count">${s.total}</span>
8131
8953
  </header>
8132
8954
  ${s.view.length === 0 ? `
@@ -8135,11 +8957,11 @@ function openTileTabMenu({
8135
8957
  ${!_query && s.pages > 1 ? `
8136
8958
  <div class="twm-tile-tabmenu__pager">
8137
8959
  <button type="button" data-action="prev"
8138
- data-section="${_esc7(s.src.navKind)}"
8960
+ data-section="${_esc8(s.src.navKind)}"
8139
8961
  ${s.page === 0 ? "disabled" : ""}>&lt;</button>
8140
8962
  <span>${s.page + 1} / ${s.pages}</span>
8141
8963
  <button type="button" data-action="next"
8142
- data-section="${_esc7(s.src.navKind)}"
8964
+ data-section="${_esc8(s.src.navKind)}"
8143
8965
  ${s.page >= s.pages - 1 ? "disabled" : ""}>&gt;</button>
8144
8966
  </div>
8145
8967
  ` : ""}
@@ -8203,7 +9025,7 @@ function openTileTabSwitcher({ x, y, tabs, activeIdx = 0, onPick }) {
8203
9025
  <li class="twm-tile-tabswitch__item${i === activeIdx ? " twm-tile-tabswitch__item--on" : ""}"
8204
9026
  data-idx="${i}" role="option" aria-selected="${i === activeIdx}">
8205
9027
  <span class="material-symbols-outlined twm-tile-tabswitch__check">${i === activeIdx ? "check" : ""}</span>
8206
- <span class="twm-tile-tabswitch__label">${_esc7(t.title || t.kind || "Tab")}</span>
9028
+ <span class="twm-tile-tabswitch__label">${_esc8(t.title || t.kind || "Tab")}</span>
8207
9029
  </li>
8208
9030
  `).join("");
8209
9031
  overlay.innerHTML = `
@@ -8304,7 +9126,7 @@ function _matchesShaped(shaped, query) {
8304
9126
  const hint = String(shaped.hint ?? "").toLowerCase();
8305
9127
  return id.includes(query) || label.includes(query) || hint.includes(query);
8306
9128
  }
8307
- function _esc7(s) {
9129
+ function _esc8(s) {
8308
9130
  return String(s ?? "").replace(/[&<>"']/g, (c) => ({
8309
9131
  "&": "&amp;",
8310
9132
  "<": "&lt;",
@@ -8442,17 +9264,34 @@ async function createShell({
8442
9264
  tabLayout = null,
8443
9265
  newTabButton = true,
8444
9266
  desktopBar = {},
9267
+ menuBar: menuBarCfg = null,
8445
9268
  chrome = {},
8446
9269
  // An embedder that moved its sections out of the top bar — into an icon
8447
9270
  // rail, say — passes the selector its own buttons match, and F1..F8 keep
8448
9271
  // working. Omitted, the default top-bar selector applies.
8449
- navSelector = null
9272
+ navSelector = null,
9273
+ keymap = null
8450
9274
  } = {}) {
8451
9275
  if (!root || typeof root.appendChild !== "function") {
8452
9276
  throw new TypeError("createShell: `root` must be an element");
8453
9277
  }
8454
9278
  if (!taxonomy) throw new Error("createShell: a taxonomy is required");
8455
9279
  if (!entities) throw new Error("createShell: an entity catalog is required");
9280
+ if (chrome.menuBar && !menuBarCfg?.menus) {
9281
+ throw new TypeError("createShell: `chrome.menuBar` needs `menuBar: { menus }`");
9282
+ }
9283
+ if (menuBarCfg && !chrome.menuBar) {
9284
+ throw new TypeError("createShell: `menuBar` needs `chrome.menuBar`, the element it is painted into");
9285
+ }
9286
+ if (keymap != null) {
9287
+ if (typeof keymap !== "object" || Array.isArray(keymap)) {
9288
+ throw new TypeError("createShell: `keymap` must be an object of { bindings }");
9289
+ }
9290
+ const extra = Object.keys(keymap).filter((k) => k !== "bindings");
9291
+ if (extra.length) {
9292
+ throw new TypeError(`createShell: unknown \`keymap\` key(s) ${extra.map((k) => `"${k}"`).join(", ")} \u2014 the only key is \`bindings\``);
9293
+ }
9294
+ }
8456
9295
  const log = logger ?? { info() {
8457
9296
  }, warn() {
8458
9297
  }, error() {
@@ -8503,21 +9342,28 @@ async function createShell({
8503
9342
  },
8504
9343
  onChange: () => syncChrome()
8505
9344
  });
9345
+ const menuBar = chrome.menuBar ? createMenuBar(chrome.menuBar, menuBarCfg) : null;
9346
+ const ownCommands = paletteCfg.commands;
9347
+ const commands = menuBar && menuBarCfg.palette !== false ? () => [...typeof ownCommands === "function" ? ownCommands() || [] : [], ...menuBar.commands()] : ownCommands;
9348
+ const bindings = keymap?.bindings ?? null;
8506
9349
  const palette = createCommandPalette({
8507
9350
  wm,
8508
9351
  api,
8509
9352
  taxonomy,
8510
9353
  catalog: entities,
8511
- ...paletteCfg
9354
+ // What *Keyboard shortcuts* lists is what the keys do in THIS shell.
9355
+ shortcuts: keymapShortcuts(bindings),
9356
+ ...paletteCfg,
9357
+ commands
8512
9358
  });
8513
- const disposeKeymap = installKeymap({ wm, palette, navSelector });
9359
+ const disposeKeymap = installKeymap({ wm, palette, navSelector, bindings, logger });
8514
9360
  const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
8515
9361
  topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
8516
9362
  desktopsEl = mountDesktopBar(chrome.desktops, wm, desktopBar || {});
8517
9363
  const zoom = mountZoomControl(chrome.zoom, { root, host });
8518
9364
  if (zoom) await zoom.ready;
8519
9365
  toggles = bindPanelToggles(chrome.panelToggles, wm);
8520
- eventBus?.on?.("wm:changed", syncChrome);
9366
+ const chromeSubscription = eventBus?.on?.("wm:changed", syncChrome);
8521
9367
  await wm.load();
8522
9368
  syncChrome();
8523
9369
  log.info?.("shell mounted");
@@ -8535,7 +9381,9 @@ async function createShell({
8535
9381
  panelToggles: toggles,
8536
9382
  // `get`/`set` so an embedder can drive the zoom from its own settings
8537
9383
  // pane, or read it, without reaching into the control's DOM.
8538
- zoom
9384
+ zoom,
9385
+ // STORY-0030. `setEnabled` / `setChecked` / `open` / `commands`.
9386
+ menuBar
8539
9387
  }),
8540
9388
  // A shell that can be built can be built TWICE — an embedder that
8541
9389
  // rebuilds on a context change (a different project, a different
@@ -8544,7 +9392,7 @@ async function createShell({
8544
9392
  // with the first one's keyboard.
8545
9393
  dispose: () => {
8546
9394
  try {
8547
- eventBus?.off?.("wm:changed", syncChrome);
9395
+ chromeSubscription?.dispose?.();
8548
9396
  } catch {
8549
9397
  }
8550
9398
  try {
@@ -8568,6 +9416,10 @@ async function createShell({
8568
9416
  desktopsEl?.remove();
8569
9417
  } catch {
8570
9418
  }
9419
+ try {
9420
+ menuBar?.dispose();
9421
+ } catch {
9422
+ }
8571
9423
  }
8572
9424
  });
8573
9425
  }
@@ -8619,6 +9471,7 @@ function mountPaletteButton(hostEl, palette) {
8619
9471
  hostEl.insertBefore(btn, hostEl.firstChild);
8620
9472
  return btn;
8621
9473
  }
9474
+ var OWN_TOOLTIPS = /* @__PURE__ */ new WeakMap();
8622
9475
  function bindPanelToggles(map, wm) {
8623
9476
  const fresh = {};
8624
9477
  for (const side of ["left", "right", "bottom"]) {
@@ -8631,6 +9484,10 @@ function bindPanelToggles(map, wm) {
8631
9484
  el.removeAttribute("aria-pressed");
8632
9485
  btn.replaceWith(el);
8633
9486
  el.addEventListener("click", () => wm.togglePanel(side));
9487
+ OWN_TOOLTIPS.set(el, {
9488
+ tooltip: el.getAttribute("data-tooltip"),
9489
+ title: el.getAttribute("title")
9490
+ });
8634
9491
  fresh[side] = el;
8635
9492
  }
8636
9493
  return fresh;
@@ -8640,6 +9497,21 @@ function syncPanelToggles(map, wm) {
8640
9497
  const btn = map?.[side];
8641
9498
  if (!btn) continue;
8642
9499
  btn.classList.toggle("twm-panel-toggle-btn--on", wm.isPanelOpen(side));
9500
+ const { available, reason } = wm.getPanelAvailability(side);
9501
+ const own = OWN_TOOLTIPS.get(btn) || {};
9502
+ const restore = (attr, value) => value == null ? btn.removeAttribute(attr) : btn.setAttribute(attr, value);
9503
+ if (available) {
9504
+ btn.removeAttribute("aria-disabled");
9505
+ restore("data-tooltip", own.tooltip);
9506
+ restore("title", own.title);
9507
+ continue;
9508
+ }
9509
+ btn.setAttribute("aria-disabled", "true");
9510
+ if (btn.classList.contains("twm-has-tooltip")) {
9511
+ restore("data-tooltip", reason ?? own.tooltip);
9512
+ } else {
9513
+ restore("title", reason ?? own.title);
9514
+ }
8643
9515
  }
8644
9516
  }
8645
9517
  function mountDesktopBar(hostEl, wm, { switchOnAdd = false } = {}) {
@@ -8795,6 +9667,8 @@ function _tileContextMenu(wm, leafId, x, y) {
8795
9667
  const isPanel = String(leaf.content?.kind || "").startsWith("panel:");
8796
9668
  tree.focus(leafId);
8797
9669
  wm.renderer._updateFocusClasses();
9670
+ const single = !!leaf.content && wm.isSingleKind(leaf.content.kind);
9671
+ const singleTitle = single ? wm.taxonomy.singleReason(leaf.content.kind) : void 0;
8798
9672
  const items = [
8799
9673
  { label: "Split horizontally", icon: "splitscreen_vertical_add", action: "split-h" },
8800
9674
  { label: "Split vertically", icon: "splitscreen_add", action: "split-v" },
@@ -8803,7 +9677,8 @@ function _tileContextMenu(wm, leafId, x, y) {
8803
9677
  label: "Open in new tab",
8804
9678
  icon: "tab",
8805
9679
  action: "open-tab",
8806
- disabled: isPanel || !leaf.content
9680
+ disabled: isPanel || !leaf.content || single,
9681
+ title: singleTitle
8807
9682
  },
8808
9683
  // TWO DIFFERENT GLYPHS FOR TWO DIFFERENT DESTINATIONS. `web_asset` is a
8809
9684
  // window INSIDE the application — the same glyph `ManagedWindow` uses
@@ -8816,7 +9691,8 @@ function _tileContextMenu(wm, leafId, x, y) {
8816
9691
  label: "Open a copy in a window",
8817
9692
  icon: "web_asset",
8818
9693
  action: "open-window",
8819
- disabled: isPanel || !leaf.content
9694
+ disabled: isPanel || !leaf.content || single,
9695
+ title: singleTitle
8820
9696
  },
8821
9697
  // C20, THE OTHER HALF — and it was missing while the `close` half
8822
9698
  // below carried a paragraph explaining why it could not be.
@@ -8882,6 +9758,8 @@ function _tileContextMenu(wm, leafId, x, y) {
8882
9758
  export {
8883
9759
  BADGE_LEVELS,
8884
9760
  DesktopManager,
9761
+ KEYBOARD_SHORTCUTS_TOPIC_ID,
9762
+ KEYMAP_SHORTCUTS,
8885
9763
  PLACEHOLDER_KIND,
8886
9764
  PanelKeyRouter,
8887
9765
  TILE_TAB_MIME,
@@ -8910,6 +9788,8 @@ export {
8910
9788
  getPanelKeyRouter,
8911
9789
  installKeymap,
8912
9790
  installPanelKeyRouter,
9791
+ keyboardShortcutsTopic,
9792
+ keymapShortcuts,
8913
9793
  loadDesktops,
8914
9794
  makeLeaf,
8915
9795
  makeLoadingOverlay,