flexdesk 0.2.0 → 0.4.0

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