flexdesk 0.2.0 → 0.3.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 (56) hide show
  1. package/css/base.css +1484 -181
  2. package/css/flexdesk.css +1311 -18
  3. package/css/overrides.css +44 -0
  4. package/css/tokens.css +45 -0
  5. package/dist/charts.js +5 -3
  6. package/dist/charts.js.map +1 -1
  7. package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
  8. package/dist/chunk-ELXVW542.js.map +7 -0
  9. package/dist/chunk-LH5TSOZW.js +1237 -0
  10. package/dist/chunk-LH5TSOZW.js.map +7 -0
  11. package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
  12. package/dist/chunk-O5OHMWBB.js.map +7 -0
  13. package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
  14. package/dist/chunk-QIU5S2RU.js.map +7 -0
  15. package/dist/chunk-QNQHQ24V.js +408 -0
  16. package/dist/chunk-QNQHQ24V.js.map +7 -0
  17. package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
  18. package/dist/chunk-XKDTIT4Q.js.map +7 -0
  19. package/dist/editor.js +3 -380
  20. package/dist/editor.js.map +3 -3
  21. package/dist/flexdesk.css +1311 -18
  22. package/dist/tiles.js +168 -41
  23. package/dist/tiles.js.map +2 -2
  24. package/dist/tokens.css +45 -0
  25. package/dist/widgets.js +44 -14
  26. package/dist/widgets.js.map +2 -2
  27. package/dist/wm.js +2983 -142
  28. package/dist/wm.js.map +4 -4
  29. package/package.json +3 -2
  30. package/src/charts/chart_types.js +167 -0
  31. package/src/charts/plotly_wrapper.js +178 -10
  32. package/src/editor/notebook_tab_bar.js +39 -3
  33. package/src/tiles/tile_base.js +143 -35
  34. package/src/tiles/tile_grid.js +52 -1
  35. package/src/tiling/command_palette.js +71 -18
  36. package/src/tiling/desktops.js +36 -12
  37. package/src/tiling/keymap.js +24 -4
  38. package/src/tiling/shell.js +135 -24
  39. package/src/tiling/tab_strip.js +184 -0
  40. package/src/tiling/tile_breadcrumb.js +34 -2
  41. package/src/tiling/tile_renderer.js +1386 -21
  42. package/src/tiling/tile_tab_menu.js +101 -0
  43. package/src/tiling/tile_tree.js +82 -0
  44. package/src/tiling/wm.js +2352 -74
  45. package/src/ui/components/action_dropdown.js +34 -3
  46. package/src/ui/components/autocomplete_field.js +65 -13
  47. package/src/ui/components/context_menu.js +79 -8
  48. package/src/ui/components/data_table.js +508 -84
  49. package/src/ui/components/managed_window.js +928 -36
  50. package/src/ui/components/modal.js +214 -8
  51. package/dist/chunk-CT4YXXLP.js.map +0 -7
  52. package/dist/chunk-DRYCDMEG.js.map +0 -7
  53. package/dist/chunk-DVU44T77.js.map +0 -7
  54. package/dist/chunk-TLZUUFOE.js.map +0 -7
  55. package/dist/chunk-UCJ2WD4D.js +0 -625
  56. package/dist/chunk-UCJ2WD4D.js.map +0 -7
package/dist/wm.js CHANGED
@@ -2,11 +2,15 @@ import {
2
2
  HelpModal,
3
3
  openForm,
4
4
  showContextMenu
5
- } from "./chunk-DVU44T77.js";
5
+ } from "./chunk-ELXVW542.js";
6
6
  import {
7
7
  ManagedWindow
8
- } from "./chunk-UCJ2WD4D.js";
8
+ } from "./chunk-LH5TSOZW.js";
9
9
  import "./chunk-FL5KFNQH.js";
10
+ import {
11
+ NotebookTabBar
12
+ } from "./chunk-QNQHQ24V.js";
13
+ import "./chunk-WVFGV5FT.js";
10
14
  import "./chunk-JYWURG5T.js";
11
15
 
12
16
  // src/tiling/command_palette.js
@@ -21,7 +25,14 @@ var STATIC_COMMANDS = [
21
25
  action: () => HelpModal.open("keyboard-shortcuts")
22
26
  }
23
27
  ];
24
- function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = "Search\u2026" }) {
28
+ function createCommandPalette({
29
+ wm,
30
+ api,
31
+ taxonomy,
32
+ catalog,
33
+ placeholder = "Search\u2026",
34
+ onPick = null
35
+ }) {
25
36
  if (!taxonomy) throw new Error("createCommandPalette: a taxonomy is required");
26
37
  if (!catalog) throw new Error("createCommandPalette: an entity catalog is required");
27
38
  let overlay = null;
@@ -40,7 +51,7 @@ function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = "Searc
40
51
  overlay = document.createElement("div");
41
52
  overlay.id = ROOT_ID;
42
53
  overlay.className = "twm-cmdpal-overlay";
43
- overlay.innerHTML = _markup(taxonomy, placeholder);
54
+ overlay.innerHTML = _markup(taxonomy, placeholder, wm);
44
55
  document.body.appendChild(overlay);
45
56
  overlay.addEventListener("click", (e) => {
46
57
  if (e.target === overlay) close();
@@ -120,6 +131,15 @@ function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = "Searc
120
131
  }
121
132
  return;
122
133
  }
134
+ if (onPick) {
135
+ let handled = false;
136
+ try {
137
+ handled = onPick(pick) ?? false;
138
+ } catch (err) {
139
+ console.error("[cmdpal] onPick threw", err);
140
+ }
141
+ if (handled) return;
142
+ }
123
143
  wm.openInPrimary(pick.kind, pick.props || { id: pick.id, label: pick.label });
124
144
  };
125
145
  const tryConsumePrefix = () => {
@@ -186,7 +206,17 @@ function createCommandPalette({ wm, api, taxonomy, catalog, placeholder = "Searc
186
206
  };
187
207
  return { open, close, toggle, isOpen };
188
208
  }
189
- function _markup(taxonomy, placeholder) {
209
+ function _markup(taxonomy, placeholder, wm) {
210
+ const PANEL_CHIPS = [
211
+ { side: "left", icon: "menu", label: "Left nav" },
212
+ { side: "right", icon: "dock_to_left", label: "Right panel" },
213
+ { side: "bottom", icon: "dock_to_bottom", label: "Bottom panel" }
214
+ ];
215
+ const panelToggles = PANEL_CHIPS.filter((p) => wm.content?.has?.(`panel:${p.side}`) !== false).map((p) => `
216
+ <button class="twm-chip" data-toggle="${p.side}">
217
+ <span class="material-symbols-outlined">${p.icon}</span>
218
+ ${p.label}
219
+ </button>`).join("");
190
220
  const chips = taxonomy.topNavEntries().map((k) => `
191
221
  <button class="twm-chip" data-shortcut="${k.kind}">
192
222
  <span class="material-symbols-outlined">${k.icon}</span>
@@ -202,20 +232,7 @@ function _markup(taxonomy, placeholder) {
202
232
  autocomplete="off" />
203
233
  </div>
204
234
  <div class="twm-cmdpal__chips">${chips}</div>
205
- <div class="twm-cmdpal__toggles">
206
- <button class="twm-chip" data-toggle="left">
207
- <span class="material-symbols-outlined">menu</span>
208
- Left nav
209
- </button>
210
- <button class="twm-chip" data-toggle="right">
211
- <span class="material-symbols-outlined">dock_to_left</span>
212
- Right panel
213
- </button>
214
- <button class="twm-chip" data-toggle="bottom">
215
- <span class="material-symbols-outlined">dock_to_bottom</span>
216
- Bottom panel
217
- </button>
218
- </div>
235
+ <div class="twm-cmdpal__toggles">${panelToggles}</div>
219
236
  <div class="twm-cmdpal__list" data-role="list"></div>
220
237
  <div class="twm-cmdpal__footer">
221
238
  <span><kbd>\u2191</kbd><kbd>\u2193</kbd> navigate</span>
@@ -821,6 +838,70 @@ var TileTree = class _TileTree {
821
838
  _syncActiveTab(n);
822
839
  return true;
823
840
  }
841
+ /**
842
+ * C33. MOVE ONE TAB FROM ONE LEAF TO ANOTHER, AS ONE MUTATION.
843
+ *
844
+ * Every other tab operation on this class takes ONE `leafId`, and that was
845
+ * a complete description of the model until a tab could be dragged into a
846
+ * different tile: `appendLeafTab`, `removeLeafTab`, `moveLeafTab` and the
847
+ * three bulk closes all begin and end inside a single leaf. The renderer's
848
+ * refusal to wire `onDropFromOtherPane` named this absence as one of its
849
+ * two reasons (`tile_renderer.js`, `_topTabCallbacks`); this is that half.
850
+ *
851
+ * ONE FUNCTION RATHER THAN A COMPOSE, and that is why it lives here rather
852
+ * than in the WM. `removeLeafTab` then `appendLeafTab` is two mutations
853
+ * with a moment between them in which the tab exists nowhere — and the
854
+ * second can FAIL (a panel destination refuses tabs), which leaves the tree
855
+ * short one tab and nothing on screen saying where it went. Every guard is
856
+ * therefore taken before the first splice.
857
+ *
858
+ * TWO NODES ARE RE-SYNCED, WHICH IS WHAT MAKES THIS DIFFERENT. `tabs` is
859
+ * the source of truth and `content`/`title` mirror `tabs[active]`
860
+ * (`_syncActiveTab`). Every existing mutation touches one leaf, so one
861
+ * re-sync is right; this one touches two, and skipping the SOURCE's leaves
862
+ * a pane whose chrome still names — and whose body still mounts — a tab it
863
+ * no longer holds. That is the mistake this operation invites, and it is
864
+ * what `web/js/shell/tab_drop.test.mjs` asserts against in the consumer.
865
+ *
866
+ * A same-leaf call is a REORDER and delegates, so there is one
867
+ * implementation of "a tab changed position within its strip" rather than
868
+ * two that will disagree about the active-index clamp.
869
+ *
870
+ * @param {string} fromLeafId
871
+ * @param {number} fromIdx
872
+ * @param {string} toLeafId
873
+ * @param {number} [toIdx=-1] where to insert; -1 (or past the end) appends
874
+ * @returns {{ok: boolean, toIdx: number, emptied: boolean}|null} null when
875
+ * the move was refused. `emptied` tells the caller the source pane now
876
+ * holds nothing, which is its cue to re-seed rather than leave a blank
877
+ * tile — the never-empty-tile invariant is the WM's to keep, not this
878
+ * class's.
879
+ */
880
+ moveTabToLeaf(fromLeafId, fromIdx, toLeafId, toIdx = -1) {
881
+ const from = this.get(fromLeafId);
882
+ const to = this.get(toLeafId);
883
+ if (!from || from.kind !== "leaf") return null;
884
+ if (!to || to.kind !== "leaf") return null;
885
+ const tabs = Array.isArray(from.tabs) ? from.tabs : [];
886
+ if (!Number.isInteger(fromIdx) || fromIdx < 0 || fromIdx >= tabs.length) return null;
887
+ if (_isPanel(to)) return null;
888
+ if (fromLeafId === toLeafId) {
889
+ const dest = !Number.isInteger(toIdx) || toIdx < 0 || toIdx >= tabs.length ? tabs.length - 1 : toIdx;
890
+ if (!this.moveLeafTab(fromLeafId, fromIdx, dest)) return null;
891
+ return { ok: true, toIdx: dest, emptied: false };
892
+ }
893
+ const [moved] = from.tabs.splice(fromIdx, 1);
894
+ if (from.tabs.length === 0) from.activeTabIdx = 0;
895
+ else if (fromIdx < from.activeTabIdx) from.activeTabIdx -= 1;
896
+ else if (fromIdx === from.activeTabIdx) from.activeTabIdx = Math.max(0, fromIdx - 1);
897
+ to.tabs = Array.isArray(to.tabs) ? to.tabs : [];
898
+ const at = !Number.isInteger(toIdx) || toIdx < 0 || toIdx > to.tabs.length ? to.tabs.length : toIdx;
899
+ to.tabs.splice(at, 0, moved);
900
+ to.activeTabIdx = at;
901
+ _syncActiveTab(from);
902
+ _syncActiveTab(to);
903
+ return { ok: true, toIdx: at, emptied: from.tabs.length === 0 };
904
+ }
824
905
  /**
825
906
  * Split a leaf in the given direction; existing content stays in the
826
907
  * original leaf, a new empty leaf is added next to it. Returns the
@@ -1095,7 +1176,7 @@ var DEFAULT_PANEL_STATE = {
1095
1176
  right: true,
1096
1177
  bottom: true
1097
1178
  };
1098
- function _makeDesktop(label, seed) {
1179
+ function _makeDesktop(label, seed, panelDefaults = DEFAULT_PANEL_STATE) {
1099
1180
  const tree = new TileTree();
1100
1181
  tree.setRoot(makeLeaf(seed()));
1101
1182
  return {
@@ -1103,19 +1184,27 @@ function _makeDesktop(label, seed) {
1103
1184
  label,
1104
1185
  tree,
1105
1186
  windows: [],
1106
- // boot default: left + right + bottom all open. Names are
1107
- // assigned by wm._canonicalize via PANEL_TITLES.
1108
- panels: { ...DEFAULT_PANEL_STATE }
1187
+ // Boot default: whatever the embedder asked for, left + right + bottom
1188
+ // when it asked for nothing. Names are assigned by wm._canonicalize via
1189
+ // PANEL_TITLES.
1190
+ panels: { ...panelDefaults }
1109
1191
  };
1110
1192
  }
1111
1193
  var DesktopManager = class _DesktopManager {
1112
- /** @param {{seed: () => object}} opts `seed` builds the root leaf. Required. */
1113
- constructor({ seed } = {}) {
1194
+ /**
1195
+ * @param {object} opts
1196
+ * @param {function} opts.seed builds the root leaf. Required.
1197
+ * @param {object} [opts.panelDefaults] C14. Which panel tiles a fresh
1198
+ * desktop opens with, merged over `DEFAULT_PANEL_STATE`. Omitted, every
1199
+ * desktop opens with all three — today's behaviour, unchanged.
1200
+ */
1201
+ constructor({ seed, panelDefaults = null } = {}) {
1114
1202
  if (typeof seed !== "function") {
1115
1203
  throw new Error("DesktopManager: a `seed` function is required (the taxonomy root leaf)");
1116
1204
  }
1117
1205
  this.seed = seed;
1118
- this.desktops = [_makeDesktop("1", seed)];
1206
+ this.panelDefaults = { ...DEFAULT_PANEL_STATE, ...panelDefaults || {} };
1207
+ this.desktops = [_makeDesktop("1", seed, this.panelDefaults)];
1119
1208
  this.activeIdx = 0;
1120
1209
  }
1121
1210
  active() {
@@ -1129,11 +1218,19 @@ var DesktopManager = class _DesktopManager {
1129
1218
  }
1130
1219
  ensureCount(n) {
1131
1220
  while (this.desktops.length < n) {
1132
- this.desktops.push(_makeDesktop(String(this.desktops.length + 1), this.seed));
1221
+ this.desktops.push(_makeDesktop(
1222
+ String(this.desktops.length + 1),
1223
+ this.seed,
1224
+ this.panelDefaults
1225
+ ));
1133
1226
  }
1134
1227
  }
1135
1228
  addDesktop(label = null) {
1136
- const d = _makeDesktop(label || String(this.desktops.length + 1), this.seed);
1229
+ const d = _makeDesktop(
1230
+ label || String(this.desktops.length + 1),
1231
+ this.seed,
1232
+ this.panelDefaults
1233
+ );
1137
1234
  this.desktops.push(d);
1138
1235
  return d;
1139
1236
  }
@@ -1150,15 +1247,19 @@ var DesktopManager = class _DesktopManager {
1150
1247
  }))
1151
1248
  };
1152
1249
  }
1153
- static deserialize(blob, { seed } = {}) {
1154
- const m = new _DesktopManager({ seed });
1250
+ static deserialize(blob, { seed, panelDefaults = null } = {}) {
1251
+ const m = new _DesktopManager({ seed, panelDefaults });
1155
1252
  if (!blob || !Array.isArray(blob.desktops) || blob.desktops.length === 0) return m;
1156
1253
  m.desktops = blob.desktops.map((raw) => ({
1157
1254
  id: raw.id || `desk-${Math.random().toString(36).slice(2, 8)}`,
1158
1255
  label: raw.label || "?",
1159
1256
  tree: raw.tree ? TileTree.deserialize(raw.tree) : new TileTree(),
1160
1257
  windows: [],
1161
- panels: { ...DEFAULT_PANEL_STATE, ...raw.panels || {} }
1258
+ // A RESTORED desktop's own answer wins over the default: the user
1259
+ // closed that panel, and re-opening it on every reload is the bug
1260
+ // this merge order avoids. The default only fills a key the stored
1261
+ // blob predates.
1262
+ panels: { ...m.panelDefaults, ...raw.panels || {} }
1162
1263
  }));
1163
1264
  for (const d of m.desktops) {
1164
1265
  if (!d.tree.rootId) d.tree.setRoot(makeLeaf(seed()));
@@ -1298,8 +1399,8 @@ function createEntityCatalog({ sources, aliases = {}, aggregate = null } = {}) {
1298
1399
  }
1299
1400
 
1300
1401
  // src/tiling/keymap.js
1301
- function installKeymap({ wm, palette }) {
1302
- document.addEventListener("keydown", (e) => {
1402
+ function installKeymap({ wm, palette, ...opts } = {}) {
1403
+ const onKeyDown = (e) => {
1303
1404
  const inField = e.target?.closest?.(
1304
1405
  'input, textarea, select, [contenteditable="true"]'
1305
1406
  );
@@ -1327,7 +1428,7 @@ function installKeymap({ wm, palette }) {
1327
1428
  const fMatch = /^F([1-9]|1[0-2])$/.exec(e.key);
1328
1429
  if (fMatch && !inField && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
1329
1430
  const btns = document.querySelectorAll(
1330
- ".twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn"
1431
+ opts.navSelector || ".twm-global-top-bar .twm-bar-center.twm-top-nav .twm-top-nav__btn"
1331
1432
  );
1332
1433
  const idx = Number(fMatch[1]) - 1;
1333
1434
  if (idx < btns.length) {
@@ -1408,7 +1509,9 @@ function installKeymap({ wm, palette }) {
1408
1509
  if (key.length === 1 && /[a-z]/.test(key) && !inField) {
1409
1510
  e.preventDefault();
1410
1511
  }
1411
- });
1512
+ };
1513
+ document.addEventListener("keydown", onKeyDown);
1514
+ return () => document.removeEventListener("keydown", onKeyDown);
1412
1515
  }
1413
1516
 
1414
1517
  // src/tiling/kind_taxonomy.js
@@ -2239,8 +2342,20 @@ function mountTileBreadcrumb(kind, props, ctx) {
2239
2342
  }
2240
2343
  };
2241
2344
  let rootLabel = rootCrumb?.label || "";
2345
+ const trailOf = () => {
2346
+ if (typeof ctx?.trailSegments !== "function") return [];
2347
+ try {
2348
+ return ctx.trailSegments() || [];
2349
+ } catch (err) {
2350
+ console.warn("[breadcrumb] trailSegments threw", err);
2351
+ return [];
2352
+ }
2353
+ };
2242
2354
  const render = () => {
2243
- _renderInto(root, _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate));
2355
+ _renderInto(
2356
+ root,
2357
+ _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate, trailOf())
2358
+ );
2244
2359
  };
2245
2360
  render();
2246
2361
  let unsubscribe = null;
@@ -2256,6 +2371,10 @@ function mountTileBreadcrumb(kind, props, ctx) {
2256
2371
  }
2257
2372
  return {
2258
2373
  el: root,
2374
+ /** Repaint. A trail-driven breadcrumb changes without the tile
2375
+ * remounting — following a lookup replaces the active tab's content in
2376
+ * place — so the embedder that grew the trail says when. */
2377
+ refresh: render,
2259
2378
  destroy: () => {
2260
2379
  try {
2261
2380
  unsubscribe?.();
@@ -2265,7 +2384,7 @@ function mountTileBreadcrumb(kind, props, ctx) {
2265
2384
  }
2266
2385
  };
2267
2386
  }
2268
- function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate) {
2387
+ function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate, trail) {
2269
2388
  const segs = [];
2270
2389
  const meta = taxonomy.meta(kind);
2271
2390
  if (meta?.appGlobal) {
@@ -2287,6 +2406,14 @@ function _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate) {
2287
2406
  onClick: () => navigate(topNav)
2288
2407
  });
2289
2408
  }
2409
+ for (const step of trail || []) {
2410
+ if (!step?.kind) continue;
2411
+ segs.push({
2412
+ icon: taxonomy.meta(step.kind)?.icon || "description",
2413
+ label: step.title || step.props?.label || step.props?.id || step.kind,
2414
+ onClick: () => navigate(step.kind, step.props || {})
2415
+ });
2416
+ }
2290
2417
  for (const anc of taxonomy.ancestors(kind, props)) {
2291
2418
  segs.push({
2292
2419
  icon: taxonomy.meta(anc.kind)?.icon || "description",
@@ -2551,8 +2678,47 @@ function createPageFactories({ eventBus = null, events = {}, refreshOn = {} } =
2551
2678
 
2552
2679
  // src/tiling/tile_renderer.js
2553
2680
  var SPLITTER_PX = 4;
2681
+ var TAB_LAYOUTS = ["bottom", "top"];
2682
+ var DEFAULT_TAB_LAYOUT = "bottom";
2683
+ function _normalizeTabLayout(value) {
2684
+ return TAB_LAYOUTS.includes(value) ? value : DEFAULT_TAB_LAYOUT;
2685
+ }
2686
+ function _tabKey(idx) {
2687
+ return `builtin://tab/${idx}`;
2688
+ }
2689
+ function _tabKeyIndex(key) {
2690
+ const n = Number(String(key ?? "").replace("builtin://tab/", ""));
2691
+ return Number.isInteger(n) && n >= 0 ? n : -1;
2692
+ }
2693
+ var TILE_LIFT_PX = 24;
2694
+ var CHROME_NO_FLOAT = "button, .twm-leaf__tab";
2695
+ function _isDownwardPull(down, sideways) {
2696
+ return down > 0 && sideways <= down;
2697
+ }
2698
+ var TILE_TAB_MIME = "application/x-twm-tile-tab";
2699
+ function _isTileTabDrag(e) {
2700
+ try {
2701
+ return !!e.dataTransfer?.types?.includes(TILE_TAB_MIME);
2702
+ } catch {
2703
+ return false;
2704
+ }
2705
+ }
2554
2706
  var TileRenderer = class {
2555
- constructor({ root, tree, content, ctx, onFocusChange }) {
2707
+ /** C33. Readable from a consumer that holds only the renderer — the Tables
2708
+ * drop suite drives real drag events and has to build a `dataTransfer`
2709
+ * stub that this renderer will admit. */
2710
+ static get TAB_MIME() {
2711
+ return TILE_TAB_MIME;
2712
+ }
2713
+ constructor({
2714
+ root,
2715
+ tree,
2716
+ content,
2717
+ ctx,
2718
+ onFocusChange,
2719
+ onAfterRender,
2720
+ tabLayout = null
2721
+ }) {
2556
2722
  if (!content || typeof content.mount !== "function") {
2557
2723
  throw new Error("TileRenderer: a content registry is required");
2558
2724
  }
@@ -2562,12 +2728,33 @@ var TileRenderer = class {
2562
2728
  this.ctx = ctx || {};
2563
2729
  this.onFocusChange = onFocusChange || (() => {
2564
2730
  });
2731
+ this.onAfterRender = onAfterRender || (() => {
2732
+ });
2565
2733
  this._leafCache = /* @__PURE__ */ new Map();
2566
2734
  this._drag = null;
2735
+ this._tabDrag = null;
2736
+ this._chromePull = null;
2737
+ this._tabDropProbe = null;
2738
+ this._tabDropPreviewEl = null;
2739
+ this.disposed = false;
2567
2740
  this.root.classList.add("twm-root");
2741
+ this.tabLayout = _normalizeTabLayout(
2742
+ this.root.dataset?.twmTabs ?? tabLayout ?? DEFAULT_TAB_LAYOUT
2743
+ );
2744
+ if (this.root.dataset) this.root.dataset.twmTabs = this.tabLayout;
2745
+ this._layoutObserver = typeof MutationObserver === "function" ? new MutationObserver(() => this.setTabLayout(this.root.dataset?.twmTabs)) : null;
2746
+ this._layoutObserver?.observe(
2747
+ this.root,
2748
+ { attributes: true, attributeFilter: ["data-twm-tabs"] }
2749
+ );
2568
2750
  this.root.addEventListener("mousedown", this._onMouseDown.bind(this));
2751
+ this.root.addEventListener("dragover", this._onTabDragOver.bind(this));
2752
+ this.root.addEventListener("drop", this._onTabDrop.bind(this));
2753
+ this.root.addEventListener("dragleave", this._onTabDragLeave.bind(this));
2754
+ this.root.addEventListener("dragend", this._onTabDragEnd.bind(this));
2569
2755
  }
2570
2756
  render() {
2757
+ if (this.disposed) return;
2571
2758
  const savedScrolls = /* @__PURE__ */ new Map();
2572
2759
  for (const [leafId, entry] of this._leafCache.entries()) {
2573
2760
  const snap = [];
@@ -2579,6 +2766,8 @@ var TileRenderer = class {
2579
2766
  savedScrolls.set(leafId, snap);
2580
2767
  entry.wrapEl.remove();
2581
2768
  }
2769
+ const floats = [...this.root.children].filter((el) => el.classList?.contains("twm-managed-window") || el.classList?.contains("twm-managed-window__backdrop"));
2770
+ for (const el of floats) el.remove();
2582
2771
  this.root.innerHTML = "";
2583
2772
  const tree = this.tree;
2584
2773
  if (!tree.rootId) {
@@ -2587,11 +2776,13 @@ var TileRenderer = class {
2587
2776
  empty.textContent = "Empty desktop \u2014 Ctrl+K to open something.";
2588
2777
  this.root.appendChild(empty);
2589
2778
  this._cleanCache(/* @__PURE__ */ new Set());
2779
+ for (const el of floats) this.root.appendChild(el);
2590
2780
  return;
2591
2781
  }
2592
2782
  const liveLeafIds = /* @__PURE__ */ new Set();
2593
2783
  this._mount(tree.rootId, this.root, liveLeafIds);
2594
2784
  this._cleanCache(liveLeafIds);
2785
+ for (const el of floats) this.root.appendChild(el);
2595
2786
  this._updateFocusClasses();
2596
2787
  const restore = () => {
2597
2788
  for (const [leafId, snap] of savedScrolls) {
@@ -2606,6 +2797,24 @@ var TileRenderer = class {
2606
2797
  };
2607
2798
  restore();
2608
2799
  requestAnimationFrame(restore);
2800
+ try {
2801
+ this.onAfterRender();
2802
+ } catch (err) {
2803
+ console.error("[tile-renderer] onAfterRender threw", err);
2804
+ }
2805
+ }
2806
+ /** Unmount everything and stop painting. `dispose()` on the shell calls
2807
+ * this: `root.innerHTML = ''` detaches DOM without telling a single content
2808
+ * factory, so a page module that installed a `window` listener or an
2809
+ * interval keeps both, invisibly, for the life of the tab. */
2810
+ destroy() {
2811
+ this._layoutObserver?.disconnect();
2812
+ this._layoutObserver = null;
2813
+ this._clearTabDropPreview();
2814
+ this._tabDrag = null;
2815
+ this._tabDropProbe = null;
2816
+ this._cleanCache(/* @__PURE__ */ new Set());
2817
+ this.disposed = true;
2609
2818
  }
2610
2819
  _cleanCache(liveSet) {
2611
2820
  for (const [leafId, entry] of [...this._leafCache.entries()]) {
@@ -2614,6 +2823,7 @@ var TileRenderer = class {
2614
2823
  entry.content?.destroy?.();
2615
2824
  } catch (_) {
2616
2825
  }
2826
+ this._disposeTabStrip(entry);
2617
2827
  entry.wrapEl.remove();
2618
2828
  this._leafCache.delete(leafId);
2619
2829
  }
@@ -2651,18 +2861,20 @@ var TileRenderer = class {
2651
2861
  }
2652
2862
  _leafEl(leaf) {
2653
2863
  const activeTab = Array.isArray(leaf.tabs) && leaf.tabs.length > 0 ? leaf.tabs[Math.max(0, Math.min(leaf.tabs.length - 1, leaf.activeTabIdx || 0))] : null;
2654
- const tabFingerprint = (leaf.tabs || []).map((t) => `${t.kind}::${JSON.stringify(t.props || {})}`).join("|") + `#${leaf.activeTabIdx || 0}`;
2655
- const kindKey = leaf.content ? `${leaf.content.kind}::${JSON.stringify(leaf.content.props || {})}::tabs:${tabFingerprint}` : "__empty__";
2864
+ const kindKey = this._leafKindKey(leaf);
2656
2865
  let entry = this._leafCache.get(leaf.id);
2657
2866
  if (entry && entry.kindKey === kindKey) {
2658
2867
  entry.titleEl.textContent = leaf.title || (leaf.content ? leaf.content.kind : "empty");
2868
+ _paintLeafIcon(entry.iconEl, leaf, entry.content, this.ctx);
2659
2869
  return entry.wrapEl;
2660
2870
  }
2661
2871
  if (entry) {
2872
+ this._leafCache.delete(leaf.id);
2662
2873
  try {
2663
2874
  entry.content?.destroy?.();
2664
2875
  } catch (_) {
2665
2876
  }
2877
+ this._disposeTabStrip(entry);
2666
2878
  entry.wrapEl.remove();
2667
2879
  }
2668
2880
  const wrap = document.createElement("div");
@@ -2670,11 +2882,15 @@ var TileRenderer = class {
2670
2882
  wrap.dataset.leafId = leaf.id;
2671
2883
  const chrome = document.createElement("div");
2672
2884
  chrome.className = "twm-leaf__chrome";
2885
+ const icon = document.createElement("span");
2886
+ icon.className = "twm-leaf__icon material-symbols-outlined";
2673
2887
  const title = document.createElement("span");
2674
2888
  title.className = "twm-leaf__title";
2675
2889
  title.textContent = leaf.title || (leaf.content ? leaf.content.kind : "empty");
2676
2890
  const actions = document.createElement("span");
2677
2891
  actions.className = "twm-leaf__actions";
2892
+ const contentActions = document.createElement("span");
2893
+ contentActions.className = "twm-leaf__actions twm-leaf__actions--content";
2678
2894
  const isPanel = String(leaf.content?.kind || "").startsWith("panel:");
2679
2895
  actions.innerHTML = `
2680
2896
  ${isPanel ? "" : `
@@ -2684,14 +2900,15 @@ var TileRenderer = class {
2684
2900
  <button class="twm-leaf__btn" data-action="split-v" title="Split vertically (Alt+V)">
2685
2901
  <span class="material-symbols-outlined">splitscreen_add</span>
2686
2902
  </button>
2687
- <button class="twm-leaf__btn" data-action="promote" title="Promote to window (Alt+F)">
2688
- <span class="material-symbols-outlined">open_in_new</span>
2903
+ <button class="twm-leaf__btn" data-action="promote" title="Float this pane as a window (Alt+F)">
2904
+ <span class="material-symbols-outlined">web_asset</span>
2689
2905
  </button>`}
2690
2906
  <button class="twm-leaf__btn" data-action="close" title="Close (Alt+W)">
2691
2907
  <span class="material-symbols-outlined">close</span>
2692
2908
  </button>
2693
2909
  `;
2694
- chrome.append(title, actions);
2910
+ chrome.append(icon, title, contentActions, actions);
2911
+ _paintLeafIcon(icon, leaf, null, this.ctx);
2695
2912
  const body = document.createElement("div");
2696
2913
  body.className = "twm-leaf__body";
2697
2914
  body.tabIndex = -1;
@@ -2700,7 +2917,7 @@ var TileRenderer = class {
2700
2917
  if (!Array.isArray(leaf.tabs) || leaf.tabs.length <= 1) {
2701
2918
  tabBar.classList.add("twm-leaf__tabbar--hidden");
2702
2919
  }
2703
- wrap.append(chrome, body, tabBar);
2920
+ wrap.append(chrome, ...this.tabLayout === "top" ? [tabBar, body] : [body, tabBar]);
2704
2921
  wrap.addEventListener("mousedown", (e) => {
2705
2922
  if (e.target.closest(".twm-splitter")) return;
2706
2923
  this.tree.focus(leaf.id);
@@ -2718,6 +2935,38 @@ var TileRenderer = class {
2718
2935
  }, 0);
2719
2936
  }
2720
2937
  });
2938
+ chrome.addEventListener("pointerdown", (e) => {
2939
+ if (e.button !== 0) return;
2940
+ if (e.target.closest(CHROME_NO_FLOAT)) return;
2941
+ const startY = e.clientY;
2942
+ const startX = e.clientX;
2943
+ const pull = { startX, startY, x: startX, y: startY, lifted: false };
2944
+ this._chromePull = pull;
2945
+ const onMove = (move) => {
2946
+ pull.x = move.clientX;
2947
+ pull.y = move.clientY;
2948
+ if (pull.lifted) return;
2949
+ const down = move.clientY - startY;
2950
+ const sideways = Math.abs(move.clientX - startX);
2951
+ if (down < TILE_LIFT_PX || !_isDownwardPull(down, sideways)) return;
2952
+ pull.lifted = true;
2953
+ window.removeEventListener("pointermove", onMove);
2954
+ this.ctx.onLeafAction?.(leaf.id, "promote");
2955
+ };
2956
+ const cleanup = () => {
2957
+ if (this._chromePull === pull) this._chromePull = null;
2958
+ window.removeEventListener("pointermove", onMove);
2959
+ window.removeEventListener("pointerup", cleanup);
2960
+ window.removeEventListener("pointercancel", cleanup);
2961
+ };
2962
+ window.addEventListener("pointermove", onMove);
2963
+ window.addEventListener("pointerup", cleanup);
2964
+ window.addEventListener("pointercancel", cleanup);
2965
+ });
2966
+ chrome.addEventListener("dblclick", (e) => {
2967
+ if (e.target.closest(CHROME_NO_FLOAT)) return;
2968
+ this.ctx.onLeafAction?.(leaf.id, "promote");
2969
+ });
2721
2970
  chrome.addEventListener("contextmenu", (e) => {
2722
2971
  e.preventDefault();
2723
2972
  this.tree.focus(leaf.id);
@@ -2737,6 +2986,9 @@ var TileRenderer = class {
2737
2986
  const activeProps = activeTab?.props ?? leaf.content.props;
2738
2987
  content = this.content.mount(leaf.content.kind, body, activeProps, leafCtx);
2739
2988
  if (content.title) title.textContent = content.title;
2989
+ _paintLeafIcon(icon, leaf, content, this.ctx);
2990
+ _paintContentActions(contentActions, content.chromeActions);
2991
+ _vetoStructuralActions(actions, content.chrome);
2740
2992
  } else {
2741
2993
  body.innerHTML = `<div class="tile-placeholder"><div class="tile-placeholder__hint">empty tile</div></div>`;
2742
2994
  }
@@ -2745,33 +2997,418 @@ var TileRenderer = class {
2745
2997
  bodyEl: body,
2746
2998
  chromeEl: chrome,
2747
2999
  titleEl: title,
3000
+ iconEl: icon,
2748
3001
  content,
2749
- kindKey,
2750
- tabBarEl: tabBar
3002
+ // DERIVED HERE, NOT REUSED FROM ABOVE. `kindKey` was computed
3003
+ // before the mount; content that records its sub-tab or its
3004
+ // scroll offset WHILE mounting has already written to the
3005
+ // tree by now, so the value captured earlier is stale and the
3006
+ // very next repaint would tear down the tile that had just
3007
+ // said where it was.
3008
+ kindKey: this._leafKindKey(leaf),
3009
+ tabBarEl: tabBar,
3010
+ // C22. The `top` layout's NotebookTabBar, and the child it
3011
+ // mounts into. Null under the `bottom` layout, which is plain
3012
+ // markup this file writes itself.
3013
+ tabStrip: null,
3014
+ tabStripHostEl: null
2751
3015
  };
2752
3016
  this._leafCache.set(leaf.id, entry);
2753
3017
  this._renderTabBar(leaf, entry);
2754
3018
  return wrap;
2755
3019
  }
2756
- /** Paint a leaf's bottom tab strip. The strip is hidden for
2757
- * single-tab leaves (the common case) so existing layouts read as
2758
- * identical to today. Hamburger button at the start, then one
2759
- * trapezoid-shaped tab per stored tab spec. */
3020
+ /**
3021
+ * The cache key for a leaf: everything that must change before its wrap is
3022
+ * torn down and rebuilt. Extracted so `rebaselineLeaf` below computes the
3023
+ * same string this does two spellings of one key is a cache that misses
3024
+ * on every render or never misses at all, and both look like working code.
3025
+ */
3026
+ _leafKindKey(leaf) {
3027
+ const tabFingerprint = (leaf.tabs || []).map((t) => `${t.kind}::${JSON.stringify(t.props || {})}`).join("|") + `#${leaf.activeTabIdx || 0}`;
3028
+ return leaf.content ? `${leaf.content.kind}::${JSON.stringify(leaf.content.props || {})}::tabs:${tabFingerprint}` : "__empty__";
3029
+ }
3030
+ /**
3031
+ * The cache key a leaf has RIGHT NOW, for a caller that is about to change
3032
+ * the tree and wants to say what it expected to be changing from. See
3033
+ * `rebaselineLeaf`.
3034
+ */
3035
+ leafKey(leafId) {
3036
+ const leaf = this.tree?.get?.(leafId);
3037
+ return leaf && leaf.kind === "leaf" ? this._leafKindKey(leaf) : null;
3038
+ }
3039
+ /**
3040
+ * C34. ACCEPT A PROPS WRITE THE CONTENT MADE ABOUT ITSELF, WITHOUT
3041
+ * REBUILDING THE TILE THAT MADE IT.
3042
+ *
3043
+ * The cache key above includes every tab's props, and that is right for
3044
+ * NAVIGATION: a `table` tab whose `id` changes is different content and the
3045
+ * tile must be re-mounted. It is exactly wrong for VIEW STATE. The
3046
+ * framework hands every tile a `workspaceTabs.updateProps(patch)`
3047
+ * (`page_factory.js`) documented as *"persist editor sub-state into this
3048
+ * tile's WM tab props"* — a scroll offset, an open section, a selected
3049
+ * sub-tab. Writing one changed the fingerprint, so the NEXT repaint (a
3050
+ * focus change, a tab switch, a window promoted three tiles away) missed
3051
+ * the cache, destroyed the content and mounted it again. The tile was torn
3052
+ * down BY the call that existed to let it remember something, and because
3053
+ * the rebuild reads the props back the result looked almost right — the
3054
+ * editor came back at the saved scroll position, with everything uncommitted
3055
+ * in it gone.
3056
+ *
3057
+ * So the key is re-baselined instead: the entry keeps its live DOM and
3058
+ * starts answering to the new props. The next real navigation still misses
3059
+ * and still rebuilds, because that changes the kind or the tab set and this
3060
+ * only ever accepts what is already on screen.
3061
+ *
3062
+ * ══ IT ACCEPTS ONLY THE DELTA IT WAS CALLED FOR ═══════════════════
3063
+ *
3064
+ * The key is re-derived from the tree as it is NOW, so a first version of
3065
+ * this swallowed every difference at once — including a change the tree had
3066
+ * taken and the renderer had not drawn yet. Any mutation that does not
3067
+ * repaint (`updateActiveTabProps` is itself one, and an embedder writing
3068
+ * through `TileTree` directly is another) followed by a props write would
3069
+ * have been accepted onto a wrap still showing the OLD content, and the
3070
+ * tile would never have re-mounted: one thing drawn under another's title,
3071
+ * permanently, with nothing left that knows the two disagree.
3072
+ *
3073
+ * `expected` is the fix and it is the caller's own honesty: the key it read
3074
+ * BEFORE its write. If the cached entry is not still at that key, something
3075
+ * else has changed since the tile was mounted and this is not the caller's
3076
+ * to accept — refuse, and let the ordinary miss rebuild it.
3077
+ *
3078
+ * A structural test was tried first and is not enough. *Same kind, different
3079
+ * props* is a scroll offset AND it is a navigation to another table; nothing
3080
+ * in the leaf can tell them apart, because the difference is which caller
3081
+ * asked. `expected` asks the caller.
3082
+ *
3083
+ * @param {string} leafId
3084
+ * @param {string} [expected] the key the caller read before its own write.
3085
+ * Omitted means "accept whatever is there", which is what the first
3086
+ * version did and is kept only so an older caller does not silently
3087
+ * change behaviour — every caller in this tree passes one.
3088
+ * @returns {boolean} whether a cached wrap was re-baselined
3089
+ */
3090
+ rebaselineLeaf(leafId, expected = void 0) {
3091
+ const entry = this._leafCache.get(leafId);
3092
+ const leaf = this.tree?.get?.(leafId);
3093
+ if (!entry || !leaf || leaf.kind !== "leaf") return false;
3094
+ if (expected !== void 0 && entry.kindKey !== expected) return false;
3095
+ entry.kindKey = this._leafKindKey(leaf);
3096
+ return true;
3097
+ }
3098
+ /**
3099
+ * C22. Change the tab layout of a LIVE renderer.
3100
+ *
3101
+ * Only the strip is rebuilt. The tab bar element is MOVED between its two
3102
+ * positions and the body is never detached, so no content factory is
3103
+ * unmounted — which is the whole reason this is a method rather than
3104
+ * "rebuild the shell with the other option". A grid holding staged edits
3105
+ * must not lose them because someone changed where its tabs are drawn.
3106
+ *
3107
+ * Accepts anything: the value arrives from a DOM attribute and from
3108
+ * persisted user settings, and an unknown one means the default.
3109
+ */
3110
+ setTabLayout(layout) {
3111
+ const next = _normalizeTabLayout(layout);
3112
+ if (next === this.tabLayout) return;
3113
+ this.tabLayout = next;
3114
+ if (this.root.dataset && this.root.dataset.twmTabs !== next) {
3115
+ this.root.dataset.twmTabs = next;
3116
+ }
3117
+ if (this.disposed) return;
3118
+ for (const [leafId, entry] of this._leafCache) {
3119
+ this._placeTabBar(entry);
3120
+ this._disposeTabStrip(entry);
3121
+ entry.tabBarEl.innerHTML = "";
3122
+ const leaf = this.tree.get(leafId);
3123
+ if (leaf) this._renderTabBar(leaf, entry);
3124
+ }
3125
+ }
3126
+ /** Put a leaf's tab bar on the side the current layout says. Moving an
3127
+ * attached element is a re-parent, not a rebuild — the body keeps its
3128
+ * DOM, its listeners and its scroll. */
3129
+ _placeTabBar(entry) {
3130
+ const { wrapEl, bodyEl, tabBarEl } = entry;
3131
+ if (!wrapEl || !tabBarEl || !bodyEl) return;
3132
+ if (this.tabLayout === "top") wrapEl.insertBefore(tabBarEl, bodyEl);
3133
+ else wrapEl.appendChild(tabBarEl);
3134
+ }
3135
+ /** Tear down the `top` layout's component, if this leaf has one. Safe to
3136
+ * call on a leaf that never had one, and on one that already lost it. */
3137
+ _disposeTabStrip(entry) {
3138
+ if (!entry?.tabStrip) return;
3139
+ try {
3140
+ entry.tabStrip.dispose();
3141
+ } catch (err) {
3142
+ console.error("[tile] tab strip dispose threw", err);
3143
+ }
3144
+ entry.tabStrip = null;
3145
+ entry.tabStripHostEl = null;
3146
+ }
3147
+ /** Paint a leaf's tab strip in whichever layout is current. The strip is
3148
+ * hidden for single-tab leaves in BOTH layouts — the common case, and the
3149
+ * reason existing single-pane layouts read as identical to today. */
2760
3150
  _renderTabBar(leaf, entry) {
3151
+ this._syncChromeDragSource(leaf, entry);
2761
3152
  const bar = entry.tabBarEl;
2762
3153
  if (!bar) return;
2763
3154
  const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
3155
+ bar.classList.toggle("twm-leaf__tabbar--top", this.tabLayout === "top");
2764
3156
  if (tabs.length <= 1) {
2765
3157
  bar.classList.add("twm-leaf__tabbar--hidden");
3158
+ this._disposeTabStrip(entry);
2766
3159
  bar.innerHTML = "";
2767
3160
  return;
2768
3161
  }
2769
3162
  bar.classList.remove("twm-leaf__tabbar--hidden");
3163
+ if (this.tabLayout === "top") this._renderTopTabBar(leaf, entry, tabs);
3164
+ else this._renderBottomTabBar(leaf, entry, tabs);
3165
+ }
3166
+ /**
3167
+ * C33. THE CHROME IS THE DRAG SOURCE FOR A LEAF THAT HAS ONE TAB.
3168
+ *
3169
+ * Product owner, 2026-08-27. The full argument is in the file header; what
3170
+ * this function owns is the THREE conditions and why each is a condition
3171
+ * rather than a preference:
3172
+ *
3173
+ * EXACTLY ONE TAB. With two or more, the strip is drawn and names each
3174
+ * tab; a chrome drag would then have to guess which one was meant, and
3175
+ * guessing is what the strip exists to avoid. With exactly one, "this
3176
+ * tab" and "what is in this pane" are the same thing.
3177
+ *
3178
+ * NOT A PANEL. Panel tiles are chrome, not content — the same exclusion
3179
+ * `_floatableLeaf` and `_snapProbe` already make, and for the same reason:
3180
+ * there is nothing in them that belongs anywhere else.
3181
+ *
3182
+ * THE CONTENT DID NOT VETO `promote` (C20). One rule instead of two: a
3183
+ * tab you may not lift out of its pane is a tab you may not drag into
3184
+ * another one. This is what excludes an embedder's master tile — the
3185
+ * ground its floating windows stand on — whose whole reason for existing
3186
+ * is that it stays where it is.
3187
+ *
3188
+ * THE LISTENERS ARE BOUND ONCE PER CHROME ELEMENT and the ATTRIBUTE is
3189
+ * re-decided on every pass. That split is deliberate: `draggable` changes
3190
+ * the moment a second tab arrives, while the chrome element itself survives
3191
+ * for as long as its wrap does, and re-adding a listener on every render
3192
+ * would stack one per repaint.
3193
+ */
3194
+ _syncChromeDragSource(leaf, entry) {
3195
+ const chrome = entry?.chromeEl;
3196
+ if (!chrome) return;
3197
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
3198
+ const isPanel = String(leaf.content?.kind || "").startsWith("panel:");
3199
+ const vetoed = this.leafChrome(leaf.id)?.promote === false;
3200
+ const on = tabs.length === 1 && !isPanel && !vetoed;
3201
+ if (on) chrome.setAttribute("draggable", "true");
3202
+ else chrome.removeAttribute("draggable");
3203
+ if (chrome.__twmTabDragBound) return;
3204
+ chrome.__twmTabDragBound = true;
3205
+ chrome.addEventListener("dragstart", (ev) => {
3206
+ if (ev.target?.closest?.(CHROME_NO_FLOAT)) {
3207
+ ev.preventDefault();
3208
+ return;
3209
+ }
3210
+ const pull = this._chromePull;
3211
+ if (pull && _isDownwardPull(
3212
+ pull.y - pull.startY,
3213
+ Math.abs(pull.x - pull.startX)
3214
+ )) {
3215
+ ev.preventDefault();
3216
+ return;
3217
+ }
3218
+ const live = this.tree.get(leaf.id);
3219
+ if ((live?.tabs || []).length !== 1) {
3220
+ ev.preventDefault();
3221
+ return;
3222
+ }
3223
+ this._beginTabDrag(ev, leaf.id, 0, chrome, { seedPlainText: true });
3224
+ });
3225
+ chrome.addEventListener("dragend", () => this._onTabDragEnd());
3226
+ }
3227
+ /**
3228
+ * C22. The `top` layout: `NotebookTabBar`, the editor tab strip.
3229
+ *
3230
+ * The component is mounted into a CHILD of the bar rather than into the bar
3231
+ * itself, because `mount()` assigns `container.className = 'tabs
3232
+ * notebook-tabs'` (`notebook_tab_bar.js:61`) — handing it `.twm-leaf__tabbar`
3233
+ * would take that class, and with it the strip's height, its background and
3234
+ * `--hidden`, off the element this file still controls.
3235
+ *
3236
+ * Every gesture routes through the SAME `ctx.onLeafTabAction` vocabulary the
3237
+ * bottom strip uses, so the WM's tree mutations, its persistence and its
3238
+ * change notifications are reached by one path from both layouts.
3239
+ */
3240
+ _renderTopTabBar(leaf, entry, tabs) {
3241
+ const bar = entry.tabBarEl;
3242
+ if (!entry.tabStrip) {
3243
+ bar.innerHTML = "";
3244
+ const host = document.createElement("div");
3245
+ bar.appendChild(host);
3246
+ const strip = new NotebookTabBar();
3247
+ strip.mount(host, this._topTabCallbacks(leaf.id));
3248
+ entry.tabStrip = strip;
3249
+ entry.tabStripHostEl = host;
3250
+ host.addEventListener("contextmenu", (ev) => {
3251
+ const tabEl = ev.target.closest?.(".tab");
3252
+ if (!tabEl) return;
3253
+ ev.preventDefault();
3254
+ ev.stopPropagation();
3255
+ const idx = this._topTabIndex(host, tabEl);
3256
+ if (idx < 0) return;
3257
+ this.ctx.onLeafTabAction?.(
3258
+ leaf.id,
3259
+ "menu",
3260
+ { idx, x: ev.clientX, y: ev.clientY }
3261
+ );
3262
+ }, true);
3263
+ }
3264
+ const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
3265
+ entry.tabStrip.update(
3266
+ tabs.map((t, i) => ({
3267
+ filePath: _tabKey(i),
3268
+ label: t.title || t.kind || "",
3269
+ // The component's `fileType` picks a glyph out of a static map
3270
+ // of FILE kinds. A tile's kinds are the embedder's, and the
3271
+ // taxonomy already answers for them — see `_paintTopTabs`.
3272
+ fileType: t.kind || "unknown",
3273
+ // Nothing sets `dirty` on a tab spec today, so the dot is never
3274
+ // drawn. Read anyway, because the day a tile can say it holds
3275
+ // unsaved work this is where it says it, and the alternative is
3276
+ // a second place to remember.
3277
+ isDirty: !!t.dirty
3278
+ })),
3279
+ _tabKey(activeIdx)
3280
+ );
3281
+ this._paintTopTabs(entry.tabStripHostEl, tabs);
3282
+ }
3283
+ /**
3284
+ * The two things `NotebookTabBar` derives from a vocabulary a tile does not
3285
+ * have, corrected in one pass over the DOM it just wrote.
3286
+ *
3287
+ * Its tooltip is the file PATH (`notebook_tab_bar.js:163`) and its glyph
3288
+ * comes from a static fileType map (`:135`). Ours are `builtin://tab/3` and
3289
+ * a content kind, so left alone a tab would advertise its own array index
3290
+ * and wear the generic `description` glyph — while the tile chrome an inch
3291
+ * above it shows the taxonomy's icon for exactly the same kind.
3292
+ *
3293
+ * Reaching into a component's DOM is worth one paragraph of justification.
3294
+ * The alternative for the glyph is `NotebookTabBar.setFileTypeIcons()`,
3295
+ * which is STATIC and REPLACES the whole map — so a page that also uses the
3296
+ * editor would find its own file icons deleted by whichever of the two
3297
+ * rendered last. The class names used here are the component's published
3298
+ * contract, stated in its header comment.
3299
+ */
3300
+ _paintTopTabs(hostEl, tabs) {
3301
+ if (!hostEl) return;
3302
+ const els = hostEl.querySelectorAll(".tab");
3303
+ els.forEach((el, i) => {
3304
+ const spec = tabs[i];
3305
+ if (!spec) return;
3306
+ el.title = spec.title || spec.kind || "";
3307
+ const icon = spec.kind ? this.ctx?.taxonomy?.meta?.(spec.kind)?.icon : null;
3308
+ const glyph = el.querySelector(".tab-icon");
3309
+ if (glyph && icon) glyph.textContent = icon;
3310
+ else if (glyph && !icon) glyph.hidden = true;
3311
+ });
3312
+ }
3313
+ /** Which tab an element in the top strip is, by DOM position. Position
3314
+ * rather than the `data-path` key because the key is only ever the index
3315
+ * and reading it back would be a second, parallel answer to the same
3316
+ * question. */
3317
+ _topTabIndex(hostEl, tabEl) {
3318
+ return Array.prototype.indexOf.call(hostEl.querySelectorAll(".tab"), tabEl);
3319
+ }
3320
+ /** The callbacks `NotebookTabBar` calls. Everything the component offers
3321
+ * that a tile tab cannot honour is deliberately absent rather than stubbed
3322
+ * — `onRename` is refused by the `builtin://` key, and the rest
3323
+ * (`onDuplicate`, `onSplitRight`, `onRevealInExplorer`, `onCloseAll`, …)
3324
+ * are only ever reached from the context menu this renderer suppresses.
3325
+ *
3326
+ * `onDropFromOtherPane` IS NOW WIRED, AND THIS IS THE RECORD OF WHY IT WAS
3327
+ * NOT. The refusal read: *"its payload is the dragged tab's key alone,
3328
+ * which carries no source-leaf identity, and `TileTree` has no
3329
+ * move-a-tab-between-leaves operation to receive it. Wiring it would need
3330
+ * both, and both are tree changes."* Both were true and C33 built both.
3331
+ * `TileTree.moveTabToLeaf` is the tree operation; `TileRenderer._tabDrag`
3332
+ * is the source identity — held on the renderer rather than in the payload
3333
+ * because HTML5's protected mode makes the payload unreadable at the only
3334
+ * moment it would be needed (see `TILE_TAB_MIME`). The payload handed to
3335
+ * the callback is therefore still ignored, exactly as the refusal said it
3336
+ * would have to be.
3337
+ *
3338
+ * A DROP ON A FOREIGN STRIP APPENDS. `NotebookTabBar` hands the callback a
3339
+ * key and no event, so there is no pointer position to derive a slot from
3340
+ * — and inventing one for this strip and not for the bottom one would give
3341
+ * the two layouts different answers to the same gesture. "Add this tab to
3342
+ * that tile" is what was asked for; where it sits in the strip is a
3343
+ * reorder away, in the mechanism that already does reorders. */
3344
+ _topTabCallbacks(leafId) {
3345
+ return {
3346
+ // C33. `DragReorder` calls this through `NotebookTabBar`, which
3347
+ // sets its own `application/x-ecosim-tab` first and unchanged — so
3348
+ // an editor pane sharing the page cannot notice that tiles are
3349
+ // dragging tabs too.
3350
+ onDragStart: (ev, key, item) => {
3351
+ const idx = _tabKeyIndex(key);
3352
+ if (idx < 0) return;
3353
+ this._beginTabDrag(ev, leafId, idx, item || null);
3354
+ },
3355
+ // What lets THIS strip admit a tab dragged out of another tile's
3356
+ // strip: `#isExternalTabDrag` tests `TAB_MIME` plus whatever the
3357
+ // host names here, and defers while its own reorder is running.
3358
+ externalTabMimes: [TILE_TAB_MIME],
3359
+ onDropFromOtherPane: () => {
3360
+ const src = this._tabDrag;
3361
+ this._onTabDragEnd();
3362
+ if (!src || src.leafId === leafId) return;
3363
+ this.ctx.onLeafTabAction?.(src.leafId, "drop-into", {
3364
+ idx: src.idx,
3365
+ target: { leafId, mode: "tab", toIdx: -1 }
3366
+ });
3367
+ },
3368
+ onActivate: (key) => {
3369
+ const idx = _tabKeyIndex(key);
3370
+ if (idx >= 0) this.ctx.onLeafTabAction?.(leafId, "switch", { idx });
3371
+ },
3372
+ onClose: (key) => {
3373
+ const idx = _tabKeyIndex(key);
3374
+ if (idx >= 0) this.ctx.onLeafTabAction?.(leafId, "close", { idx });
3375
+ },
3376
+ // DRAG-TO-REORDER ARRIVES AS A PERMUTATION, and the tree moves ONE
3377
+ // tab at a time (`TileTree.moveLeafTab(leafId, from, to)`). They
3378
+ // reconcile because a drag only ever moves one element: every other
3379
+ // key shifts by exactly one place, so the element that travelled
3380
+ // furthest between the two orders IS the one that was dragged.
3381
+ onReorder: (order) => {
3382
+ const leaf = this.tree.get(leafId);
3383
+ const count = (leaf?.tabs || []).length;
3384
+ if (!Array.isArray(order) || order.length !== count) return;
3385
+ let from = -1;
3386
+ let to = -1;
3387
+ let furthest = 0;
3388
+ order.forEach((key, newIdx) => {
3389
+ const oldIdx = _tabKeyIndex(key);
3390
+ if (oldIdx < 0) return;
3391
+ const travelled = Math.abs(newIdx - oldIdx);
3392
+ if (travelled > furthest) {
3393
+ furthest = travelled;
3394
+ from = oldIdx;
3395
+ to = newIdx;
3396
+ }
3397
+ });
3398
+ if (from < 0 || from === to) return;
3399
+ this.ctx.onLeafTabAction?.(leafId, "move", { from, to });
3400
+ }
3401
+ };
3402
+ }
3403
+ /** The `bottom` layout — the framework's own strip, unchanged. Hamburger
3404
+ * button at the start, then one trapezoid-shaped tab per stored tab spec. */
3405
+ _renderBottomTabBar(leaf, entry, tabs) {
3406
+ const bar = entry.tabBarEl;
2770
3407
  const activeIdx = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
2771
3408
  bar.innerHTML = `
2772
3409
  <button type="button" class="twm-leaf__tab-hamburger"
2773
3410
  data-action="tab-menu"
2774
- title="Open in new tab from this page's content">
3411
+ title="Show open tabs" aria-label="Show open tabs">
2775
3412
  <span class="material-symbols-outlined">menu</span>
2776
3413
  </button>
2777
3414
  <ol class="twm-leaf__tabs" role="tablist">
@@ -2834,6 +3471,11 @@ var TileRenderer = class {
2834
3471
  ev.dataTransfer.setData("text/plain", String(dragFromIdx));
2835
3472
  } catch {
2836
3473
  }
3474
+ this._beginTabDrag(ev, leaf.id, dragFromIdx, li);
3475
+ });
3476
+ li.addEventListener("dragend", () => {
3477
+ dragFromIdx = null;
3478
+ this._onTabDragEnd();
2837
3479
  });
2838
3480
  li.addEventListener("dragover", (ev) => {
2839
3481
  if (dragFromIdx == null) return;
@@ -2854,6 +3496,189 @@ var TileRenderer = class {
2854
3496
  dragFromIdx = null;
2855
3497
  });
2856
3498
  });
3499
+ const foreign = (ev) => !!this._tabDrag && this._tabDrag.leafId !== leaf.id && _isTileTabDrag(ev);
3500
+ if (bar.__twmBarDropBound) return;
3501
+ bar.__twmBarDropBound = true;
3502
+ bar.addEventListener("dragover", (ev) => {
3503
+ if (!foreign(ev)) return;
3504
+ ev.preventDefault();
3505
+ try {
3506
+ ev.dataTransfer.dropEffect = "move";
3507
+ } catch {
3508
+ }
3509
+ this._clearTileDropZone();
3510
+ bar.classList.add("twm-leaf__tabbar--drop-target");
3511
+ });
3512
+ bar.addEventListener("dragleave", (ev) => {
3513
+ if (!bar.contains(ev.relatedTarget)) {
3514
+ bar.classList.remove("twm-leaf__tabbar--drop-target");
3515
+ }
3516
+ });
3517
+ bar.addEventListener("drop", (ev) => {
3518
+ if (!foreign(ev)) return;
3519
+ ev.preventDefault();
3520
+ bar.classList.remove("twm-leaf__tabbar--drop-target");
3521
+ const src = this._tabDrag;
3522
+ this._onTabDragEnd();
3523
+ this.ctx.onLeafTabAction?.(src.leafId, "drop-into", {
3524
+ idx: src.idx,
3525
+ target: { leafId: leaf.id, mode: "tab", toIdx: -1 }
3526
+ });
3527
+ });
3528
+ }
3529
+ // ══ C33. THE TAB DRAG ═══════════════════════════════════════════════
3530
+ /** Take the identity of the tab now being carried, and mark it.
3531
+ *
3532
+ * `seedPlainText` is for the CHROME source only. The two strips already
3533
+ * set `text/plain` themselves — `DragReorder._start` writes the reorder
3534
+ * key, the bottom strip writes the index — and overwriting either would
3535
+ * hand `NotebookTabBar.#onStripDrop`'s `getData(TAB_MIME) ||
3536
+ * getData('text/plain')` fallback a number where it expects a key. The
3537
+ * chrome has no such writer and Firefox refuses to begin a drag with an
3538
+ * empty `dataTransfer`, so it supplies its own. */
3539
+ _beginTabDrag(ev, leafId, idx, el, { seedPlainText = false } = {}) {
3540
+ this._tabDrag = { leafId, idx, el: el || null };
3541
+ this._tabDropProbe = null;
3542
+ try {
3543
+ ev.dataTransfer.effectAllowed = "move";
3544
+ ev.dataTransfer.setData(TILE_TAB_MIME, "1");
3545
+ if (seedPlainText) ev.dataTransfer.setData("text/plain", String(idx));
3546
+ } catch {
3547
+ }
3548
+ el?.classList?.add("dragging");
3549
+ }
3550
+ /**
3551
+ * Arm — or refuse — the tile under the pointer.
3552
+ *
3553
+ * NOTHING HERE MAY RENDER. `render()` clears the root, which detaches the
3554
+ * element the browser is dragging, and the browser cancels the gesture the
3555
+ * moment that happens. The DOM afterwards reads perfectly correct, which is
3556
+ * what makes this failure so hard to see; it is the drag-and-drop cousin of
3557
+ * the `mousedown`-repaint defect recorded five times against the taskbar.
3558
+ *
3559
+ * `preventDefault()` is not decoration either: without it the browser
3560
+ * refuses the drop outright and `drop` never fires, which reads exactly
3561
+ * like a broken handler.
3562
+ */
3563
+ _onTabDragOver(e) {
3564
+ const src = this._tabDrag;
3565
+ if (!src || !_isTileTabDrag(e)) return;
3566
+ if (e.target?.closest?.(".twm-leaf__tabbar")) {
3567
+ this._clearTileDropZone();
3568
+ return;
3569
+ }
3570
+ const probe = this.ctx.wm?.tabDropProbe?.(e, { sourceLeafId: src.leafId }) || null;
3571
+ this._tabDropProbe = probe;
3572
+ if (!probe) {
3573
+ this._clearTabDropZone();
3574
+ return;
3575
+ }
3576
+ e.preventDefault();
3577
+ try {
3578
+ e.dataTransfer.dropEffect = "move";
3579
+ } catch {
3580
+ }
3581
+ for (const [leafId, entry] of this._leafCache) {
3582
+ entry.wrapEl.classList.toggle("twm-leaf--drop-target", leafId === probe.leafId);
3583
+ }
3584
+ this._showTabDropPreview(probe.rect);
3585
+ }
3586
+ /** Release. The zone that was ARMED is the zone that runs — the stashed
3587
+ * probe rather than a fresh one — because C15's rule is that the rectangle
3588
+ * drawn during the drag is the rectangle the drop delivers, and a pointer
3589
+ * one pixel outside the band at release must not quietly mean something
3590
+ * else. */
3591
+ _onTabDrop(e) {
3592
+ const src = this._tabDrag;
3593
+ const probe = this._tabDropProbe;
3594
+ if (!src || !_isTileTabDrag(e)) return;
3595
+ if (e.target?.closest?.(".twm-leaf__tabbar")) return;
3596
+ e.preventDefault();
3597
+ this._onTabDragEnd();
3598
+ if (!probe) return;
3599
+ this.ctx.onLeafTabAction?.(src.leafId, "drop-into", {
3600
+ idx: src.idx,
3601
+ target: {
3602
+ leafId: probe.leafId,
3603
+ mode: probe.mode,
3604
+ // The same derivation `_snapCommit` applies to a window drop
3605
+ // (`wm.js`, the `_dock` literal) — one reading of a side, so a
3606
+ // tab and a window cannot land on opposite halves of one edge.
3607
+ dir: probe.side === "left" || probe.side === "right" ? "h" : "v",
3608
+ before: probe.side === "left" || probe.side === "top",
3609
+ toIdx: -1
3610
+ }
3611
+ });
3612
+ }
3613
+ /** Leaving the root entirely disarms, and only that. The drag is still
3614
+ * live — it may come back — so `_tabDrag` survives and only the painting
3615
+ * goes. */
3616
+ _onTabDragLeave(e) {
3617
+ if (!this._tabDrag) return;
3618
+ if (this.root.contains(e.relatedTarget)) return;
3619
+ this._clearTabDropZone();
3620
+ this._tabDropProbe = null;
3621
+ }
3622
+ /** The only guaranteed end of a drag. Escape produces this and no `drop`;
3623
+ * so does a release over a target that refused. Idempotent, because the
3624
+ * drop path calls it too and `dragend` still arrives afterwards. */
3625
+ _onTabDragEnd() {
3626
+ this._tabDrag?.el?.classList?.remove("dragging");
3627
+ this._tabDrag = null;
3628
+ this._tabDropProbe = null;
3629
+ this._clearTabDropZone();
3630
+ }
3631
+ /** The TILE zone only — the outlined pane and the preview rectangle. Split
3632
+ * out from the whole because a strip that has just armed itself must not
3633
+ * be disarmed by the root handler running behind it. */
3634
+ _clearTileDropZone() {
3635
+ for (const [, entry] of this._leafCache) {
3636
+ entry.wrapEl.classList.remove("twm-leaf--drop-target");
3637
+ }
3638
+ this._clearTabDropPreview();
3639
+ }
3640
+ /** Everything: the tile zone and both strips'. The end of a gesture, where
3641
+ * nothing may be left painted. */
3642
+ _clearTabDropZone() {
3643
+ this._clearTileDropZone();
3644
+ for (const [, entry] of this._leafCache) {
3645
+ entry.tabBarEl?.classList?.remove("twm-leaf__tabbar--drop-target");
3646
+ }
3647
+ }
3648
+ /** The rectangle a release would fill.
3649
+ *
3650
+ * IT IS THE WINDOW DROP'S OWN PREVIEW ELEMENT — same two classes, same
3651
+ * stylesheet rules (`css/base.css`, `.twm-snap-preview`) — so a tab drop
3652
+ * and a window drop cannot come to disagree about what a drop looks like.
3653
+ * `--viewport` is what makes `position: fixed` apply, and that is required
3654
+ * rather than cosmetic: the rectangle came from `getBoundingClientRect` on
3655
+ * a leaf, which speaks viewport pixels.
3656
+ *
3657
+ * Parented to `document.body` and not to the root, for R13's reason:
3658
+ * `render()`'s `innerHTML = ''` takes every direct child of the root, and
3659
+ * a repaint during a drag needs nothing more exotic than the drag itself. */
3660
+ _showTabDropPreview(rect) {
3661
+ if (!rect) {
3662
+ this._clearTabDropPreview();
3663
+ return;
3664
+ }
3665
+ if (!this._tabDropPreviewEl) {
3666
+ const el2 = document.createElement("div");
3667
+ el2.className = "twm-snap-preview twm-snap-preview--viewport";
3668
+ el2.setAttribute("aria-hidden", "true");
3669
+ this._tabDropPreviewEl = el2;
3670
+ }
3671
+ const el = this._tabDropPreviewEl;
3672
+ Object.assign(el.style, {
3673
+ left: `${rect.left ?? rect.x}px`,
3674
+ top: `${rect.top ?? rect.y}px`,
3675
+ width: `${rect.width}px`,
3676
+ height: `${rect.height}px`
3677
+ });
3678
+ if (el.parentNode !== document.body) document.body.appendChild(el);
3679
+ }
3680
+ _clearTabDropPreview() {
3681
+ this._tabDropPreviewEl?.remove();
2857
3682
  }
2858
3683
  _updateFocusClasses() {
2859
3684
  const focused = this.tree.focusedLeafId;
@@ -2867,6 +3692,42 @@ var TileRenderer = class {
2867
3692
  leafEl(leafId) {
2868
3693
  return this._leafCache.get(leafId)?.wrapEl || null;
2869
3694
  }
3695
+ /** C20, extended. The `chrome` veto object the mounted content declared —
3696
+ * `{ promote: false, close: false }` — or null when the leaf is not
3697
+ * rendered or its content declared nothing.
3698
+ *
3699
+ * IT EXISTS BECAUSE A VETO PAINTED ON A BUTTON IS NOT A VETO. C20 landed
3700
+ * as `_vetoStructuralActions`, which removes the button from this strip —
3701
+ * and a removed button is only the door the CONTENT can see. The verb has
3702
+ * three other doors: the tile's right-click menu (`shell.js`'s "Float this
3703
+ * pane as a window"), the chrome pull-down, and the chrome's double-click.
3704
+ * All three reach `WindowManager.floatPane` without passing this file, so
3705
+ * a master tile that declared itself unfloatable was floated by any of
3706
+ * them — reproduced: the ground pane floats, every window standing on it
3707
+ * is force-closed, and the pane is re-seeded WITHOUT its `canvas` prop.
3708
+ *
3709
+ * So the WM asks the renderer what the content said, and enforces it in
3710
+ * `_floatableLeaf` where every door already converges. The renderer stays
3711
+ * the only place that knows what was mounted; the WM stays the only place
3712
+ * that decides whether a verb runs. `closeFocused` now reads `close` the
3713
+ * same way, for the same reason and after the same defect: Alt+W, the tile
3714
+ * context menu and the tab strip's × all closed a pane whose own button
3715
+ * was greyed out with a tooltip saying it could not be.
3716
+ *
3717
+ * ══ THIS RETURNS THE FACTORY'S LIVE OBJECT, AND THAT IS A CONTRACT ══
3718
+ *
3719
+ * Not a copy and not a snapshot. `chrome` is READ ONCE, at mount — a
3720
+ * repaint of a cached leaf re-reads only the title and the glyph — so a
3721
+ * veto whose ANSWER CHANGES over the life of the tile must be kept up to
3722
+ * date by the content that stated it, by mutating the object it returned.
3723
+ * Tables' ground pane is exactly that case: its close is refused only
3724
+ * while it is the last content pane, and it re-syncs on `wm:changed`.
3725
+ * Repainting the button alone is not enough now that a verb consults this
3726
+ * — a stale `{disabled: true}` refuses a close every affordance on screen
3727
+ * says is available, which is the same class of lie as a dead control. */
3728
+ leafChrome(leafId) {
3729
+ return this._leafCache.get(leafId)?.content?.chrome || null;
3730
+ }
2870
3731
  // ── Drag-resize ────────────────────────────────────────────────
2871
3732
  _onMouseDown(e) {
2872
3733
  const splitter = e.target.closest(".twm-splitter");
@@ -2937,6 +3798,162 @@ function _esc6(s) {
2937
3798
  "'": "&#39;"
2938
3799
  })[c]);
2939
3800
  }
3801
+ function _paintLeafIcon(iconEl, leaf, content, ctx) {
3802
+ if (!iconEl) return;
3803
+ const kind = leaf?.content?.kind;
3804
+ const name = content?.icon || (kind ? ctx?.taxonomy?.meta?.(kind)?.icon : null);
3805
+ iconEl.textContent = name || "";
3806
+ iconEl.hidden = !name;
3807
+ }
3808
+ function _paintContentActions(hostEl, specs) {
3809
+ hostEl.innerHTML = "";
3810
+ if (!Array.isArray(specs) || specs.length === 0) {
3811
+ hostEl.hidden = true;
3812
+ return;
3813
+ }
3814
+ hostEl.hidden = false;
3815
+ for (const spec of specs) {
3816
+ if (!spec || !spec.icon) continue;
3817
+ const btn = document.createElement("button");
3818
+ btn.type = "button";
3819
+ btn.className = "twm-leaf__btn";
3820
+ btn.title = spec.title || "";
3821
+ btn.setAttribute("aria-label", spec.title || "");
3822
+ btn.innerHTML = `<span class="material-symbols-outlined">${spec.icon}</span>`;
3823
+ btn.addEventListener("click", (e) => {
3824
+ e.stopPropagation();
3825
+ try {
3826
+ spec.onClick?.();
3827
+ } catch (err) {
3828
+ console.error("[tile] action threw", err);
3829
+ }
3830
+ });
3831
+ hostEl.appendChild(btn);
3832
+ }
3833
+ }
3834
+ function _vetoStructuralActions(hostEl, chrome) {
3835
+ if (!chrome) return;
3836
+ for (const [action, rule] of Object.entries(chrome)) {
3837
+ const btn = hostEl.querySelector(`[data-action="${action}"]`);
3838
+ if (!btn) continue;
3839
+ if (rule === false) {
3840
+ btn.remove();
3841
+ continue;
3842
+ }
3843
+ if (rule && typeof rule === "object" && rule.disabled) {
3844
+ btn.disabled = true;
3845
+ btn.setAttribute("aria-disabled", "true");
3846
+ btn.classList.add("twm-leaf__btn--disabled");
3847
+ if (rule.title) btn.title = rule.title;
3848
+ }
3849
+ }
3850
+ }
3851
+
3852
+ // src/tiling/tab_strip.js
3853
+ function tabKey(idx) {
3854
+ return `builtin://tab/${idx}`;
3855
+ }
3856
+ function tabKeyIndex(key) {
3857
+ const n = Number(String(key ?? "").replace("builtin://tab/", ""));
3858
+ return Number.isInteger(n) && n >= 0 ? n : -1;
3859
+ }
3860
+ function reorderToMove(order, count) {
3861
+ if (!Array.isArray(order) || order.length !== count) return null;
3862
+ let from = -1;
3863
+ let to = -1;
3864
+ let furthest = 0;
3865
+ order.forEach((key, newIdx) => {
3866
+ const oldIdx = tabKeyIndex(key);
3867
+ if (oldIdx < 0) return;
3868
+ const travelled = Math.abs(newIdx - oldIdx);
3869
+ if (travelled > furthest) {
3870
+ furthest = travelled;
3871
+ from = oldIdx;
3872
+ to = newIdx;
3873
+ }
3874
+ });
3875
+ if (from < 0 || from === to) return null;
3876
+ return { from, to };
3877
+ }
3878
+ function createTabStrip({ hostEl, taxonomy = null, onAction }) {
3879
+ const host = document.createElement("div");
3880
+ hostEl.appendChild(host);
3881
+ const strip = new NotebookTabBar();
3882
+ let tabs = [];
3883
+ strip.mount(host, {
3884
+ onActivate: (key) => {
3885
+ const idx = tabKeyIndex(key);
3886
+ if (idx >= 0) onAction?.("switch", { idx });
3887
+ },
3888
+ onClose: (key) => {
3889
+ const idx = tabKeyIndex(key);
3890
+ if (idx >= 0) onAction?.("close", { idx });
3891
+ },
3892
+ onReorder: (order) => {
3893
+ const move = reorderToMove(order, tabs.length);
3894
+ if (move) onAction?.("move", move);
3895
+ }
3896
+ // Everything else the component offers that a tab here cannot honour is
3897
+ // deliberately absent rather than stubbed — `onRename` is refused by the
3898
+ // `builtin://` key, and the rest (`onDuplicate`, `onSplitRight`,
3899
+ // `onRevealInExplorer`, `onCloseAll`, …) are only ever reached from the
3900
+ // context menu suppressed below.
3901
+ });
3902
+ const onContextMenu = (ev) => {
3903
+ const tabEl = ev.target.closest?.(".tab");
3904
+ if (!tabEl) return;
3905
+ ev.preventDefault();
3906
+ ev.stopPropagation();
3907
+ const idx = Array.prototype.indexOf.call(host.querySelectorAll(".tab"), tabEl);
3908
+ if (idx < 0) return;
3909
+ onAction?.("menu", { idx, x: ev.clientX, y: ev.clientY });
3910
+ };
3911
+ host.addEventListener("contextmenu", onContextMenu, true);
3912
+ const repaint = () => {
3913
+ host.querySelectorAll(".tab").forEach((el, i) => {
3914
+ const spec = tabs[i];
3915
+ if (!spec) return;
3916
+ el.title = spec.title || spec.kind || "";
3917
+ const icon = spec.kind ? taxonomy?.meta?.(spec.kind)?.icon : null;
3918
+ const glyph = el.querySelector(".tab-icon");
3919
+ if (!glyph) return;
3920
+ if (icon) {
3921
+ glyph.textContent = icon;
3922
+ glyph.hidden = false;
3923
+ } else glyph.hidden = true;
3924
+ });
3925
+ };
3926
+ return {
3927
+ /** @param {Array<{kind: string, props?: object, title?: string, dirty?: boolean}>} next */
3928
+ update(next, activeIdx) {
3929
+ tabs = Array.isArray(next) ? next : [];
3930
+ const active = Math.max(0, Math.min(tabs.length - 1, activeIdx || 0));
3931
+ strip.update(
3932
+ tabs.map((t, i) => ({
3933
+ filePath: tabKey(i),
3934
+ label: t.title || t.kind || "",
3935
+ fileType: t.kind || "unknown",
3936
+ // Nothing sets `dirty` on a tab spec today, so the dot is
3937
+ // never drawn. Read anyway, because the day a tab can say it
3938
+ // holds unsaved work this is where it says it, and the
3939
+ // alternative is a second place to remember.
3940
+ isDirty: !!t.dirty
3941
+ })),
3942
+ tabKey(active)
3943
+ );
3944
+ repaint();
3945
+ },
3946
+ dispose() {
3947
+ host.removeEventListener("contextmenu", onContextMenu, true);
3948
+ try {
3949
+ strip.dispose();
3950
+ } catch (err) {
3951
+ console.error("[tab-strip] dispose threw", err);
3952
+ }
3953
+ host.remove();
3954
+ }
3955
+ };
3956
+ }
2940
3957
 
2941
3958
  // src/tiling/wm.js
2942
3959
  var PANEL_KINDS = /* @__PURE__ */ new Set(["panel:left", "panel:right", "panel:bottom"]);
@@ -2945,8 +3962,22 @@ var PANEL_TITLES = {
2945
3962
  right: "Inspector",
2946
3963
  bottom: "Console"
2947
3964
  };
2948
- var WindowManager = class {
2949
- constructor({ rootEl, api, ctx, onChange, eventBus, host, taxonomy, events, content }) {
3965
+ var WindowManager = class _WindowManager {
3966
+ constructor({
3967
+ rootEl,
3968
+ api,
3969
+ ctx,
3970
+ onChange,
3971
+ eventBus,
3972
+ host,
3973
+ taxonomy,
3974
+ events,
3975
+ content,
3976
+ panelDefaults = null,
3977
+ snapPromotion = false,
3978
+ promoteInPlace = false,
3979
+ tabLayout = null
3980
+ }) {
2950
3981
  if (!taxonomy) throw new Error("WindowManager: a taxonomy is required");
2951
3982
  if (!content || typeof content.mount !== "function") {
2952
3983
  throw new Error("WindowManager: a content registry is required (createContentRegistry / createShell owns it)");
@@ -2968,18 +3999,34 @@ var WindowManager = class {
2968
3999
  title: this.taxonomy.meta(kind)?.label || kind
2969
4000
  };
2970
4001
  };
2971
- this.desktops = new DesktopManager({ seed: this._rootLeaf });
4002
+ this.panelDefaults = panelDefaults || null;
4003
+ this.snapPromotion = !!snapPromotion;
4004
+ this.promoteInPlace = !!promoteInPlace;
4005
+ this._snapCtl = null;
4006
+ this.desktops = new DesktopManager({
4007
+ seed: this._rootLeaf,
4008
+ panelDefaults: this.panelDefaults
4009
+ });
2972
4010
  this.renderer = new TileRenderer({
2973
4011
  root: rootEl,
2974
4012
  tree: this.desktops.active().tree,
2975
4013
  content,
4014
+ /** C22. Where a multi-tab leaf draws its tabs — `'bottom'`
4015
+ * (the framework's own spreadsheet strip, and the DEFAULT so no
4016
+ * existing embedder's panes rearrange on upgrade) or `'top'`
4017
+ * (the editor tab bar, between the chrome and the body).
4018
+ * The renderer also mirrors this onto the root as
4019
+ * `data-twm-tabs` and watches it, so an embedder can change it
4020
+ * live without holding a renderer reference. */
4021
+ tabLayout,
2976
4022
  ctx: {
2977
4023
  ...this.ctx,
2978
4024
  wm: this,
2979
4025
  onLeafAction: (leafId, action) => this._leafAction(leafId, action),
2980
4026
  onLeafTabAction: (leafId, action, data) => this._leafTabAction(leafId, action, data)
2981
4027
  },
2982
- onFocusChange: () => this._notifyChange()
4028
+ onFocusChange: () => this._notifyChange(),
4029
+ onAfterRender: () => this._rehomeContainedWindows()
2983
4030
  });
2984
4031
  this._persistTimer = null;
2985
4032
  this._windowToLeaf = /* @__PURE__ */ new Map();
@@ -3001,6 +4048,16 @@ var WindowManager = class {
3001
4048
  });
3002
4049
  }
3003
4050
  }
4051
+ /** C22. Change the tab layout of every tile, live. Delegates to the
4052
+ * renderer, which moves each strip rather than rebuilding the tiles — so
4053
+ * nothing mounted in a tile is unmounted and no staged work is lost.
4054
+ *
4055
+ * Not persisted here: which layout a user prefers is a USER setting, and
4056
+ * the WM persists LAYOUT (`desktops`). An embedder that stores it does so
4057
+ * under its own key and passes it back as `createShell({ tabLayout })`. */
4058
+ setTabLayout(layout) {
4059
+ this.renderer.setTabLayout(layout);
4060
+ }
3004
4061
  /** Emit on bus + call onChange. Use this instead of the bare callback
3005
4062
  * so other surfaces (palette, top-bar toggles, page shortcuts) can
3006
4063
  * subscribe through the existing event system. */
@@ -3035,7 +4092,10 @@ var WindowManager = class {
3035
4092
  async load() {
3036
4093
  const blob = await loadDesktops(this.host?.state);
3037
4094
  if (blob) {
3038
- this.desktops = DesktopManager.deserialize(blob, { seed: this._rootLeaf });
4095
+ this.desktops = DesktopManager.deserialize(blob, {
4096
+ seed: this._rootLeaf,
4097
+ panelDefaults: this.panelDefaults
4098
+ });
3039
4099
  this.renderer.tree = this.desktops.active().tree;
3040
4100
  }
3041
4101
  for (const d of this.desktops.desktops) {
@@ -3160,7 +4220,9 @@ var WindowManager = class {
3160
4220
  updateActiveTabProps(leafId, patch) {
3161
4221
  const tree = this._tree();
3162
4222
  if (!tree?.updateActiveTabProps) return;
4223
+ const expected = this.renderer?.leafKey?.(leafId) ?? void 0;
3163
4224
  tree.updateActiveTabProps(leafId, patch);
4225
+ this.renderer?.rebaselineLeaf?.(leafId, expected);
3164
4226
  this._persist();
3165
4227
  }
3166
4228
  /** Navigate inside the current tab — preserves every other tab in
@@ -3233,7 +4295,7 @@ var WindowManager = class {
3233
4295
  this._notifyChange();
3234
4296
  }
3235
4297
  /** Replace a managed window's content in place. Tears down the
3236
- * previous mount, mounts the new kind into the same contentEl,
4298
+ * previous mount, mounts the new kind into the same body element,
3237
4299
  * and updates the window's title. */
3238
4300
  openInWindow(winId, kind, props = {}) {
3239
4301
  const rec = this._windowToLeaf.get(winId);
@@ -3245,10 +4307,11 @@ var WindowManager = class {
3245
4307
  rec.mountInfo?.destroy?.();
3246
4308
  } catch {
3247
4309
  }
3248
- rec.contentEl.innerHTML = "";
4310
+ const host = rec.bodyEl || rec.contentEl;
4311
+ host.innerHTML = "";
3249
4312
  const mountInfo = this.content.mount(
3250
4313
  kind,
3251
- rec.contentEl,
4314
+ host,
3252
4315
  props,
3253
4316
  { ...this.ctx, wm: this, windowId: winId }
3254
4317
  );
@@ -3258,12 +4321,14 @@ var WindowManager = class {
3258
4321
  props: { ...props || {} },
3259
4322
  title: mountInfo?.title || kind
3260
4323
  };
3261
- try {
3262
- const titleEl = rec.window.element?.querySelector(".twm-managed-window__title");
3263
- if (titleEl) titleEl.textContent = rec.original.title;
3264
- if (rec.window) rec.window.title = rec.original.title;
3265
- } catch {
3266
- }
4324
+ const tab = (rec.tabs || [])[rec.activeTabIdx];
4325
+ if (tab) {
4326
+ tab.kind = kind;
4327
+ tab.props = { ...props || {} };
4328
+ tab.title = rec.original.title;
4329
+ rec.strip?.update(rec.tabs, rec.activeTabIdx);
4330
+ }
4331
+ this._setWindowTitle(rec, rec.original.title);
3267
4332
  this._persist();
3268
4333
  this._notifyChange("window-content-changed");
3269
4334
  }
@@ -3300,11 +4365,22 @@ var WindowManager = class {
3300
4365
  const tree = this._tree();
3301
4366
  const focused = tree.focusedLeafId;
3302
4367
  if (!focused) return;
3303
- tree.split(focused, dir);
4368
+ const newId = tree.split(focused, dir);
4369
+ if (newId) this._seedHome(tree, newId);
3304
4370
  this.renderer.render();
3305
4371
  this._persist();
3306
4372
  this._notifyChange();
3307
4373
  }
4374
+ /** Seed a leaf with the default HOME content (the taxonomy root kind).
4375
+ * Used to keep the never-empty-tile invariant: the pane freed by a
4376
+ * split, or emptied when its last tab floats into a window, is
4377
+ * re-homed instead of destroyed or left blank. Embedder-agnostic —
4378
+ * the HOME kind comes from the taxonomy, exactly like a fresh
4379
+ * desktop's seed leaf. */
4380
+ _seedHome(tree, leafId) {
4381
+ const seed = this._rootLeaf();
4382
+ tree.setLeafContent(leafId, seed.content, seed.title);
4383
+ }
3308
4384
  /** Split `leafId` along `dir` and mount `kind`/`props` in the freshly
3309
4385
  * created sibling — the "open this content in a new split" primitive
3310
4386
  * behind the code-pane split buttons, the per-pane context menu and
@@ -3368,6 +4444,8 @@ var WindowManager = class {
3368
4444
  if (!focusedId) return;
3369
4445
  const leaf = tree.get(focusedId);
3370
4446
  const kind = leaf?.content?.kind;
4447
+ const closeChrome = this.renderer?.leafChrome?.(focusedId)?.close;
4448
+ if (closeChrome === false || closeChrome?.disabled === true) return;
3371
4449
  if (kind === PLACEHOLDER_KIND) {
3372
4450
  const winId = leaf.content?.props?.windowId;
3373
4451
  const rec = winId ? this._windowToLeaf.get(winId) : null;
@@ -3387,6 +4465,10 @@ var WindowManager = class {
3387
4465
  } else {
3388
4466
  tree.close(focusedId);
3389
4467
  }
4468
+ if (!tree.leaves().some((l) => !String(l.content?.kind || "").startsWith("panel:"))) {
4469
+ const spawned = this._spawnContentLeaf(tree);
4470
+ if (spawned) this._seedHome(tree, spawned);
4471
+ }
3390
4472
  this._canonicalize(tree, this.desktops.active());
3391
4473
  this.renderer.render();
3392
4474
  this._persist();
@@ -3464,30 +4546,228 @@ var WindowManager = class {
3464
4546
  }
3465
4547
  }
3466
4548
  // ── Tile <-> Managed window ─────────────────────────────────────
3467
- /** Promote the focused tile into a managed window and CLOSE the
3468
- * source tile — promoting means the content leaves the grid, so the
3469
- * origin slot is removed rather than left as an empty placeholder.
3470
- * "Back to tile" re-docks the content into the primary tile. */
4549
+ /** Float the focused pane into a managed window.
4550
+ *
4551
+ * R8. THE WHOLE PANE, not its active tab. This used to float one tab and
4552
+ * leave the rest behind, which made "float this pane as a window" a
4553
+ * different verb from the one its own tooltip named: a pane with three
4554
+ * tables in it became a window holding one and a pane holding two, and
4555
+ * nothing on screen said which of the three you were going to get. The
4556
+ * product owner's words are the whole specification — *"to window includes
4557
+ * the tab-strip"* — so the tabs travel with the pane and the strip is
4558
+ * rendered INSIDE the window.
4559
+ *
4560
+ * Floating ONE tab is still available and is still wanted; it moved to
4561
+ * where it was always meant to be, which is the right-click menu on the
4562
+ * tab itself (R9, `floatTabAsWindow`). A verb that acts on one tab belongs
4563
+ * on that tab, not on the pane's chrome.
4564
+ *
4565
+ * Never-empty-tile invariant, unchanged: the emptied pane is RE-SEEDED
4566
+ * with the default HOME content rather than destroyed, so the grid never
4567
+ * ends up with a missing or blank main tile. */
3471
4568
  toggleManagedFocused() {
3472
4569
  const tree = this._tree();
3473
4570
  const focused = tree.focused();
3474
- if (!focused || !focused.content) return;
3475
- if (PANEL_KINDS.has(focused.content.kind)) return;
3476
- if (focused.content.kind === PLACEHOLDER_KIND) return;
3477
- const leafId = focused.id;
3478
- const desktopIdx = this.desktops.activeIdx;
3479
- const original = {
3480
- kind: focused.content.kind,
3481
- props: { ...focused.content.props || {} },
3482
- title: focused.title
4571
+ if (!focused) return null;
4572
+ return this.floatPane(focused.id);
4573
+ }
4574
+ /** R8. Float a pane — every tab, with the strip — into a managed window.
4575
+ * Returns the window id, or null when the leaf is not something that can
4576
+ * be floated. */
4577
+ floatPane(leafId) {
4578
+ const leaf = this._floatableLeaf(leafId);
4579
+ if (!leaf) return null;
4580
+ const tabs = _leafTabSpecs(leaf);
4581
+ const active = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
4582
+ return this._promote(leafId, tabs, active, { wholePane: true });
4583
+ }
4584
+ /** R9. Float ONE tab of a pane into a managed window, leaving its siblings
4585
+ * where they are — which is exactly what `toggleManagedFocused` did before
4586
+ * R8, so the behaviour survives, it just moved to the gesture that names
4587
+ * it. The tab's right-click menu is the only caller. */
4588
+ floatTabAsWindow(leafId, idx) {
4589
+ const leaf = this._floatableLeaf(leafId);
4590
+ if (!leaf) return null;
4591
+ const tabs = _leafTabSpecs(leaf);
4592
+ if (!Number.isInteger(idx) || idx < 0 || idx >= tabs.length) return null;
4593
+ return this._promote(leafId, [tabs[idx]], 0, { wholePane: false, tabIdx: idx });
4594
+ }
4595
+ /**
4596
+ * C33. MOVE ONE TAB INTO ANOTHER TILE — the same verb as `floatTabAsWindow`
4597
+ * above with a TILE as the destination instead of a window, which is why it
4598
+ * sits beside it.
4599
+ *
4600
+ * ══ THE ORDER IS LOAD-BEARING ═══════════════════════════════════════
4601
+ *
4602
+ * Four steps, and three of them are in this order for a reason that a
4603
+ * plausible-looking rewrite would destroy:
4604
+ *
4605
+ * (a) READ THE TAB SPEC FIRST. `fromIdx` is an ARRAY INDEX — the only
4606
+ * identity a tile tab has (`tile_renderer._tabKey`) — so it is stale
4607
+ * the instant anything splices a tab list. Everything below works
4608
+ * from the copy taken here.
4609
+ *
4610
+ * (b) SPLIT BEFORE REMOVING. When the destination IS the source pane —
4611
+ * "tear this tab off into a split beside its siblings" — removing
4612
+ * first can empty that pane and send it through `_seedHome`, so the
4613
+ * split would then be splitting a freshly seeded ground rather than
4614
+ * the pane the preview drew. Splitting first cannot go wrong in the
4615
+ * other direction: `tree.split` never touches tabs.
4616
+ *
4617
+ * (c) THE MOVE ITSELF IS ONE TREE CALL for `tab` — `moveTabToLeaf`, which
4618
+ * exists so the tab cannot be in flight between two mutations — and
4619
+ * remove-then-`setLeafContent` for `fill`/`split`, where the
4620
+ * destination is ground or brand new and REPLACING is the point.
4621
+ *
4622
+ * (d) RE-SEED AND MERGE, exactly as `_promote` does when the last tab
4623
+ * leaves a pane (`_seedHome` then `_mergeStartTiles`). A pane is
4624
+ * never left blank, and two grounds never end up side by side with a
4625
+ * splitter between them for no reason.
4626
+ *
4627
+ * ══ `wm:tab-moved` IS EMITTED BEFORE THE REPAINT ════════════════════
4628
+ *
4629
+ * An embedder that keys live content by leaf id — the Tables grid registry
4630
+ * does, on `(leaf, table)`, because a DOM element exists in exactly one
4631
+ * place — has to re-key BEFORE the render mounts the destination, or the
4632
+ * destination misses its entry, builds a second grid, and the source tile's
4633
+ * deferred teardown destroys the first one along with everything typed into
4634
+ * it and not yet committed. Emitting after the render would lose that race
4635
+ * silently, which is the failure this repository keeps recording. The tree
4636
+ * is already correct at this point; only the DOM is stale.
4637
+ *
4638
+ * ══ WHAT THIS DELIBERATELY DOES NOT DO ══════════════════════════════
4639
+ *
4640
+ * A tab is not dragged OUT OF A FLOATING WINDOW's strip, and a tab dropped
4641
+ * on empty space does not become a window. Both are refused by omission
4642
+ * rather than half-built, and both have a reason. A window's tabs live in
4643
+ * `_windowToLeaf`'s record and not in the tree, so their source policy is
4644
+ * `_windowTabAction`'s and not this function's. And "dropped on nothing" in
4645
+ * HTML5 drag-and-drop is `dragend` with no `drop` — which is also exactly
4646
+ * what pressing Escape produces, so floating a window on it would float one
4647
+ * every time a user changed their mind. Crossing DESKTOPS is out for a
4648
+ * third reason: only the active desktop is rendered, so there is no target
4649
+ * to hit.
4650
+ *
4651
+ * @param {string} fromLeafId
4652
+ * @param {number} fromIdx
4653
+ * @param {{leafId: string, mode?: 'tab'|'fill'|'split', dir?: 'h'|'v',
4654
+ * before?: boolean, toIdx?: number}} target a `tabDropProbe`
4655
+ * answer, translated by the renderer
4656
+ * @returns {string|null} the leaf the tab landed in, or null if refused
4657
+ */
4658
+ moveTabInto(fromLeafId, fromIdx, target = {}) {
4659
+ const tree = this._tree();
4660
+ const from = tree.get(fromLeafId);
4661
+ if (!from || from.kind !== "leaf") return null;
4662
+ const tabs = Array.isArray(from.tabs) ? from.tabs : [];
4663
+ if (!Number.isInteger(fromIdx) || fromIdx < 0 || fromIdx >= tabs.length) return null;
4664
+ let destId = target?.leafId || null;
4665
+ const dest = destId ? tree.get(destId) : null;
4666
+ if (!dest || dest.kind !== "leaf") return null;
4667
+ if (String(dest.content?.kind || "").startsWith("panel:")) return null;
4668
+ const mode = target.mode === "fill" || target.mode === "split" ? target.mode : "tab";
4669
+ if (destId === fromLeafId && mode !== "split") return null;
4670
+ if (destId === fromLeafId && tabs.length <= 1) return null;
4671
+ const src = tabs[fromIdx];
4672
+ const spec = {
4673
+ kind: src.kind,
4674
+ props: { ...src.props || {} },
4675
+ title: src.title || src.kind || ""
3483
4676
  };
4677
+ if (mode === "split") {
4678
+ const newId = tree.split(destId, target.dir === "v" ? "v" : "h");
4679
+ if (!newId) return null;
4680
+ _halveInto(tree, destId, newId);
4681
+ if (target.before) _swapSiblings(tree, destId, newId);
4682
+ destId = newId;
4683
+ }
4684
+ if (mode === "tab") {
4685
+ const moved = tree.moveTabToLeaf(
4686
+ fromLeafId,
4687
+ fromIdx,
4688
+ destId,
4689
+ Number.isInteger(target.toIdx) ? target.toIdx : -1
4690
+ );
4691
+ if (!moved?.ok) return null;
4692
+ } else {
4693
+ tree.removeLeafTab(fromLeafId, fromIdx);
4694
+ tree.setLeafContent(destId, { kind: spec.kind, props: spec.props }, spec.title);
4695
+ }
4696
+ if (!(tree.get(fromLeafId)?.tabs || []).length) {
4697
+ this._seedHome(tree, fromLeafId);
4698
+ this._mergeStartTiles(tree, fromLeafId);
4699
+ }
4700
+ this._canonicalize(tree, this.desktops.active());
4701
+ try {
4702
+ this.eventBus?.emit?.(
4703
+ "wm:tab-moved",
4704
+ { fromLeafId, toLeafId: destId, tab: spec, mode }
4705
+ );
4706
+ } catch (err) {
4707
+ console.warn("[wm] tab-moved emit failed", err);
4708
+ }
4709
+ if (tree.get(destId)) tree.focus(destId);
4710
+ this.renderer.render();
4711
+ this._persist();
4712
+ this._notifyChange("tab-moved");
4713
+ return destId;
4714
+ }
4715
+ /** The guards both float verbs share. A panel tile is chrome, not content;
4716
+ * a window placeholder is already a window; an empty tile has nothing to
4717
+ * carry — and, since C20 was extended, content that declared itself
4718
+ * unfloatable is not floated by ANY door.
4719
+ *
4720
+ * THE LAST ONE IS WHY THIS FUNCTION IS THE RIGHT PLACE. C20 let a content
4721
+ * factory return `chrome: { promote: false }`, and the renderer honoured
4722
+ * it by not PAINTING the float button. That is one door of four: the
4723
+ * tile's right-click menu has offered "Float this pane as a window" all
4724
+ * along (`shell.js`'s `_tileContextMenu`, whose only guard is
4725
+ * `isPanel || !leaf.content`), the chrome pull-down asks for `promote`,
4726
+ * and so now does the chrome's double-click. Each of them arrives here.
4727
+ *
4728
+ * The case it protects is an embedder's MASTER tile: the ground that
4729
+ * floating windows stand on. Floating it promotes the ground into a
4730
+ * window, which force-closes every window standing on it and re-seeds the
4731
+ * pane WITHOUT the props that made it a ground — reproduced end to end
4732
+ * before this guard existed. A veto the content states once should hold
4733
+ * for every gesture, not only the one the renderer draws. */
4734
+ _floatableLeaf(leafId) {
4735
+ const leaf = leafId ? this._tree().get(leafId) : null;
4736
+ if (!leaf || leaf.kind !== "leaf" || !leaf.content) return null;
4737
+ if (PANEL_KINDS.has(leaf.content.kind)) return null;
4738
+ if (leaf.content.kind === PLACEHOLDER_KIND) return null;
4739
+ if (this.renderer?.leafChrome?.(leaf.id)?.promote === false) return null;
4740
+ return leaf;
4741
+ }
4742
+ /**
4743
+ * The promote itself: build the window, mount the active tab in it, and
4744
+ * take the tabs out of the tree.
4745
+ *
4746
+ * @param {string} leafId the pane the tabs are coming out of
4747
+ * @param {object[]} tabs `{kind, props, title}`, in order
4748
+ * @param {number} activeTabIdx which of them the window shows first
4749
+ * @param {{wholePane: boolean, tabIdx?: number}} opts
4750
+ */
4751
+ _promote(leafId, tabs, activeTabIdx, { wholePane, tabIdx = -1 }) {
4752
+ const tree = this._tree();
4753
+ const leaf = tree.get(leafId);
4754
+ const desktopIdx = this.desktops.activeIdx;
4755
+ const tabCount = Array.isArray(leaf.tabs) ? leaf.tabs.length : 1;
4756
+ const active = Math.max(0, Math.min(tabs.length - 1, activeTabIdx || 0));
4757
+ const original = { ...tabs[active], props: { ...tabs[active].props || {} } };
3484
4758
  const contentEl = document.createElement("div");
3485
4759
  contentEl.className = "twm-window-content";
3486
4760
  contentEl.style.cssText = "display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; height:100%;";
4761
+ const tabBarEl = document.createElement("div");
4762
+ tabBarEl.className = "twm-window-tabbar";
4763
+ const bodyEl = document.createElement("div");
4764
+ bodyEl.className = "twm-window-body";
4765
+ bodyEl.style.cssText = "display:flex; flex-direction:column; flex:1; min-width:0; min-height:0;";
4766
+ contentEl.append(tabBarEl, bodyEl);
3487
4767
  const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
3488
4768
  const mountInfo = this.content.mount(
3489
4769
  original.kind,
3490
- contentEl,
4770
+ bodyEl,
3491
4771
  original.props,
3492
4772
  { ...this.ctx, wm: this, windowId: winId }
3493
4773
  );
@@ -3500,66 +4780,1207 @@ var WindowManager = class {
3500
4780
  canMaximize: true,
3501
4781
  canResize: true,
3502
4782
  modal: false,
3503
- onClose: () => this._onManagedWindowClosed(winId, mountInfo)
4783
+ // C15. Dropping a promoted window on a tile PUTS IT BACK — as that
4784
+ // tile's content, as a split of it, or as one of its tabs. Off
4785
+ // unless the embedder asked, because it changes what a drag to an
4786
+ // edge means.
4787
+ snap: this.snapPromotion,
4788
+ snapController: this.snapPromotion ? this._snapController() : null,
4789
+ // R1. THE PANE IS A BOX WITH `overflow: hidden`. A window contained
4790
+ // to one (C21) cannot be dragged a single pixel outside it, so
4791
+ // "drag a window from one tile to another" — the gesture all three
4792
+ // drop behaviours are built on — was not merely awkward, it was
4793
+ // invisible. For the length of a drag the window is re-parented
4794
+ // here, to the root every tile is inside; on release it goes back
4795
+ // into a pane, either the one it was dropped on or the one it came
4796
+ // from. Resolved per drag: the root outlives any tile, and a tile
4797
+ // grabbed once does not survive its own repaint.
4798
+ dragHost: () => this.rootEl,
4799
+ dragBounds: () => this._tileBounds(),
4800
+ // R7. MAXIMISE MEANS BACK TO TILE. This window came OUT of the
4801
+ // tree; the useful thing to do with it is put it back, and filling
4802
+ // the screen with it is the one gesture that makes putting it back
4803
+ // harder. So the maximize button docks — and the separate demote
4804
+ // button the WM used to inject beside it is gone, because two
4805
+ // buttons for one verb is how you get a chrome nobody reads.
4806
+ onMaximize: () => this.bringBackWindow(winId),
4807
+ maximizeIcon: "close_fullscreen",
4808
+ maximizeTitle: "Back to tile",
4809
+ onClose: () => this._onManagedWindowClosed(winId, null)
3504
4810
  });
3505
4811
  this._windowToLeaf.set(winId, {
3506
- // Promoting CLOSES the source tile the content lives in the
3507
- // window now, not the tree. leafId is null so "back to tile"
3508
- // re-docks into the primary tile (see _onManagedWindowClosed).
4812
+ // The floated tabs now live in the window, not the tree. leafId
4813
+ // is null so "back to tile" re-docks into the desktop's primary
4814
+ // tile (see _onManagedWindowClosed) — the source tile itself
4815
+ // survives (re-seeded with HOME when it was emptied).
3509
4816
  leafId: null,
3510
4817
  desktopIdx,
3511
4818
  original,
3512
4819
  mountInfo,
3513
4820
  window: win,
3514
4821
  contentEl,
4822
+ bodyEl,
4823
+ tabBarEl,
4824
+ // R8. THE PANE'S TABS TRAVEL WITH IT, and this is where they live
4825
+ // while the window is open. `original` still mirrors the ACTIVE one
4826
+ // so every existing reader of the record — `openInWindow`, each of
4827
+ // `_onManagedWindowClosed`'s docks — keeps working unchanged; the
4828
+ // list beside it is what makes a dock restore ALL of them.
4829
+ tabs,
4830
+ activeTabIdx: active,
4831
+ strip: null,
4832
+ // ══ C21. WRITTEN HERE, BEFORE THE TREE IS TOUCHED ═══════════
4833
+ //
4834
+ // `homeLeafId` is the pane this window stands on, and it used to be
4835
+ // assigned at the BOTTOM of this function — after `_seedHome`,
4836
+ // after `_mergeStartTiles`, after the repaint. That ordering
4837
+ // destroyed a tile per promotion, and it looked like a window bug
4838
+ // because a window is what the user had just moved.
4839
+ //
4840
+ // `_mergeStartTiles` (below) refuses to merge a start tile that has
4841
+ // a window standing on it, and `_paneHoldsWindows` answers that
4842
+ // question two ways: THIS FIELD, and a DOM probe for a
4843
+ // `.twm-managed-window` inside the leaf. At the old assignment point
4844
+ // neither could be true yet — the field was unwritten and the window
4845
+ // had not been `moveTo`'d into the pane — so the pane that was one
4846
+ // line away from becoming this window's ground answered *nothing
4847
+ // floats here* and was merged into its neighbour.
4848
+ //
4849
+ // Three panes floated one after another ended as ONE pane: the
4850
+ // first promotion left a start tile, the second merged its own
4851
+ // freshly-seeded tile away, and so did the third. Every window
4852
+ // after the first was then left with a `homeLeafId` naming a leaf
4853
+ // the tree no longer had — so the `moveTo` below was skipped and the
4854
+ // window never became contained, `_rehomeContainedWindows` found no
4855
+ // element to re-home it into, and `bringBackWindow` fell through to
4856
+ // the PRIMARY tile and docked as a tab onto the ground ANOTHER
4857
+ // window was standing on. That is the whole of the reported
4858
+ // *"expand one to a tile > influences others or even tiles lost"*.
4859
+ //
4860
+ // Writing it here is the smallest fix that closes all of it: a
4861
+ // guard that already existed starts being able to see the window it
4862
+ // was written to protect. The `moveTo` stays at the bottom, because
4863
+ // it needs the wrap the repaint rebuilds.
4864
+ homeLeafId: this.promoteInPlace ? leafId : null,
3515
4865
  // Set to true by bringBackWindow so the close path knows to
3516
4866
  // restore the content instead of destroying it.
3517
4867
  _demoting: false
3518
4868
  });
3519
- tree.close(leafId);
4869
+ this._syncWindowTabs(winId);
4870
+ if (!wholePane && tabCount > 1) tree.removeLeafTab(leafId, tabIdx);
4871
+ else this._seedHome(tree, leafId);
4872
+ this._mergeStartTiles(tree, leafId);
4873
+ tree.focus(leafId);
3520
4874
  this._canonicalize(tree, this.desktops.active());
3521
4875
  this.renderer.render();
4876
+ if (this.promoteInPlace) {
4877
+ const paneEl = this.renderer.leafEl(leafId);
4878
+ if (paneEl) win.moveTo(paneEl);
4879
+ }
3522
4880
  win.show();
3523
4881
  this._decorateManagedWindow(win, winId);
3524
4882
  this._persist();
3525
4883
  this._notifyChange("window-promoted");
4884
+ return winId;
3526
4885
  }
3527
- /** Dock the window's content back into the desktop's primary tile
3528
- * (the source tile was closed on promote), then close the window. */
4886
+ // ══ R8. The tab strip inside a floated pane ═══════════════════════
4887
+ /**
4888
+ * Draw (or hide) a window's tab strip, and keep its title honest.
4889
+ *
4890
+ * Hidden below two tabs, exactly as a pane's strip is
4891
+ * (`tile_renderer._renderTabBar`): the common case is one tab, and a strip
4892
+ * naming the one thing you are already looking at is a line of chrome
4893
+ * saying nothing. Building the strip lazily also means a window promoted
4894
+ * out of a single-tab pane costs no `NotebookTabBar` at all.
4895
+ */
4896
+ _syncWindowTabs(winId) {
4897
+ const rec = this._windowToLeaf.get(winId);
4898
+ if (!rec) return;
4899
+ const tabs = rec.tabs || [];
4900
+ rec.activeTabIdx = Math.max(0, Math.min(tabs.length - 1, rec.activeTabIdx || 0));
4901
+ if (tabs.length <= 1) {
4902
+ try {
4903
+ rec.strip?.dispose();
4904
+ } catch {
4905
+ }
4906
+ rec.strip = null;
4907
+ rec.tabBarEl?.classList.add("twm-window-tabbar--hidden");
4908
+ return;
4909
+ }
4910
+ rec.tabBarEl?.classList.remove("twm-window-tabbar--hidden");
4911
+ if (!rec.strip) {
4912
+ rec.strip = createTabStrip({
4913
+ hostEl: rec.tabBarEl,
4914
+ taxonomy: this.taxonomy,
4915
+ onAction: (action, data) => this._windowTabAction(winId, action, data)
4916
+ });
4917
+ }
4918
+ rec.strip.update(tabs, rec.activeTabIdx);
4919
+ }
4920
+ /** The window strip's half of `_leafTabAction` — the same four verbs
4921
+ * against the window record instead of against the tree. */
4922
+ _windowTabAction(winId, action, data = {}) {
4923
+ const rec = this._windowToLeaf.get(winId);
4924
+ if (!rec) return;
4925
+ const tabs = rec.tabs || [];
4926
+ if (action === "switch") {
4927
+ this.showWindowTab(winId, data.idx);
4928
+ return;
4929
+ }
4930
+ if (action === "close") {
4931
+ if (data.idx < 0 || data.idx >= tabs.length) return;
4932
+ if (tabs.length <= 1) {
4933
+ try {
4934
+ rec.window.close({ force: true });
4935
+ } catch {
4936
+ }
4937
+ return;
4938
+ }
4939
+ tabs.splice(data.idx, 1);
4940
+ if (data.idx < rec.activeTabIdx) rec.activeTabIdx -= 1;
4941
+ else if (data.idx === rec.activeTabIdx) {
4942
+ rec.activeTabIdx = Math.max(0, data.idx - 1);
4943
+ this._mountWindowTab(winId);
4944
+ }
4945
+ this._syncWindowTabs(winId);
4946
+ this._notifyChange("window-tab-close");
4947
+ return;
4948
+ }
4949
+ if (action === "move") {
4950
+ const { from, to } = data;
4951
+ if (from == null || to == null) return;
4952
+ if (from < 0 || from >= tabs.length || to < 0 || to >= tabs.length) return;
4953
+ const moved = tabs.splice(from, 1)[0];
4954
+ tabs.splice(to, 0, moved);
4955
+ if (rec.activeTabIdx === from) rec.activeTabIdx = to;
4956
+ else if (from < rec.activeTabIdx && to >= rec.activeTabIdx) rec.activeTabIdx -= 1;
4957
+ else if (from > rec.activeTabIdx && to <= rec.activeTabIdx) rec.activeTabIdx += 1;
4958
+ this._syncWindowTabs(winId);
4959
+ return;
4960
+ }
4961
+ if (action === "menu") this._showWindowTabContextMenu(winId, data.idx, data.x, data.y);
4962
+ }
4963
+ /** Show one of a floated pane's tabs. Public because a window is the only
4964
+ * place this list exists — nothing else can reach it. */
4965
+ showWindowTab(winId, idx) {
4966
+ const rec = this._windowToLeaf.get(winId);
4967
+ if (!rec) return false;
4968
+ const tabs = rec.tabs || [];
4969
+ if (!Number.isInteger(idx) || idx < 0 || idx >= tabs.length) return false;
4970
+ if (idx === rec.activeTabIdx) return true;
4971
+ rec.activeTabIdx = idx;
4972
+ this._mountWindowTab(winId);
4973
+ this._syncWindowTabs(winId);
4974
+ this._persist();
4975
+ this._notifyChange("window-tab-switch");
4976
+ return true;
4977
+ }
4978
+ /** Tear the current mount down and mount the active tab in its place.
4979
+ * `rec.original` follows, so a later dock puts back what is on screen. */
4980
+ _mountWindowTab(winId) {
4981
+ const rec = this._windowToLeaf.get(winId);
4982
+ if (!rec) return;
4983
+ const tab = (rec.tabs || [])[rec.activeTabIdx];
4984
+ if (!tab) return;
4985
+ try {
4986
+ rec.mountInfo?.destroy?.();
4987
+ } catch {
4988
+ }
4989
+ rec.bodyEl.innerHTML = "";
4990
+ rec.mountInfo = this.content.mount(
4991
+ tab.kind,
4992
+ rec.bodyEl,
4993
+ tab.props || {},
4994
+ { ...this.ctx, wm: this, windowId: winId }
4995
+ );
4996
+ rec.original = {
4997
+ kind: tab.kind,
4998
+ props: { ...tab.props || {} },
4999
+ title: rec.mountInfo?.title || tab.title || tab.kind
5000
+ };
5001
+ this._setWindowTitle(rec, rec.original.title);
5002
+ }
5003
+ /** The window's title, in both places it is kept. */
5004
+ _setWindowTitle(rec, title) {
5005
+ try {
5006
+ const titleEl = rec.window?.element?.querySelector(".twm-managed-window__title");
5007
+ if (titleEl) titleEl.textContent = title;
5008
+ if (rec.window) rec.window.title = title;
5009
+ } catch {
5010
+ }
5011
+ }
5012
+ /** The window strip's context menu. Deliberately the close verbs and
5013
+ * nothing else: a tab in a window is already out of the tree, so
5014
+ * "open in a window" — the verb R9 adds to a PANE's tab menu — has
5015
+ * nowhere further to go. */
5016
+ _showWindowTabContextMenu(winId, idx, x, y) {
5017
+ const rec = this._windowToLeaf.get(winId);
5018
+ const tabs = rec?.tabs || [];
5019
+ if (!tabs.length) return;
5020
+ const items = [{ label: "Close tab", icon: "close", action: "close" }];
5021
+ if (tabs.length > 1) {
5022
+ items.push({ label: "Close other tabs", icon: "tab_close", action: "close-others" });
5023
+ }
5024
+ showContextMenu(x, y, items, (action) => {
5025
+ const live = this._windowToLeaf.get(winId);
5026
+ if (!live) return;
5027
+ if (action === "close") this._windowTabAction(winId, "close", { idx });
5028
+ else if (action === "close-others") {
5029
+ const keep = live.tabs[idx];
5030
+ if (!keep) return;
5031
+ const remount = idx !== live.activeTabIdx;
5032
+ live.tabs = [keep];
5033
+ live.activeTabIdx = 0;
5034
+ if (remount) this._mountWindowTab(winId);
5035
+ this._syncWindowTabs(winId);
5036
+ this._notifyChange("window-tab-close-others");
5037
+ }
5038
+ });
5039
+ }
5040
+ /**
5041
+ * Re-parent every pane-contained window into its pane's CURRENT wrap.
5042
+ *
5043
+ * Called after each repaint. A leaf's wrap is cached per (kind, props, tab
5044
+ * fingerprint) and rebuilt when any of those change, so a window parented
5045
+ * into it is thrown away with the old wrap — silently, because nothing
5046
+ * throws and the window object is still perfectly alive.
5047
+ *
5048
+ * `moveTo` returns false when the container has not changed, so this is a
5049
+ * no-op on every repaint that did not rebuild the pane in question.
5050
+ */
5051
+ _rehomeContainedWindows() {
5052
+ if (!this.promoteInPlace) return;
5053
+ for (const [, rec] of this._windowToLeaf) {
5054
+ if (!rec.homeLeafId || !rec.window) continue;
5055
+ if (rec.window.dragOrigin) continue;
5056
+ if (rec.window.isMaximized && rec.window.container === this.rootEl) continue;
5057
+ if (rec.desktopIdx !== this.desktops.activeIdx) {
5058
+ if (!rec.homeContainer && rec.window.element?.isConnected) {
5059
+ try {
5060
+ rec.window.element.remove();
5061
+ } catch {
5062
+ }
5063
+ }
5064
+ continue;
5065
+ }
5066
+ if (!rec.homeContainer && rec.homeLeafId) {
5067
+ const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
5068
+ if (tree && !tree.get(rec.homeLeafId)) {
5069
+ let survivor = tree.primaryLeafId();
5070
+ if (!survivor) {
5071
+ const spawned = this._spawnContentLeaf(tree);
5072
+ if (spawned) {
5073
+ this._seedHome(tree, spawned);
5074
+ this._canonicalize(tree, this.desktops.desktops[rec.desktopIdx]);
5075
+ survivor = tree.primaryLeafId();
5076
+ }
5077
+ }
5078
+ if (survivor) rec.homeLeafId = survivor;
5079
+ }
5080
+ }
5081
+ const paneEl = rec.homeContainer ? rec.homeContainer() || null : this.renderer.leafEl(rec.homeLeafId);
5082
+ if (!paneEl) continue;
5083
+ if (paneEl === rec.window.container) {
5084
+ const el = rec.window.element;
5085
+ if (el && !el.isConnected) {
5086
+ try {
5087
+ paneEl.appendChild(el);
5088
+ } catch (err) {
5089
+ console.warn("[wm] re-attach failed", err);
5090
+ }
5091
+ }
5092
+ continue;
5093
+ }
5094
+ try {
5095
+ rec.window.moveTo(paneEl);
5096
+ } catch (err) {
5097
+ console.warn("[wm] re-home failed", err);
5098
+ }
5099
+ }
5100
+ }
5101
+ /**
5102
+ * C21, as a verb a consumer can call: put THIS window back where it belongs
5103
+ * and say whether it moved.
5104
+ *
5105
+ * The taskbar needs it. A minimised window's element may be out of the
5106
+ * document — its desktop is not on screen, or its pane was closed — and
5107
+ * un-minimising it in that state clears `isMinimized` (so its button
5108
+ * disappears, the last handle on it) while showing nothing. `restore` has
5109
+ * to be able to repair the window BEFORE it makes it visible, and
5110
+ * `_rehomeContainedWindows` is the thing that knows how; it was simply not
5111
+ * reachable, and `taskbar.js`'s own docstring asserted it ran for these
5112
+ * windows when the guard above meant it did not.
5113
+ *
5114
+ * IT SWITCHES DESKTOPS WHEN IT HAS TO, and that is the half a bare re-home
5115
+ * cannot do. A window belongs to one desktop; if that desktop is not on
5116
+ * screen, the honest answer to *show me this window* is the one every
5117
+ * taskbar in every window manager gives — go to where it lives. Restoring
5118
+ * it onto the page the user happens to be looking at would move a window
5119
+ * between pages as a side effect of asking to see it, and that is a tile
5120
+ * decision being made by a window verb.
5121
+ *
5122
+ * @param {object} win a live ManagedWindow
5123
+ * @returns {boolean} whether this WM owns it (and so has revealed it)
5124
+ */
5125
+ revealWindow(win) {
5126
+ if (!win) return false;
5127
+ let rec = null;
5128
+ for (const [, r] of this._windowToLeaf) {
5129
+ if (r.window === win) {
5130
+ rec = r;
5131
+ break;
5132
+ }
5133
+ }
5134
+ if (!rec) return false;
5135
+ if (!rec.homeLeafId && !rec.homeContainer) return false;
5136
+ if (rec.desktopIdx !== this.desktops.activeIdx && this.desktops.desktops[rec.desktopIdx]) {
5137
+ this.switchDesktop(rec.desktopIdx);
5138
+ } else {
5139
+ this._rehomeContainedWindows();
5140
+ }
5141
+ return true;
5142
+ }
5143
+ /**
5144
+ * R12. The rectangle an ESCAPED window may occupy — the tiles, and not the
5145
+ * panels — in the root's own coordinates.
5146
+ *
5147
+ * R1 let a window leave its pane so it could reach another one, and the
5148
+ * cheapest box to let it leave into is the root every tile shares. But the
5149
+ * root holds the docked panels too, so the bottom edge stopped being an
5150
+ * edge: a window could be dragged down over the bottom panel and dropped
5151
+ * there, half-covering a surface that has its own scroll and its own
5152
+ * chrome, with no way to tell it had happened except that it looked wrong.
5153
+ *
5154
+ * The answer is the UNION OF THE CONTENT LEAVES rather than "the root minus
5155
+ * the panel I know about": panels dock left, right and bottom, an embedder
5156
+ * may show any combination of them, and each one may be collapsed. A union
5157
+ * of the tiles is right for all of those without enumerating any of them,
5158
+ * and it degrades to the root when a desktop is somehow all panel.
5159
+ */
5160
+ _tileBounds() {
5161
+ const root = this.rootEl;
5162
+ if (!root) return null;
5163
+ const layer = this._layerRect();
5164
+ if (!layer) return null;
5165
+ const rootRect = root.getBoundingClientRect();
5166
+ const ox = rootRect.left + root.clientLeft - root.scrollLeft;
5167
+ const oy = rootRect.top + root.clientTop - root.scrollTop;
5168
+ return {
5169
+ minX: layer.left - ox,
5170
+ minY: layer.top - oy,
5171
+ width: layer.width,
5172
+ height: layer.height
5173
+ };
5174
+ }
5175
+ /**
5176
+ * R13. THE LAYER, in the VIEWPORT pixels a hit-test speaks — the union of
5177
+ * the content leaves, before it is converted into anybody's coordinates.
5178
+ *
5179
+ * This is `_tileBounds` with the last step taken off, and it stays a
5180
+ * separate function rather than being folded back into it because the two
5181
+ * frames have different readers: `_tileBounds` answers `dragBounds`, which
5182
+ * `ManagedWindow._bounds()` uses to clamp a window in the ROOT's
5183
+ * coordinates, and this answers anything measuring against the page.
5184
+ *
5185
+ * R14 REMOVED ITS OTHER READER. R13's maximise preview was drawn from here
5186
+ * so that it would be the same measurement `toggleMaximize` would deliver
5187
+ * through `dragBounds` — C15's rule, THE PREVIEW MAY NOT PROMISE A
5188
+ * RECTANGLE THE DROP DOES NOT DELIVER, applied to the one mode that did not
5189
+ * dock. There is no such mode now: the top edge docks like every other
5190
+ * zone, its preview is the TILE (`_homeDockTarget`), and the layer's only
5191
+ * remaining job is the clamp. Kept as its own function because the clamp
5192
+ * still needs the union of the CONTENT leaves rather than the root, which
5193
+ * is a definition, not a call site.
5194
+ *
5195
+ * The union of the CONTENT LEAVES rather than the root, for the reason
5196
+ * `_tileBounds` gives at length: the root holds the docked panels too.
5197
+ *
5198
+ * Null when nothing has a box yet — a layout that has not happened, a
5199
+ * desktop whose tiles are all zero-sized. Every caller treats that as "do
5200
+ * not promise anything", which is the only honest answer available.
5201
+ */
5202
+ _layerRect() {
5203
+ let l = Infinity, tp = Infinity, r = -Infinity, b = -Infinity;
5204
+ for (const leaf of this._tree().leaves()) {
5205
+ if (PANEL_KINDS.has(leaf.content?.kind)) continue;
5206
+ const el = this.renderer.leafEl(leaf.id);
5207
+ if (!el) continue;
5208
+ const box = el.getBoundingClientRect();
5209
+ if (!box.width || !box.height) continue;
5210
+ l = Math.min(l, box.left);
5211
+ tp = Math.min(tp, box.top);
5212
+ r = Math.max(r, box.right);
5213
+ b = Math.max(b, box.bottom);
5214
+ }
5215
+ if (!Number.isFinite(l)) return null;
5216
+ return { left: l, top: tp, width: r - l, height: b - tp };
5217
+ }
5218
+ /** Dock the window's content back into the tile it came from — or, when it
5219
+ * came from none, into the desktop's primary tile — then close the window.
5220
+ *
5221
+ * R7. This is what the MAXIMIZE button now does, so it is reached far more
5222
+ * often than it was as a button of its own, and "somewhere other than where
5223
+ * the window came from" stopped being a defensible answer. Promoting a pane
5224
+ * re-seeds it with the root kind — ground for the window to stand on — so
5225
+ * FILLING that pane is the exact inverse: the content goes back where it
5226
+ * was lifted from, replacing the ground it has been standing on.
5227
+ *
5228
+ * A home pane that has since acquired content is a different story. The
5229
+ * user opened something there, and replacing it would destroy work the
5230
+ * window knows nothing about, so the content joins it as a tab instead.
5231
+ * With no home pane at all — an Alt+N window, or any window under an
5232
+ * embedder that does not confine promotions — this is the primary-tile tab
5233
+ * it has always been. */
3529
5234
  bringBackWindow(windowId) {
3530
5235
  const rec = this._windowToLeaf.get(windowId);
3531
- if (!rec) return;
5236
+ if (!rec) return false;
5237
+ const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
5238
+ const home = rec.homeLeafId ? tree?.get(rec.homeLeafId) : null;
5239
+ if (home && home.kind === "leaf") {
5240
+ rec._dock = {
5241
+ leafId: rec.homeLeafId,
5242
+ mode: this._isStartTile(home) ? "fill" : "tab"
5243
+ };
5244
+ }
3532
5245
  rec._demoting = true;
3533
5246
  try {
3534
5247
  rec.window.close({ force: true });
3535
5248
  } catch (err) {
3536
5249
  console.warn("[wm] bringBack: close failed", err);
3537
5250
  }
5251
+ return true;
5252
+ }
5253
+ /**
5254
+ * R11. ADOPT A WINDOW THE EMBEDDER BUILT ITSELF.
5255
+ *
5256
+ * Everything R1–R10 gave a window — escaping its pane for the length of a
5257
+ * drag, going half-transparent once it is outside, the edge/body/ground
5258
+ * drops, maximise meaning *back to tile* — is wired in `_promote`, and so
5259
+ * belongs only to windows this WM lifted out of the tree. An embedder that
5260
+ * stands its own `ManagedWindow` on a pane (`snap: true` against the pane's
5261
+ * ground) got none of it: `_snapCommit` resolves the window through
5262
+ * `_windowToLeaf` and returns false for one it never built, so every drop
5263
+ * previewed correctly and then quietly did nothing.
5264
+ *
5265
+ * The fix is not to make the WM build those windows — the embedder has its
5266
+ * own reasons for the ones it builds, and taking that over would mean
5267
+ * taking over their content, their identity and their lifetime. It is to
5268
+ * let a window JOIN the tree's world after the fact, which needs exactly
5269
+ * two things: the drag options set on the component, and a record saying
5270
+ * what content to restore when the window is docked.
5271
+ *
5272
+ * CALL THIS BEFORE `show()`. `maximizeIcon` is read when the chrome is
5273
+ * built (`managed_window.js:703`) and the chrome is built lazily by `show`
5274
+ * (`:281`), so a window adopted afterwards would carry the right behaviour
5275
+ * behind a button still drawing a square.
5276
+ *
5277
+ * TEARDOWN STAYS THE EMBEDDER'S. `mountInfo` is optional and normally
5278
+ * omitted: a window that already destroys its own content in its `onClose`
5279
+ * would otherwise destroy it twice, once here and once there. The
5280
+ * embedder's handler is chained, not replaced, and runs after this one — so
5281
+ * a dock has already re-mounted the content into the tile by the time the
5282
+ * window's own teardown disposes of the copy that was floating.
5283
+ *
5284
+ * @param {object} win a live ManagedWindow, not yet shown
5285
+ * @param {object} spec
5286
+ * @param {string} spec.kind content kind to restore into a tile
5287
+ * @param {object} [spec.props] its props
5288
+ * @param {string} [spec.title] the tab title after a dock
5289
+ * @param {string} [spec.homeLeafId] the pane it stands on: what "back to
5290
+ * tile" targets, and what the probe stays silent inside
5291
+ * @param {function} [spec.homeContainer] `() => HTMLElement` — the box
5292
+ * WITHIN that pane the window is contained to. A canvas pane's
5293
+ * ground is not the leaf wrap, and re-homing to the wrap after a
5294
+ * repaint would lift the window out of the ground it belongs to.
5295
+ * @param {object} [spec.mountInfo] `{destroy}`, if teardown is ours
5296
+ * @returns {string|null} the window id, or null if it could not be adopted
5297
+ */
5298
+ adoptWindow(win, spec = {}) {
5299
+ const winId = win?.id;
5300
+ if (!winId || !spec.kind) return null;
5301
+ if (this._windowToLeaf.has(winId)) return winId;
5302
+ if (this.snapPromotion) {
5303
+ win.snap = win.snap && win.canDrag && win.canResize;
5304
+ win.snapController = this._snapController();
5305
+ }
5306
+ win.dragHost = () => this.rootEl;
5307
+ win.dragBounds = () => this._tileBounds();
5308
+ win.onMaximize = () => this.bringBackWindow(winId);
5309
+ win.maximizeIcon = "close_fullscreen";
5310
+ win.maximizeTitle = "Back to tile";
5311
+ const original = {
5312
+ kind: spec.kind,
5313
+ props: spec.props || {},
5314
+ title: spec.title || spec.kind
5315
+ };
5316
+ this._windowToLeaf.set(winId, {
5317
+ leafId: null,
5318
+ desktopIdx: this.desktops.activeIdx,
5319
+ original,
5320
+ mountInfo: spec.mountInfo || null,
5321
+ window: win,
5322
+ contentEl: null,
5323
+ bodyEl: null,
5324
+ tabBarEl: null,
5325
+ tabs: [original],
5326
+ activeTabIdx: 0,
5327
+ strip: null,
5328
+ homeLeafId: spec.homeLeafId || null,
5329
+ homeContainer: spec.homeContainer || null,
5330
+ adopted: true,
5331
+ _demoting: false
5332
+ });
5333
+ const prior = win.onClose;
5334
+ win.onClose = () => {
5335
+ this._onManagedWindowClosed(winId, null);
5336
+ prior?.();
5337
+ };
5338
+ return winId;
5339
+ }
5340
+ // ══ C15. Snap-to-promote ══════════════════════════════════════════
5341
+ /**
5342
+ * The snap controller a promoted window is given. It answers the two
5343
+ * questions ManagedWindow's own C11 snap cannot, because both are about a
5344
+ * tree it does not know exists:
5345
+ *
5346
+ * probe which TILE is under the pointer, and — since R15 — which edge
5347
+ * of it THE DRAGGED WINDOW'S OWN BORDERS have reached, and what
5348
+ * would dropping there actually produce: a half of that tile, a
5349
+ * quarter of the layer, the tile entire, or (R13, at the top edge
5350
+ * of the pane the window already stands on) the whole layer,
5351
+ * which is the one answer that is not a dock at all. The preview
5352
+ * draws exactly that rectangle, because a preview that promises a
5353
+ * half and delivers a quarter is worse than no preview.
5354
+ * commit put the window in the tree — or, for R13's maximise, leave it
5355
+ * floating and give it the layer. Over an EMPTY tile the dock is
5356
+ * unambiguous and happens on release. Over an OCCUPIED tile the
5357
+ * edges are unambiguous too — the drag chose a side, so the
5358
+ * side is the split — and only the CENTRE was ever genuinely a
5359
+ * question, which is why it is the zone that changed most.
5360
+ *
5361
+ * Built once and reused: the probe runs per pointermove and allocating a
5362
+ * closure per window per drag is free, but the memo keeps the identity
5363
+ * stable for anyone comparing controllers.
5364
+ */
5365
+ _snapController() {
5366
+ if (this._snapCtl) return this._snapCtl;
5367
+ this._snapCtl = {
5368
+ probe: (e, win) => this._snapProbe(e, win),
5369
+ commit: (probe, win) => this._snapCommit(probe, win)
5370
+ };
5371
+ return this._snapCtl;
5372
+ }
5373
+ /**
5374
+ * How close to a tile's edge the DRAGGED WINDOW'S matching edge must come
5375
+ * for a dock to arm — in PIXELS, and a narrow band. Since R15 it is also
5376
+ * the minimum distance the drag must have travelled toward that edge
5377
+ * inside the window's own pane; `_snapSide` argues both, and this is the
5378
+ * one constant either of them is measured in.
5379
+ *
5380
+ * This was a third of the tile, measured as a fraction, with the remaining
5381
+ * middle ninth treated as a fourth zone that offered a three-way choice.
5382
+ * Both halves of that were wrong, and together they made docking the
5383
+ * DEFAULT rather than a deliberate gesture:
5384
+ *
5385
+ * - A fraction means the band grows with the tile. On a maximised layer
5386
+ * a "third" is several hundred pixels, so a window could not be moved
5387
+ * anywhere near the left half of the screen without arming a split.
5388
+ * - The centre zone armed over the whole middle of every tile and
5389
+ * previewed the ENTIRE tile, so simply picking a window up and moving
5390
+ * it a few pixels lit the whole pane. Every move looked like a dock
5391
+ * because every move WAS one.
5392
+ *
5393
+ * Aero snap is an edge gesture: you push THE WINDOW at an edge — which is
5394
+ * what R15 finally made it measure. So the band is a fixed 28px from the
5395
+ * edge, and what lies past it is decided by the
5396
+ * pane rather than by the pointer: in the window's OWN pane the centre
5397
+ * arms nothing at all and the drop is simply a window that moved (R2), and
5398
+ * in any other pane it is the non-destructive tab or fill of R5/R6. The
5399
+ * band itself never grows with the tile, which is the whole of the fix.
5400
+ * Docking a whole tile is also still available without any drag at all —
5401
+ * the "back to tile" button in the window's own chrome, which names the
5402
+ * destination instead of guessing it.
5403
+ */
5404
+ static get SNAP_EDGE_PX() {
5405
+ return 28;
5406
+ }
5407
+ _snapProbe(e, win) {
5408
+ const leafEl = this._leafElAt(e.clientX, e.clientY, win);
5409
+ if (!leafEl) return null;
5410
+ const own = !!(win?.dragOrigin && leafEl.contains(win.dragOrigin));
5411
+ const leafId = leafEl.dataset.leafId;
5412
+ const desktopIdx = this.desktops.activeIdx;
5413
+ const leaf = this._tree().get(leafId);
5414
+ if (!leaf || leaf.kind !== "leaf") return null;
5415
+ if (String(leaf.content?.kind || "").startsWith("panel:")) return null;
5416
+ const r = leafEl.getBoundingClientRect();
5417
+ const side = leaf.content ? this._snapSide(r, e, win, own) : null;
5418
+ if (own && side === "top") {
5419
+ const home = this._homeDockTarget(win);
5420
+ if (!home) return null;
5421
+ return {
5422
+ key: `${home.leafId}:home`,
5423
+ rect: home.rect,
5424
+ leafId: home.leafId,
5425
+ desktopIdx,
5426
+ side,
5427
+ mode: "home",
5428
+ leafRect: r
5429
+ };
5430
+ }
5431
+ return this._dropZoneFor({ leafId, leaf, r, side, own, desktopIdx });
5432
+ }
5433
+ /**
5434
+ * R17 (C33). THE ZONE MATRIX'S TAIL — SPLIT / NOTHING / TAB / FILL — SHARED
5435
+ * BY THE TWO THINGS THAT CAN BE DROPPED ON A TILE.
5436
+ *
5437
+ * A dragged WINDOW and a dragged TAB ask the same question of a pane: given
5438
+ * that the pointer is in this leaf and the edge test answered `side`, what
5439
+ * would releasing here produce? Every answer below was written for the
5440
+ * window drop and every one of them is right for a tab, so this is an
5441
+ * extraction and not a generalisation — `_snapProbe` keeps everything ABOVE
5442
+ * it unchanged, including R14's `own && side === 'top'` home branch, which
5443
+ * is a window's alone (a tab has no window to bring back) and therefore
5444
+ * stays where it was, between the side computation and this call.
5445
+ *
5446
+ * The alternative was a second copy in `tabDropProbe`, and a second copy of
5447
+ * a matrix the product owner has already revised four times (R2, R4, R5/R6,
5448
+ * R14) is a guarantee that the two gestures will one day disagree about
5449
+ * what the centre of a start tile means. `web/js/shell/snap_zones.test.mjs`
5450
+ * in the Tables consumer asserts every cell of the window matrix and is the
5451
+ * regression gate on this extraction: byte-identical window behaviour is
5452
+ * the whole of its back-compatibility claim.
5453
+ */
5454
+ _dropZoneFor({ leafId, leaf, r, side, own, desktopIdx }) {
5455
+ if (side) {
5456
+ return {
5457
+ key: `${leafId}:${side}`,
5458
+ rect: _halfOf(r, side),
5459
+ leafId,
5460
+ desktopIdx,
5461
+ side,
5462
+ mode: "split",
5463
+ leafRect: r
5464
+ };
5465
+ }
5466
+ if (own) return null;
5467
+ const fills = !leaf.content || this._isStartTile(leaf);
5468
+ return {
5469
+ key: `${leafId}:${fills ? "fill" : "tab"}`,
5470
+ rect: _halfOf(r, null),
5471
+ leafId,
5472
+ desktopIdx,
5473
+ side: null,
5474
+ mode: fills ? "fill" : "tab",
5475
+ leafRect: r
5476
+ };
5477
+ }
5478
+ /**
5479
+ * R18 (C33). THE SAME PROBE, FOR A DRAGGED TAB — PUBLIC, because the
5480
+ * renderer is what holds the drag and the renderer is not the WM.
5481
+ *
5482
+ * ══ WHY THIS IS NOT `_snapProbe(e, null)` ═══════════════════════════
5483
+ *
5484
+ * It very nearly is, and the geometry underneath is literally the same
5485
+ * code: `_snapSide(r, e, null, false)` falls to the POINTER-distance branch
5486
+ * by construction — `_draggedRect(null)` is null, so `dist` takes the
5487
+ * `e.clientX/Y` arm and `along` scores every edge zero. A tab has no
5488
+ * rectangle being dragged and no `_dragState`, and that is not a gap to
5489
+ * paper over: the pointer IS the whole gesture for a tab, which is exactly
5490
+ * the pre-R15 model that `_snapSide`'s fallback preserves.
5491
+ *
5492
+ * Two things differ, and neither could be expressed by passing a null
5493
+ * window to `_snapProbe`:
5494
+ *
5495
+ * R14's HOME BRANCH IS A WINDOW'S. `own && side === 'top'` means "put the
5496
+ * window back in its tile", and a tab is already in a tile. Reaching that
5497
+ * branch with `win === null` would ask `_homeDockTarget(null)`, which
5498
+ * answers null, so the top edge of the source pane would fall silent
5499
+ * rather than split — a hole in the matrix produced by inheritance.
5500
+ *
5501
+ * A SINGLE-TAB SOURCE PANE ARMS NOTHING, ANYWHERE. `_dropZoneFor` already
5502
+ * silences the source pane's CENTRE; its edges are useful for a pane with
5503
+ * siblings ("tear this tab off into a split beside the others") and are a
5504
+ * wash for a pane with one tab, where the outcome is the pane's only
5505
+ * content in one half and a freshly seeded ground in the other. That is a
5506
+ * preview promising something no one wants, so it is refused BEFORE the
5507
+ * preview is drawn rather than at the drop — C15's rule is that the
5508
+ * rectangle drawn is the one released, and the honest way to keep it is
5509
+ * never to draw one.
5510
+ *
5511
+ * `own: false` is passed to `_snapSide` deliberately. Its `own` parameter
5512
+ * gates R2's direction guard, which measures a WINDOW's travel out of
5513
+ * `_dragState`; a tab drag has none, so `guarded` would be false anyway and
5514
+ * passing `true` would only obscure that. `own` still governs the centre,
5515
+ * which is why it goes to `_dropZoneFor` and not to `_snapSide`.
5516
+ *
5517
+ * @param {{clientX: number, clientY: number}} e the pointer, mid-drag
5518
+ * @param {{sourceLeafId?: string}} [opts] the leaf the tab left
5519
+ * @returns {object|null} the same probe shape a window drop produces
5520
+ */
5521
+ tabDropProbe(e, { sourceLeafId = null } = {}) {
5522
+ const leafEl = this._leafElAt(e.clientX, e.clientY, null);
5523
+ if (!leafEl) return null;
5524
+ const leafId = leafEl.dataset.leafId;
5525
+ const tree = this._tree();
5526
+ const leaf = tree.get(leafId);
5527
+ if (!leaf || leaf.kind !== "leaf") return null;
5528
+ if (String(leaf.content?.kind || "").startsWith("panel:")) return null;
5529
+ const own = !!sourceLeafId && leafId === sourceLeafId;
5530
+ if (own) {
5531
+ const src = tree.get(sourceLeafId);
5532
+ const count = Array.isArray(src?.tabs) ? src.tabs.length : 0;
5533
+ if (count <= 1) return null;
5534
+ }
5535
+ const r = leafEl.getBoundingClientRect();
5536
+ const side = leaf.content ? this._snapSide(r, e, null, false) : null;
5537
+ return this._dropZoneFor({
5538
+ leafId,
5539
+ leaf,
5540
+ r,
5541
+ side,
5542
+ own,
5543
+ desktopIdx: this.desktops.activeIdx
5544
+ });
5545
+ }
5546
+ /**
5547
+ * R15. WHICH EDGE OF THE PANE THE *WINDOW* IS BEING PUSHED INTO.
5548
+ *
5549
+ * ══ THE BUG THIS EXISTS TO FIX ═══════════════════════════════════════
5550
+ *
5551
+ * The band was measured from the POINTER, and the pointer is wherever the
5552
+ * hand happened to grab the title bar. Grab a 900px window in the middle
5553
+ * of its bar and shove it right: `dragBounds` clamps it, its right border
5554
+ * sits hard against the layer's right edge, and the pointer is still 450px
5555
+ * away from that edge — outside every band, so nothing arms and the window
5556
+ * simply stops dead against the side of the screen. Reported twice:
5557
+ * *"snapping enables based on mouse position but actually it needs to
5558
+ * enable based on the dragged window bounds (e.g. window right border
5559
+ * distance from right snapping area)"*.
5560
+ *
5561
+ * The bigger the window the worse it got, and the gesture only ever worked
5562
+ * if you happened to grab near the edge you were aiming at — the bottom
5563
+ * edge was effectively unreachable for any tall window, because a title bar
5564
+ * is at the TOP of the thing you are dragging.
5565
+ *
5566
+ * So each of the four distances is now between the window's own border and
5567
+ * the matching border of the pane. `right` arms when the window's right
5568
+ * border comes within the band of the pane's right border, and so on round.
5569
+ *
5570
+ * ══ WHAT DID *NOT* CHANGE ════════════════════════════════════════════
5571
+ *
5572
+ * WHICH PANE is still the pointer's answer (`_leafElAt`), and so is `own`.
5573
+ * The zone matrix is about a pane — the window's own pane means something
5574
+ * different from any other pane — and a window can lie across three of
5575
+ * them at once while the pointer is in exactly one. Only the question
5576
+ * *"which edge of THIS pane"* moved onto the window's rectangle; the
5577
+ * question *"which pane"* was never the one the product owner complained
5578
+ * about. Everything downstream is untouched: the preview is still
5579
+ * `_halfOf(paneRect, side)`, so the rectangle drawn is the rectangle the
5580
+ * drop delivers, and a `side` reaching the branches below means exactly
5581
+ * what it meant before.
5582
+ *
5583
+ * ══ SHORTFALL CLAMPED AT ZERO, BECAUSE A WINDOW OVERHANGS ═════════════
5584
+ *
5585
+ * (R16 corrects R15 here. R15 said *unsigned*, and unsigned was wrong;
5586
+ * the paragraph below is why, and `_snapSide` carries the measurement.)
5587
+ *
5588
+ * The pointer is inside the pane by construction — `_leafElAt` found the
5589
+ * pane by hit-testing it — so a signed distance was always positive. A
5590
+ * WINDOW has no such guarantee: it is clamped to the layer, not to the
5591
+ * pane, so a window wider than the pane under the pointer sticks out of
5592
+ * both sides of it and its border is 20px PAST the pane's border rather
5593
+ * than 20px short of it. Both readings are "hard against that edge".
5594
+ *
5595
+ * R15 spelled that `Math.abs`, and `Math.abs` only holds the reading while
5596
+ * the overhang stays inside the band. Past that it counts UP again, so the
5597
+ * zone armed and then DISARMED as the shove continued, and a window
5598
+ * meaningfully wider than the pane armed nothing at all. The right spelling
5599
+ * is a shortfall clamped at zero: **past the edge IS the edge**, at
5600
+ * distance zero, and it stays there however far the shove carries it.
5601
+ *
5602
+ * ══ THE DIRECTION GUARD, WHICH IS WHAT KEEPS R2 ALIVE ════════════════
5603
+ *
5604
+ * Edge-based testing has a failure the pointer never had: a window that is
5605
+ * ALREADY at an edge is in that band before the drag starts. A window
5606
+ * parked at the left of its pane would arm a left split on the first
5607
+ * millimetre of any drag, and a window that fills its pane would arm on
5608
+ * every drag in every direction — which is precisely the *"every move
5609
+ * looked like a dock because every move WAS one"* failure `SNAP_EDGE_PX`
5610
+ * was written to end, arriving from the other direction.
5611
+ *
5612
+ * So in the window's OWN pane an edge arms only if the drag actually
5613
+ * carried the window at it: the pointer must have travelled more than one
5614
+ * band's width toward that edge since the press. A nudge (R2's complaint,
5615
+ * and the surviving reason the own-pane centre is silent) moves a handful
5616
+ * of pixels and arms nothing; a shove moves hundreds and arms the edge it
5617
+ * was aimed at. The band's own width is the unit, because a movement
5618
+ * smaller than the band cannot be the difference between being in it and
5619
+ * not.
5620
+ *
5621
+ * IN ANY OTHER PANE THE GUARD IS OFF, deliberately. R2 is a rule about the
5622
+ * pane a window already lives on — the only place a "nudge" exists. Drag a
5623
+ * window rightwards out of pane A and into pane B and its LEFT border is
5624
+ * what enters pane B first: with the guard on, aiming at the left half of
5625
+ * the pane to your right would be impossible, since arriving there always
5626
+ * means travelling right. The window is translucent by then (R3) and every
5627
+ * drop on a foreign pane docks, so there is no nudge to protect.
5628
+ *
5629
+ * The displacement is read from `ManagedWindow._dragState.startX/startY`,
5630
+ * the POINTER's position at the press — not from the window's own x/y,
5631
+ * which stop changing the moment the clamp bites while the gesture very
5632
+ * much continues. A caller with no drag state (a synthetic probe, an
5633
+ * embedder driving the controller by hand) yields no displacement at all
5634
+ * and the guard is skipped rather than failing closed: it can only ever
5635
+ * suppress an edge, never invent one.
5636
+ *
5637
+ * ══ CORNERS: THE PRECEDENCE, MADE EXPLICIT ═══════════════════════════
5638
+ *
5639
+ * Two edges can be in range at once, and with window borders that is no
5640
+ * longer the rarity it was with a pointer — shove a window into a corner
5641
+ * and the clamp puts BOTH borders at distance zero, exactly. Under R16 it
5642
+ * is not even a corner case: a window as wide as its pane is at zero on
5643
+ * the left AND the right for every horizontal position it can occupy, and
5644
+ * a floated canvas pane's window is *exactly* that wide. So the order is
5645
+ * stated rather than left to whichever way the loop happens to run:
5646
+ *
5647
+ * 1. NEAREST WINS. Unchanged from R4, and it is what keeps a corner from
5648
+ * being a dead spot: one of the two is always closer.
5649
+ * 2. ON A TIE, THE EDGE THE DRAG PUSHED TOWARD WINS. (R16: *toward that
5650
+ * edge*, signed — R15 said "the axis pushed furthest" and spelled it
5651
+ * `Math.abs`, which gives the two ends of one axis the SAME score, so
5652
+ * a left/right tie never broke at all and 'left' won every time by
5653
+ * loop order.) The honest tie-break is the gesture: shove it
5654
+ * rightwards and you get the right zone, upwards and you get the top
5655
+ * zone. Every zone stays reachable and which one you get is something
5656
+ * a hand can aim.
5657
+ * 3. STILL TIED — a perfect diagonal, or a probe with no drag state —
5658
+ * falls to the fixed order left, right, top, bottom. That is the order
5659
+ * the R4 loop already resolved ties in (`Object.entries` insertion
5660
+ * order, with a strict `<`), kept so the pointer fallback below
5661
+ * answers exactly what it answered before.
5662
+ *
5663
+ * ══ THE FALLBACK ═════════════════════════════════════════════════════
5664
+ *
5665
+ * With no measurable window rectangle — no element, detached, or a box of
5666
+ * zero area because layout has not happened — there is nothing to measure
5667
+ * and the pointer is the only information in the room. That path is the
5668
+ * pre-R15 code, unchanged, signed distances and all. It is what a headless
5669
+ * probe gets (jsdom lays nothing out, so every `getBoundingClientRect` is
5670
+ * zero), and it is why `web/js/shell/snap_zones.test.mjs` in the Tables
5671
+ * consumer still asserts the same matrix against the same coordinates.
5672
+ *
5673
+ * @param {DOMRect} r the pane, in viewport pixels
5674
+ * @param {{clientX: number, clientY: number}} e the pointer
5675
+ * @param {object} win the ManagedWindow being dragged
5676
+ * @param {boolean} own is `r` the pane this window's drag escaped?
5677
+ * @returns {'left'|'right'|'top'|'bottom'|null}
5678
+ */
5679
+ _snapSide(r, e, win, own) {
5680
+ const edge = _WindowManager.SNAP_EDGE_PX;
5681
+ const w = this._draggedRect(win);
5682
+ const push = w ? this._dragPush(e, win) : null;
5683
+ const guarded = !!(own && push);
5684
+ const dist = w ? {
5685
+ left: Math.max(0, w.left - r.left),
5686
+ right: Math.max(0, r.right - w.right),
5687
+ top: Math.max(0, w.top - r.top),
5688
+ bottom: Math.max(0, r.bottom - w.bottom)
5689
+ } : {
5690
+ left: e.clientX - r.left,
5691
+ right: r.right - e.clientX,
5692
+ top: e.clientY - r.top,
5693
+ bottom: r.bottom - e.clientY
5694
+ };
5695
+ const toward = (name) => {
5696
+ if (!guarded) return true;
5697
+ if (name === "left") return push.x <= -edge;
5698
+ if (name === "right") return push.x >= edge;
5699
+ if (name === "top") return push.y <= -edge;
5700
+ return push.y >= edge;
5701
+ };
5702
+ const along = (name) => {
5703
+ const dx = push?.x ?? 0, dy = push?.y ?? 0;
5704
+ if (name === "left") return -dx;
5705
+ if (name === "right") return dx;
5706
+ if (name === "top") return -dy;
5707
+ return dy;
5708
+ };
5709
+ let best = null;
5710
+ for (const name of ["left", "right", "top", "bottom"]) {
5711
+ const d = dist[name];
5712
+ if (!(d < edge) || !toward(name)) continue;
5713
+ const p = along(name);
5714
+ if (!best || d < best.d || d === best.d && p > best.p) best = { name, d, p };
5715
+ }
5716
+ return best ? best.name : null;
5717
+ }
5718
+ /** R15. The dragged window's rectangle in VIEWPORT pixels — the frame a
5719
+ * leaf's `getBoundingClientRect` speaks, so the two are directly
5720
+ * comparable — or null when there is nothing to measure.
5721
+ *
5722
+ * Read off the element rather than computed from `win.x/y/width/height`,
5723
+ * because those are in whatever container the window is currently parented
5724
+ * to and mid-drag that is the drag host, not the pane being probed.
5725
+ *
5726
+ * A zero-area box is "nothing to measure" rather than a rectangle at the
5727
+ * origin: it is what an unlaid-out document gives, and treating it as real
5728
+ * would put every window in the top-left corner of every pane. Same test
5729
+ * `_homeDockTarget` applies to a leaf, for the same reason. */
5730
+ _draggedRect(win) {
5731
+ const el = win?.element;
5732
+ if (!el || el.isConnected === false) return null;
5733
+ if (typeof el.getBoundingClientRect !== "function") return null;
5734
+ const b = el.getBoundingClientRect();
5735
+ if (!b || !b.width || !b.height) return null;
5736
+ return b;
5737
+ }
5738
+ /** R15. How far the POINTER has travelled since the press that began this
5739
+ * drag, or null if this window is not in a drag the WM can see.
5740
+ *
5741
+ * The pointer rather than the window: `_applyPosition` clamps the window
5742
+ * to `dragBounds`, so a window shoved at the edge of the layer stops
5743
+ * moving while the gesture continues — and "it stopped because it is
5744
+ * against the edge" is exactly the situation the guard must not read as
5745
+ * "it is not being pushed". */
5746
+ _dragPush(e, win) {
5747
+ const ds = win?._dragState;
5748
+ if (!ds || typeof ds.startX !== "number" || typeof ds.startY !== "number") return null;
5749
+ return { x: e.clientX - ds.startX, y: e.clientY - ds.startY };
5750
+ }
5751
+ /** The topmost `.twm-leaf` under the pointer that is not part of the window
5752
+ * being dragged. `elementsFromPoint` rather than `elementFromPoint`: the
5753
+ * dragged window IS under the pointer — it is what the pointer is holding
5754
+ * — and a hit-test that stops at the first element only ever finds it.
5755
+ *
5756
+ * R15 left this alone on purpose: WHICH pane is still the pointer's
5757
+ * answer, and only WHICH EDGE of it moved onto the window's borders. See
5758
+ * `_snapSide`. */
5759
+ _leafElAt(x, y, win) {
5760
+ const stack = document.elementsFromPoint(x, y);
5761
+ for (const el of stack) {
5762
+ if (win?.element && win.element.contains(el)) continue;
5763
+ const leafEl = el.closest?.(".twm-leaf");
5764
+ if (leafEl && this.rootEl.contains(leafEl)) return leafEl;
5765
+ }
5766
+ return null;
3538
5767
  }
3539
- /** Re-home a managed window to another desktop. The window itself
3540
- * stays on screen (managed windows are global) and leaves no tile
3541
- * behind on either desktop; only its "home" changes, so bringing it
3542
- * back will land on the new desktop's primary tile. */
5768
+ /**
5769
+ * R14. THE TILE "BACK TO TILE" WOULD PUT THIS WINDOW IN, and the rectangle
5770
+ * that draws it in the VIEWPORT pixels a snap preview is positioned in.
5771
+ *
5772
+ * `bringBackWindow` resolves its destination privately and then closes the
5773
+ * window to reach it, which is everything a button press needs and useless
5774
+ * to a PREVIEW. C15's rule is that the rectangle drawn during a drag is the
5775
+ * one the drop delivers, and under R14 the drop delivers A TILE — so the
5776
+ * resolution has to be readable before the gesture is committed. Reading it
5777
+ * out here is what makes the top edge honest: the probe draws what this
5778
+ * returns and `_snapCommit` calls `bringBackWindow`, which resolves the
5779
+ * same way, from the same record, against the same tree.
5780
+ *
5781
+ * It is deliberately NOT a second copy of that resolution reduced to "the
5782
+ * home leaf". `bringBackWindow`'s fall-through — no home leaf, or one the
5783
+ * tree no longer has — is the desktop's PRIMARY tile, which is where
5784
+ * `_onManagedWindowClosed` sends a demotion carrying no `_dock`; a probe
5785
+ * that previewed the home leaf and then landed in the primary tile would be
5786
+ * the bait-and-switch with extra steps.
5787
+ *
5788
+ * `renderer.leafEl` only knows the leaves of the desktop currently on
5789
+ * screen, which is the property that makes the desktop check implicit: a
5790
+ * window whose home is on another desktop resolves to no element, this
5791
+ * answers null, and the top edge arms nothing rather than previewing a
5792
+ * rectangle on a desktop the user cannot see.
5793
+ *
5794
+ * @param {object} win a live ManagedWindow
5795
+ * @returns {{leafId: string, rect: DOMRect}|null} null when there is
5796
+ * nothing honest to promise: a window the WM never adopted, a desktop
5797
+ * that has gone, a tree with no content leaf at all, or a leaf with no
5798
+ * measurable box (a layout that has not happened yet).
5799
+ */
5800
+ _homeDockTarget(win) {
5801
+ const rec = [...this._windowToLeaf.values()].find((r) => r.window === win);
5802
+ if (!rec) return null;
5803
+ const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
5804
+ if (!tree) return null;
5805
+ const home = rec.homeLeafId ? tree.get(rec.homeLeafId) : null;
5806
+ const leafId = home && home.kind === "leaf" ? rec.homeLeafId : tree.primaryLeafId();
5807
+ if (!leafId) return null;
5808
+ const el = this.renderer.leafEl(leafId);
5809
+ if (!el) return null;
5810
+ const rect = el.getBoundingClientRect();
5811
+ if (!rect.width || !rect.height) return null;
5812
+ return { leafId, rect };
5813
+ }
5814
+ _snapCommit(probe, win) {
5815
+ if (!probe) return false;
5816
+ const rec = [...this._windowToLeaf.entries()].find(([, r]) => r.window === win);
5817
+ if (!rec) return false;
5818
+ const [winId, record] = rec;
5819
+ const { leafId, desktopIdx, side } = probe;
5820
+ if (probe.mode === "home") {
5821
+ if (!this.bringBackWindow(winId)) return false;
5822
+ win.clearSnapPreview();
5823
+ return true;
5824
+ }
5825
+ if (typeof desktopIdx === "number") record.desktopIdx = desktopIdx;
5826
+ const docked = this.dockWindowInto(winId, {
5827
+ leafId,
5828
+ mode: probe.mode || "split",
5829
+ dir: side === "left" || side === "right" ? "h" : "v",
5830
+ before: side === "left" || side === "top"
5831
+ });
5832
+ if (!docked) return false;
5833
+ win.clearSnapPreview();
5834
+ return true;
5835
+ }
5836
+ /**
5837
+ * Put a floating window's content back into the tree at a NAMED place.
5838
+ *
5839
+ * `bringBackWindow` is this with `{ mode: 'tab' }` against the primary tile
5840
+ * — the answer when the user pressed a button in the window's own chrome
5841
+ * and named no destination. A drop names one.
5842
+ *
5843
+ * The window is CLOSED to do it, exactly as a demote is: the content
5844
+ * factory re-mounts inside the tile, and a factory that must not lose live
5845
+ * state across that boundary is the embedder's problem to solve (it is why
5846
+ * the registry is keyed on (kind, props) rather than on a DOM node).
5847
+ *
5848
+ * @param {string} windowId
5849
+ * @param {{leafId: string, mode: 'fill'|'tab'|'split', dir?: 'h'|'v',
5850
+ * before?: boolean}} target
5851
+ */
5852
+ dockWindowInto(windowId, target) {
5853
+ const rec = this._windowToLeaf.get(windowId);
5854
+ if (!rec || !target?.leafId) return false;
5855
+ rec._dock = { ...target };
5856
+ rec._demoting = true;
5857
+ try {
5858
+ rec.window.close({ force: true });
5859
+ } catch (err) {
5860
+ console.warn("[wm] dock: close failed", err);
5861
+ return false;
5862
+ }
5863
+ return true;
5864
+ }
5865
+ /**
5866
+ * Move a managed window to another desktop.
5867
+ *
5868
+ * ══ IT USED TO REWRITE ONE INTEGER, AND THAT MOVED NOTHING ═════════
5869
+ *
5870
+ * The sentence that stood here — *"the window itself stays on screen
5871
+ * (managed windows are global)"* — was true of a window floating over the
5872
+ * root and false of every window this WM promotes under `promoteInPlace`,
5873
+ * which is CONTAINED IN A TILE (C21). Rewriting `desktopIdx` left such a
5874
+ * window standing in the pane it was already in, on the page the user was
5875
+ * already looking at: *Move to desktop Views* appeared to do nothing at
5876
+ * all. Then *Back to tile* resolved against the new desktop's tree and
5877
+ * docked the content onto a page nobody was watching, so the window
5878
+ * vanished here and its table turned up over there.
5879
+ *
5880
+ * Three things move it for real. The index, so every later resolution
5881
+ * agrees. The HOME LEAF, re-pointed at a ground that exists in the
5882
+ * destination — without it the record names a leaf of the tree it just
5883
+ * left, and `_rehomeContainedWindows` would either skip it forever or
5884
+ * repair it to a pane on the wrong page. And a render, which is where the
5885
+ * element is taken off the page the window has left (or parented into its
5886
+ * new ground, when the destination is the desktop on screen).
5887
+ *
5888
+ * An ADOPTED window (`homeContainer`) keeps its own resolution: the
5889
+ * embedder owns the box it stands in, and re-pointing a leaf id it does not
5890
+ * read would be a change with no effect wearing the look of one.
5891
+ */
3543
5892
  moveWindowToDesktop(windowId, targetIdx) {
3544
5893
  const rec = this._windowToLeaf.get(windowId);
3545
5894
  if (!rec) return;
3546
5895
  if (rec.desktopIdx === targetIdx) return;
3547
5896
  this.desktops.ensureCount(targetIdx + 1);
3548
5897
  rec.desktopIdx = targetIdx;
5898
+ if (!rec.homeContainer && rec.homeLeafId) {
5899
+ const target = this.desktops.desktops[targetIdx]?.tree;
5900
+ rec.homeLeafId = target?.primaryLeafId() || null;
5901
+ }
5902
+ this.renderer.render();
3549
5903
  this._persist();
3550
5904
  this._notifyChange("window-moved");
3551
5905
  }
5906
+ /**
5907
+ * R8. Put a floated pane's tabs back into a leaf — ALL of them, in the
5908
+ * order they had, with the one that was showing still showing.
5909
+ *
5910
+ * Every dock goes through here, and that is the point: `bringBackWindow`,
5911
+ * a drop on a tile's body, a drop on an edge and the fall-through when the
5912
+ * named destination vanished are four routes to one question — *where do
5913
+ * these tabs go* — and four copies of the answer would disagree about the
5914
+ * third one within a release. A window promoted before R8 (or by
5915
+ * `_navigateWindow`, which never had tabs) carries no list, so `original`
5916
+ * is the fallback and the single-tab path reduces to exactly what this
5917
+ * replaced.
5918
+ *
5919
+ * `replace` is the difference between filling a leaf and joining one: a
5920
+ * fresh split leaf and a `fill` drop want the first tab to BECOME the
5921
+ * leaf's content, while a `tab` drop and "back to tile" append beside what
5922
+ * is already there.
5923
+ */
5924
+ _restoreTabs(tree, leafId, rec, { replace }) {
5925
+ if (!leafId) return false;
5926
+ const tabs = Array.isArray(rec.tabs) && rec.tabs.length ? rec.tabs : [{
5927
+ kind: rec.original.kind,
5928
+ props: rec.original.props,
5929
+ title: rec.original.title
5930
+ }];
5931
+ const active = Math.max(0, Math.min(tabs.length - 1, rec.activeTabIdx || 0));
5932
+ let firstIdx = -1;
5933
+ tabs.forEach((tab, i) => {
5934
+ const content = { kind: tab.kind, props: tab.props || {} };
5935
+ const title = tab.title || tab.kind || "";
5936
+ if (i === 0 && replace) {
5937
+ tree.setLeafContent(leafId, content, title);
5938
+ firstIdx = 0;
5939
+ return;
5940
+ }
5941
+ const at = tree.appendLeafTab(leafId, content, title);
5942
+ if (at >= 0 && firstIdx < 0) firstIdx = at;
5943
+ });
5944
+ if (firstIdx < 0) return false;
5945
+ tree.setActiveLeafTab(leafId, firstIdx + active);
5946
+ tree.focus(leafId);
5947
+ return true;
5948
+ }
3552
5949
  _onManagedWindowClosed(winId, mountInfo) {
3553
5950
  const rec = this._windowToLeaf.get(winId);
3554
5951
  this._windowToLeaf.delete(winId);
3555
5952
  if (!rec) return;
3556
5953
  try {
3557
- mountInfo?.destroy?.();
5954
+ (rec.mountInfo || mountInfo)?.destroy?.();
5955
+ } catch {
5956
+ }
5957
+ try {
5958
+ rec.strip?.dispose();
3558
5959
  } catch {
3559
5960
  }
3560
5961
  const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
3561
5962
  if (!tree) return;
3562
- if (rec._demoting) {
5963
+ if (rec._demoting && rec._dock) {
5964
+ const target = tree.get(rec._dock.leafId);
5965
+ if (target && target.kind === "leaf") {
5966
+ const { mode, dir, before } = rec._dock;
5967
+ if (mode === "tab") {
5968
+ this._restoreTabs(tree, rec._dock.leafId, rec, { replace: false });
5969
+ } else if (mode === "split") {
5970
+ const newId = tree.split(rec._dock.leafId, dir);
5971
+ if (newId) {
5972
+ this._restoreTabs(tree, newId, rec, { replace: true });
5973
+ _halveInto(tree, rec._dock.leafId, newId);
5974
+ if (before) _swapSiblings(tree, rec._dock.leafId, newId);
5975
+ tree.focus(newId);
5976
+ }
5977
+ } else {
5978
+ this._restoreTabs(tree, rec._dock.leafId, rec, { replace: true });
5979
+ }
5980
+ } else {
5981
+ this._restoreTabs(tree, tree.primaryLeafId(), rec, { replace: false });
5982
+ }
5983
+ } else if (rec._demoting) {
3563
5984
  let pid = tree.primaryLeafId();
3564
5985
  let spawned = false;
3565
5986
  if (!pid) {
@@ -3567,12 +5988,7 @@ var WindowManager = class {
3567
5988
  spawned = true;
3568
5989
  }
3569
5990
  if (pid) {
3570
- tree.appendLeafTab(
3571
- pid,
3572
- { kind: rec.original.kind, props: rec.original.props },
3573
- rec.original.title
3574
- );
3575
- tree.focus(pid);
5991
+ this._restoreTabs(tree, pid, rec, { replace: false });
3576
5992
  if (spawned) this._canonicalize(tree, this.desktops.desktops[rec.desktopIdx]);
3577
5993
  }
3578
5994
  }
@@ -3580,24 +5996,26 @@ var WindowManager = class {
3580
5996
  this._persist();
3581
5997
  this._notifyChange(rec._demoting ? "window-demoted" : "window-closed");
3582
5998
  }
3583
- /** Post-show DOM hook: inject a "back to tile" button into the
3584
- * window chrome and wire a right-click context menu on the topbar. */
5999
+ /** Post-show DOM hook: wire a right-click context menu on the topbar.
6000
+ *
6001
+ * R7. IT USED TO INJECT A BUTTON HERE, and that is the whole of what
6002
+ * changed. "Back to tile" was a fourth button squeezed left of Close,
6003
+ * built by reaching into four of ManagedWindow's internal class names —
6004
+ * the coupling C6 exists to avoid — and it sat next to a MAXIMIZE button
6005
+ * that did the one thing a window lifted out of a tile has no use for.
6006
+ * Now the maximize button IS "back to tile" (`onMaximize`, passed where
6007
+ * the window is built), so the verb has one control instead of two and
6008
+ * this hook has no markup of its own to keep in step.
6009
+ *
6010
+ * Gone with it: the rule that hid MINIMIZE while the window was maximised.
6011
+ * It existed because minimising a full-screen window strands it — nothing
6012
+ * on screen points at it any more — and a window that cannot maximise
6013
+ * cannot be in that state at all. `managed-window-maximized` (C16) still
6014
+ * fires for everyone else, and `--suppressed` is still styled for the next
6015
+ * consumer that needs to hide one of these. */
3585
6016
  _decorateManagedWindow(win, winId) {
3586
6017
  const topbar = win.element?.querySelector?.(".twm-managed-window__topbar");
3587
- const buttons = topbar?.querySelector?.(".twm-managed-window__buttons");
3588
- if (!buttons) return;
3589
- const backBtn = document.createElement("button");
3590
- backBtn.type = "button";
3591
- backBtn.className = "twm-managed-window__btn managed-window__btn--demote";
3592
- backBtn.title = "Back to tile";
3593
- backBtn.innerHTML = `<span class="material-symbols-outlined" style="font-size:14px">close_fullscreen</span>`;
3594
- backBtn.addEventListener("click", (e) => {
3595
- e.stopPropagation();
3596
- this.bringBackWindow(winId);
3597
- });
3598
- const closeBtn = buttons.querySelector(".twm-managed-window__btn--close");
3599
- if (closeBtn) buttons.insertBefore(backBtn, closeBtn);
3600
- else buttons.appendChild(backBtn);
6018
+ if (!topbar) return;
3601
6019
  topbar.addEventListener("contextmenu", (e) => {
3602
6020
  e.preventDefault();
3603
6021
  const rec = this._windowToLeaf.get(winId);
@@ -3636,6 +6054,135 @@ var WindowManager = class {
3636
6054
  });
3637
6055
  });
3638
6056
  }
6057
+ // ══ R10. Adjacent start tiles are one start tile ═══════════════════
6058
+ /**
6059
+ * Merge every run of side-by-side START TILES into one.
6060
+ *
6061
+ * A start tile is a leaf holding the taxonomy ROOT — the pane a fresh
6062
+ * desktop opens with, and the pane `_seedHome` puts back when a tile is
6063
+ * emptied. It is not a document: it is the ground, the empty canvas, the
6064
+ * "nothing is open here" surface. So two of them side by side are ONE
6065
+ * surface with a splitter drawn through it for no reason, and the splitter
6066
+ * is worse than decoration — it offers to resize a boundary between two
6067
+ * things that are the same thing.
6068
+ *
6069
+ * This is deliberately NOT run on every tree change, and the reason is
6070
+ * `split()`: splitting a start tile seeds the new pane with the root kind
6071
+ * too (the never-empty-tile invariant), so a merge on every mutation would
6072
+ * undo an Alt+H the instant it happened. It runs where the product owner
6073
+ * put it — *"when a maximized (tiled) panel is window-ized, all adjacent
6074
+ * non-panel (start tile) tiles get merged to one"* — and is public so an
6075
+ * embedder that empties a pane its own way can ask for the same tidy-up.
6076
+ *
6077
+ * THREE THINGS ARE NEVER MERGED, and each one is a way to lose work:
6078
+ *
6079
+ * - `panel:*` leaves. They are chrome, not content; the navigator is not
6080
+ * a start tile and a panel BETWEEN two start tiles means those two are
6081
+ * not adjacent.
6082
+ * - A start tile with WINDOWS STANDING ON IT. The whole point of the
6083
+ * surface is that things float on it, and closing the leaf takes its
6084
+ * ground — and every window clamped to it — out of the document. When
6085
+ * one of a pair is occupied the other merges INTO it; when both are,
6086
+ * neither moves.
6087
+ * - A start tile holding tabs, live or archived. `leaf.content.kind`
6088
+ * names the ACTIVE tab only, and a pane whose other tabs are tables, or
6089
+ * whose `pageTabs` archive holds the three tables a rail click put
6090
+ * there, is a pane with work in it wearing a start tile's face.
6091
+ *
6092
+ * @param {TileTree} tree
6093
+ * @param {string|null} preferLeafId the leaf to keep when a run is
6094
+ * otherwise a free choice — the pane the caller just emptied, so the
6095
+ * merged surface is the one the user is looking at.
6096
+ * @returns {number} how many leaves were absorbed.
6097
+ */
6098
+ _mergeStartTiles(tree, preferLeafId = null) {
6099
+ if (!tree) return 0;
6100
+ let absorbed = 0;
6101
+ for (; ; ) {
6102
+ const pair = this._nextMergeablePair(tree, preferLeafId);
6103
+ if (!pair) break;
6104
+ const { split, keepIdx, dropIdx, keepId, dropId } = pair;
6105
+ split.sizes[keepIdx] = (split.sizes[keepIdx] || 1) + (split.sizes[dropIdx] || 1);
6106
+ const hadFocus = tree.focusedLeafId === dropId;
6107
+ tree.close(dropId);
6108
+ if (tree.nodes.has(dropId)) break;
6109
+ if (hadFocus) tree.focus(keepId);
6110
+ absorbed += 1;
6111
+ }
6112
+ return absorbed;
6113
+ }
6114
+ /** The first two adjacent start tiles that may be merged, and which of
6115
+ * them survives. Null when there are none. */
6116
+ _nextMergeablePair(tree, preferLeafId) {
6117
+ for (const node of [...tree.nodes.values()]) {
6118
+ if (node.kind !== "split") continue;
6119
+ if (!tree.nodes.has(node.id)) continue;
6120
+ for (let i = 0; i < node.children.length - 1; i += 1) {
6121
+ const a = tree.get(node.children[i]);
6122
+ const b = tree.get(node.children[i + 1]);
6123
+ if (!this._isStartTile(a) || !this._isStartTile(b)) continue;
6124
+ const aHolds = this._paneHoldsWindows(a.id);
6125
+ const bHolds = this._paneHoldsWindows(b.id);
6126
+ if (aHolds && bHolds) continue;
6127
+ let keepIdx = i;
6128
+ if (bHolds) keepIdx = i + 1;
6129
+ else if (!aHolds && node.children[i + 1] === preferLeafId) keepIdx = i + 1;
6130
+ const dropIdx = keepIdx === i ? i + 1 : i;
6131
+ return {
6132
+ split: node,
6133
+ keepIdx,
6134
+ dropIdx,
6135
+ keepId: node.children[keepIdx],
6136
+ dropId: node.children[dropIdx]
6137
+ };
6138
+ }
6139
+ }
6140
+ return null;
6141
+ }
6142
+ /** Is this leaf the empty ground and nothing else? See the three
6143
+ * exclusions in `_mergeStartTiles`. */
6144
+ _isStartTile(leaf) {
6145
+ if (!leaf || leaf.kind !== "leaf") return false;
6146
+ const kind = leaf.content?.kind;
6147
+ if (!kind || kind !== this.taxonomy.root) return false;
6148
+ if (PANEL_KINDS.has(kind) || kind === PLACEHOLDER_KIND) return false;
6149
+ if ((Array.isArray(leaf.tabs) ? leaf.tabs.length : 0) > 1) return false;
6150
+ for (const page of Object.values(leaf.pageTabs || {})) {
6151
+ if (Array.isArray(page?.tabs) && page.tabs.length) return false;
6152
+ }
6153
+ return true;
6154
+ }
6155
+ /**
6156
+ * Does anything float on this pane?
6157
+ *
6158
+ * Two sources, because there are two kinds of window and the WM only knows
6159
+ * about one of them. `homeLeafId` is set for a window this WM contained in
6160
+ * its own pane (C21); an EMBEDDER's windows — a canvas pane that opens its
6161
+ * own `ManagedWindow` against the pane's ground — are not in
6162
+ * `_windowToLeaf` at all, and the only honest way to see them is to look.
6163
+ * The DOM answer covers both, and covers a window whose record has been
6164
+ * dropped but whose element is still standing.
6165
+ */
6166
+ _paneHoldsWindows(leafId) {
6167
+ for (const [, rec] of this._windowToLeaf) {
6168
+ if (rec.homeLeafId === leafId) return true;
6169
+ }
6170
+ const el = this.renderer.leafEl?.(leafId);
6171
+ return !!el?.querySelector?.(".twm-managed-window");
6172
+ }
6173
+ /** R10, as a verb an embedder can use. Merges, then repaints and persists
6174
+ * — the promote path calls `_mergeStartTiles` directly because it is
6175
+ * already going to do all three. */
6176
+ mergeStartTiles(preferLeafId = null) {
6177
+ const tree = this._tree();
6178
+ const absorbed = this._mergeStartTiles(tree, preferLeafId);
6179
+ if (!absorbed) return 0;
6180
+ this._canonicalize(tree, this.desktops.active());
6181
+ this.renderer.render();
6182
+ this._persist();
6183
+ this._notifyChange("start-tiles-merged");
6184
+ return absorbed;
6185
+ }
3639
6186
  // ── Panel-tiles (left nav / right / bottom) ─────────────────────
3640
6187
  /** Panels are virtual: they live in the active desktop's tree as
3641
6188
  * leaves with content kinds 'panel:left', 'panel:right',
@@ -3800,12 +6347,42 @@ var WindowManager = class {
3800
6347
  const next = (cur + (dir < 0 ? -1 : 1) + tabs.length) % tabs.length;
3801
6348
  this._leafTabAction(leafId, "switch", { idx: next });
3802
6349
  }
6350
+ /**
6351
+ * Remove a desktop — AND RE-INDEX THE WINDOWS, which is the half that was
6352
+ * missing.
6353
+ *
6354
+ * `desktopIdx` on a window record is an ARRAY INDEX into `desktops`, so a
6355
+ * splice silently re-points every record above the removed one at its
6356
+ * neighbour. Nothing threw and nothing looked wrong: the window kept
6357
+ * floating, and the next *Back to tile* resolved `rec._dock` against the
6358
+ * WRONG TREE. `_onManagedWindowClosed` reads `desktops[rec.desktopIdx]`,
6359
+ * finds a tree that never held this window, and its `if (!tree) return`
6360
+ * closes the window and drops the content on the floor — staged edits
6361
+ * included, with no error and nothing on screen to say a table was lost.
6362
+ *
6363
+ * Windows homed on the desktop being removed do not die with it. The
6364
+ * ruling is that a tile operation may move a window and never destroy it,
6365
+ * and removing a desktop is the largest tile operation there is: they come
6366
+ * across to the desktop that ends up active, re-homed onto its ground by
6367
+ * `_rehomeContainedWindows` on the render below.
6368
+ */
3803
6369
  removeDesktop(idx) {
3804
6370
  const m = this.desktops;
3805
6371
  if (m.desktops.length <= 1) return false;
3806
6372
  if (idx < 0 || idx >= m.desktops.length) return false;
3807
6373
  m.desktops.splice(idx, 1);
6374
+ if (idx < m.activeIdx) m.activeIdx -= 1;
3808
6375
  if (m.activeIdx >= m.desktops.length) m.activeIdx = m.desktops.length - 1;
6376
+ for (const [, rec] of this._windowToLeaf) {
6377
+ if (rec.desktopIdx === idx) {
6378
+ rec.desktopIdx = m.activeIdx;
6379
+ if (!rec.homeContainer && rec.homeLeafId) {
6380
+ rec.homeLeafId = m.active().tree.primaryLeafId() || null;
6381
+ }
6382
+ } else if (rec.desktopIdx > idx) {
6383
+ rec.desktopIdx -= 1;
6384
+ }
6385
+ }
3809
6386
  this.renderer.tree = m.active().tree;
3810
6387
  this.renderer.render();
3811
6388
  this._persist();
@@ -3816,6 +6393,8 @@ var WindowManager = class {
3816
6393
  const tree = this._tree();
3817
6394
  const focused = tree.focused();
3818
6395
  if (!focused || !focused.content) return;
6396
+ const closeChrome = this.renderer?.leafChrome?.(focused.id)?.close;
6397
+ if (closeChrome === false || closeChrome?.disabled === true) return;
3819
6398
  const payload = { kind: focused.content.kind, props: focused.content.props, title: focused.title };
3820
6399
  this.desktops.ensureCount(idx + 1);
3821
6400
  const target = this.desktops.desktops[idx];
@@ -3824,6 +6403,11 @@ var WindowManager = class {
3824
6403
  if (primary) targetTree.setLeafContent(primary, { kind: payload.kind, props: payload.props }, payload.title);
3825
6404
  tree.close(focused.id);
3826
6405
  if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
6406
+ if (!tree.leaves().some((l) => !String(l.content?.kind || "").startsWith("panel:"))) {
6407
+ const spawned = this._spawnContentLeaf(tree);
6408
+ if (spawned) this._seedHome(tree, spawned);
6409
+ }
6410
+ this._canonicalize(tree, this.desktops.active());
3827
6411
  this.renderer.render();
3828
6412
  this._persist();
3829
6413
  this._notifyChange();
@@ -3880,6 +6464,14 @@ var WindowManager = class {
3880
6464
  this._showTabContextMenu(leafId, data.idx, data.x, data.y);
3881
6465
  return;
3882
6466
  }
6467
+ if (action === "to-window") {
6468
+ this.floatTabAsWindow(leafId, data.idx);
6469
+ return;
6470
+ }
6471
+ if (action === "drop-into") {
6472
+ this.moveTabInto(leafId, data.idx, data.target);
6473
+ return;
6474
+ }
3883
6475
  if (action === "close-others") {
3884
6476
  tree.closeOtherTabs(leafId, data.idx);
3885
6477
  this.renderer.render();
@@ -4031,6 +6623,33 @@ var WindowManager = class {
4031
6623
  canMaximize: true,
4032
6624
  canResize: true,
4033
6625
  modal: false,
6626
+ // C15, and this is the SECOND of the two places the WM builds a
6627
+ // window. Alt+N and "Open in new window" produce a window that is
6628
+ // every bit as dockable as a promoted one, and a window that can be
6629
+ // dragged onto a tile in one case and not the other is a rule
6630
+ // nobody can learn.
6631
+ snap: this.snapPromotion,
6632
+ snapController: this.snapPromotion ? this._snapController() : null,
6633
+ // R1. THE PANE IS A BOX WITH `overflow: hidden`. A window contained
6634
+ // to one (C21) cannot be dragged a single pixel outside it, so
6635
+ // "drag a window from one tile to another" — the gesture all three
6636
+ // drop behaviours are built on — was not merely awkward, it was
6637
+ // invisible. For the length of a drag the window is re-parented
6638
+ // here, to the root every tile is inside; on release it goes back
6639
+ // into a pane, either the one it was dropped on or the one it came
6640
+ // from. Resolved per drag: the root outlives any tile, and a tile
6641
+ // grabbed once does not survive its own repaint.
6642
+ dragHost: () => this.rootEl,
6643
+ dragBounds: () => this._tileBounds(),
6644
+ // R7. MAXIMISE MEANS BACK TO TILE. This window came OUT of the
6645
+ // tree; the useful thing to do with it is put it back, and filling
6646
+ // the screen with it is the one gesture that makes putting it back
6647
+ // harder. So the maximize button docks — and the separate demote
6648
+ // button the WM used to inject beside it is gone, because two
6649
+ // buttons for one verb is how you get a chrome nobody reads.
6650
+ onMaximize: () => this.bringBackWindow(winId),
6651
+ maximizeIcon: "close_fullscreen",
6652
+ maximizeTitle: "Back to tile",
4034
6653
  onClose: () => this._onManagedWindowClosed(winId, mountInfo)
4035
6654
  });
4036
6655
  this._windowToLeaf.set(winId, {
@@ -4068,9 +6687,13 @@ var WindowManager = class {
4068
6687
  if (!leaf || leaf.kind !== "leaf") return;
4069
6688
  const tabs = leaf.tabs || [];
4070
6689
  if (tabs.length === 0) return;
4071
- const items = [
4072
- { label: "Close tab", icon: "close", action: "close" }
4073
- ];
6690
+ const items = [];
6691
+ const tab = tabs[idx];
6692
+ if (tab && !String(tab.kind || "").startsWith("panel:") && tab.kind !== PLACEHOLDER_KIND) {
6693
+ items.push({ label: "Open in a window", icon: "web_asset", action: "to-window" });
6694
+ items.push({ separator: true });
6695
+ }
6696
+ items.push({ label: "Close tab", icon: "close", action: "close" });
4074
6697
  if (tabs.length > 1) {
4075
6698
  items.push({ label: "Close other tabs", icon: "tab_close", action: "close-others" });
4076
6699
  }
@@ -4142,6 +6765,66 @@ function _tabTitle(kind, props) {
4142
6765
  if (props && (typeof props.id === "string" || typeof props.id === "number") && String(props.id)) return String(props.id);
4143
6766
  return kind || "";
4144
6767
  }
6768
+ function _leafTabSpecs(leaf) {
6769
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
6770
+ if (tabs.length) {
6771
+ return tabs.map((t) => ({
6772
+ kind: t.kind,
6773
+ props: { ...t.props || {} },
6774
+ title: t.title || t.kind || ""
6775
+ }));
6776
+ }
6777
+ return [{
6778
+ kind: leaf.content.kind,
6779
+ props: { ...leaf.content.props || {} },
6780
+ title: leaf.title || leaf.content.kind || ""
6781
+ }];
6782
+ }
6783
+ function _halfOf(r, side) {
6784
+ const w = Math.round(r.width / 2);
6785
+ const h = Math.round(r.height / 2);
6786
+ switch (side) {
6787
+ case "left":
6788
+ return { left: r.left, top: r.top, width: w, height: r.height };
6789
+ case "right":
6790
+ return { left: r.left + r.width - w, top: r.top, width: w, height: r.height };
6791
+ case "top":
6792
+ return { left: r.left, top: r.top, width: r.width, height: h };
6793
+ case "bottom":
6794
+ return { left: r.left, top: r.top + r.height - h, width: r.width, height: h };
6795
+ default:
6796
+ return { left: r.left, top: r.top, width: r.width, height: r.height };
6797
+ }
6798
+ }
6799
+ function _halveInto(tree, sourceId, newId) {
6800
+ const src = tree.get(sourceId);
6801
+ if (!src) return false;
6802
+ const parent = tree.get(src.parentId);
6803
+ if (!parent || parent.kind !== "split") return false;
6804
+ const i = parent.children.indexOf(sourceId);
6805
+ const j = parent.children.indexOf(newId);
6806
+ if (i < 0 || j < 0) return false;
6807
+ const share = (parent.sizes[i] ?? 1) / 2;
6808
+ parent.sizes[i] = share;
6809
+ parent.sizes[j] = share;
6810
+ return true;
6811
+ }
6812
+ function _swapSiblings(tree, aId, bId) {
6813
+ const a = tree.get(aId);
6814
+ const b = tree.get(bId);
6815
+ if (!a || !b || a.parentId !== b.parentId) return false;
6816
+ const parent = tree.get(a.parentId);
6817
+ if (!parent || parent.kind !== "split") return false;
6818
+ const i = parent.children.indexOf(aId);
6819
+ const j = parent.children.indexOf(bId);
6820
+ if (i < 0 || j < 0) return false;
6821
+ parent.children[i] = bId;
6822
+ parent.children[j] = aId;
6823
+ const size = parent.sizes[i];
6824
+ parent.sizes[i] = parent.sizes[j];
6825
+ parent.sizes[j] = size;
6826
+ return true;
6827
+ }
4145
6828
 
4146
6829
  // src/tiling/tile_tab_menu.js
4147
6830
  var PAGE_SIZE = 10;
@@ -4411,6 +7094,110 @@ function openTileTabMenu({
4411
7094
  searchInput.focus();
4412
7095
  return close;
4413
7096
  }
7097
+ function openTileTabSwitcher({ x, y, tabs, activeIdx = 0, onPick }) {
7098
+ const list = Array.isArray(tabs) ? tabs : [];
7099
+ if (list.length === 0) return () => {
7100
+ };
7101
+ const overlay = document.createElement("div");
7102
+ overlay.className = "twm-tile-tabswitch-overlay";
7103
+ const rows = list.map((t, i) => `
7104
+ <li class="twm-tile-tabswitch__item${i === activeIdx ? " twm-tile-tabswitch__item--on" : ""}"
7105
+ data-idx="${i}" role="option" aria-selected="${i === activeIdx}">
7106
+ <span class="material-symbols-outlined twm-tile-tabswitch__check">${i === activeIdx ? "check" : ""}</span>
7107
+ <span class="twm-tile-tabswitch__label">${_esc7(t.title || t.kind || "Tab")}</span>
7108
+ </li>
7109
+ `).join("");
7110
+ overlay.innerHTML = `
7111
+ <div class="twm-tile-tabswitch" role="dialog" aria-label="Open tabs">
7112
+ <header class="twm-tile-tabswitch__head">
7113
+ <span class="material-symbols-outlined">tab</span>
7114
+ <span class="twm-tile-tabswitch__head-label">Open tabs</span>
7115
+ </header>
7116
+ <ul class="twm-tile-tabswitch__list" role="listbox">${rows}</ul>
7117
+ </div>
7118
+ `;
7119
+ document.body.appendChild(overlay);
7120
+ const panel = overlay.querySelector(".twm-tile-tabswitch");
7121
+ const W = 260;
7122
+ panel.style.position = "fixed";
7123
+ panel.style.left = `${Math.max(8, Math.min(window.innerWidth - W - 8, x))}px`;
7124
+ panel.style.bottom = `${Math.max(8, window.innerHeight - y + 4)}px`;
7125
+ panel.style.maxHeight = `${Math.max(120, y - 16)}px`;
7126
+ let alive = true;
7127
+ let cursor = Math.max(0, Math.min(list.length - 1, activeIdx));
7128
+ const close = () => {
7129
+ if (!alive) return;
7130
+ alive = false;
7131
+ document.removeEventListener("mousedown", onOutside, true);
7132
+ document.removeEventListener("keydown", onKey, true);
7133
+ overlay.remove();
7134
+ };
7135
+ const onOutside = (ev) => {
7136
+ if (!overlay.contains(ev.target)) close();
7137
+ };
7138
+ const pick = (idx) => {
7139
+ close();
7140
+ try {
7141
+ onPick?.(idx);
7142
+ } catch (err) {
7143
+ console.warn("[tile-tab-switch] pick failed", err);
7144
+ }
7145
+ };
7146
+ const paint = () => {
7147
+ overlay.querySelectorAll(".twm-tile-tabswitch__item").forEach((li) => {
7148
+ const on = Number(li.dataset.idx) === cursor;
7149
+ li.classList.toggle("twm-tile-tabswitch__item--cursor", on);
7150
+ if (on) li.scrollIntoView({ block: "nearest" });
7151
+ });
7152
+ };
7153
+ const onKey = (ev) => {
7154
+ if (!alive || ev.isComposing) return;
7155
+ switch (ev.key) {
7156
+ case "Escape":
7157
+ ev.preventDefault();
7158
+ close();
7159
+ return;
7160
+ case "ArrowDown":
7161
+ ev.preventDefault();
7162
+ cursor = Math.min(list.length - 1, cursor + 1);
7163
+ paint();
7164
+ return;
7165
+ case "ArrowUp":
7166
+ ev.preventDefault();
7167
+ cursor = Math.max(0, cursor - 1);
7168
+ paint();
7169
+ return;
7170
+ case "Home":
7171
+ ev.preventDefault();
7172
+ cursor = 0;
7173
+ paint();
7174
+ return;
7175
+ case "End":
7176
+ ev.preventDefault();
7177
+ cursor = list.length - 1;
7178
+ paint();
7179
+ return;
7180
+ case "Enter":
7181
+ ev.preventDefault();
7182
+ pick(cursor);
7183
+ return;
7184
+ }
7185
+ };
7186
+ overlay.querySelectorAll(".twm-tile-tabswitch__item").forEach((li) => {
7187
+ const idx = Number(li.dataset.idx);
7188
+ li.addEventListener("mousemove", () => {
7189
+ if (cursor !== idx) {
7190
+ cursor = idx;
7191
+ paint();
7192
+ }
7193
+ });
7194
+ li.addEventListener("click", () => pick(idx));
7195
+ });
7196
+ document.addEventListener("mousedown", onOutside, true);
7197
+ document.addEventListener("keydown", onKey, true);
7198
+ paint();
7199
+ return close;
7200
+ }
4414
7201
  function _matchesShaped(shaped, query) {
4415
7202
  if (!shaped) return false;
4416
7203
  const id = String(shaped.id ?? "").toLowerCase();
@@ -4442,7 +7229,15 @@ async function createShell({
4442
7229
  events = {},
4443
7230
  rootCrumb = null,
4444
7231
  palette: paletteCfg = {},
4445
- chrome = {}
7232
+ panels = null,
7233
+ snapPromotion = false,
7234
+ promoteInPlace = false,
7235
+ tabLayout = null,
7236
+ chrome = {},
7237
+ // An embedder that moved its sections out of the top bar — into an icon
7238
+ // rail, say — passes the selector its own buttons match, and F1..F8 keep
7239
+ // working. Omitted, the default top-bar selector applies.
7240
+ navSelector = null
4446
7241
  } = {}) {
4447
7242
  if (!root || typeof root.appendChild !== "function") {
4448
7243
  throw new TypeError("createShell: `root` must be an element");
@@ -4473,6 +7268,10 @@ async function createShell({
4473
7268
  host,
4474
7269
  taxonomy,
4475
7270
  events,
7271
+ panelDefaults: panels,
7272
+ snapPromotion,
7273
+ promoteInPlace,
7274
+ tabLayout,
4476
7275
  // `ctx` is the delivery vehicle for leaf-mounted chrome: tile_renderer
4477
7276
  // spreads it into every content factory, which is how the breadcrumb
4478
7277
  // gets `taxonomy` + `rootCrumb` without a content factory knowing they
@@ -4499,7 +7298,7 @@ async function createShell({
4499
7298
  catalog: entities,
4500
7299
  ...paletteCfg
4501
7300
  });
4502
- installKeymap({ wm, palette });
7301
+ const disposeKeymap = installKeymap({ wm, palette, navSelector });
4503
7302
  const paletteBtn = mountPaletteButton(chrome.paletteButton, palette);
4504
7303
  topNavEl = mountTopNav(chrome.topNav, taxonomy, wm);
4505
7304
  desktopsEl = mountDesktopBar(chrome.desktops, wm);
@@ -4521,11 +7320,29 @@ async function createShell({
4521
7320
  desktopsEl,
4522
7321
  panelToggles: toggles
4523
7322
  }),
7323
+ // A shell that can be built can be built TWICE — an embedder that
7324
+ // rebuilds on a context change (a different project, a different
7325
+ // workspace) does exactly that. Everything this function installs
7326
+ // outside `root` has to come off, or the second shell shares the page
7327
+ // with the first one's keyboard.
4524
7328
  dispose: () => {
4525
7329
  try {
4526
7330
  eventBus?.off?.("wm:changed", syncChrome);
4527
7331
  } catch {
4528
7332
  }
7333
+ try {
7334
+ disposeKeymap?.();
7335
+ } catch {
7336
+ }
7337
+ try {
7338
+ palette?.close?.();
7339
+ } catch {
7340
+ }
7341
+ try {
7342
+ wm.renderer.destroy();
7343
+ } catch (err) {
7344
+ log.warn?.("renderer teardown", err);
7345
+ }
4529
7346
  }
4530
7347
  });
4531
7348
  }
@@ -4565,8 +7382,7 @@ function syncTopNav(hostEl, wm) {
4565
7382
  }
4566
7383
  function mountPaletteButton(hostEl, palette) {
4567
7384
  if (!hostEl) return null;
4568
- const existing = hostEl.querySelector("#twm-palette-btn");
4569
- if (existing) return existing;
7385
+ hostEl.querySelector("#twm-palette-btn")?.remove();
4570
7386
  const btn = document.createElement("button");
4571
7387
  btn.id = "twm-palette-btn";
4572
7388
  btn.className = "twm-panel-toggle-btn twm-has-tooltip";
@@ -4708,24 +7524,20 @@ function syncDesktopBar(el, wm) {
4708
7524
  function _tileTabMenu(wm, leafId, x, y) {
4709
7525
  const tree = wm.desktops.active().tree;
4710
7526
  const leaf = tree.get(leafId);
4711
- if (!leaf) return;
4712
- const kind = leaf.content?.kind || wm.taxonomy.root;
4713
- openTileTabMenu({
7527
+ if (!leaf || leaf.kind !== "leaf") return;
7528
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
7529
+ if (tabs.length === 0) return;
7530
+ openTileTabSwitcher({
4714
7531
  x,
4715
7532
  y,
4716
- leafKind: kind,
4717
- api: wm.api,
4718
- taxonomy: wm.taxonomy,
4719
- entities: wm.ctx.entities,
4720
- onPick: (navKind, shaped) => {
4721
- tree.appendLeafTab(leafId, {
4722
- kind: navKind,
4723
- props: { id: shaped.id, label: shaped.label }
4724
- }, shaped.label || shaped.id);
7533
+ tabs,
7534
+ activeIdx: Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0)),
7535
+ onPick: (idx) => {
7536
+ tree.setActiveLeafTab(leafId, idx);
4725
7537
  tree.focus(leafId);
4726
7538
  wm.renderer.render();
4727
7539
  wm._persist?.();
4728
- wm._notifyChange?.("tab-open-from-menu");
7540
+ wm._notifyChange?.("tab-switch-from-menu");
4729
7541
  }
4730
7542
  });
4731
7543
  }
@@ -4746,17 +7558,41 @@ function _tileContextMenu(wm, leafId, x, y) {
4746
7558
  action: "open-tab",
4747
7559
  disabled: isPanel || !leaf.content
4748
7560
  },
7561
+ // TWO DIFFERENT GLYPHS FOR TWO DIFFERENT DESTINATIONS. `web_asset` is a
7562
+ // window INSIDE the application — the same glyph `ManagedWindow` uses
7563
+ // for itself — and an embedder that can also send content to a real
7564
+ // browser window keeps `open_in_new`, which is the universal "this
7565
+ // leaves the page". One glyph for both is how a user learns that the
7566
+ // two commands are the same command, and then loses a window looking
7567
+ // for it on the other screen.
4749
7568
  {
4750
- label: "Open in new window",
4751
- icon: "open_in_full",
7569
+ label: "Open a copy in a window",
7570
+ icon: "web_asset",
4752
7571
  action: "open-window",
4753
7572
  disabled: isPanel || !leaf.content
4754
7573
  },
7574
+ // C20, THE OTHER HALF — and it was missing while the `close` half
7575
+ // below carried a paragraph explaining why it could not be.
7576
+ //
7577
+ // `_floatableLeaf` (`wm.js`) refuses to float content that declared
7578
+ // `chrome: { promote: false }`, and every door converges there — so
7579
+ // this row offered the verb, enabled, and returned null. The chrome's
7580
+ // own float BUTTON does not have the problem: C20 removes it from the
7581
+ // strip. That asymmetry is what hid this: the affordance the reader
7582
+ // checks is correct, and the menu one layer down is not.
7583
+ //
7584
+ // `=== false` EXACTLY, because that is the test the verb makes
7585
+ // (`wm.js`, `_floatableLeaf`: *"content that says nothing about
7586
+ // `promote` stays floatable"*). A falsy test here would grey the row
7587
+ // on every leaf whose content returned no `chrome` at all, which is
7588
+ // most of them — a menu disagreeing with its verb in the generous
7589
+ // direction is a dead control; in the mean direction it is a missing
7590
+ // feature, and this file has shipped one of each.
4755
7591
  {
4756
- label: "Promote to window",
4757
- icon: "open_in_new",
7592
+ label: "Float this pane as a window",
7593
+ icon: "web_asset",
4758
7594
  action: "promote",
4759
- disabled: isPanel || !leaf.content
7595
+ disabled: isPanel || !leaf.content || wm.renderer?.leafChrome?.(leafId)?.promote === false
4760
7596
  }
4761
7597
  ];
4762
7598
  if (wm.desktops.desktops.length > 1 && !isPanel) {
@@ -4770,12 +7606,15 @@ function _tileContextMenu(wm, leafId, x, y) {
4770
7606
  }
4771
7607
  }
4772
7608
  items.push({ separator: true });
7609
+ const closeVeto = wm.renderer?.leafChrome?.(leafId)?.close;
7610
+ const closeVetoed = closeVeto === false || closeVeto?.disabled === true;
4773
7611
  items.push({
4774
7612
  label: "Close tile",
4775
7613
  icon: "close",
4776
7614
  action: "close",
4777
7615
  danger: true,
4778
- disabled: isPanel
7616
+ disabled: isPanel || closeVetoed,
7617
+ title: closeVetoed ? closeVeto?.title || void 0 : void 0
4779
7618
  });
4780
7619
  showContextMenu(x, y, items, (action) => {
4781
7620
  if (action === "split-h") wm.split("h");
@@ -4797,6 +7636,7 @@ export {
4797
7636
  DesktopManager,
4798
7637
  PLACEHOLDER_KIND,
4799
7638
  PanelKeyRouter,
7639
+ TILE_TAB_MIME,
4800
7640
  TileRenderer,
4801
7641
  TileTree,
4802
7642
  WindowManager,
@@ -4822,6 +7662,7 @@ export {
4822
7662
  mountNavPanel,
4823
7663
  mountTileBreadcrumb,
4824
7664
  openTileTabMenu,
7665
+ openTileTabSwitcher,
4825
7666
  registerPanelKeys,
4826
7667
  saveDesktops,
4827
7668
  uninstallPanelKeyRouter,