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/src/tiling/wm.js CHANGED
@@ -21,6 +21,7 @@ import { PLACEHOLDER_KIND } from './content_registry.js';
21
21
  import { installPanelKeyRouter } from './panel_keys.js';
22
22
  import { ManagedWindow } from '../ui/components/managed_window.js';
23
23
  import { showContextMenu } from '../ui/components/context_menu.js';
24
+ import { createTabStrip } from './tab_strip.js';
24
25
 
25
26
  const PANEL_KINDS = new Set(['panel:left', 'panel:right', 'panel:bottom']);
26
27
 
@@ -31,7 +32,9 @@ const PANEL_TITLES = {
31
32
  };
32
33
 
33
34
  export class WindowManager {
34
- constructor({ rootEl, api, ctx, onChange, eventBus, host, taxonomy, events, content }) {
35
+ constructor({ rootEl, api, ctx, onChange, eventBus, host, taxonomy, events, content,
36
+ panelDefaults = null, snapPromotion = false,
37
+ promoteInPlace = false, tabLayout = null }) {
35
38
  if (!taxonomy) throw new Error('WindowManager: a taxonomy is required');
36
39
  if (!content || typeof content.mount !== 'function') {
37
40
  throw new Error('WindowManager: a content registry is required '
@@ -66,11 +69,35 @@ export class WindowManager {
66
69
  title: this.taxonomy.meta(kind)?.label || kind,
67
70
  };
68
71
  };
69
- this.desktops = new DesktopManager({ seed: this._rootLeaf });
72
+ // C14. Which panel tiles a fresh desktop opens with. An embedder whose
73
+ // navigator and inspector live outside the WM root passes `false` for
74
+ // them and gets no placeholder tiles it never registered a factory for.
75
+ this.panelDefaults = panelDefaults || null;
76
+ /** C15. Whether a window this WM promoted may be dropped back INTO the
77
+ * tree. Default off: it changes what a drag to an edge does, and an
78
+ * existing embedder upgrading the library must not find its promoted
79
+ * windows behaving differently. */
80
+ this.snapPromotion = !!snapPromotion;
81
+ /** Whether a window promoted out of a tile stays CONFINED to the pane it
82
+ * came from, rather than floating over the whole root. Default off: it
83
+ * changes where a promoted window can be dragged, and an existing
84
+ * embedder upgrading must not find its windows suddenly clipped. */
85
+ this.promoteInPlace = !!promoteInPlace;
86
+ this._snapCtl = null;
87
+ this.desktops = new DesktopManager({
88
+ seed: this._rootLeaf, panelDefaults: this.panelDefaults });
70
89
  this.renderer = new TileRenderer({
71
90
  root: rootEl,
72
91
  tree: this.desktops.active().tree,
73
92
  content,
93
+ /** C22. Where a multi-tab leaf draws its tabs — `'bottom'`
94
+ * (the framework's own spreadsheet strip, and the DEFAULT so no
95
+ * existing embedder's panes rearrange on upgrade) or `'top'`
96
+ * (the editor tab bar, between the chrome and the body).
97
+ * The renderer also mirrors this onto the root as
98
+ * `data-twm-tabs` and watches it, so an embedder can change it
99
+ * live without holding a renderer reference. */
100
+ tabLayout,
74
101
  ctx: {
75
102
  ...this.ctx,
76
103
  wm: this,
@@ -79,6 +106,7 @@ export class WindowManager {
79
106
  this._leafTabAction(leafId, action, data),
80
107
  },
81
108
  onFocusChange: () => this._notifyChange(),
109
+ onAfterRender: () => this._rehomeContainedWindows(),
82
110
  });
83
111
  this._persistTimer = null;
84
112
  // Window-id → leaf-id mapping so a managed-window demote can
@@ -115,6 +143,17 @@ export class WindowManager {
115
143
  }
116
144
  }
117
145
 
146
+ /** C22. Change the tab layout of every tile, live. Delegates to the
147
+ * renderer, which moves each strip rather than rebuilding the tiles — so
148
+ * nothing mounted in a tile is unmounted and no staged work is lost.
149
+ *
150
+ * Not persisted here: which layout a user prefers is a USER setting, and
151
+ * the WM persists LAYOUT (`desktops`). An embedder that stores it does so
152
+ * under its own key and passes it back as `createShell({ tabLayout })`. */
153
+ setTabLayout(layout) {
154
+ this.renderer.setTabLayout(layout);
155
+ }
156
+
118
157
  /** Emit on bus + call onChange. Use this instead of the bare callback
119
158
  * so other surfaces (palette, top-bar toggles, page shortcuts) can
120
159
  * subscribe through the existing event system. */
@@ -145,7 +184,8 @@ export class WindowManager {
145
184
  async load() {
146
185
  const blob = await loadDesktops(this.host?.state);
147
186
  if (blob) {
148
- this.desktops = DesktopManager.deserialize(blob, { seed: this._rootLeaf });
187
+ this.desktops = DesktopManager.deserialize(blob, {
188
+ seed: this._rootLeaf, panelDefaults: this.panelDefaults });
149
189
  this.renderer.tree = this.desktops.active().tree;
150
190
  }
151
191
  // Normalize: managed windows don't survive a reload, so any
@@ -287,7 +327,20 @@ export class WindowManager {
287
327
  updateActiveTabProps(leafId, patch) {
288
328
  const tree = this._tree();
289
329
  if (!tree?.updateActiveTabProps) return;
330
+ // READ BEFORE THE WRITE. This is what `rebaselineLeaf` checks the
331
+ // cached entry against, and it is the whole safety of accepting a props
332
+ // change onto a live tile: if the mounted wrap is not still at the key
333
+ // the tree had a moment ago, something else changed that this call has
334
+ // no business swallowing.
335
+ const expected = this.renderer?.leafKey?.(leafId) ?? undefined;
290
336
  tree.updateActiveTabProps(leafId, patch);
337
+ // C34. THE TILE THAT SAVED ITS STATE MUST NOT BE REBUILT BY SAVING IT.
338
+ // The renderer caches a leaf's wrap under a key that includes every
339
+ // tab's props, so this write invalidated the tile it was called from —
340
+ // not here, but on whatever repainted next, which made it look like an
341
+ // unrelated bug. `rebaselineLeaf` accepts the new props onto the live
342
+ // entry; the argument is in its docstring.
343
+ this.renderer?.rebaselineLeaf?.(leafId, expected);
291
344
  this._persist();
292
345
  }
293
346
 
@@ -364,26 +417,39 @@ export class WindowManager {
364
417
  }
365
418
 
366
419
  /** Replace a managed window's content in place. Tears down the
367
- * previous mount, mounts the new kind into the same contentEl,
420
+ * previous mount, mounts the new kind into the same body element,
368
421
  * and updates the window's title. */
369
422
  openInWindow(winId, kind, props = {}) {
370
423
  const rec = this._windowToLeaf.get(winId);
371
424
  if (!rec) { this.openInPrimary(kind, props); return; }
372
425
  try { rec.mountInfo?.destroy?.(); } catch {}
373
- rec.contentEl.innerHTML = '';
374
- const mountInfo = this.content.mount(kind, rec.contentEl, props,
426
+ // R8. THE BODY, NOT THE CONTENT ELEMENT. A floated pane's window holds
427
+ // a tab strip above its body, and both are children of `contentEl`
428
+ // so clearing `contentEl` here would delete the strip, silently, on
429
+ // the first in-window navigation, and the window would keep every one
430
+ // of its tabs in a list nothing could reach. `_navigateWindow` builds
431
+ // a window with no strip and no body, hence the fallback.
432
+ const host = rec.bodyEl || rec.contentEl;
433
+ host.innerHTML = '';
434
+ const mountInfo = this.content.mount(kind, host, props,
375
435
  { ...this.ctx, wm: this, windowId: winId });
376
436
  rec.mountInfo = mountInfo;
377
437
  rec.original = {
378
438
  kind, props: { ...(props || {}) },
379
439
  title: mountInfo?.title || kind,
380
440
  };
441
+ // R8. And the TAB the window is showing is now that content, so the
442
+ // strip renames with it and a later dock puts back what is on screen
443
+ // rather than what the tab was called when it was promoted.
444
+ const tab = (rec.tabs || [])[rec.activeTabIdx];
445
+ if (tab) {
446
+ tab.kind = kind;
447
+ tab.props = { ...(props || {}) };
448
+ tab.title = rec.original.title;
449
+ rec.strip?.update(rec.tabs, rec.activeTabIdx);
450
+ }
381
451
  // Update window title (DOM + the ManagedWindow instance).
382
- try {
383
- const titleEl = rec.window.element?.querySelector('.twm-managed-window__title');
384
- if (titleEl) titleEl.textContent = rec.original.title;
385
- if (rec.window) rec.window.title = rec.original.title;
386
- } catch {}
452
+ this._setWindowTitle(rec, rec.original.title);
387
453
  // `rec.original` now holds the latest content, so a later "back to
388
454
  // tile" docks the current view (not the kind first promoted). The
389
455
  // window owns no tile in the tree, so there is no leaf to update.
@@ -430,12 +496,29 @@ export class WindowManager {
430
496
  const tree = this._tree();
431
497
  const focused = tree.focusedLeafId;
432
498
  if (!focused) return;
433
- tree.split(focused, dir);
499
+ // Chrome split buttons mirror Alt+H / Alt+V. The freshly created
500
+ // pane is SEEDED with the default HOME content (the taxonomy root)
501
+ // rather than left empty — the never-empty-tile invariant: a tile
502
+ // in the grid always holds content the user can act on, and the
503
+ // main tile is never left blank.
504
+ const newId = tree.split(focused, dir);
505
+ if (newId) this._seedHome(tree, newId);
434
506
  this.renderer.render();
435
507
  this._persist();
436
508
  this._notifyChange();
437
509
  }
438
510
 
511
+ /** Seed a leaf with the default HOME content (the taxonomy root kind).
512
+ * Used to keep the never-empty-tile invariant: the pane freed by a
513
+ * split, or emptied when its last tab floats into a window, is
514
+ * re-homed instead of destroyed or left blank. Embedder-agnostic —
515
+ * the HOME kind comes from the taxonomy, exactly like a fresh
516
+ * desktop's seed leaf. */
517
+ _seedHome(tree, leafId) {
518
+ const seed = this._rootLeaf();
519
+ tree.setLeafContent(leafId, seed.content, seed.title);
520
+ }
521
+
439
522
  /** Split `leafId` along `dir` and mount `kind`/`props` in the freshly
440
523
  * created sibling — the "open this content in a new split" primitive
441
524
  * behind the code-pane split buttons, the per-pane context menu and
@@ -502,6 +585,29 @@ export class WindowManager {
502
585
  const leaf = tree.get(focusedId);
503
586
  const kind = leaf?.content?.kind;
504
587
 
588
+ // ══ C20. THE CONTENT'S VETO IS HONOURED HERE, NOT ONLY PAINTED ══
589
+ //
590
+ // `chrome: { close: false }` — or `{ close: { disabled, title } }`,
591
+ // which is the same refusal with a sentence attached — is read at mount
592
+ // and `_vetoStructuralActions` acts on it by disabling the × in the
593
+ // tile's chrome. That is ONE door. This verb has three more: Alt+W
594
+ // (`keymap.js`), the tile's right-click *Close tile* (`shell.js`), and
595
+ // the tab strip's × on a leaf's LAST tab (`_leafTabAction`, which
596
+ // delegates straight to here). All three closed a pane whose own button
597
+ // was greyed out with a tooltip promising it could not be closed.
598
+ //
599
+ // This is the same asymmetry `_floatableLeaf` was written to end for
600
+ // `promote`, resolved the same way and for the same reason: the
601
+ // renderer stays the only thing that knows what was mounted, and the WM
602
+ // stays the only thing that decides whether a verb runs. A veto the
603
+ // content states once holds for every door, or it is decoration.
604
+ //
605
+ // Silent rather than noisy: the control that offers this is already
606
+ // disabled and already carries the explanation, so a keystroke that
607
+ // does nothing is consistent with what the screen says.
608
+ const closeChrome = this.renderer?.leafChrome?.(focusedId)?.close;
609
+ if (closeChrome === false || closeChrome?.disabled === true) return;
610
+
505
611
  // Closing a placeholder closes its window — the window's onClose
506
612
  // handler restores the leaf, then we close that leaf too.
507
613
  if (kind === PLACEHOLDER_KIND) {
@@ -526,11 +632,28 @@ export class WindowManager {
526
632
  tree.close(focusedId);
527
633
  }
528
634
 
529
- // Re-canonicalize so the remaining panels fill the area
530
- // properly (no leftover wrap from the content leaf). The user
531
- // is allowed to close the last content tile the panels can
532
- // fully occupy the WM. A new content leaf is spawned later by
533
- // openInPrimary when something needs it.
635
+ // THE MASTER TILE COMES BACK.
636
+ //
637
+ // Closing the last content tile used to leave the panels occupying the
638
+ // whole WM, on the reasoning that `openInPrimary` would spawn a new leaf
639
+ // when something needed one. That is true and it is not enough: the
640
+ // content area is also the GROUND floating windows stand on, so an
641
+ // embedder that promotes a tile into a window and then closes the tile
642
+ // it came from is left with a window floating over nothing, and no
643
+ // surface to drop it back onto. There is nowhere to put it and nothing
644
+ // saying what to do next.
645
+ //
646
+ // So the invariant is now the same one `toggleManagedFocused` already
647
+ // keeps for a lone tile: there is ALWAYS a content leaf, and it holds
648
+ // the taxonomy root. `_seedHome` is the same call, so the tile that
649
+ // comes back is the same tile a fresh desktop starts with.
650
+ if (!tree.leaves().some((l) => !String(l.content?.kind || '').startsWith('panel:'))) {
651
+ const spawned = this._spawnContentLeaf(tree);
652
+ if (spawned) this._seedHome(tree, spawned);
653
+ }
654
+
655
+ // Re-canonicalize so the remaining panels fill the area properly (no
656
+ // leftover wrap from the closed content leaf).
534
657
  this._canonicalize(tree, this.desktops.active());
535
658
 
536
659
  this.renderer.render();
@@ -611,31 +734,256 @@ export class WindowManager {
611
734
  }
612
735
 
613
736
  // ── Tile <-> Managed window ─────────────────────────────────────
614
- /** Promote the focused tile into a managed window and CLOSE the
615
- * source tile — promoting means the content leaves the grid, so the
616
- * origin slot is removed rather than left as an empty placeholder.
617
- * "Back to tile" re-docks the content into the primary tile. */
737
+ /** Float the focused pane into a managed window.
738
+ *
739
+ * R8. THE WHOLE PANE, not its active tab. This used to float one tab and
740
+ * leave the rest behind, which made "float this pane as a window" a
741
+ * different verb from the one its own tooltip named: a pane with three
742
+ * tables in it became a window holding one and a pane holding two, and
743
+ * nothing on screen said which of the three you were going to get. The
744
+ * product owner's words are the whole specification — *"to window includes
745
+ * the tab-strip"* — so the tabs travel with the pane and the strip is
746
+ * rendered INSIDE the window.
747
+ *
748
+ * Floating ONE tab is still available and is still wanted; it moved to
749
+ * where it was always meant to be, which is the right-click menu on the
750
+ * tab itself (R9, `floatTabAsWindow`). A verb that acts on one tab belongs
751
+ * on that tab, not on the pane's chrome.
752
+ *
753
+ * Never-empty-tile invariant, unchanged: the emptied pane is RE-SEEDED
754
+ * with the default HOME content rather than destroyed, so the grid never
755
+ * ends up with a missing or blank main tile. */
618
756
  toggleManagedFocused() {
619
757
  const tree = this._tree();
620
758
  const focused = tree.focused();
621
- if (!focused || !focused.content) return;
622
- if (PANEL_KINDS.has(focused.content.kind)) return;
623
- if (focused.content.kind === PLACEHOLDER_KIND) return;
759
+ if (!focused) return null;
760
+ return this.floatPane(focused.id);
761
+ }
762
+
763
+ /** R8. Float a pane — every tab, with the strip — into a managed window.
764
+ * Returns the window id, or null when the leaf is not something that can
765
+ * be floated. */
766
+ floatPane(leafId) {
767
+ const leaf = this._floatableLeaf(leafId);
768
+ if (!leaf) return null;
769
+ const tabs = _leafTabSpecs(leaf);
770
+ const active = Math.max(0, Math.min(tabs.length - 1, leaf.activeTabIdx || 0));
771
+ return this._promote(leafId, tabs, active, { wholePane: true });
772
+ }
773
+
774
+ /** R9. Float ONE tab of a pane into a managed window, leaving its siblings
775
+ * where they are — which is exactly what `toggleManagedFocused` did before
776
+ * R8, so the behaviour survives, it just moved to the gesture that names
777
+ * it. The tab's right-click menu is the only caller. */
778
+ floatTabAsWindow(leafId, idx) {
779
+ const leaf = this._floatableLeaf(leafId);
780
+ if (!leaf) return null;
781
+ const tabs = _leafTabSpecs(leaf);
782
+ if (!Number.isInteger(idx) || idx < 0 || idx >= tabs.length) return null;
783
+ return this._promote(leafId, [tabs[idx]], 0, { wholePane: false, tabIdx: idx });
784
+ }
785
+
786
+ /**
787
+ * C33. MOVE ONE TAB INTO ANOTHER TILE — the same verb as `floatTabAsWindow`
788
+ * above with a TILE as the destination instead of a window, which is why it
789
+ * sits beside it.
790
+ *
791
+ * ══ THE ORDER IS LOAD-BEARING ═══════════════════════════════════════
792
+ *
793
+ * Four steps, and three of them are in this order for a reason that a
794
+ * plausible-looking rewrite would destroy:
795
+ *
796
+ * (a) READ THE TAB SPEC FIRST. `fromIdx` is an ARRAY INDEX — the only
797
+ * identity a tile tab has (`tile_renderer._tabKey`) — so it is stale
798
+ * the instant anything splices a tab list. Everything below works
799
+ * from the copy taken here.
800
+ *
801
+ * (b) SPLIT BEFORE REMOVING. When the destination IS the source pane —
802
+ * "tear this tab off into a split beside its siblings" — removing
803
+ * first can empty that pane and send it through `_seedHome`, so the
804
+ * split would then be splitting a freshly seeded ground rather than
805
+ * the pane the preview drew. Splitting first cannot go wrong in the
806
+ * other direction: `tree.split` never touches tabs.
807
+ *
808
+ * (c) THE MOVE ITSELF IS ONE TREE CALL for `tab` — `moveTabToLeaf`, which
809
+ * exists so the tab cannot be in flight between two mutations — and
810
+ * remove-then-`setLeafContent` for `fill`/`split`, where the
811
+ * destination is ground or brand new and REPLACING is the point.
812
+ *
813
+ * (d) RE-SEED AND MERGE, exactly as `_promote` does when the last tab
814
+ * leaves a pane (`_seedHome` then `_mergeStartTiles`). A pane is
815
+ * never left blank, and two grounds never end up side by side with a
816
+ * splitter between them for no reason.
817
+ *
818
+ * ══ `wm:tab-moved` IS EMITTED BEFORE THE REPAINT ════════════════════
819
+ *
820
+ * An embedder that keys live content by leaf id — the Tables grid registry
821
+ * does, on `(leaf, table)`, because a DOM element exists in exactly one
822
+ * place — has to re-key BEFORE the render mounts the destination, or the
823
+ * destination misses its entry, builds a second grid, and the source tile's
824
+ * deferred teardown destroys the first one along with everything typed into
825
+ * it and not yet committed. Emitting after the render would lose that race
826
+ * silently, which is the failure this repository keeps recording. The tree
827
+ * is already correct at this point; only the DOM is stale.
828
+ *
829
+ * ══ WHAT THIS DELIBERATELY DOES NOT DO ══════════════════════════════
830
+ *
831
+ * A tab is not dragged OUT OF A FLOATING WINDOW's strip, and a tab dropped
832
+ * on empty space does not become a window. Both are refused by omission
833
+ * rather than half-built, and both have a reason. A window's tabs live in
834
+ * `_windowToLeaf`'s record and not in the tree, so their source policy is
835
+ * `_windowTabAction`'s and not this function's. And "dropped on nothing" in
836
+ * HTML5 drag-and-drop is `dragend` with no `drop` — which is also exactly
837
+ * what pressing Escape produces, so floating a window on it would float one
838
+ * every time a user changed their mind. Crossing DESKTOPS is out for a
839
+ * third reason: only the active desktop is rendered, so there is no target
840
+ * to hit.
841
+ *
842
+ * @param {string} fromLeafId
843
+ * @param {number} fromIdx
844
+ * @param {{leafId: string, mode?: 'tab'|'fill'|'split', dir?: 'h'|'v',
845
+ * before?: boolean, toIdx?: number}} target a `tabDropProbe`
846
+ * answer, translated by the renderer
847
+ * @returns {string|null} the leaf the tab landed in, or null if refused
848
+ */
849
+ moveTabInto(fromLeafId, fromIdx, target = {}) {
850
+ const tree = this._tree();
851
+ const from = tree.get(fromLeafId);
852
+ if (!from || from.kind !== 'leaf') return null;
853
+ const tabs = Array.isArray(from.tabs) ? from.tabs : [];
854
+ if (!Number.isInteger(fromIdx) || fromIdx < 0 || fromIdx >= tabs.length) return null;
855
+ let destId = target?.leafId || null;
856
+ const dest = destId ? tree.get(destId) : null;
857
+ if (!dest || dest.kind !== 'leaf') return null;
858
+ if (String(dest.content?.kind || '').startsWith('panel:')) return null;
859
+ const mode = (target.mode === 'fill' || target.mode === 'split')
860
+ ? target.mode : 'tab';
861
+ // The two no-ops `tabDropProbe` already refuses, refused a second time
862
+ // here because this is a PUBLIC verb and a keyboard or palette caller
863
+ // never went through the probe: a tab dropped into the pane it already
864
+ // lives in, and a single-tab pane split against itself.
865
+ if (destId === fromLeafId && mode !== 'split') return null;
866
+ if (destId === fromLeafId && tabs.length <= 1) return null;
867
+
868
+ // (a) the spec, copied before any mutation makes the index a lie.
869
+ const src = tabs[fromIdx];
870
+ const spec = { kind: src.kind, props: { ...(src.props || {}) },
871
+ title: src.title || src.kind || '' };
872
+
873
+ // (b) the split, before the removal.
874
+ if (mode === 'split') {
875
+ const newId = tree.split(destId, target.dir === 'v' ? 'v' : 'h');
876
+ if (!newId) return null;
877
+ // THE PREVIEW PROMISED HALF OF THAT TILE, and `TileTree.split` only
878
+ // delivers a half when it has to WRAP — into a row that already
879
+ // runs this way it INSERTS, handing the newcomer the average of the
880
+ // existing sizes. The same two corrections `_onManagedWindowClosed`
881
+ // applies to a window drop (C15), for the same reason and in the
882
+ // same order.
883
+ _halveInto(tree, destId, newId);
884
+ if (target.before) _swapSiblings(tree, destId, newId);
885
+ destId = newId;
886
+ }
887
+
888
+ // (c) the move.
889
+ if (mode === 'tab') {
890
+ const moved = tree.moveTabToLeaf(fromLeafId, fromIdx, destId,
891
+ Number.isInteger(target.toIdx) ? target.toIdx : -1);
892
+ if (!moved?.ok) return null;
893
+ } else {
894
+ tree.removeLeafTab(fromLeafId, fromIdx);
895
+ tree.setLeafContent(destId, { kind: spec.kind, props: spec.props }, spec.title);
896
+ }
897
+
898
+ // (d) the source pane, if the last tab just left it.
899
+ if (!((tree.get(fromLeafId)?.tabs || []).length)) {
900
+ this._seedHome(tree, fromLeafId);
901
+ this._mergeStartTiles(tree, fromLeafId);
902
+ }
903
+ this._canonicalize(tree, this.desktops.active());
904
+
905
+ // BEFORE THE REPAINT. See the block comment above — this is the moment
906
+ // the tree is right and the DOM has not moved yet, and it is the only
907
+ // moment an embedder can re-key content it holds by leaf id.
908
+ try {
909
+ this.eventBus?.emit?.('wm:tab-moved',
910
+ { fromLeafId, toLeafId: destId, tab: spec, mode });
911
+ } catch (err) { console.warn('[wm] tab-moved emit failed', err); }
912
+
913
+ if (tree.get(destId)) tree.focus(destId);
914
+ this.renderer.render();
915
+ this._persist();
916
+ this._notifyChange('tab-moved');
917
+ return destId;
918
+ }
624
919
 
625
- const leafId = focused.id;
920
+ /** The guards both float verbs share. A panel tile is chrome, not content;
921
+ * a window placeholder is already a window; an empty tile has nothing to
922
+ * carry — and, since C20 was extended, content that declared itself
923
+ * unfloatable is not floated by ANY door.
924
+ *
925
+ * THE LAST ONE IS WHY THIS FUNCTION IS THE RIGHT PLACE. C20 let a content
926
+ * factory return `chrome: { promote: false }`, and the renderer honoured
927
+ * it by not PAINTING the float button. That is one door of four: the
928
+ * tile's right-click menu has offered "Float this pane as a window" all
929
+ * along (`shell.js`'s `_tileContextMenu`, whose only guard is
930
+ * `isPanel || !leaf.content`), the chrome pull-down asks for `promote`,
931
+ * and so now does the chrome's double-click. Each of them arrives here.
932
+ *
933
+ * The case it protects is an embedder's MASTER tile: the ground that
934
+ * floating windows stand on. Floating it promotes the ground into a
935
+ * window, which force-closes every window standing on it and re-seeds the
936
+ * pane WITHOUT the props that made it a ground — reproduced end to end
937
+ * before this guard existed. A veto the content states once should hold
938
+ * for every gesture, not only the one the renderer draws. */
939
+ _floatableLeaf(leafId) {
940
+ const leaf = leafId ? this._tree().get(leafId) : null;
941
+ if (!leaf || leaf.kind !== 'leaf' || !leaf.content) return null;
942
+ if (PANEL_KINDS.has(leaf.content.kind)) return null;
943
+ if (leaf.content.kind === PLACEHOLDER_KIND) return null;
944
+ // `=== false` rather than falsy: content that says nothing about
945
+ // `promote` stays floatable, which is what every existing embedder
946
+ // relies on. Only an explicit refusal refuses.
947
+ if (this.renderer?.leafChrome?.(leaf.id)?.promote === false) return null;
948
+ return leaf;
949
+ }
950
+
951
+ /**
952
+ * The promote itself: build the window, mount the active tab in it, and
953
+ * take the tabs out of the tree.
954
+ *
955
+ * @param {string} leafId the pane the tabs are coming out of
956
+ * @param {object[]} tabs `{kind, props, title}`, in order
957
+ * @param {number} activeTabIdx which of them the window shows first
958
+ * @param {{wholePane: boolean, tabIdx?: number}} opts
959
+ */
960
+ _promote(leafId, tabs, activeTabIdx, { wholePane, tabIdx = -1 }) {
961
+ const tree = this._tree();
962
+ const leaf = tree.get(leafId);
626
963
  const desktopIdx = this.desktops.activeIdx;
627
- const original = {
628
- kind: focused.content.kind,
629
- props: { ...(focused.content.props || {}) },
630
- title: focused.title,
631
- };
964
+ const tabCount = Array.isArray(leaf.tabs) ? leaf.tabs.length : 1;
965
+ const active = Math.max(0, Math.min(tabs.length - 1, activeTabIdx || 0));
966
+ const original = { ...tabs[active], props: { ...(tabs[active].props || {}) } };
632
967
 
633
968
  const contentEl = document.createElement('div');
634
969
  contentEl.className = 'twm-window-content';
635
970
  contentEl.style.cssText = 'display:flex; flex-direction:column; flex:1; min-width:0; min-height:0; height:100%;';
971
+ // R8. THE STRIP IS INSIDE THE WINDOW. A pane's tabs and a pane's body
972
+ // are one thing — *"to window includes the tab-strip"* — so the window
973
+ // gets both, in the order the `top` layout draws them, and the content
974
+ // mounts into the BODY rather than into the window's content element.
975
+ // Everything that used to write straight into `contentEl` (see
976
+ // `openInWindow`) has to write into the body now, or it takes the strip
977
+ // with it the first time the window's content is replaced.
978
+ const tabBarEl = document.createElement('div');
979
+ tabBarEl.className = 'twm-window-tabbar';
980
+ const bodyEl = document.createElement('div');
981
+ bodyEl.className = 'twm-window-body';
982
+ bodyEl.style.cssText = 'display:flex; flex-direction:column; flex:1; min-width:0; min-height:0;';
983
+ contentEl.append(tabBarEl, bodyEl);
636
984
 
637
985
  const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
638
- const mountInfo = this.content.mount(original.kind, contentEl, original.props,
986
+ const mountInfo = this.content.mount(original.kind, bodyEl, original.props,
639
987
  { ...this.ctx, wm: this, windowId: winId });
640
988
  const win = new ManagedWindow({
641
989
  id: winId,
@@ -646,68 +994,1642 @@ export class WindowManager {
646
994
  canMaximize: true,
647
995
  canResize: true,
648
996
  modal: false,
649
- onClose: () => this._onManagedWindowClosed(winId, mountInfo),
997
+ // C15. Dropping a promoted window on a tile PUTS IT BACK — as that
998
+ // tile's content, as a split of it, or as one of its tabs. Off
999
+ // unless the embedder asked, because it changes what a drag to an
1000
+ // edge means.
1001
+ snap: this.snapPromotion,
1002
+ snapController: this.snapPromotion ? this._snapController() : null,
1003
+ // R1. THE PANE IS A BOX WITH `overflow: hidden`. A window contained
1004
+ // to one (C21) cannot be dragged a single pixel outside it, so
1005
+ // "drag a window from one tile to another" — the gesture all three
1006
+ // drop behaviours are built on — was not merely awkward, it was
1007
+ // invisible. For the length of a drag the window is re-parented
1008
+ // here, to the root every tile is inside; on release it goes back
1009
+ // into a pane, either the one it was dropped on or the one it came
1010
+ // from. Resolved per drag: the root outlives any tile, and a tile
1011
+ // grabbed once does not survive its own repaint.
1012
+ dragHost: () => this.rootEl,
1013
+ dragBounds: () => this._tileBounds(),
1014
+ // R7. MAXIMISE MEANS BACK TO TILE. This window came OUT of the
1015
+ // tree; the useful thing to do with it is put it back, and filling
1016
+ // the screen with it is the one gesture that makes putting it back
1017
+ // harder. So the maximize button docks — and the separate demote
1018
+ // button the WM used to inject beside it is gone, because two
1019
+ // buttons for one verb is how you get a chrome nobody reads.
1020
+ onMaximize: () => this.bringBackWindow(winId),
1021
+ maximizeIcon: 'close_fullscreen',
1022
+ maximizeTitle: 'Back to tile',
1023
+ onClose: () => this._onManagedWindowClosed(winId, null),
650
1024
  });
651
1025
 
652
1026
  this._windowToLeaf.set(winId, {
653
- // Promoting CLOSES the source tile the content lives in the
654
- // window now, not the tree. leafId is null so "back to tile"
655
- // re-docks into the primary tile (see _onManagedWindowClosed).
1027
+ // The floated tabs now live in the window, not the tree. leafId
1028
+ // is null so "back to tile" re-docks into the desktop's primary
1029
+ // tile (see _onManagedWindowClosed) — the source tile itself
1030
+ // survives (re-seeded with HOME when it was emptied).
656
1031
  leafId: null, desktopIdx, original, mountInfo, window: win,
657
- contentEl,
1032
+ contentEl, bodyEl, tabBarEl,
1033
+ // R8. THE PANE'S TABS TRAVEL WITH IT, and this is where they live
1034
+ // while the window is open. `original` still mirrors the ACTIVE one
1035
+ // so every existing reader of the record — `openInWindow`, each of
1036
+ // `_onManagedWindowClosed`'s docks — keeps working unchanged; the
1037
+ // list beside it is what makes a dock restore ALL of them.
1038
+ tabs, activeTabIdx: active, strip: null,
1039
+ // ══ C21. WRITTEN HERE, BEFORE THE TREE IS TOUCHED ═══════════
1040
+ //
1041
+ // `homeLeafId` is the pane this window stands on, and it used to be
1042
+ // assigned at the BOTTOM of this function — after `_seedHome`,
1043
+ // after `_mergeStartTiles`, after the repaint. That ordering
1044
+ // destroyed a tile per promotion, and it looked like a window bug
1045
+ // because a window is what the user had just moved.
1046
+ //
1047
+ // `_mergeStartTiles` (below) refuses to merge a start tile that has
1048
+ // a window standing on it, and `_paneHoldsWindows` answers that
1049
+ // question two ways: THIS FIELD, and a DOM probe for a
1050
+ // `.twm-managed-window` inside the leaf. At the old assignment point
1051
+ // neither could be true yet — the field was unwritten and the window
1052
+ // had not been `moveTo`'d into the pane — so the pane that was one
1053
+ // line away from becoming this window's ground answered *nothing
1054
+ // floats here* and was merged into its neighbour.
1055
+ //
1056
+ // Three panes floated one after another ended as ONE pane: the
1057
+ // first promotion left a start tile, the second merged its own
1058
+ // freshly-seeded tile away, and so did the third. Every window
1059
+ // after the first was then left with a `homeLeafId` naming a leaf
1060
+ // the tree no longer had — so the `moveTo` below was skipped and the
1061
+ // window never became contained, `_rehomeContainedWindows` found no
1062
+ // element to re-home it into, and `bringBackWindow` fell through to
1063
+ // the PRIMARY tile and docked as a tab onto the ground ANOTHER
1064
+ // window was standing on. That is the whole of the reported
1065
+ // *"expand one to a tile > influences others or even tiles lost"*.
1066
+ //
1067
+ // Writing it here is the smallest fix that closes all of it: a
1068
+ // guard that already existed starts being able to see the window it
1069
+ // was written to protect. The `moveTo` stays at the bottom, because
1070
+ // it needs the wrap the repaint rebuilds.
1071
+ homeLeafId: this.promoteInPlace ? leafId : null,
658
1072
  // Set to true by bringBackWindow so the close path knows to
659
1073
  // restore the content instead of destroying it.
660
1074
  _demoting: false,
661
1075
  });
1076
+ this._syncWindowTabs(winId);
1077
+
1078
+ // R9. ONE TAB out of a multi-tab pane: the siblings stay put. R8: the
1079
+ // pane is emptied and re-seeded, because the whole of it left.
1080
+ if (!wholePane && tabCount > 1) tree.removeLeafTab(leafId, tabIdx);
1081
+ else this._seedHome(tree, leafId);
662
1082
 
663
- // Close the origin tile so it doesn't linger as an empty
664
- // "(window)" placeholder. Re-canonicalize so panels reflow; the
665
- // desktop is allowed to end up with no content tile (panels can
666
- // fill it, and openInPrimary re-spawns one on demand) — same as
667
- // closeFocused on the last tile.
668
- tree.close(leafId);
1083
+ // R10. The pane that just emptied is a START TILE now, and so may be
1084
+ // the pane beside it. Two of them side by side are one surface with a
1085
+ // splitter through it for no reason see `_mergeStartTiles`, which is
1086
+ // where the rest of that argument lives.
1087
+ this._mergeStartTiles(tree, leafId);
1088
+
1089
+ tree.focus(leafId);
669
1090
  this._canonicalize(tree, this.desktops.active());
670
1091
 
671
1092
  this.renderer.render();
1093
+
1094
+ // CONFINED TO THE PANE IT CAME FROM, when the embedder asked for it.
1095
+ //
1096
+ // Done AFTER the render and BEFORE `show()`, and both halves of that
1097
+ // matter. After, because promoting re-seeds the source leaf, which
1098
+ // changes its cache key and REBUILDS its wrap — the element grabbed
1099
+ // before the render is destroyed by it. Before, because `moveTo` on a
1100
+ // window that has not been built yet simply records the container
1101
+ // (`managed_window.js` returns early on a null element), so `show()`
1102
+ // mounts straight into the pane instead of appearing at the viewport
1103
+ // and jumping.
1104
+ //
1105
+ // The LEAF WRAP, not the `.twm-slot` around it: slots are recreated on
1106
+ // every render (`tile_renderer.js` clears the root and rebuilds them)
1107
+ // while leaf wraps are cached and re-parented, so a window contained to
1108
+ // a slot would be orphaned the first time anything repainted. The wrap
1109
+ // fills the slot, so the two are the same box.
1110
+ if (this.promoteInPlace) {
1111
+ // `homeLeafId` was written with the record, above, and the argument
1112
+ // for that is there. It is what `_rehomeContainedWindows` re-parents
1113
+ // against after every repaint — without it the SECOND promotion out
1114
+ // of a two-tab pane deletes the first one's window: the leaf's wrap
1115
+ // is rebuilt when its tab set changes, and the first window was
1116
+ // parented into the wrap that the rebuild threw away.
1117
+ const paneEl = this.renderer.leafEl(leafId);
1118
+ if (paneEl) win.moveTo(paneEl);
1119
+ }
1120
+
672
1121
  win.show();
673
1122
  this._decorateManagedWindow(win, winId);
674
1123
  this._persist();
675
1124
  this._notifyChange('window-promoted');
1125
+ return winId;
1126
+ }
1127
+
1128
+ // ══ R8. The tab strip inside a floated pane ═══════════════════════
1129
+ /**
1130
+ * Draw (or hide) a window's tab strip, and keep its title honest.
1131
+ *
1132
+ * Hidden below two tabs, exactly as a pane's strip is
1133
+ * (`tile_renderer._renderTabBar`): the common case is one tab, and a strip
1134
+ * naming the one thing you are already looking at is a line of chrome
1135
+ * saying nothing. Building the strip lazily also means a window promoted
1136
+ * out of a single-tab pane costs no `NotebookTabBar` at all.
1137
+ */
1138
+ _syncWindowTabs(winId) {
1139
+ const rec = this._windowToLeaf.get(winId);
1140
+ if (!rec) return;
1141
+ const tabs = rec.tabs || [];
1142
+ rec.activeTabIdx = Math.max(0, Math.min(tabs.length - 1, rec.activeTabIdx || 0));
1143
+ if (tabs.length <= 1) {
1144
+ try { rec.strip?.dispose(); } catch { /* already gone */ }
1145
+ rec.strip = null;
1146
+ rec.tabBarEl?.classList.add('twm-window-tabbar--hidden');
1147
+ return;
1148
+ }
1149
+ rec.tabBarEl?.classList.remove('twm-window-tabbar--hidden');
1150
+ if (!rec.strip) {
1151
+ rec.strip = createTabStrip({
1152
+ hostEl: rec.tabBarEl,
1153
+ taxonomy: this.taxonomy,
1154
+ onAction: (action, data) => this._windowTabAction(winId, action, data),
1155
+ });
1156
+ }
1157
+ rec.strip.update(tabs, rec.activeTabIdx);
1158
+ }
1159
+
1160
+ /** The window strip's half of `_leafTabAction` — the same four verbs
1161
+ * against the window record instead of against the tree. */
1162
+ _windowTabAction(winId, action, data = {}) {
1163
+ const rec = this._windowToLeaf.get(winId);
1164
+ if (!rec) return;
1165
+ const tabs = rec.tabs || [];
1166
+ if (action === 'switch') {
1167
+ this.showWindowTab(winId, data.idx);
1168
+ return;
1169
+ }
1170
+ if (action === 'close') {
1171
+ if (data.idx < 0 || data.idx >= tabs.length) return;
1172
+ // THE LAST TAB CLOSES THE WINDOW, which is what closing the last
1173
+ // tab of a pane does to the pane (`_leafTabAction`). A window with
1174
+ // no content in it is a title bar over nothing.
1175
+ if (tabs.length <= 1) {
1176
+ try { rec.window.close({ force: true }); } catch { /* gone */ }
1177
+ return;
1178
+ }
1179
+ tabs.splice(data.idx, 1);
1180
+ if (data.idx < rec.activeTabIdx) rec.activeTabIdx -= 1;
1181
+ else if (data.idx === rec.activeTabIdx) {
1182
+ rec.activeTabIdx = Math.max(0, data.idx - 1);
1183
+ this._mountWindowTab(winId);
1184
+ }
1185
+ this._syncWindowTabs(winId);
1186
+ this._notifyChange('window-tab-close');
1187
+ return;
1188
+ }
1189
+ if (action === 'move') {
1190
+ const { from, to } = data;
1191
+ if (from == null || to == null) return;
1192
+ if (from < 0 || from >= tabs.length || to < 0 || to >= tabs.length) return;
1193
+ const moved = tabs.splice(from, 1)[0];
1194
+ tabs.splice(to, 0, moved);
1195
+ // The same index arithmetic `TileTree.moveLeafTab` does, for the
1196
+ // same reason: the tab that was showing must still be showing.
1197
+ if (rec.activeTabIdx === from) rec.activeTabIdx = to;
1198
+ else if (from < rec.activeTabIdx && to >= rec.activeTabIdx) rec.activeTabIdx -= 1;
1199
+ else if (from > rec.activeTabIdx && to <= rec.activeTabIdx) rec.activeTabIdx += 1;
1200
+ this._syncWindowTabs(winId);
1201
+ return;
1202
+ }
1203
+ if (action === 'menu') this._showWindowTabContextMenu(winId, data.idx, data.x, data.y);
1204
+ }
1205
+
1206
+ /** Show one of a floated pane's tabs. Public because a window is the only
1207
+ * place this list exists — nothing else can reach it. */
1208
+ showWindowTab(winId, idx) {
1209
+ const rec = this._windowToLeaf.get(winId);
1210
+ if (!rec) return false;
1211
+ const tabs = rec.tabs || [];
1212
+ if (!Number.isInteger(idx) || idx < 0 || idx >= tabs.length) return false;
1213
+ if (idx === rec.activeTabIdx) return true;
1214
+ rec.activeTabIdx = idx;
1215
+ this._mountWindowTab(winId);
1216
+ this._syncWindowTabs(winId);
1217
+ this._persist();
1218
+ this._notifyChange('window-tab-switch');
1219
+ return true;
1220
+ }
1221
+
1222
+ /** Tear the current mount down and mount the active tab in its place.
1223
+ * `rec.original` follows, so a later dock puts back what is on screen. */
1224
+ _mountWindowTab(winId) {
1225
+ const rec = this._windowToLeaf.get(winId);
1226
+ if (!rec) return;
1227
+ const tab = (rec.tabs || [])[rec.activeTabIdx];
1228
+ if (!tab) return;
1229
+ try { rec.mountInfo?.destroy?.(); } catch { /* factory threw on the way out */ }
1230
+ rec.bodyEl.innerHTML = '';
1231
+ rec.mountInfo = this.content.mount(tab.kind, rec.bodyEl, tab.props || {},
1232
+ { ...this.ctx, wm: this, windowId: winId });
1233
+ rec.original = { kind: tab.kind, props: { ...(tab.props || {}) },
1234
+ title: rec.mountInfo?.title || tab.title || tab.kind };
1235
+ this._setWindowTitle(rec, rec.original.title);
1236
+ }
1237
+
1238
+ /** The window's title, in both places it is kept. */
1239
+ _setWindowTitle(rec, title) {
1240
+ try {
1241
+ const titleEl = rec.window?.element?.querySelector('.twm-managed-window__title');
1242
+ if (titleEl) titleEl.textContent = title;
1243
+ if (rec.window) rec.window.title = title;
1244
+ } catch { /* the window is already torn down */ }
1245
+ }
1246
+
1247
+ /** The window strip's context menu. Deliberately the close verbs and
1248
+ * nothing else: a tab in a window is already out of the tree, so
1249
+ * "open in a window" — the verb R9 adds to a PANE's tab menu — has
1250
+ * nowhere further to go. */
1251
+ _showWindowTabContextMenu(winId, idx, x, y) {
1252
+ const rec = this._windowToLeaf.get(winId);
1253
+ const tabs = rec?.tabs || [];
1254
+ if (!tabs.length) return;
1255
+ const items = [{ label: 'Close tab', icon: 'close', action: 'close' }];
1256
+ if (tabs.length > 1) {
1257
+ items.push({ label: 'Close other tabs', icon: 'tab_close', action: 'close-others' });
1258
+ }
1259
+ showContextMenu(x, y, items, (action) => {
1260
+ const live = this._windowToLeaf.get(winId);
1261
+ if (!live) return;
1262
+ if (action === 'close') this._windowTabAction(winId, 'close', { idx });
1263
+ else if (action === 'close-others') {
1264
+ const keep = live.tabs[idx];
1265
+ if (!keep) return;
1266
+ // RE-MOUNT ONLY IF THE KEPT TAB IS NOT THE ONE ON SCREEN.
1267
+ // Mounting is destroy-then-build, and a content factory that
1268
+ // rebuilds is a factory that can lose what is staged in it —
1269
+ // "close the other tabs" must not be a way to discard the edits
1270
+ // in the tab you kept.
1271
+ const remount = idx !== live.activeTabIdx;
1272
+ live.tabs = [keep];
1273
+ live.activeTabIdx = 0;
1274
+ if (remount) this._mountWindowTab(winId);
1275
+ this._syncWindowTabs(winId);
1276
+ this._notifyChange('window-tab-close-others');
1277
+ }
1278
+ });
1279
+ }
1280
+
1281
+ /**
1282
+ * Re-parent every pane-contained window into its pane's CURRENT wrap.
1283
+ *
1284
+ * Called after each repaint. A leaf's wrap is cached per (kind, props, tab
1285
+ * fingerprint) and rebuilt when any of those change, so a window parented
1286
+ * into it is thrown away with the old wrap — silently, because nothing
1287
+ * throws and the window object is still perfectly alive.
1288
+ *
1289
+ * `moveTo` returns false when the container has not changed, so this is a
1290
+ * no-op on every repaint that did not rebuild the pane in question.
1291
+ */
1292
+ _rehomeContainedWindows() {
1293
+ if (!this.promoteInPlace) return;
1294
+ for (const [, rec] of this._windowToLeaf) {
1295
+ if (!rec.homeLeafId || !rec.window) continue;
1296
+ // R1. NOT WHILE IT IS BEING DRAGGED OUT. A window mid-escape is
1297
+ // parented to the root deliberately and is following the pointer in
1298
+ // the root's coordinates; re-homing it into its pane here would clip
1299
+ // it, re-clamp it, and leave the rest of the drag computing deltas
1300
+ // against a frame that moved underneath it. The escape puts it back
1301
+ // itself on release, and adopts the current wrap when it does.
1302
+ if (rec.window.dragOrigin) continue;
1303
+ // R13, NOW A GUARD RATHER THAN A PATH. NOR WHILE IT IS MAXIMISED
1304
+ // ONTO THE DRAG HOST. A window filling the layer is parented to the
1305
+ // ROOT of necessity — `.twm-leaf` is `overflow: hidden`, so a
1306
+ // window bigger than one tile cannot be a child of one — and
1307
+ // `moveTo` re-clamps into the new container without converting, so
1308
+ // re-homing it here would shrink it back into the pane on the first
1309
+ // repaint after the gesture.
1310
+ //
1311
+ // WHICH ROUTE STILL REACHES IT, honestly stated, because a guard
1312
+ // whose route nobody can name is dead code wearing a comment. R14
1313
+ // removed the one this was WRITTEN for: the aero-snap top edge used
1314
+ // to maximise onto the layer and now docks, so no gesture in this
1315
+ // layer leaves a window maximised on the root any more. What
1316
+ // remains is the library API — `toggleMaximize({claimable: false})`
1317
+ // is public, documented on `ManagedWindow` as the way past a
1318
+ // consumer's claim, and an embedder that calls it on an adopted
1319
+ // window sitting on the drag host produces exactly this state. It
1320
+ // costs one comparison to keep and a silently shrinking window to
1321
+ // remove, so it stays.
1322
+ //
1323
+ // BOTH CONDITIONS, and neither alone. `isMaximized` by itself would
1324
+ // also skip a window maximised INSIDE its pane — that one still has
1325
+ // to follow its wrap when the wrap is rebuilt, which is the whole
1326
+ // job of this pass, and `snap_zones.test.mjs` asserts it. The
1327
+ // container by itself would skip a window the escape left on the
1328
+ // root because its origin was destroyed mid-drag, which is
1329
+ // precisely the case this pass exists to adopt.
1330
+ if (rec.window.isMaximized && rec.window.container === this.rootEl) continue;
1331
+
1332
+ // ══ C21. A WINDOW BELONGS TO ONE DESKTOP ═══════════════════
1333
+ //
1334
+ // …and is in the document only while that desktop is on screen.
1335
+ // That has always been TRUE and was never STATED: switching
1336
+ // desktops evicts the old tree's leaves from the wrap cache
1337
+ // (`tile_renderer._cleanCache`), which takes the windows standing
1338
+ // on them out of the document as a side effect. Saying it here is
1339
+ // what lets `moveWindowToDesktop` actually move a contained window
1340
+ // — it re-points the record and this pass takes the element off
1341
+ // the page the window has just left. Coming back re-parents it:
1342
+ // the destination's wraps are rebuilt on arrival, so `moveTo` sees
1343
+ // a container it has not seen and appends.
1344
+ if (rec.desktopIdx !== this.desktops.activeIdx) {
1345
+ if (!rec.homeContainer && rec.window.element?.isConnected) {
1346
+ try { rec.window.element.remove(); } catch { /* already out */ }
1347
+ }
1348
+ continue;
1349
+ }
1350
+
1351
+ // ══ A HOME LEAF THAT HAS LEFT THE TREE IS REPAIRED, NOT SKIPPED ══
1352
+ //
1353
+ // *"tiles must only influence tiles, not windows"* — the product
1354
+ // owner, 2026-09-01. A tile can be destroyed by four gestures
1355
+ // (`closeFocused`, the tab strip's × on a last tab,
1356
+ // `moveFocusedToDesktop`, and `_mergeStartTiles`) and NONE of them
1357
+ // asked what was standing on it. `_paneHoldsWindows` — the guard
1358
+ // written for exactly that question — is consulted from one place
1359
+ // in the whole file, and no close path is it.
1360
+ //
1361
+ // What happened when a pane with a window on it was closed:
1362
+ // `_cleanCache` removed the wrap, and the window's element with it,
1363
+ // twenty-three lines BEFORE this pass runs. The window was left
1364
+ // alive with `isVisible: true` and `isMinimized: false` — so it was
1365
+ // on no surface AND in no taskbar (Tables' strip lists the
1366
+ // minimised), holding a mounted grid and its staged edits, with no
1367
+ // chrome to press and no route back. Reproduced end to end.
1368
+ //
1369
+ // Repairing it HERE rather than at each of the four call sites is
1370
+ // deliberate: this pass already runs after every render, it already
1371
+ // owns *where a contained window is parented*, and a fifth gesture
1372
+ // that closes a leaf gets the same answer without knowing this rule
1373
+ // exists. The window keeps floating — it re-homes onto the tile
1374
+ // that survived rather than docking itself — which is the ruling:
1375
+ // a tile operation may move a window, never convert or destroy it.
1376
+ //
1377
+ // NOT for an adopted window (`homeContainer`): the embedder resolves
1378
+ // its own ground and owns the lifetime of the box it stands in.
1379
+ if (!rec.homeContainer && rec.homeLeafId) {
1380
+ const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
1381
+ if (tree && !tree.get(rec.homeLeafId)) {
1382
+ // A TREE WITH NO CONTENT LEAF GETS ONE. `closeFocused`
1383
+ // keeps that invariant itself, but `moveFocusedToDesktop`
1384
+ // did not — with a panel open it leaves a page holding
1385
+ // nothing but chrome, and then there is no ground for the
1386
+ // windows that were standing there. Spawning it here is the
1387
+ // same call `closeFocused` makes and the same tile a fresh
1388
+ // desktop starts with; leaving `homeLeafId` dangling would
1389
+ // put the window back in the state above.
1390
+ let survivor = tree.primaryLeafId();
1391
+ if (!survivor) {
1392
+ const spawned = this._spawnContentLeaf(tree);
1393
+ if (spawned) {
1394
+ this._seedHome(tree, spawned);
1395
+ this._canonicalize(tree, this.desktops.desktops[rec.desktopIdx]);
1396
+ survivor = tree.primaryLeafId();
1397
+ }
1398
+ }
1399
+ if (survivor) rec.homeLeafId = survivor;
1400
+ }
1401
+ }
1402
+
1403
+ // R11. AN ADOPTED WINDOW IS CONTAINED TO A BOX INSIDE THE PANE,
1404
+ // not to the pane itself — a canvas ground sits within the leaf
1405
+ // wrap, under whatever chrome the embedder draws above it. Re-homing
1406
+ // such a window to the wrap would lift it out of its ground and
1407
+ // stand it over that chrome, which is a slower and stranger version
1408
+ // of the bug this whole function exists to prevent.
1409
+ const paneEl = rec.homeContainer
1410
+ ? (rec.homeContainer() || null)
1411
+ : this.renderer.leafEl(rec.homeLeafId);
1412
+ // NO PANE ELEMENT, ON THE ACTIVE DESKTOP, AFTER THE REPAIR ABOVE.
1413
+ // What is left is an embedder's ground that has not mounted yet and
1414
+ // a tree with no content leaf at all — neither of which is a window
1415
+ // to rescue. The sentence that used to stand here said "closed, or
1416
+ // on another desktop … rather than orphaning it into a detached
1417
+ // node", and both halves were wrong: the other desktop is handled
1418
+ // above, and by the time this ran on a CLOSED pane the node was
1419
+ // already detached — `_cleanCache` (`tile_renderer.js`) runs before
1420
+ // `onAfterRender`, not after it.
1421
+ if (!paneEl) continue;
1422
+ if (paneEl === rec.window.container) {
1423
+ // THE CONTAINER IS UNCHANGED AND THE ELEMENT IS NOT IN IT.
1424
+ //
1425
+ // The desktop rule above takes a window off the page it has
1426
+ // left by removing the ELEMENT; it deliberately leaves
1427
+ // `container` alone, because the wrap is still the window's
1428
+ // home and re-pointing it would lose that. Coming back through
1429
+ // a desktop SWITCH is safe — the wrap is evicted and rebuilt,
1430
+ // so `moveTo` sees a new container and appends. Coming back any
1431
+ // other way is not: `moveWindowToDesktop` and `removeDesktop`
1432
+ // re-point `desktopIdx` and render with the same tree on
1433
+ // screen, so the wrap is still cached, `moveTo` returns false
1434
+ // for an unchanged container, and the window stayed detached
1435
+ // for good — the exact state this whole pass exists to prevent,
1436
+ // reached through the desktop door instead of the close door.
1437
+ //
1438
+ // `appendChild` rather than `moveTo`: nothing about the
1439
+ // window's home has changed, so there is no clamp to redo, no
1440
+ // resize observer to rebuild and no `managed-window-moved` to
1441
+ // announce. It is the same element going back into the same
1442
+ // box.
1443
+ const el = rec.window.element;
1444
+ if (el && !el.isConnected) {
1445
+ try { paneEl.appendChild(el); }
1446
+ catch (err) { console.warn('[wm] re-attach failed', err); }
1447
+ }
1448
+ continue;
1449
+ }
1450
+ try { rec.window.moveTo(paneEl); }
1451
+ catch (err) { console.warn('[wm] re-home failed', err); }
1452
+ }
676
1453
  }
677
1454
 
678
- /** Dock the window's content back into the desktop's primary tile
679
- * (the source tile was closed on promote), then close the window. */
1455
+ /**
1456
+ * C21, as a verb a consumer can call: put THIS window back where it belongs
1457
+ * and say whether it moved.
1458
+ *
1459
+ * The taskbar needs it. A minimised window's element may be out of the
1460
+ * document — its desktop is not on screen, or its pane was closed — and
1461
+ * un-minimising it in that state clears `isMinimized` (so its button
1462
+ * disappears, the last handle on it) while showing nothing. `restore` has
1463
+ * to be able to repair the window BEFORE it makes it visible, and
1464
+ * `_rehomeContainedWindows` is the thing that knows how; it was simply not
1465
+ * reachable, and `taskbar.js`'s own docstring asserted it ran for these
1466
+ * windows when the guard above meant it did not.
1467
+ *
1468
+ * IT SWITCHES DESKTOPS WHEN IT HAS TO, and that is the half a bare re-home
1469
+ * cannot do. A window belongs to one desktop; if that desktop is not on
1470
+ * screen, the honest answer to *show me this window* is the one every
1471
+ * taskbar in every window manager gives — go to where it lives. Restoring
1472
+ * it onto the page the user happens to be looking at would move a window
1473
+ * between pages as a side effect of asking to see it, and that is a tile
1474
+ * decision being made by a window verb.
1475
+ *
1476
+ * @param {object} win a live ManagedWindow
1477
+ * @returns {boolean} whether this WM owns it (and so has revealed it)
1478
+ */
1479
+ revealWindow(win) {
1480
+ if (!win) return false;
1481
+ let rec = null;
1482
+ for (const [, r] of this._windowToLeaf) {
1483
+ if (r.window === win) { rec = r; break; }
1484
+ }
1485
+ if (!rec) return false;
1486
+ // AN UNCONTAINED WINDOW HAS NO PAGE, SO THERE IS NOTHING TO REVEAL.
1487
+ // `_navigateWindow`'s windows (Alt+N, "Open a copy in a window") float
1488
+ // over the root and stay visible on every desktop; `desktopIdx` on such
1489
+ // a record is only where it happened to be OPENED. Switching to it
1490
+ // would throw the user off the page they are on to show them a window
1491
+ // that was already in front of them. Returning false hands the restore
1492
+ // back to the embedder, which is right for the canvas registry's
1493
+ // windows too.
1494
+ if (!rec.homeLeafId && !rec.homeContainer) return false;
1495
+ if (rec.desktopIdx !== this.desktops.activeIdx
1496
+ && this.desktops.desktops[rec.desktopIdx]) {
1497
+ // Renders, and the render re-homes — so there is nothing to do
1498
+ // afterwards, and doing it twice would be a second repaint.
1499
+ this.switchDesktop(rec.desktopIdx);
1500
+ } else {
1501
+ this._rehomeContainedWindows();
1502
+ }
1503
+ return true;
1504
+ }
1505
+
1506
+ /**
1507
+ * R12. The rectangle an ESCAPED window may occupy — the tiles, and not the
1508
+ * panels — in the root's own coordinates.
1509
+ *
1510
+ * R1 let a window leave its pane so it could reach another one, and the
1511
+ * cheapest box to let it leave into is the root every tile shares. But the
1512
+ * root holds the docked panels too, so the bottom edge stopped being an
1513
+ * edge: a window could be dragged down over the bottom panel and dropped
1514
+ * there, half-covering a surface that has its own scroll and its own
1515
+ * chrome, with no way to tell it had happened except that it looked wrong.
1516
+ *
1517
+ * The answer is the UNION OF THE CONTENT LEAVES rather than "the root minus
1518
+ * the panel I know about": panels dock left, right and bottom, an embedder
1519
+ * may show any combination of them, and each one may be collapsed. A union
1520
+ * of the tiles is right for all of those without enumerating any of them,
1521
+ * and it degrades to the root when a desktop is somehow all panel.
1522
+ */
1523
+ _tileBounds() {
1524
+ const root = this.rootEl;
1525
+ if (!root) return null;
1526
+ const layer = this._layerRect();
1527
+ if (!layer) return null;
1528
+ const rootRect = root.getBoundingClientRect();
1529
+ // Into the host's coordinates. `left`/`top` are written against the
1530
+ // PADDING box, so the root's own border comes off as well — the same
1531
+ // conversion the escape does, and for the same reason.
1532
+ const ox = rootRect.left + root.clientLeft - root.scrollLeft;
1533
+ const oy = rootRect.top + root.clientTop - root.scrollTop;
1534
+ return { minX: layer.left - ox, minY: layer.top - oy,
1535
+ width: layer.width, height: layer.height };
1536
+ }
1537
+
1538
+ /**
1539
+ * R13. THE LAYER, in the VIEWPORT pixels a hit-test speaks — the union of
1540
+ * the content leaves, before it is converted into anybody's coordinates.
1541
+ *
1542
+ * This is `_tileBounds` with the last step taken off, and it stays a
1543
+ * separate function rather than being folded back into it because the two
1544
+ * frames have different readers: `_tileBounds` answers `dragBounds`, which
1545
+ * `ManagedWindow._bounds()` uses to clamp a window in the ROOT's
1546
+ * coordinates, and this answers anything measuring against the page.
1547
+ *
1548
+ * R14 REMOVED ITS OTHER READER. R13's maximise preview was drawn from here
1549
+ * so that it would be the same measurement `toggleMaximize` would deliver
1550
+ * through `dragBounds` — C15's rule, THE PREVIEW MAY NOT PROMISE A
1551
+ * RECTANGLE THE DROP DOES NOT DELIVER, applied to the one mode that did not
1552
+ * dock. There is no such mode now: the top edge docks like every other
1553
+ * zone, its preview is the TILE (`_homeDockTarget`), and the layer's only
1554
+ * remaining job is the clamp. Kept as its own function because the clamp
1555
+ * still needs the union of the CONTENT leaves rather than the root, which
1556
+ * is a definition, not a call site.
1557
+ *
1558
+ * The union of the CONTENT LEAVES rather than the root, for the reason
1559
+ * `_tileBounds` gives at length: the root holds the docked panels too.
1560
+ *
1561
+ * Null when nothing has a box yet — a layout that has not happened, a
1562
+ * desktop whose tiles are all zero-sized. Every caller treats that as "do
1563
+ * not promise anything", which is the only honest answer available.
1564
+ */
1565
+ _layerRect() {
1566
+ let l = Infinity, tp = Infinity, r = -Infinity, b = -Infinity;
1567
+ for (const leaf of this._tree().leaves()) {
1568
+ if (PANEL_KINDS.has(leaf.content?.kind)) continue;
1569
+ const el = this.renderer.leafEl(leaf.id);
1570
+ if (!el) continue;
1571
+ const box = el.getBoundingClientRect();
1572
+ if (!box.width || !box.height) continue;
1573
+ l = Math.min(l, box.left); tp = Math.min(tp, box.top);
1574
+ r = Math.max(r, box.right); b = Math.max(b, box.bottom);
1575
+ }
1576
+ if (!Number.isFinite(l)) return null;
1577
+ return { left: l, top: tp, width: r - l, height: b - tp };
1578
+ }
1579
+
1580
+ /** Dock the window's content back into the tile it came from — or, when it
1581
+ * came from none, into the desktop's primary tile — then close the window.
1582
+ *
1583
+ * R7. This is what the MAXIMIZE button now does, so it is reached far more
1584
+ * often than it was as a button of its own, and "somewhere other than where
1585
+ * the window came from" stopped being a defensible answer. Promoting a pane
1586
+ * re-seeds it with the root kind — ground for the window to stand on — so
1587
+ * FILLING that pane is the exact inverse: the content goes back where it
1588
+ * was lifted from, replacing the ground it has been standing on.
1589
+ *
1590
+ * A home pane that has since acquired content is a different story. The
1591
+ * user opened something there, and replacing it would destroy work the
1592
+ * window knows nothing about, so the content joins it as a tab instead.
1593
+ * With no home pane at all — an Alt+N window, or any window under an
1594
+ * embedder that does not confine promotions — this is the primary-tile tab
1595
+ * it has always been. */
680
1596
  bringBackWindow(windowId) {
681
1597
  const rec = this._windowToLeaf.get(windowId);
682
- if (!rec) return;
1598
+ // C28. IT SAYS SO WHEN IT DOES NOTHING. This resolves through
1599
+ // `_windowToLeaf` and is silent for a window the WM never built and
1600
+ // never adopted — correct, since there is no tile to bring such a
1601
+ // window back to. The `onMaximize` wrappers below returned `true`
1602
+ // regardless, so `toggleMaximize` treated the gesture as CLAIMED and
1603
+ // returned early, and the window neither docked nor maximised: the
1604
+ // button and the double-click both did nothing at all, which is a
1605
+ // dead control rather than a limitation anybody can read. Returning
1606
+ // false lets the geometric maximise happen instead — the answer every
1607
+ // other window manager gives a window with nowhere to go back to.
1608
+ if (!rec) return false;
1609
+ const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
1610
+ const home = rec.homeLeafId ? tree?.get(rec.homeLeafId) : null;
1611
+ if (home && home.kind === 'leaf') {
1612
+ rec._dock = { leafId: rec.homeLeafId,
1613
+ mode: this._isStartTile(home) ? 'fill' : 'tab' };
1614
+ }
683
1615
  rec._demoting = true;
684
1616
  try { rec.window.close({ force: true }); }
685
1617
  catch (err) { console.warn('[wm] bringBack: close failed', err); }
1618
+ return true;
1619
+ }
1620
+
1621
+ /**
1622
+ * R11. ADOPT A WINDOW THE EMBEDDER BUILT ITSELF.
1623
+ *
1624
+ * Everything R1–R10 gave a window — escaping its pane for the length of a
1625
+ * drag, going half-transparent once it is outside, the edge/body/ground
1626
+ * drops, maximise meaning *back to tile* — is wired in `_promote`, and so
1627
+ * belongs only to windows this WM lifted out of the tree. An embedder that
1628
+ * stands its own `ManagedWindow` on a pane (`snap: true` against the pane's
1629
+ * ground) got none of it: `_snapCommit` resolves the window through
1630
+ * `_windowToLeaf` and returns false for one it never built, so every drop
1631
+ * previewed correctly and then quietly did nothing.
1632
+ *
1633
+ * The fix is not to make the WM build those windows — the embedder has its
1634
+ * own reasons for the ones it builds, and taking that over would mean
1635
+ * taking over their content, their identity and their lifetime. It is to
1636
+ * let a window JOIN the tree's world after the fact, which needs exactly
1637
+ * two things: the drag options set on the component, and a record saying
1638
+ * what content to restore when the window is docked.
1639
+ *
1640
+ * CALL THIS BEFORE `show()`. `maximizeIcon` is read when the chrome is
1641
+ * built (`managed_window.js:703`) and the chrome is built lazily by `show`
1642
+ * (`:281`), so a window adopted afterwards would carry the right behaviour
1643
+ * behind a button still drawing a square.
1644
+ *
1645
+ * TEARDOWN STAYS THE EMBEDDER'S. `mountInfo` is optional and normally
1646
+ * omitted: a window that already destroys its own content in its `onClose`
1647
+ * would otherwise destroy it twice, once here and once there. The
1648
+ * embedder's handler is chained, not replaced, and runs after this one — so
1649
+ * a dock has already re-mounted the content into the tile by the time the
1650
+ * window's own teardown disposes of the copy that was floating.
1651
+ *
1652
+ * @param {object} win a live ManagedWindow, not yet shown
1653
+ * @param {object} spec
1654
+ * @param {string} spec.kind content kind to restore into a tile
1655
+ * @param {object} [spec.props] its props
1656
+ * @param {string} [spec.title] the tab title after a dock
1657
+ * @param {string} [spec.homeLeafId] the pane it stands on: what "back to
1658
+ * tile" targets, and what the probe stays silent inside
1659
+ * @param {function} [spec.homeContainer] `() => HTMLElement` — the box
1660
+ * WITHIN that pane the window is contained to. A canvas pane's
1661
+ * ground is not the leaf wrap, and re-homing to the wrap after a
1662
+ * repaint would lift the window out of the ground it belongs to.
1663
+ * @param {object} [spec.mountInfo] `{destroy}`, if teardown is ours
1664
+ * @returns {string|null} the window id, or null if it could not be adopted
1665
+ */
1666
+ adoptWindow(win, spec = {}) {
1667
+ const winId = win?.id;
1668
+ if (!winId || !spec.kind) return null;
1669
+ if (this._windowToLeaf.has(winId)) return winId;
1670
+
1671
+ // The same bag `_promote` builds, and deliberately the same values: two
1672
+ // windows on one desktop behaving differently under the same gesture is
1673
+ // the kind of difference a user reads as a bug in whichever one they
1674
+ // tried second.
1675
+ if (this.snapPromotion) {
1676
+ win.snap = win.snap && win.canDrag && win.canResize;
1677
+ win.snapController = this._snapController();
1678
+ }
1679
+ win.dragHost = () => this.rootEl;
1680
+ win.dragBounds = () => this._tileBounds();
1681
+ win.onMaximize = () => this.bringBackWindow(winId);
1682
+ win.maximizeIcon = 'close_fullscreen';
1683
+ win.maximizeTitle = 'Back to tile';
1684
+
1685
+ const original = { kind: spec.kind, props: spec.props || {},
1686
+ title: spec.title || spec.kind };
1687
+ this._windowToLeaf.set(winId, {
1688
+ leafId: null,
1689
+ desktopIdx: this.desktops.activeIdx,
1690
+ original,
1691
+ mountInfo: spec.mountInfo || null,
1692
+ window: win,
1693
+ contentEl: null, bodyEl: null, tabBarEl: null,
1694
+ tabs: [original], activeTabIdx: 0, strip: null,
1695
+ homeLeafId: spec.homeLeafId || null,
1696
+ homeContainer: spec.homeContainer || null,
1697
+ adopted: true,
1698
+ _demoting: false,
1699
+ });
1700
+
1701
+ const prior = win.onClose;
1702
+ win.onClose = () => {
1703
+ this._onManagedWindowClosed(winId, null);
1704
+ prior?.();
1705
+ };
1706
+ return winId;
686
1707
  }
687
1708
 
688
- /** Re-home a managed window to another desktop. The window itself
689
- * stays on screen (managed windows are global) and leaves no tile
690
- * behind on either desktop; only its "home" changes, so bringing it
691
- * back will land on the new desktop's primary tile. */
1709
+ // ══ C15. Snap-to-promote ══════════════════════════════════════════
1710
+ /**
1711
+ * The snap controller a promoted window is given. It answers the two
1712
+ * questions ManagedWindow's own C11 snap cannot, because both are about a
1713
+ * tree it does not know exists:
1714
+ *
1715
+ * probe which TILE is under the pointer, and — since R15 — which edge
1716
+ * of it THE DRAGGED WINDOW'S OWN BORDERS have reached, and what
1717
+ * would dropping there actually produce: a half of that tile, a
1718
+ * quarter of the layer, the tile entire, or (R13, at the top edge
1719
+ * of the pane the window already stands on) the whole layer,
1720
+ * which is the one answer that is not a dock at all. The preview
1721
+ * draws exactly that rectangle, because a preview that promises a
1722
+ * half and delivers a quarter is worse than no preview.
1723
+ * commit put the window in the tree — or, for R13's maximise, leave it
1724
+ * floating and give it the layer. Over an EMPTY tile the dock is
1725
+ * unambiguous and happens on release. Over an OCCUPIED tile the
1726
+ * edges are unambiguous too — the drag chose a side, so the
1727
+ * side is the split — and only the CENTRE was ever genuinely a
1728
+ * question, which is why it is the zone that changed most.
1729
+ *
1730
+ * Built once and reused: the probe runs per pointermove and allocating a
1731
+ * closure per window per drag is free, but the memo keeps the identity
1732
+ * stable for anyone comparing controllers.
1733
+ */
1734
+ _snapController() {
1735
+ if (this._snapCtl) return this._snapCtl;
1736
+ this._snapCtl = {
1737
+ probe: (e, win) => this._snapProbe(e, win),
1738
+ commit: (probe, win) => this._snapCommit(probe, win),
1739
+ };
1740
+ return this._snapCtl;
1741
+ }
1742
+
1743
+ /**
1744
+ * How close to a tile's edge the DRAGGED WINDOW'S matching edge must come
1745
+ * for a dock to arm — in PIXELS, and a narrow band. Since R15 it is also
1746
+ * the minimum distance the drag must have travelled toward that edge
1747
+ * inside the window's own pane; `_snapSide` argues both, and this is the
1748
+ * one constant either of them is measured in.
1749
+ *
1750
+ * This was a third of the tile, measured as a fraction, with the remaining
1751
+ * middle ninth treated as a fourth zone that offered a three-way choice.
1752
+ * Both halves of that were wrong, and together they made docking the
1753
+ * DEFAULT rather than a deliberate gesture:
1754
+ *
1755
+ * - A fraction means the band grows with the tile. On a maximised layer
1756
+ * a "third" is several hundred pixels, so a window could not be moved
1757
+ * anywhere near the left half of the screen without arming a split.
1758
+ * - The centre zone armed over the whole middle of every tile and
1759
+ * previewed the ENTIRE tile, so simply picking a window up and moving
1760
+ * it a few pixels lit the whole pane. Every move looked like a dock
1761
+ * because every move WAS one.
1762
+ *
1763
+ * Aero snap is an edge gesture: you push THE WINDOW at an edge — which is
1764
+ * what R15 finally made it measure. So the band is a fixed 28px from the
1765
+ * edge, and what lies past it is decided by the
1766
+ * pane rather than by the pointer: in the window's OWN pane the centre
1767
+ * arms nothing at all and the drop is simply a window that moved (R2), and
1768
+ * in any other pane it is the non-destructive tab or fill of R5/R6. The
1769
+ * band itself never grows with the tile, which is the whole of the fix.
1770
+ * Docking a whole tile is also still available without any drag at all —
1771
+ * the "back to tile" button in the window's own chrome, which names the
1772
+ * destination instead of guessing it.
1773
+ */
1774
+ static get SNAP_EDGE_PX() { return 28; }
1775
+
1776
+ _snapProbe(e, win) {
1777
+ const leafEl = this._leafElAt(e.clientX, e.clientY, win);
1778
+ if (!leafEl) return null;
1779
+ // R2, NARROWED BY R13. THE PANE IT CAME FROM ARMS AT ITS EDGES AND
1780
+ // NOWHERE ELSE — its centre still arms nothing at all.
1781
+ //
1782
+ // The rule was a blanket one: the origin pane armed nothing anywhere,
1783
+ // neither an edge nor the centre. It answered a real complaint — a
1784
+ // window that lives on a pane could not be nudged two pixels without
1785
+ // the pane lighting up to say it was about to swallow it — but it
1786
+ // answered it with more than the complaint asked for. A nudge happens
1787
+ // in the MIDDLE of a pane, which is where a window sits and where a
1788
+ // hand moving it goes; the 28px edge bands are somewhere a drag only
1789
+ // arrives on purpose — which under R15, where the bands are tested
1790
+ // against the WINDOW's borders and a window can already be sitting in
1791
+ // one before the hand touches it, is true only because `_snapSide`'s
1792
+ // direction guard makes it true. That guard is R2's other half and it
1793
+ // is why it applies in this pane and nowhere else.
1794
+ // So the centre is still silent, the nudge is still
1795
+ // fixed in full, and the edges of a window's own pane now mean what
1796
+ // they mean in every other pane — with one addition, TOP, which in the
1797
+ // pane a window already occupies has nothing to split and means
1798
+ // MAXIMISE instead (see below).
1799
+ //
1800
+ // DECIDED HERE, SPENT BELOW. `dragOrigin` is in hand at this point and
1801
+ // the answer cannot change while the probe runs, but which zone it
1802
+ // applies to is not known until `side` is. The origin is the window's
1803
+ // own answer (`dragOrigin` — the container its drag escaped), so the
1804
+ // two halves of the rule cannot disagree: the same boundary that turns
1805
+ // the window half-transparent is the one that arms the probe.
1806
+ //
1807
+ // A window with no origin — one opened straight into a float, which
1808
+ // never belonged to a pane — has no inside to be in, and every pane
1809
+ // under it is foreign. It arms everywhere, which is correct: there is
1810
+ // no "just moving it around at home" for a window with no home. It also
1811
+ // never maximises by this gesture, and that is not an omission — see
1812
+ // the maximise branch, which can only promise the layer for a window
1813
+ // whose bounds are the layer.
1814
+ //
1815
+ // CONTAINS, not equals. The WM confines its own promotions to the leaf
1816
+ // WRAP, but an embedder is free to confine a window to a box it built
1817
+ // inside the leaf — a canvas pane whose ground is a sibling of its own
1818
+ // taskbar strip does exactly that — and that window's home pane is the
1819
+ // leaf around it. `contains` is true of the element itself, so the
1820
+ // WM's own case is the same test.
1821
+ const own = !!(win?.dragOrigin && leafEl.contains(win.dragOrigin));
1822
+ const leafId = leafEl.dataset.leafId;
1823
+ // THE ACTIVE DESKTOP, and the dock has to agree. A promoted window is
1824
+ // global — it stays on screen across a desktop switch — so the tile
1825
+ // under the pointer belongs to whichever desktop is showing, not to the
1826
+ // one the window was promoted from. `_onManagedWindowClosed` applies
1827
+ // `_dock` against `rec.desktopIdx`, so the drop carries the index it
1828
+ // was made on and re-homes the record to it.
1829
+ const desktopIdx = this.desktops.activeIdx;
1830
+ const leaf = this._tree().get(leafId);
1831
+ if (!leaf || leaf.kind !== 'leaf') return null;
1832
+ // Panel tiles are chrome, not content. A window dropped on the
1833
+ // navigator has nowhere to go and the preview must not suggest it has.
1834
+ if (String(leaf.content?.kind || '').startsWith('panel:')) return null;
1835
+
1836
+ const r = leafEl.getBoundingClientRect();
1837
+ // R4, RE-AIMED BY R15. PIXELS FROM EACH EDGE, not fractions of the
1838
+ // tile — and the edges measured are now THE DRAGGED WINDOW'S, not the
1839
+ // pointer's. `_snapSide` owns that argument in full, including the
1840
+ // corner precedence and the one case that still falls back to the
1841
+ // pointer.
1842
+ //
1843
+ // One exclusion survives unchanged: a leaf with NO content at all has
1844
+ // no edges, because splitting nothing produces two nothings and the
1845
+ // preview would be drawing a half of a pane that has nothing to halve.
1846
+ const side = leaf.content ? this._snapSide(r, e, win, own) : null;
1847
+ // R14 (was R13). TOP, IN THE WINDOW'S OWN PANE, IS *BACK TO TILE*.
1848
+ //
1849
+ // Everywhere else `top` splits off the pane's upper half. In the pane
1850
+ // the window is already floating over, that is the one edge with
1851
+ // nothing to say — splitting a pane in order to put a window into the
1852
+ // half of it the window already covers is a gesture whose only effect
1853
+ // is work to undo. Aero's answer is the one worth copying: the top edge
1854
+ // is the "make this as big as it goes" edge.
1855
+ //
1856
+ // R13 read that as the GEOMETRIC maximise and made "as big as it goes"
1857
+ // mean THE LAYER. The product owner overruled it — *"putting a window
1858
+ // to maximize > maximize here means back to tile"* — and, asked how far
1859
+ // that went, chose everywhere, adding *"this is not yet correct for the
1860
+ // snap at top gesture"*, which names this zone as the part still wrong.
1861
+ // So there is now ONE meaning of maximise in the whole layer: the
1862
+ // window stops being a window and its content goes back into a tile.
1863
+ // The chrome button, the title bar's double-click, the embedder's
1864
+ // window menu and this edge are four doors onto one verb, which is the
1865
+ // only arrangement in which a user learns it once. No geometric
1866
+ // maximise remains reachable by gesture; `toggleMaximize({claimable:
1867
+ // false})` stays on `ManagedWindow` as the library escape hatch for a
1868
+ // consumer that wants the rectangle, and nothing in here calls it.
1869
+ //
1870
+ // THE RECT IS THE TILE, and it must be the tile the DOCK picks rather
1871
+ // than the tile the pointer happens to be in. That is C15's rule, which
1872
+ // R13 obeyed in the other direction, and it is why this branch resolves
1873
+ // its destination up front instead of committing blind:
1874
+ // `_homeDockTarget` is `bringBackWindow`'s own resolution read out, so
1875
+ // the preview and the drop cannot disagree — one function, called from
1876
+ // both halves.
1877
+ //
1878
+ // NOTHING TO PROMISE, NOTHING DRAWN. A window the WM never adopted, a
1879
+ // home leaf on a desktop that is not showing, a layout that has not
1880
+ // happened: `_homeDockTarget` answers null and the top edge falls
1881
+ // SILENT, which is exactly what the CENTRE of the window's own pane
1882
+ // already does (R2's surviving half, a dozen lines below). That is not
1883
+ // the dead-control failure the disabled-item convention exists to
1884
+ // avoid — the release still runs `_endDragEscape`, which puts the
1885
+ // window back where the drag found it, a visible outcome and the same
1886
+ // one the centre gives. Arming, previewing a tile and THEN declining on
1887
+ // release is the bait-and-switch, and it is the thing forbidden here.
1888
+ if (own && side === 'top') {
1889
+ const home = this._homeDockTarget(win);
1890
+ if (!home) return null;
1891
+ return { key: `${home.leafId}:home`, rect: home.rect,
1892
+ leafId: home.leafId, desktopIdx, side, mode: 'home',
1893
+ leafRect: r };
1894
+ }
1895
+ return this._dropZoneFor({ leafId, leaf, r, side, own, desktopIdx });
1896
+ }
1897
+
1898
+ /**
1899
+ * R17 (C33). THE ZONE MATRIX'S TAIL — SPLIT / NOTHING / TAB / FILL — SHARED
1900
+ * BY THE TWO THINGS THAT CAN BE DROPPED ON A TILE.
1901
+ *
1902
+ * A dragged WINDOW and a dragged TAB ask the same question of a pane: given
1903
+ * that the pointer is in this leaf and the edge test answered `side`, what
1904
+ * would releasing here produce? Every answer below was written for the
1905
+ * window drop and every one of them is right for a tab, so this is an
1906
+ * extraction and not a generalisation — `_snapProbe` keeps everything ABOVE
1907
+ * it unchanged, including R14's `own && side === 'top'` home branch, which
1908
+ * is a window's alone (a tab has no window to bring back) and therefore
1909
+ * stays where it was, between the side computation and this call.
1910
+ *
1911
+ * The alternative was a second copy in `tabDropProbe`, and a second copy of
1912
+ * a matrix the product owner has already revised four times (R2, R4, R5/R6,
1913
+ * R14) is a guarantee that the two gestures will one day disagree about
1914
+ * what the centre of a start tile means. `web/js/shell/snap_zones.test.mjs`
1915
+ * in the Tables consumer asserts every cell of the window matrix and is the
1916
+ * regression gate on this extraction: byte-identical window behaviour is
1917
+ * the whole of its back-compatibility claim.
1918
+ */
1919
+ _dropZoneFor({ leafId, leaf, r, side, own, desktopIdx }) {
1920
+ if (side) {
1921
+ return { key: `${leafId}:${side}`, rect: _halfOf(r, side),
1922
+ leafId, desktopIdx, side, mode: 'split', leafRect: r };
1923
+ }
1924
+ // R2, THE HALF THAT SURVIVED. Past the edge bands, inside the pane it
1925
+ // came from, a window is being MOVED and not docked: no key, no
1926
+ // preview, and on release `_endDragEscape` simply puts it back where
1927
+ // the drag left it. This sits after the edges and before the centre
1928
+ // because it is only the centre it refuses — an own-pane leaf with no
1929
+ // content has no edges either (`side` stays null above), and refusing
1930
+ // that is right for the same reason: there is nothing to dock into that
1931
+ // the window is not already standing on.
1932
+ //
1933
+ // A TAB READS THIS CELL THE SAME WAY, arrived at from the other side:
1934
+ // dropping a tab into the pane it already lives in is a gesture whose
1935
+ // only effect is work to undo, which is R2's argument with the word
1936
+ // "nudge" removed.
1937
+ if (own) return null;
1938
+
1939
+ // R5/R6. THE CENTRE IS NO LONGER NOTHING — it is the other two thirds
1940
+ // of the model, and which one it is depends on what the pane already is.
1941
+ //
1942
+ // a pane with content → a new TAB in it. Non-destructive, and the
1943
+ // honest reading of "dropping where an
1944
+ // existing tile is would cause a new tab".
1945
+ // a START tile → the window FILLS the pane, which is the
1946
+ // same verb as "back to tile": the window
1947
+ // stops floating and becomes that pane's
1948
+ // content. Under R14 that is what the top
1949
+ // edge of the window's OWN pane now means as
1950
+ // well — the difference between the two is
1951
+ // only which tile is named, this one the pane
1952
+ // under the pointer and R14's the pane the
1953
+ // window came from. A start tile is ground —
1954
+ // there is nothing there to lose, which is
1955
+ // exactly why this one may replace rather
1956
+ // than append.
1957
+ //
1958
+ // The preview is the WHOLE pane for both, because the whole pane is
1959
+ // what the window ends up occupying either way. C15's rule stands: the
1960
+ // preview may not promise a rectangle the drop does not deliver.
1961
+ const fills = !leaf.content || this._isStartTile(leaf);
1962
+ return { key: `${leafId}:${fills ? 'fill' : 'tab'}`, rect: _halfOf(r, null),
1963
+ leafId, desktopIdx, side: null,
1964
+ mode: fills ? 'fill' : 'tab', leafRect: r };
1965
+ }
1966
+
1967
+ /**
1968
+ * R18 (C33). THE SAME PROBE, FOR A DRAGGED TAB — PUBLIC, because the
1969
+ * renderer is what holds the drag and the renderer is not the WM.
1970
+ *
1971
+ * ══ WHY THIS IS NOT `_snapProbe(e, null)` ═══════════════════════════
1972
+ *
1973
+ * It very nearly is, and the geometry underneath is literally the same
1974
+ * code: `_snapSide(r, e, null, false)` falls to the POINTER-distance branch
1975
+ * by construction — `_draggedRect(null)` is null, so `dist` takes the
1976
+ * `e.clientX/Y` arm and `along` scores every edge zero. A tab has no
1977
+ * rectangle being dragged and no `_dragState`, and that is not a gap to
1978
+ * paper over: the pointer IS the whole gesture for a tab, which is exactly
1979
+ * the pre-R15 model that `_snapSide`'s fallback preserves.
1980
+ *
1981
+ * Two things differ, and neither could be expressed by passing a null
1982
+ * window to `_snapProbe`:
1983
+ *
1984
+ * R14's HOME BRANCH IS A WINDOW'S. `own && side === 'top'` means "put the
1985
+ * window back in its tile", and a tab is already in a tile. Reaching that
1986
+ * branch with `win === null` would ask `_homeDockTarget(null)`, which
1987
+ * answers null, so the top edge of the source pane would fall silent
1988
+ * rather than split — a hole in the matrix produced by inheritance.
1989
+ *
1990
+ * A SINGLE-TAB SOURCE PANE ARMS NOTHING, ANYWHERE. `_dropZoneFor` already
1991
+ * silences the source pane's CENTRE; its edges are useful for a pane with
1992
+ * siblings ("tear this tab off into a split beside the others") and are a
1993
+ * wash for a pane with one tab, where the outcome is the pane's only
1994
+ * content in one half and a freshly seeded ground in the other. That is a
1995
+ * preview promising something no one wants, so it is refused BEFORE the
1996
+ * preview is drawn rather than at the drop — C15's rule is that the
1997
+ * rectangle drawn is the one released, and the honest way to keep it is
1998
+ * never to draw one.
1999
+ *
2000
+ * `own: false` is passed to `_snapSide` deliberately. Its `own` parameter
2001
+ * gates R2's direction guard, which measures a WINDOW's travel out of
2002
+ * `_dragState`; a tab drag has none, so `guarded` would be false anyway and
2003
+ * passing `true` would only obscure that. `own` still governs the centre,
2004
+ * which is why it goes to `_dropZoneFor` and not to `_snapSide`.
2005
+ *
2006
+ * @param {{clientX: number, clientY: number}} e the pointer, mid-drag
2007
+ * @param {{sourceLeafId?: string}} [opts] the leaf the tab left
2008
+ * @returns {object|null} the same probe shape a window drop produces
2009
+ */
2010
+ tabDropProbe(e, { sourceLeafId = null } = {}) {
2011
+ const leafEl = this._leafElAt(e.clientX, e.clientY, null);
2012
+ if (!leafEl) return null;
2013
+ const leafId = leafEl.dataset.leafId;
2014
+ const tree = this._tree();
2015
+ const leaf = tree.get(leafId);
2016
+ if (!leaf || leaf.kind !== 'leaf') return null;
2017
+ // Panel tiles are chrome, not content — the same refusal `_snapProbe`
2018
+ // makes, for the same reason: there is nowhere for the drop to go and
2019
+ // the preview must not suggest there is.
2020
+ if (String(leaf.content?.kind || '').startsWith('panel:')) return null;
2021
+ const own = !!sourceLeafId && leafId === sourceLeafId;
2022
+ if (own) {
2023
+ const src = tree.get(sourceLeafId);
2024
+ const count = Array.isArray(src?.tabs) ? src.tabs.length : 0;
2025
+ if (count <= 1) return null;
2026
+ }
2027
+ const r = leafEl.getBoundingClientRect();
2028
+ const side = leaf.content ? this._snapSide(r, e, null, false) : null;
2029
+ return this._dropZoneFor({ leafId, leaf, r, side, own,
2030
+ desktopIdx: this.desktops.activeIdx });
2031
+ }
2032
+
2033
+ /**
2034
+ * R15. WHICH EDGE OF THE PANE THE *WINDOW* IS BEING PUSHED INTO.
2035
+ *
2036
+ * ══ THE BUG THIS EXISTS TO FIX ═══════════════════════════════════════
2037
+ *
2038
+ * The band was measured from the POINTER, and the pointer is wherever the
2039
+ * hand happened to grab the title bar. Grab a 900px window in the middle
2040
+ * of its bar and shove it right: `dragBounds` clamps it, its right border
2041
+ * sits hard against the layer's right edge, and the pointer is still 450px
2042
+ * away from that edge — outside every band, so nothing arms and the window
2043
+ * simply stops dead against the side of the screen. Reported twice:
2044
+ * *"snapping enables based on mouse position but actually it needs to
2045
+ * enable based on the dragged window bounds (e.g. window right border
2046
+ * distance from right snapping area)"*.
2047
+ *
2048
+ * The bigger the window the worse it got, and the gesture only ever worked
2049
+ * if you happened to grab near the edge you were aiming at — the bottom
2050
+ * edge was effectively unreachable for any tall window, because a title bar
2051
+ * is at the TOP of the thing you are dragging.
2052
+ *
2053
+ * So each of the four distances is now between the window's own border and
2054
+ * the matching border of the pane. `right` arms when the window's right
2055
+ * border comes within the band of the pane's right border, and so on round.
2056
+ *
2057
+ * ══ WHAT DID *NOT* CHANGE ════════════════════════════════════════════
2058
+ *
2059
+ * WHICH PANE is still the pointer's answer (`_leafElAt`), and so is `own`.
2060
+ * The zone matrix is about a pane — the window's own pane means something
2061
+ * different from any other pane — and a window can lie across three of
2062
+ * them at once while the pointer is in exactly one. Only the question
2063
+ * *"which edge of THIS pane"* moved onto the window's rectangle; the
2064
+ * question *"which pane"* was never the one the product owner complained
2065
+ * about. Everything downstream is untouched: the preview is still
2066
+ * `_halfOf(paneRect, side)`, so the rectangle drawn is the rectangle the
2067
+ * drop delivers, and a `side` reaching the branches below means exactly
2068
+ * what it meant before.
2069
+ *
2070
+ * ══ SHORTFALL CLAMPED AT ZERO, BECAUSE A WINDOW OVERHANGS ═════════════
2071
+ *
2072
+ * (R16 corrects R15 here. R15 said *unsigned*, and unsigned was wrong;
2073
+ * the paragraph below is why, and `_snapSide` carries the measurement.)
2074
+ *
2075
+ * The pointer is inside the pane by construction — `_leafElAt` found the
2076
+ * pane by hit-testing it — so a signed distance was always positive. A
2077
+ * WINDOW has no such guarantee: it is clamped to the layer, not to the
2078
+ * pane, so a window wider than the pane under the pointer sticks out of
2079
+ * both sides of it and its border is 20px PAST the pane's border rather
2080
+ * than 20px short of it. Both readings are "hard against that edge".
2081
+ *
2082
+ * R15 spelled that `Math.abs`, and `Math.abs` only holds the reading while
2083
+ * the overhang stays inside the band. Past that it counts UP again, so the
2084
+ * zone armed and then DISARMED as the shove continued, and a window
2085
+ * meaningfully wider than the pane armed nothing at all. The right spelling
2086
+ * is a shortfall clamped at zero: **past the edge IS the edge**, at
2087
+ * distance zero, and it stays there however far the shove carries it.
2088
+ *
2089
+ * ══ THE DIRECTION GUARD, WHICH IS WHAT KEEPS R2 ALIVE ════════════════
2090
+ *
2091
+ * Edge-based testing has a failure the pointer never had: a window that is
2092
+ * ALREADY at an edge is in that band before the drag starts. A window
2093
+ * parked at the left of its pane would arm a left split on the first
2094
+ * millimetre of any drag, and a window that fills its pane would arm on
2095
+ * every drag in every direction — which is precisely the *"every move
2096
+ * looked like a dock because every move WAS one"* failure `SNAP_EDGE_PX`
2097
+ * was written to end, arriving from the other direction.
2098
+ *
2099
+ * So in the window's OWN pane an edge arms only if the drag actually
2100
+ * carried the window at it: the pointer must have travelled more than one
2101
+ * band's width toward that edge since the press. A nudge (R2's complaint,
2102
+ * and the surviving reason the own-pane centre is silent) moves a handful
2103
+ * of pixels and arms nothing; a shove moves hundreds and arms the edge it
2104
+ * was aimed at. The band's own width is the unit, because a movement
2105
+ * smaller than the band cannot be the difference between being in it and
2106
+ * not.
2107
+ *
2108
+ * IN ANY OTHER PANE THE GUARD IS OFF, deliberately. R2 is a rule about the
2109
+ * pane a window already lives on — the only place a "nudge" exists. Drag a
2110
+ * window rightwards out of pane A and into pane B and its LEFT border is
2111
+ * what enters pane B first: with the guard on, aiming at the left half of
2112
+ * the pane to your right would be impossible, since arriving there always
2113
+ * means travelling right. The window is translucent by then (R3) and every
2114
+ * drop on a foreign pane docks, so there is no nudge to protect.
2115
+ *
2116
+ * The displacement is read from `ManagedWindow._dragState.startX/startY`,
2117
+ * the POINTER's position at the press — not from the window's own x/y,
2118
+ * which stop changing the moment the clamp bites while the gesture very
2119
+ * much continues. A caller with no drag state (a synthetic probe, an
2120
+ * embedder driving the controller by hand) yields no displacement at all
2121
+ * and the guard is skipped rather than failing closed: it can only ever
2122
+ * suppress an edge, never invent one.
2123
+ *
2124
+ * ══ CORNERS: THE PRECEDENCE, MADE EXPLICIT ═══════════════════════════
2125
+ *
2126
+ * Two edges can be in range at once, and with window borders that is no
2127
+ * longer the rarity it was with a pointer — shove a window into a corner
2128
+ * and the clamp puts BOTH borders at distance zero, exactly. Under R16 it
2129
+ * is not even a corner case: a window as wide as its pane is at zero on
2130
+ * the left AND the right for every horizontal position it can occupy, and
2131
+ * a floated canvas pane's window is *exactly* that wide. So the order is
2132
+ * stated rather than left to whichever way the loop happens to run:
2133
+ *
2134
+ * 1. NEAREST WINS. Unchanged from R4, and it is what keeps a corner from
2135
+ * being a dead spot: one of the two is always closer.
2136
+ * 2. ON A TIE, THE EDGE THE DRAG PUSHED TOWARD WINS. (R16: *toward that
2137
+ * edge*, signed — R15 said "the axis pushed furthest" and spelled it
2138
+ * `Math.abs`, which gives the two ends of one axis the SAME score, so
2139
+ * a left/right tie never broke at all and 'left' won every time by
2140
+ * loop order.) The honest tie-break is the gesture: shove it
2141
+ * rightwards and you get the right zone, upwards and you get the top
2142
+ * zone. Every zone stays reachable and which one you get is something
2143
+ * a hand can aim.
2144
+ * 3. STILL TIED — a perfect diagonal, or a probe with no drag state —
2145
+ * falls to the fixed order left, right, top, bottom. That is the order
2146
+ * the R4 loop already resolved ties in (`Object.entries` insertion
2147
+ * order, with a strict `<`), kept so the pointer fallback below
2148
+ * answers exactly what it answered before.
2149
+ *
2150
+ * ══ THE FALLBACK ═════════════════════════════════════════════════════
2151
+ *
2152
+ * With no measurable window rectangle — no element, detached, or a box of
2153
+ * zero area because layout has not happened — there is nothing to measure
2154
+ * and the pointer is the only information in the room. That path is the
2155
+ * pre-R15 code, unchanged, signed distances and all. It is what a headless
2156
+ * probe gets (jsdom lays nothing out, so every `getBoundingClientRect` is
2157
+ * zero), and it is why `web/js/shell/snap_zones.test.mjs` in the Tables
2158
+ * consumer still asserts the same matrix against the same coordinates.
2159
+ *
2160
+ * @param {DOMRect} r the pane, in viewport pixels
2161
+ * @param {{clientX: number, clientY: number}} e the pointer
2162
+ * @param {object} win the ManagedWindow being dragged
2163
+ * @param {boolean} own is `r` the pane this window's drag escaped?
2164
+ * @returns {'left'|'right'|'top'|'bottom'|null}
2165
+ */
2166
+ _snapSide(r, e, win, own) {
2167
+ const edge = WindowManager.SNAP_EDGE_PX;
2168
+ const w = this._draggedRect(win);
2169
+ // The displacement is read whenever there is one, because BOTH rules
2170
+ // below want it and they do not want it in the same places: the corner
2171
+ // tie-break is about aim and applies in every pane, while the guard is
2172
+ // R2's rule about nudging a window at home and applies in one.
2173
+ const push = w ? this._dragPush(e, win) : null;
2174
+ const guarded = !!(own && push);
2175
+ // R16. SHORTFALL, CLAMPED AT ZERO — *not* `Math.abs`.
2176
+ //
2177
+ // R15 measured `Math.abs(r.right - w.right)`, which conflates the two
2178
+ // sides of an edge: a border 20px SHORT of the pane's border and one
2179
+ // 20px PAST it both read as 20. The reading past the edge is wrong,
2180
+ // and wrong in the direction that breaks the gesture. Two defects came
2181
+ // out of it and both are this one line of arithmetic:
2182
+ //
2183
+ // THE BAND OPENED AND THEN CLOSED AGAIN. Shove a window right; its
2184
+ // right border approaches the pane's right border, enters the 28px
2185
+ // band, reaches it — and keeps going. One pixel past, `abs` starts
2186
+ // counting UP again, and 28px past the edge the zone disarms.
2187
+ // Measured on the real code: a 300px window in PANE_A armed `right`
2188
+ // only for pointer deltas 173..227, and was null on either side. **A
2189
+ // firm shove disarmed the snap that a hesitant one armed** — the
2190
+ // product owner's original complaint arriving from the far side.
2191
+ //
2192
+ // AND A WINDOW WIDER THAN THE PANE ARMED NOTHING — though that case
2193
+ // is exactly what `abs` was reached for. The R15 docstring argues
2194
+ // for it because such a window "sticks out of both sides and its
2195
+ // border is 20px PAST the pane's rather than 20px short", which is
2196
+ // true; what it misses is that `abs` holds that reading only while
2197
+ // the overhang stays under 28px. Overhang by more and every edge
2198
+ // reads out-of-band.
2199
+ //
2200
+ // Clamping at zero says what that prose always meant: **past the edge
2201
+ // IS the edge.** A border level with the pane's, or beyond it, sits at
2202
+ // distance zero and is fully armed, and stays armed however much
2203
+ // further the shove carries it. "How hard are you pushing past it" was
2204
+ // never a measure of aim.
2205
+ //
2206
+ // This makes a same-axis TIE ordinary rather than a corner curiosity:
2207
+ // a window wider than its pane is hard against the left and the right
2208
+ // border at once, both at zero. Breaking that tie is what the signed
2209
+ // `along` below is for, and the two changes only make sense together.
2210
+ //
2211
+ // The pointer fallback is untouched: the pointer is inside the pane by
2212
+ // construction — `_leafElAt` hit-tested it — so its distances cannot
2213
+ // go negative and there is nothing to clamp.
2214
+ const dist = w
2215
+ ? { left: Math.max(0, w.left - r.left),
2216
+ right: Math.max(0, r.right - w.right),
2217
+ top: Math.max(0, w.top - r.top),
2218
+ bottom: Math.max(0, r.bottom - w.bottom) }
2219
+ : { left: e.clientX - r.left,
2220
+ right: r.right - e.clientX,
2221
+ top: e.clientY - r.top,
2222
+ bottom: r.bottom - e.clientY };
2223
+ const toward = (name) => {
2224
+ if (!guarded) return true;
2225
+ if (name === 'left') return push.x <= -edge;
2226
+ if (name === 'right') return push.x >= edge;
2227
+ if (name === 'top') return push.y <= -edge;
2228
+ return push.y >= edge;
2229
+ };
2230
+ // R16. HOW FAR THE GESTURE TRAVELLED **TOWARD THIS EDGE** — signed to
2231
+ // the edge it is asked about, not to its axis.
2232
+ //
2233
+ // R15 wrote `Math.abs(push.x)` for both 'left' and 'right', which
2234
+ // makes the tie-break IDENTICAL for the two members of a same-axis
2235
+ // pair. `p > best.p` is then `|push.x| > |push.x|` — false, always —
2236
+ // so the tie fell through to the fixed loop order and **'left' won
2237
+ // every time, whichever way the window was shoved**.
2238
+ //
2239
+ // That was not the corner curiosity it looked like. Whenever a
2240
+ // window's width equals the pane's, `dist.left === dist.right` at
2241
+ // EVERY horizontal position, so the whole band answered 'left'; and a
2242
+ // canvas pane's window is sized `min(820, container.clientWidth)`,
2243
+ // which for any pane up to 822px wide comes out exactly equal to the
2244
+ // leaf's border-box width. So the common case — float a pane's window
2245
+ // and push it at the pane next door — always split left. Under R16's
2246
+ // clamped distance the tie is commoner still, because a window wider
2247
+ // than its pane now ties at zero on both edges by construction.
2248
+ //
2249
+ // Signing it makes the tie-break say what rule 2 always claimed:
2250
+ // *"the axis the drag pushed furthest wins"* — read as the DIRECTION
2251
+ // the drag pushed, which is the only reading a hand can aim. Shove
2252
+ // right, get right. A push away from the edge scores negative and
2253
+ // loses to any edge that was actually aimed at.
2254
+ //
2255
+ // Still zero for every edge when there is no drag state, so rule 3
2256
+ // falls through to the fixed order exactly as before.
2257
+ const along = (name) => {
2258
+ const dx = push?.x ?? 0, dy = push?.y ?? 0;
2259
+ if (name === 'left') return -dx;
2260
+ if (name === 'right') return dx;
2261
+ if (name === 'top') return -dy;
2262
+ return dy;
2263
+ };
2264
+ // THE FIXED ORDER IS RULE 3. Do not sort this array.
2265
+ let best = null;
2266
+ for (const name of ['left', 'right', 'top', 'bottom']) {
2267
+ const d = dist[name];
2268
+ if (!(d < edge) || !toward(name)) continue;
2269
+ const p = along(name);
2270
+ if (!best || d < best.d || (d === best.d && p > best.p)) best = { name, d, p };
2271
+ }
2272
+ return best ? best.name : null;
2273
+ }
2274
+
2275
+ /** R15. The dragged window's rectangle in VIEWPORT pixels — the frame a
2276
+ * leaf's `getBoundingClientRect` speaks, so the two are directly
2277
+ * comparable — or null when there is nothing to measure.
2278
+ *
2279
+ * Read off the element rather than computed from `win.x/y/width/height`,
2280
+ * because those are in whatever container the window is currently parented
2281
+ * to and mid-drag that is the drag host, not the pane being probed.
2282
+ *
2283
+ * A zero-area box is "nothing to measure" rather than a rectangle at the
2284
+ * origin: it is what an unlaid-out document gives, and treating it as real
2285
+ * would put every window in the top-left corner of every pane. Same test
2286
+ * `_homeDockTarget` applies to a leaf, for the same reason. */
2287
+ _draggedRect(win) {
2288
+ const el = win?.element;
2289
+ if (!el || el.isConnected === false) return null;
2290
+ if (typeof el.getBoundingClientRect !== 'function') return null;
2291
+ const b = el.getBoundingClientRect();
2292
+ if (!b || !b.width || !b.height) return null;
2293
+ return b;
2294
+ }
2295
+
2296
+ /** R15. How far the POINTER has travelled since the press that began this
2297
+ * drag, or null if this window is not in a drag the WM can see.
2298
+ *
2299
+ * The pointer rather than the window: `_applyPosition` clamps the window
2300
+ * to `dragBounds`, so a window shoved at the edge of the layer stops
2301
+ * moving while the gesture continues — and "it stopped because it is
2302
+ * against the edge" is exactly the situation the guard must not read as
2303
+ * "it is not being pushed". */
2304
+ _dragPush(e, win) {
2305
+ const ds = win?._dragState;
2306
+ if (!ds || typeof ds.startX !== 'number' || typeof ds.startY !== 'number') return null;
2307
+ return { x: e.clientX - ds.startX, y: e.clientY - ds.startY };
2308
+ }
2309
+
2310
+ /** The topmost `.twm-leaf` under the pointer that is not part of the window
2311
+ * being dragged. `elementsFromPoint` rather than `elementFromPoint`: the
2312
+ * dragged window IS under the pointer — it is what the pointer is holding
2313
+ * — and a hit-test that stops at the first element only ever finds it.
2314
+ *
2315
+ * R15 left this alone on purpose: WHICH pane is still the pointer's
2316
+ * answer, and only WHICH EDGE of it moved onto the window's borders. See
2317
+ * `_snapSide`. */
2318
+ _leafElAt(x, y, win) {
2319
+ const stack = document.elementsFromPoint(x, y);
2320
+ for (const el of stack) {
2321
+ if (win?.element && win.element.contains(el)) continue;
2322
+ const leafEl = el.closest?.('.twm-leaf');
2323
+ if (leafEl && this.rootEl.contains(leafEl)) return leafEl;
2324
+ }
2325
+ return null;
2326
+ }
2327
+
2328
+ /**
2329
+ * R14. THE TILE "BACK TO TILE" WOULD PUT THIS WINDOW IN, and the rectangle
2330
+ * that draws it — in the VIEWPORT pixels a snap preview is positioned in.
2331
+ *
2332
+ * `bringBackWindow` resolves its destination privately and then closes the
2333
+ * window to reach it, which is everything a button press needs and useless
2334
+ * to a PREVIEW. C15's rule is that the rectangle drawn during a drag is the
2335
+ * one the drop delivers, and under R14 the drop delivers A TILE — so the
2336
+ * resolution has to be readable before the gesture is committed. Reading it
2337
+ * out here is what makes the top edge honest: the probe draws what this
2338
+ * returns and `_snapCommit` calls `bringBackWindow`, which resolves the
2339
+ * same way, from the same record, against the same tree.
2340
+ *
2341
+ * It is deliberately NOT a second copy of that resolution reduced to "the
2342
+ * home leaf". `bringBackWindow`'s fall-through — no home leaf, or one the
2343
+ * tree no longer has — is the desktop's PRIMARY tile, which is where
2344
+ * `_onManagedWindowClosed` sends a demotion carrying no `_dock`; a probe
2345
+ * that previewed the home leaf and then landed in the primary tile would be
2346
+ * the bait-and-switch with extra steps.
2347
+ *
2348
+ * `renderer.leafEl` only knows the leaves of the desktop currently on
2349
+ * screen, which is the property that makes the desktop check implicit: a
2350
+ * window whose home is on another desktop resolves to no element, this
2351
+ * answers null, and the top edge arms nothing rather than previewing a
2352
+ * rectangle on a desktop the user cannot see.
2353
+ *
2354
+ * @param {object} win a live ManagedWindow
2355
+ * @returns {{leafId: string, rect: DOMRect}|null} null when there is
2356
+ * nothing honest to promise: a window the WM never adopted, a desktop
2357
+ * that has gone, a tree with no content leaf at all, or a leaf with no
2358
+ * measurable box (a layout that has not happened yet).
2359
+ */
2360
+ _homeDockTarget(win) {
2361
+ const rec = [...this._windowToLeaf.values()].find((r) => r.window === win);
2362
+ if (!rec) return null;
2363
+ const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
2364
+ if (!tree) return null;
2365
+ const home = rec.homeLeafId ? tree.get(rec.homeLeafId) : null;
2366
+ const leafId = (home && home.kind === 'leaf')
2367
+ ? rec.homeLeafId
2368
+ : tree.primaryLeafId();
2369
+ if (!leafId) return null;
2370
+ const el = this.renderer.leafEl(leafId);
2371
+ if (!el) return null;
2372
+ const rect = el.getBoundingClientRect();
2373
+ if (!rect.width || !rect.height) return null;
2374
+ return { leafId, rect };
2375
+ }
2376
+
2377
+ _snapCommit(probe, win) {
2378
+ if (!probe) return false;
2379
+ const rec = [...this._windowToLeaf.entries()]
2380
+ .find(([, r]) => r.window === win);
2381
+ if (!rec) return false;
2382
+ const [winId, record] = rec;
2383
+ const { leafId, desktopIdx, side } = probe;
2384
+
2385
+ // R14. THE TOP EDGE OF THE WINDOW'S OWN PANE DOCKS IT HOME.
2386
+ //
2387
+ // It goes through `bringBackWindow` rather than through
2388
+ // `dockWindowInto` below, and that is the point rather than a shortcut:
2389
+ // "back to tile" is ONE verb with one implementation, and it is the
2390
+ // implementation that knows a home pane which has since acquired
2391
+ // content must be joined as a tab instead of replaced. Naming the leaf
2392
+ // here and calling `dockWindowInto` would be a second copy of that
2393
+ // rule, and a second copy is a copy that disagrees within a release.
2394
+ //
2395
+ // IT RETURNS BEFORE `record.desktopIdx` IS REWRITTEN below, for the
2396
+ // reason R13's branch did: the destination was resolved from the
2397
+ // RECORD's own desktop by `_homeDockTarget`, so rewriting the record to
2398
+ // the desktop the pointer is on would send `bringBackWindow` looking
2399
+ // for the home leaf in a tree that does not contain it — and it would
2400
+ // then fall through to that desktop's primary tile, which is not the
2401
+ // rectangle the preview drew.
2402
+ //
2403
+ // WHAT USED TO BE HERE: `toggleMaximize({ claimable: false })`, the
2404
+ // geometric maximise, with `{claimable: false}` load-bearing precisely
2405
+ // so the call would NOT reach `bringBackWindow`. The ruling inverts
2406
+ // that — the dock is now the whole intent — so the escape hatch is
2407
+ // gone from this file altogether. It stays on `ManagedWindow` as a
2408
+ // public API for a consumer that genuinely wants a rectangle (a dialog
2409
+ // has no tile to go back to, so `openModal`'s maximise is exactly
2410
+ // that); nothing in the tiling layer calls it any more.
2411
+ if (probe.mode === 'home') {
2412
+ // NOT TAKEN UNLESS IT REALLY WAS — the same rule the dock below
2413
+ // follows, and reachable here: `bringBackWindow` returns false for
2414
+ // a window whose record has gone between the probe and the release.
2415
+ // Claiming the drop after it did nothing would leave the preview
2416
+ // painted over the page with nothing left to remove it, and the
2417
+ // window abandoned wherever the pointer let go; returning false
2418
+ // hands the release back to `_endDragEscape`, which puts the window
2419
+ // back in its pane.
2420
+ if (!this.bringBackWindow(winId)) return false;
2421
+ win.clearSnapPreview();
2422
+ return true;
2423
+ }
2424
+
2425
+ // The window's home follows the drop. Without this the dock is applied
2426
+ // to the tree of the desktop the window was PROMOTED from, and the tile
2427
+ // the user aimed at is on the one they are looking at.
2428
+ if (typeof desktopIdx === 'number') record.desktopIdx = desktopIdx;
2429
+
2430
+ // THE DRAG ALREADY ANSWERED, so there is nothing to ask — the probe
2431
+ // decided between the outcomes on the way in, and the preview has
2432
+ // been drawing that answer for as long as the pointer has been there. A
2433
+ // menu on release would be asking a question the user has already spent
2434
+ // the whole drag answering.
2435
+ //
2436
+ // `dir` and `before` are read only by the split branch; a tab or a fill
2437
+ // ignores them, which is why they can be computed unconditionally from
2438
+ // a `side` that is null for both.
2439
+ const docked = this.dockWindowInto(winId, {
2440
+ leafId,
2441
+ mode: probe.mode || 'split',
2442
+ dir: (side === 'left' || side === 'right') ? 'h' : 'v',
2443
+ before: (side === 'left' || side === 'top'),
2444
+ });
2445
+ // NOT TAKEN unless it really was. A truthy return tells the window the
2446
+ // controller owns it AND owns the preview, so returning true after a
2447
+ // failed dock would leave a rectangle painted over the page with
2448
+ // nothing left to remove it, and a window sitting wherever the drag
2449
+ // abandoned it instead of back in its pane.
2450
+ if (!docked) return false;
2451
+ win.clearSnapPreview();
2452
+ return true;
2453
+ }
2454
+
2455
+ /**
2456
+ * Put a floating window's content back into the tree at a NAMED place.
2457
+ *
2458
+ * `bringBackWindow` is this with `{ mode: 'tab' }` against the primary tile
2459
+ * — the answer when the user pressed a button in the window's own chrome
2460
+ * and named no destination. A drop names one.
2461
+ *
2462
+ * The window is CLOSED to do it, exactly as a demote is: the content
2463
+ * factory re-mounts inside the tile, and a factory that must not lose live
2464
+ * state across that boundary is the embedder's problem to solve (it is why
2465
+ * the registry is keyed on (kind, props) rather than on a DOM node).
2466
+ *
2467
+ * @param {string} windowId
2468
+ * @param {{leafId: string, mode: 'fill'|'tab'|'split', dir?: 'h'|'v',
2469
+ * before?: boolean}} target
2470
+ */
2471
+ dockWindowInto(windowId, target) {
2472
+ const rec = this._windowToLeaf.get(windowId);
2473
+ if (!rec || !target?.leafId) return false;
2474
+ rec._dock = { ...target };
2475
+ rec._demoting = true;
2476
+ try { rec.window.close({ force: true }); }
2477
+ catch (err) { console.warn('[wm] dock: close failed', err); return false; }
2478
+ return true;
2479
+ }
2480
+
2481
+ /**
2482
+ * Move a managed window to another desktop.
2483
+ *
2484
+ * ══ IT USED TO REWRITE ONE INTEGER, AND THAT MOVED NOTHING ═════════
2485
+ *
2486
+ * The sentence that stood here — *"the window itself stays on screen
2487
+ * (managed windows are global)"* — was true of a window floating over the
2488
+ * root and false of every window this WM promotes under `promoteInPlace`,
2489
+ * which is CONTAINED IN A TILE (C21). Rewriting `desktopIdx` left such a
2490
+ * window standing in the pane it was already in, on the page the user was
2491
+ * already looking at: *Move to desktop Views* appeared to do nothing at
2492
+ * all. Then *Back to tile* resolved against the new desktop's tree and
2493
+ * docked the content onto a page nobody was watching, so the window
2494
+ * vanished here and its table turned up over there.
2495
+ *
2496
+ * Three things move it for real. The index, so every later resolution
2497
+ * agrees. The HOME LEAF, re-pointed at a ground that exists in the
2498
+ * destination — without it the record names a leaf of the tree it just
2499
+ * left, and `_rehomeContainedWindows` would either skip it forever or
2500
+ * repair it to a pane on the wrong page. And a render, which is where the
2501
+ * element is taken off the page the window has left (or parented into its
2502
+ * new ground, when the destination is the desktop on screen).
2503
+ *
2504
+ * An ADOPTED window (`homeContainer`) keeps its own resolution: the
2505
+ * embedder owns the box it stands in, and re-pointing a leaf id it does not
2506
+ * read would be a change with no effect wearing the look of one.
2507
+ */
692
2508
  moveWindowToDesktop(windowId, targetIdx) {
693
2509
  const rec = this._windowToLeaf.get(windowId);
694
2510
  if (!rec) return;
695
2511
  if (rec.desktopIdx === targetIdx) return;
696
2512
  this.desktops.ensureCount(targetIdx + 1);
697
2513
  rec.desktopIdx = targetIdx;
2514
+ if (!rec.homeContainer && rec.homeLeafId) {
2515
+ const target = this.desktops.desktops[targetIdx]?.tree;
2516
+ // `|| null`, NEVER `|| rec.homeLeafId`. Falling back to the id it
2517
+ // already had keeps a leaf of the tree the window is LEAVING, and
2518
+ // `_rehomeContainedWindows` would then find that id perfectly alive
2519
+ // in the wrong tree and never repair it. Null means "not contained
2520
+ // anywhere", which the pass understands and which the repair below
2521
+ // fixes on the first render of the destination.
2522
+ rec.homeLeafId = target?.primaryLeafId() || null;
2523
+ }
2524
+ this.renderer.render();
698
2525
  this._persist();
699
2526
  this._notifyChange('window-moved');
700
2527
  }
701
2528
 
2529
+ /**
2530
+ * R8. Put a floated pane's tabs back into a leaf — ALL of them, in the
2531
+ * order they had, with the one that was showing still showing.
2532
+ *
2533
+ * Every dock goes through here, and that is the point: `bringBackWindow`,
2534
+ * a drop on a tile's body, a drop on an edge and the fall-through when the
2535
+ * named destination vanished are four routes to one question — *where do
2536
+ * these tabs go* — and four copies of the answer would disagree about the
2537
+ * third one within a release. A window promoted before R8 (or by
2538
+ * `_navigateWindow`, which never had tabs) carries no list, so `original`
2539
+ * is the fallback and the single-tab path reduces to exactly what this
2540
+ * replaced.
2541
+ *
2542
+ * `replace` is the difference between filling a leaf and joining one: a
2543
+ * fresh split leaf and a `fill` drop want the first tab to BECOME the
2544
+ * leaf's content, while a `tab` drop and "back to tile" append beside what
2545
+ * is already there.
2546
+ */
2547
+ _restoreTabs(tree, leafId, rec, { replace }) {
2548
+ if (!leafId) return false;
2549
+ const tabs = (Array.isArray(rec.tabs) && rec.tabs.length)
2550
+ ? rec.tabs
2551
+ : [{ kind: rec.original.kind, props: rec.original.props,
2552
+ title: rec.original.title }];
2553
+ const active = Math.max(0, Math.min(tabs.length - 1, rec.activeTabIdx || 0));
2554
+ let firstIdx = -1;
2555
+ tabs.forEach((tab, i) => {
2556
+ const content = { kind: tab.kind, props: tab.props || {} };
2557
+ const title = tab.title || tab.kind || '';
2558
+ if (i === 0 && replace) {
2559
+ tree.setLeafContent(leafId, content, title);
2560
+ firstIdx = 0;
2561
+ return;
2562
+ }
2563
+ // `appendLeafTab` returns -1 for a panel leaf, which is the one
2564
+ // leaf that never grows tabs. Nothing docks onto a panel — the
2565
+ // probe refuses one and `bringBackWindow` targets the primary tile
2566
+ // — so this is a guard, not a path.
2567
+ const at = tree.appendLeafTab(leafId, content, title);
2568
+ if (at >= 0 && firstIdx < 0) firstIdx = at;
2569
+ });
2570
+ if (firstIdx < 0) return false;
2571
+ tree.setActiveLeafTab(leafId, firstIdx + active);
2572
+ tree.focus(leafId);
2573
+ return true;
2574
+ }
2575
+
702
2576
  _onManagedWindowClosed(winId, mountInfo) {
703
2577
  const rec = this._windowToLeaf.get(winId);
704
2578
  this._windowToLeaf.delete(winId);
705
2579
  if (!rec) return;
706
- try { mountInfo?.destroy?.(); } catch {}
2580
+ // R8. `rec.mountInfo` IS THE LIVE ONE. The window's mount changes when
2581
+ // its tab changes (`_mountWindowTab`) and when its content is replaced
2582
+ // in place (`openInWindow`), so the closure `_promote` captured at
2583
+ // build time names a mount that was torn down long ago — destroying it
2584
+ // twice while the CURRENT one is never destroyed at all, which is a
2585
+ // leaked page module with a `window` listener on it. `_navigateWindow`
2586
+ // still passes its own and it is the same object, so the argument
2587
+ // stays for the callers that have nothing else.
2588
+ try { (rec.mountInfo || mountInfo)?.destroy?.(); } catch {}
2589
+ try { rec.strip?.dispose(); } catch { /* never mounted, or already gone */ }
707
2590
  const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
708
2591
  if (!tree) return;
709
2592
 
710
- if (rec._demoting) {
2593
+ if (rec._demoting && rec._dock) {
2594
+ // C15. A DROP named its destination, so none of the primary-tile
2595
+ // reasoning below applies. A destination that vanished between the
2596
+ // release and the close (the tile was closed by a keystroke while a
2597
+ // choice menu was open) falls through to the primary tile rather
2598
+ // than dropping the content on the floor.
2599
+ const target = tree.get(rec._dock.leafId);
2600
+ if (target && target.kind === 'leaf') {
2601
+ const { mode, dir, before } = rec._dock;
2602
+ if (mode === 'tab') {
2603
+ this._restoreTabs(tree, rec._dock.leafId, rec, { replace: false });
2604
+ } else if (mode === 'split') {
2605
+ const newId = tree.split(rec._dock.leafId, dir);
2606
+ if (newId) {
2607
+ this._restoreTabs(tree, newId, rec, { replace: true });
2608
+ // THE PREVIEW PROMISED HALF OF THAT TILE. `TileTree.split`
2609
+ // delivers it only when it has to WRAP; when the parent
2610
+ // split already runs this direction it INSERTS a sibling
2611
+ // and gives it the average of the row's existing sizes
2612
+ // (`tile_tree.js`, case 2), so a drop into a three-pane
2613
+ // row hands the newcomer a third and shrinks everyone
2614
+ // else. Halving the source's own weight and giving the
2615
+ // other half to the new leaf makes the two panes split
2616
+ // the space the source had — which is what was drawn.
2617
+ _halveInto(tree, rec._dock.leafId, newId);
2618
+ // `split` always appends the new leaf AFTER its source.
2619
+ // A drop on the LEFT or TOP edge means the window
2620
+ // belongs on that side, so the two swap places — the
2621
+ // preview drew the left half and the left half is where
2622
+ // it must land.
2623
+ if (before) _swapSiblings(tree, rec._dock.leafId, newId);
2624
+ tree.focus(newId);
2625
+ }
2626
+ } else {
2627
+ this._restoreTabs(tree, rec._dock.leafId, rec, { replace: true });
2628
+ }
2629
+ } else {
2630
+ this._restoreTabs(tree, tree.primaryLeafId(), rec, { replace: false });
2631
+ }
2632
+ } else if (rec._demoting) {
711
2633
  // "Back to tile": the source tile was closed when the window was
712
2634
  // promoted, so dock the (latest) content into the desktop's
713
2635
  // primary tile as a new tab, spawning a tile when it has none.
@@ -715,10 +2637,7 @@ export class WindowManager {
715
2637
  let spawned = false;
716
2638
  if (!pid) { pid = this._spawnContentLeaf(tree); spawned = true; }
717
2639
  if (pid) {
718
- tree.appendLeafTab(pid,
719
- { kind: rec.original.kind, props: rec.original.props },
720
- rec.original.title);
721
- tree.focus(pid);
2640
+ this._restoreTabs(tree, pid, rec, { replace: false });
722
2641
  if (spawned) this._canonicalize(tree, this.desktops.desktops[rec.desktopIdx]);
723
2642
  }
724
2643
  }
@@ -730,24 +2649,26 @@ export class WindowManager {
730
2649
  this._notifyChange(rec._demoting ? 'window-demoted' : 'window-closed');
731
2650
  }
732
2651
 
733
- /** Post-show DOM hook: inject a "back to tile" button into the
734
- * window chrome and wire a right-click context menu on the topbar. */
2652
+ /** Post-show DOM hook: wire a right-click context menu on the topbar.
2653
+ *
2654
+ * R7. IT USED TO INJECT A BUTTON HERE, and that is the whole of what
2655
+ * changed. "Back to tile" was a fourth button squeezed left of Close,
2656
+ * built by reaching into four of ManagedWindow's internal class names —
2657
+ * the coupling C6 exists to avoid — and it sat next to a MAXIMIZE button
2658
+ * that did the one thing a window lifted out of a tile has no use for.
2659
+ * Now the maximize button IS "back to tile" (`onMaximize`, passed where
2660
+ * the window is built), so the verb has one control instead of two and
2661
+ * this hook has no markup of its own to keep in step.
2662
+ *
2663
+ * Gone with it: the rule that hid MINIMIZE while the window was maximised.
2664
+ * It existed because minimising a full-screen window strands it — nothing
2665
+ * on screen points at it any more — and a window that cannot maximise
2666
+ * cannot be in that state at all. `managed-window-maximized` (C16) still
2667
+ * fires for everyone else, and `--suppressed` is still styled for the next
2668
+ * consumer that needs to hide one of these. */
735
2669
  _decorateManagedWindow(win, winId) {
736
2670
  const topbar = win.element?.querySelector?.('.twm-managed-window__topbar');
737
- const buttons = topbar?.querySelector?.('.twm-managed-window__buttons');
738
- if (!buttons) return;
739
- const backBtn = document.createElement('button');
740
- backBtn.type = 'button';
741
- backBtn.className = 'twm-managed-window__btn managed-window__btn--demote';
742
- backBtn.title = 'Back to tile';
743
- backBtn.innerHTML = `<span class="material-symbols-outlined" style="font-size:14px">close_fullscreen</span>`;
744
- backBtn.addEventListener('click', (e) => {
745
- e.stopPropagation();
746
- this.bringBackWindow(winId);
747
- });
748
- const closeBtn = buttons.querySelector('.twm-managed-window__btn--close');
749
- if (closeBtn) buttons.insertBefore(backBtn, closeBtn);
750
- else buttons.appendChild(backBtn);
2671
+ if (!topbar) return;
751
2672
 
752
2673
  topbar.addEventListener('contextmenu', (e) => {
753
2674
  e.preventDefault();
@@ -781,6 +2702,151 @@ export class WindowManager {
781
2702
  });
782
2703
  }
783
2704
 
2705
+ // ══ R10. Adjacent start tiles are one start tile ═══════════════════
2706
+ /**
2707
+ * Merge every run of side-by-side START TILES into one.
2708
+ *
2709
+ * A start tile is a leaf holding the taxonomy ROOT — the pane a fresh
2710
+ * desktop opens with, and the pane `_seedHome` puts back when a tile is
2711
+ * emptied. It is not a document: it is the ground, the empty canvas, the
2712
+ * "nothing is open here" surface. So two of them side by side are ONE
2713
+ * surface with a splitter drawn through it for no reason, and the splitter
2714
+ * is worse than decoration — it offers to resize a boundary between two
2715
+ * things that are the same thing.
2716
+ *
2717
+ * This is deliberately NOT run on every tree change, and the reason is
2718
+ * `split()`: splitting a start tile seeds the new pane with the root kind
2719
+ * too (the never-empty-tile invariant), so a merge on every mutation would
2720
+ * undo an Alt+H the instant it happened. It runs where the product owner
2721
+ * put it — *"when a maximized (tiled) panel is window-ized, all adjacent
2722
+ * non-panel (start tile) tiles get merged to one"* — and is public so an
2723
+ * embedder that empties a pane its own way can ask for the same tidy-up.
2724
+ *
2725
+ * THREE THINGS ARE NEVER MERGED, and each one is a way to lose work:
2726
+ *
2727
+ * - `panel:*` leaves. They are chrome, not content; the navigator is not
2728
+ * a start tile and a panel BETWEEN two start tiles means those two are
2729
+ * not adjacent.
2730
+ * - A start tile with WINDOWS STANDING ON IT. The whole point of the
2731
+ * surface is that things float on it, and closing the leaf takes its
2732
+ * ground — and every window clamped to it — out of the document. When
2733
+ * one of a pair is occupied the other merges INTO it; when both are,
2734
+ * neither moves.
2735
+ * - A start tile holding tabs, live or archived. `leaf.content.kind`
2736
+ * names the ACTIVE tab only, and a pane whose other tabs are tables, or
2737
+ * whose `pageTabs` archive holds the three tables a rail click put
2738
+ * there, is a pane with work in it wearing a start tile's face.
2739
+ *
2740
+ * @param {TileTree} tree
2741
+ * @param {string|null} preferLeafId the leaf to keep when a run is
2742
+ * otherwise a free choice — the pane the caller just emptied, so the
2743
+ * merged surface is the one the user is looking at.
2744
+ * @returns {number} how many leaves were absorbed.
2745
+ */
2746
+ _mergeStartTiles(tree, preferLeafId = null) {
2747
+ if (!tree) return 0;
2748
+ let absorbed = 0;
2749
+ // Pairwise, restarting after each close. `TileTree.close` splices the
2750
+ // parent's children AND may collapse the parent into ITS parent, so
2751
+ // every index in flight is stale the moment one leaf goes; restarting
2752
+ // is the cheap way to be right rather than the clever way to be wrong.
2753
+ // It terminates because each pass removes exactly one leaf.
2754
+ for (;;) {
2755
+ const pair = this._nextMergeablePair(tree, preferLeafId);
2756
+ if (!pair) break;
2757
+ const { split, keepIdx, dropIdx, keepId, dropId } = pair;
2758
+ // THE SPACE GOES WITH IT. `close` splices the size out of the row,
2759
+ // which hands it to every sibling in proportion; the merged pane is
2760
+ // supposed to occupy what the two of them occupied, so the keeper
2761
+ // takes it first.
2762
+ split.sizes[keepIdx] = (split.sizes[keepIdx] || 1) + (split.sizes[dropIdx] || 1);
2763
+ const hadFocus = tree.focusedLeafId === dropId;
2764
+ tree.close(dropId);
2765
+ // A LOOP THAT CANNOT END IS WORSE THAN A SPLITTER NOBODY WANTED.
2766
+ // The loop's termination argument is "each pass removes one leaf";
2767
+ // if `close` ever declines — a leaf that is not a leaf, a tree
2768
+ // mutated underneath us — that argument fails silently and the tab
2769
+ // freezes. Stopping is the correct answer to a merge that did not
2770
+ // happen.
2771
+ if (tree.nodes.has(dropId)) break;
2772
+ if (hadFocus) tree.focus(keepId);
2773
+ absorbed += 1;
2774
+ }
2775
+ return absorbed;
2776
+ }
2777
+
2778
+ /** The first two adjacent start tiles that may be merged, and which of
2779
+ * them survives. Null when there are none. */
2780
+ _nextMergeablePair(tree, preferLeafId) {
2781
+ for (const node of [...tree.nodes.values()]) {
2782
+ if (node.kind !== 'split') continue;
2783
+ // The snapshot above can name a split a previous pass collapsed.
2784
+ if (!tree.nodes.has(node.id)) continue;
2785
+ for (let i = 0; i < node.children.length - 1; i += 1) {
2786
+ const a = tree.get(node.children[i]);
2787
+ const b = tree.get(node.children[i + 1]);
2788
+ if (!this._isStartTile(a) || !this._isStartTile(b)) continue;
2789
+ const aHolds = this._paneHoldsWindows(a.id);
2790
+ const bHolds = this._paneHoldsWindows(b.id);
2791
+ if (aHolds && bHolds) continue;
2792
+ let keepIdx = i;
2793
+ if (bHolds) keepIdx = i + 1;
2794
+ else if (!aHolds && node.children[i + 1] === preferLeafId) keepIdx = i + 1;
2795
+ const dropIdx = keepIdx === i ? i + 1 : i;
2796
+ return { split: node, keepIdx, dropIdx,
2797
+ keepId: node.children[keepIdx], dropId: node.children[dropIdx] };
2798
+ }
2799
+ }
2800
+ return null;
2801
+ }
2802
+
2803
+ /** Is this leaf the empty ground and nothing else? See the three
2804
+ * exclusions in `_mergeStartTiles`. */
2805
+ _isStartTile(leaf) {
2806
+ if (!leaf || leaf.kind !== 'leaf') return false;
2807
+ const kind = leaf.content?.kind;
2808
+ if (!kind || kind !== this.taxonomy.root) return false;
2809
+ if (PANEL_KINDS.has(kind) || kind === PLACEHOLDER_KIND) return false;
2810
+ if ((Array.isArray(leaf.tabs) ? leaf.tabs.length : 0) > 1) return false;
2811
+ for (const page of Object.values(leaf.pageTabs || {})) {
2812
+ if (Array.isArray(page?.tabs) && page.tabs.length) return false;
2813
+ }
2814
+ return true;
2815
+ }
2816
+
2817
+ /**
2818
+ * Does anything float on this pane?
2819
+ *
2820
+ * Two sources, because there are two kinds of window and the WM only knows
2821
+ * about one of them. `homeLeafId` is set for a window this WM contained in
2822
+ * its own pane (C21); an EMBEDDER's windows — a canvas pane that opens its
2823
+ * own `ManagedWindow` against the pane's ground — are not in
2824
+ * `_windowToLeaf` at all, and the only honest way to see them is to look.
2825
+ * The DOM answer covers both, and covers a window whose record has been
2826
+ * dropped but whose element is still standing.
2827
+ */
2828
+ _paneHoldsWindows(leafId) {
2829
+ for (const [, rec] of this._windowToLeaf) {
2830
+ if (rec.homeLeafId === leafId) return true;
2831
+ }
2832
+ const el = this.renderer.leafEl?.(leafId);
2833
+ return !!el?.querySelector?.('.twm-managed-window');
2834
+ }
2835
+
2836
+ /** R10, as a verb an embedder can use. Merges, then repaints and persists
2837
+ * — the promote path calls `_mergeStartTiles` directly because it is
2838
+ * already going to do all three. */
2839
+ mergeStartTiles(preferLeafId = null) {
2840
+ const tree = this._tree();
2841
+ const absorbed = this._mergeStartTiles(tree, preferLeafId);
2842
+ if (!absorbed) return 0;
2843
+ this._canonicalize(tree, this.desktops.active());
2844
+ this.renderer.render();
2845
+ this._persist();
2846
+ this._notifyChange('start-tiles-merged');
2847
+ return absorbed;
2848
+ }
2849
+
784
2850
  // ── Panel-tiles (left nav / right / bottom) ─────────────────────
785
2851
  /** Panels are virtual: they live in the active desktop's tree as
786
2852
  * leaves with content kinds 'panel:left', 'panel:right',
@@ -981,12 +3047,60 @@ export class WindowManager {
981
3047
  this._leafTabAction(leafId, 'switch', { idx: next });
982
3048
  }
983
3049
 
3050
+ /**
3051
+ * Remove a desktop — AND RE-INDEX THE WINDOWS, which is the half that was
3052
+ * missing.
3053
+ *
3054
+ * `desktopIdx` on a window record is an ARRAY INDEX into `desktops`, so a
3055
+ * splice silently re-points every record above the removed one at its
3056
+ * neighbour. Nothing threw and nothing looked wrong: the window kept
3057
+ * floating, and the next *Back to tile* resolved `rec._dock` against the
3058
+ * WRONG TREE. `_onManagedWindowClosed` reads `desktops[rec.desktopIdx]`,
3059
+ * finds a tree that never held this window, and its `if (!tree) return`
3060
+ * closes the window and drops the content on the floor — staged edits
3061
+ * included, with no error and nothing on screen to say a table was lost.
3062
+ *
3063
+ * Windows homed on the desktop being removed do not die with it. The
3064
+ * ruling is that a tile operation may move a window and never destroy it,
3065
+ * and removing a desktop is the largest tile operation there is: they come
3066
+ * across to the desktop that ends up active, re-homed onto its ground by
3067
+ * `_rehomeContainedWindows` on the render below.
3068
+ */
984
3069
  removeDesktop(idx) {
985
3070
  const m = this.desktops;
986
3071
  if (m.desktops.length <= 1) return false;
987
3072
  if (idx < 0 || idx >= m.desktops.length) return false;
988
3073
  m.desktops.splice(idx, 1);
3074
+ // THE SPLICE MOVES THE ACTIVE DESKTOP TOO, and the clamp below never
3075
+ // said so. Removing a desktop BELOW the one you are on shifts it down
3076
+ // by one, so an unchanged `activeIdx` then names its neighbour: the
3077
+ // desktop you were working in is replaced on screen by the next one
3078
+ // along, the bar highlights the wrong button, and the wrong index is
3079
+ // persisted. The clamp only ever caught removing the LAST desktop.
3080
+ // Pre-existing, and load-bearing for the loop below, which reads
3081
+ // `activeIdx` to decide where the orphaned windows go.
3082
+ if (idx < m.activeIdx) m.activeIdx -= 1;
989
3083
  if (m.activeIdx >= m.desktops.length) m.activeIdx = m.desktops.length - 1;
3084
+ // AFTER the splice and AFTER `activeIdx` is repaired, so the survivors
3085
+ // resolve against the array the rest of this method uses.
3086
+ for (const [, rec] of this._windowToLeaf) {
3087
+ if (rec.desktopIdx === idx) {
3088
+ rec.desktopIdx = m.activeIdx;
3089
+ // ONLY A WINDOW THAT WAS ALREADY CONTAINED, which is what
3090
+ // `rec.homeLeafId` says and what `moveWindowToDesktop`'s guard
3091
+ // tests. `!rec.homeContainer` alone also catches a FREE-FLOATING
3092
+ // window — `_navigateWindow`'s, which has neither field — and
3093
+ // handing one a `homeLeafId` silently converts it into a
3094
+ // pane-contained window: it jumps into a tile, can no longer be
3095
+ // dragged outside it, and is then removed from the document by
3096
+ // the desktop rule the first time you switch pages.
3097
+ if (!rec.homeContainer && rec.homeLeafId) {
3098
+ rec.homeLeafId = m.active().tree.primaryLeafId() || null;
3099
+ }
3100
+ } else if (rec.desktopIdx > idx) {
3101
+ rec.desktopIdx -= 1;
3102
+ }
3103
+ }
990
3104
  this.renderer.tree = m.active().tree;
991
3105
  this.renderer.render();
992
3106
  this._persist();
@@ -998,6 +3112,13 @@ export class WindowManager {
998
3112
  const tree = this._tree();
999
3113
  const focused = tree.focused();
1000
3114
  if (!focused || !focused.content) return;
3115
+ // C20. IT DESTROYS A LEAF, SO IT ANSWERS TO THE SAME VETO `closeFocused`
3116
+ // does. A move is not a close from the pane's point of view, but it is
3117
+ // exactly a close from the PAGE's: this was the fourth door out of a
3118
+ // content veto, and the one nobody thought to look at because its label
3119
+ // says "Move".
3120
+ const closeChrome = this.renderer?.leafChrome?.(focused.id)?.close;
3121
+ if (closeChrome === false || closeChrome?.disabled === true) return;
1001
3122
  const payload = { kind: focused.content.kind, props: focused.content.props, title: focused.title };
1002
3123
  this.desktops.ensureCount(idx + 1);
1003
3124
  const target = this.desktops.desktops[idx];
@@ -1008,6 +3129,16 @@ export class WindowManager {
1008
3129
  // Remove from current desktop.
1009
3130
  tree.close(focused.id);
1010
3131
  if (!tree.rootId) tree.setRoot(makeLeaf(this._rootLeaf()));
3132
+ // THE SAME NEVER-EMPTY-CONTENT INVARIANT `closeFocused` KEEPS, and it
3133
+ // was missing here. `!tree.rootId` above only catches a tree with
3134
+ // nothing left at all; with a panel open the root survives and the page
3135
+ // is left showing chrome and no ground — so the windows that were
3136
+ // standing on the moved pane had nowhere to be re-homed to.
3137
+ if (!tree.leaves().some((l) => !String(l.content?.kind || '').startsWith('panel:'))) {
3138
+ const spawned = this._spawnContentLeaf(tree);
3139
+ if (spawned) this._seedHome(tree, spawned);
3140
+ }
3141
+ this._canonicalize(tree, this.desktops.active());
1011
3142
  this.renderer.render();
1012
3143
  this._persist();
1013
3144
  this._notifyChange();
@@ -1069,6 +3200,24 @@ export class WindowManager {
1069
3200
  this._showTabContextMenu(leafId, data.idx, data.x, data.y);
1070
3201
  return;
1071
3202
  }
3203
+ if (action === 'to-window') {
3204
+ // R9. `floatTabAsWindow` renders, persists and notifies on its own
3205
+ // — it is the same promote path the pane's chrome uses — so there
3206
+ // is deliberately nothing after it here.
3207
+ this.floatTabAsWindow(leafId, data.idx);
3208
+ return;
3209
+ }
3210
+ if (action === 'drop-into') {
3211
+ // C33. The tab-drop's one door into the tree. Fired against the
3212
+ // SOURCE leaf, because `_leafTabAction`'s first argument is always
3213
+ // the leaf whose tab is being acted on — the destination travels in
3214
+ // `data.target`, which is a `tabDropProbe` answer.
3215
+ //
3216
+ // Like `to-window` above, `moveTabInto` renders, persists and
3217
+ // notifies on its own, so there is deliberately nothing after it.
3218
+ this.moveTabInto(leafId, data.idx, data.target);
3219
+ return;
3220
+ }
1072
3221
  if (action === 'close-others') {
1073
3222
  tree.closeOtherTabs(leafId, data.idx);
1074
3223
  this.renderer.render();
@@ -1130,6 +3279,11 @@ export class WindowManager {
1130
3279
  * `opts.transient` — the appended tab is not persisted/restored
1131
3280
  * (e.g. an add-row form). Only meaningful with
1132
3281
  * `newTab:true`.
3282
+ * `opts.background` — with `newTab`, append the tab WITHOUT switching to
3283
+ * it or focusing its tile. "Open in a background tab"
3284
+ * means the page you are reading stays in front;
3285
+ * without it the tab arrives and takes the screen,
3286
+ * which is what an ordinary click already does.
1133
3287
  *
1134
3288
  * Back-compat: the legacy `opts.target` enum still works and maps
1135
3289
  * onto the axes — 'auto'→origin, 'tab'→origin+newTab,
@@ -1140,6 +3294,8 @@ export class WindowManager {
1140
3294
  * `openInWindow`) stay internal; callers prefer `wm.navigate(...)`. */
1141
3295
  navigate(kind, props = {}, opts = {}) {
1142
3296
  const { ctx = null, transient = false } = opts;
3297
+ // Append the tab but stay where you are. Only meaningful with `newTab`.
3298
+ const background = !!opts.background;
1143
3299
  // Resolve the two axes, honoring the legacy `target` alias.
1144
3300
  let { dest = 'main', newTab = false } = opts;
1145
3301
  if (opts.target != null) {
@@ -1165,12 +3321,12 @@ export class WindowManager {
1165
3321
  if (dest === 'window') return this._navigateWindow(kind, props);
1166
3322
  if (dest === 'main') {
1167
3323
  return newTab
1168
- ? this.openInTabInPrimary(kind, props, transient)
3324
+ ? this.openInTabInPrimary(kind, props, transient, background)
1169
3325
  : this.openInPrimary(kind, props);
1170
3326
  }
1171
3327
  // dest === 'origin'
1172
3328
  return newTab
1173
- ? this._navigateTab(ctx, kind, props, transient)
3329
+ ? this._navigateTab(ctx, kind, props, transient, background)
1174
3330
  : this._navigateAuto(ctx, kind, props);
1175
3331
  }
1176
3332
 
@@ -1197,14 +3353,14 @@ export class WindowManager {
1197
3353
  this.openInPrimary(kind, props);
1198
3354
  }
1199
3355
 
1200
- _navigateTab(ctx, kind, props, transient = false) {
3356
+ _navigateTab(ctx, kind, props, transient = false, background = false) {
1201
3357
  // Windows aren't tabbed — "open in tab" inside a window just
1202
3358
  // replaces the window's content.
1203
3359
  if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
1204
3360
  this.openInWindow(ctx.windowId, kind, props);
1205
3361
  return;
1206
3362
  }
1207
- this.openInTabFromContext(ctx || {}, kind, props, transient);
3363
+ this.openInTabFromContext(ctx || {}, kind, props, transient, background);
1208
3364
  }
1209
3365
 
1210
3366
  /** Spawn a fresh ManagedWindow with the requested content. No
@@ -1226,8 +3382,38 @@ export class WindowManager {
1226
3382
  canMaximize: true,
1227
3383
  canResize: true,
1228
3384
  modal: false,
3385
+ // C15, and this is the SECOND of the two places the WM builds a
3386
+ // window. Alt+N and "Open in new window" produce a window that is
3387
+ // every bit as dockable as a promoted one, and a window that can be
3388
+ // dragged onto a tile in one case and not the other is a rule
3389
+ // nobody can learn.
3390
+ snap: this.snapPromotion,
3391
+ snapController: this.snapPromotion ? this._snapController() : null,
3392
+ // R1. THE PANE IS A BOX WITH `overflow: hidden`. A window contained
3393
+ // to one (C21) cannot be dragged a single pixel outside it, so
3394
+ // "drag a window from one tile to another" — the gesture all three
3395
+ // drop behaviours are built on — was not merely awkward, it was
3396
+ // invisible. For the length of a drag the window is re-parented
3397
+ // here, to the root every tile is inside; on release it goes back
3398
+ // into a pane, either the one it was dropped on or the one it came
3399
+ // from. Resolved per drag: the root outlives any tile, and a tile
3400
+ // grabbed once does not survive its own repaint.
3401
+ dragHost: () => this.rootEl,
3402
+ dragBounds: () => this._tileBounds(),
3403
+ // R7. MAXIMISE MEANS BACK TO TILE. This window came OUT of the
3404
+ // tree; the useful thing to do with it is put it back, and filling
3405
+ // the screen with it is the one gesture that makes putting it back
3406
+ // harder. So the maximize button docks — and the separate demote
3407
+ // button the WM used to inject beside it is gone, because two
3408
+ // buttons for one verb is how you get a chrome nobody reads.
3409
+ onMaximize: () => this.bringBackWindow(winId),
3410
+ maximizeIcon: 'close_fullscreen',
3411
+ maximizeTitle: 'Back to tile',
1229
3412
  onClose: () => this._onManagedWindowClosed(winId, mountInfo),
1230
3413
  });
3414
+ // DECORATED ONCE, below, after the record exists. It was called here as
3415
+ // well, so every Alt+N window carried two topbar context-menu handlers
3416
+ // — and, until R7 removed it, two "back to tile" buttons.
1231
3417
  // leafId is null — `_onManagedWindowClosed` already short-circuits
1232
3418
  // both branches when there's no source leaf, so the close path
1233
3419
  // just disposes the content and drops the map entry.
@@ -1267,9 +3453,26 @@ export class WindowManager {
1267
3453
  if (!leaf || leaf.kind !== 'leaf') return;
1268
3454
  const tabs = leaf.tabs || [];
1269
3455
  if (tabs.length === 0) return;
1270
- const items = [
1271
- { label: 'Close tab', icon: 'close', action: 'close' },
1272
- ];
3456
+ const items = [];
3457
+ // R9. THE PER-TAB VERB LIVES ON THE TAB.
3458
+ //
3459
+ // Floating one tab out is what the pane's chrome button used to do, and
3460
+ // it was the wrong home for it: the button is on the PANE and named
3461
+ // "float this pane as a window", so it now floats the pane (R8) and
3462
+ // this is where the single-tab version went. A user who wants one of
3463
+ // three tables in a window right-clicks that table's tab, which is
3464
+ // where every other per-tab verb already is.
3465
+ //
3466
+ // Offered above the close verbs, and separated from them: it is the
3467
+ // only item here that does not destroy something, and a menu whose
3468
+ // first four entries all close things teaches the eye to skip it.
3469
+ const tab = tabs[idx];
3470
+ if (tab && !String(tab.kind || '').startsWith('panel:')
3471
+ && tab.kind !== PLACEHOLDER_KIND) {
3472
+ items.push({ label: 'Open in a window', icon: 'web_asset', action: 'to-window' });
3473
+ items.push({ separator: true });
3474
+ }
3475
+ items.push({ label: 'Close tab', icon: 'close', action: 'close' });
1273
3476
  if (tabs.length > 1) {
1274
3477
  items.push({ label: 'Close other tabs', icon: 'tab_close', action: 'close-others' });
1275
3478
  }
@@ -1290,7 +3493,7 @@ export class WindowManager {
1290
3493
  * Mirrors `openFromContext` (windowed / split-leaf / primary
1291
3494
  * routing) but uses `appendLeafTab` so the existing content
1292
3495
  * stays in place as a tab. */
1293
- openInTabFromContext(ctx, kind, props = {}, transient = false) {
3496
+ openInTabFromContext(ctx, kind, props = {}, transient = false, background = false) {
1294
3497
  // Managed-window content: just open in the window — managed
1295
3498
  // windows aren't tabbed (one window = one content).
1296
3499
  if (ctx?.windowId && this._windowToLeaf.has(ctx.windowId)) {
@@ -1308,8 +3511,11 @@ export class WindowManager {
1308
3511
  this.openInPrimary(kind, props);
1309
3512
  return;
1310
3513
  }
1311
- tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props), { transient });
1312
- tree.focus(leafId);
3514
+ tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
3515
+ { transient, background });
3516
+ // A BACKGROUND tab must not steal the tile's focus either — the point
3517
+ // is that the user stays exactly where they were.
3518
+ if (!background) tree.focus(leafId);
1313
3519
  this.renderer.render();
1314
3520
  this._persist();
1315
3521
  this._notifyChange('tab-open');
@@ -1322,7 +3528,7 @@ export class WindowManager {
1322
3528
  * click from outside the tile system (e.g. the bottom-panel
1323
3529
  * "Add row" button, which passes no ctx) reliably lands as a sibling
1324
3530
  * tab in the main tile rather than swapping its content. */
1325
- openInTabInPrimary(kind, props = {}, transient = false) {
3531
+ openInTabInPrimary(kind, props = {}, transient = false, background = false) {
1326
3532
  const tree = this._tree();
1327
3533
  const leafId = tree.primaryLeafId();
1328
3534
  // No content tile on this desktop (e.g. a panels-only layout) —
@@ -1330,8 +3536,11 @@ export class WindowManager {
1330
3536
  // caller asked for "a tab in the main tile"; with no main tile to
1331
3537
  // tab into, a floating window is the least-surprising fallback.
1332
3538
  if (!leafId) { this._navigateWindow(kind, props); return; }
1333
- tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props), { transient });
1334
- tree.focus(leafId);
3539
+ tree.appendLeafTab(leafId, { kind, props }, _tabTitle(kind, props),
3540
+ { transient, background });
3541
+ // A BACKGROUND tab must not steal the tile's focus either — the point
3542
+ // is that the user stays exactly where they were.
3543
+ if (!background) tree.focus(leafId);
1335
3544
  this.renderer.render();
1336
3545
  this._persist();
1337
3546
  this._notifyChange('tab-open');
@@ -1350,3 +3559,85 @@ function _tabTitle(kind, props) {
1350
3559
  && String(props.id)) return String(props.id);
1351
3560
  return kind || '';
1352
3561
  }
3562
+
3563
+ /** R8. A leaf's tabs as the plain specs a window record holds — deep enough
3564
+ * that editing one afterwards cannot reach back into the tree, which matters
3565
+ * because a floated tab keeps being edited (`openInWindow` renames it) while
3566
+ * the leaf it came from is still alive. A leaf with no tab list at all is
3567
+ * pre-tabs data; its mirrored `content`/`title` are the one tab it has. */
3568
+ function _leafTabSpecs(leaf) {
3569
+ const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
3570
+ if (tabs.length) {
3571
+ return tabs.map((t) => ({
3572
+ kind: t.kind, props: { ...(t.props || {}) },
3573
+ title: t.title || t.kind || '',
3574
+ }));
3575
+ }
3576
+ return [{
3577
+ kind: leaf.content.kind,
3578
+ props: { ...(leaf.content.props || {}) },
3579
+ title: leaf.title || leaf.content.kind || '',
3580
+ }];
3581
+ }
3582
+
3583
+ /** One half of a rectangle, in the same viewport pixels it arrived in. This is
3584
+ * what makes the drop preview honest: the rectangle a `left` drop on a tile
3585
+ * produces is half of THAT TILE, which — when the tile is itself half the
3586
+ * layer — is a quarter of the layer, and the preview says so. */
3587
+ function _halfOf(r, side) {
3588
+ const w = Math.round(r.width / 2);
3589
+ const h = Math.round(r.height / 2);
3590
+ switch (side) {
3591
+ case 'left': return { left: r.left, top: r.top, width: w, height: r.height };
3592
+ case 'right': return { left: r.left + r.width - w, top: r.top, width: w, height: r.height };
3593
+ case 'top': return { left: r.left, top: r.top, width: r.width, height: h };
3594
+ case 'bottom': return { left: r.left, top: r.top + r.height - h, width: r.width, height: h };
3595
+ default: return { left: r.left, top: r.top, width: r.width, height: r.height };
3596
+ }
3597
+ }
3598
+
3599
+ /** Give `newId` half of `sourceId`'s share of their shared split, leaving every
3600
+ * other pane in the row untouched. This is what "the preview showed half of
3601
+ * that tile" means once the split is an insertion rather than a wrap. */
3602
+ function _halveInto(tree, sourceId, newId) {
3603
+ const src = tree.get(sourceId);
3604
+ if (!src) return false;
3605
+ const parent = tree.get(src.parentId);
3606
+ if (!parent || parent.kind !== 'split') return false;
3607
+ const i = parent.children.indexOf(sourceId);
3608
+ const j = parent.children.indexOf(newId);
3609
+ if (i < 0 || j < 0) return false;
3610
+ // The wrap case already splits a fresh two-child node evenly; only the
3611
+ // insertion case has a size to correct, and correcting an even one is a
3612
+ // no-op anyway.
3613
+ const share = (parent.sizes[i] ?? 1) / 2;
3614
+ parent.sizes[i] = share;
3615
+ parent.sizes[j] = share;
3616
+ return true;
3617
+ }
3618
+
3619
+ /** Swap two sibling leaves inside their shared split. `TileTree.split` always
3620
+ * appends, and "the window goes on the left" is a legitimate outcome of a
3621
+ * drop; this is the one-line difference between the two. */
3622
+ function _swapSiblings(tree, aId, bId) {
3623
+ const a = tree.get(aId);
3624
+ const b = tree.get(bId);
3625
+ if (!a || !b || a.parentId !== b.parentId) return false;
3626
+ const parent = tree.get(a.parentId);
3627
+ if (!parent || parent.kind !== 'split') return false;
3628
+ const i = parent.children.indexOf(aId);
3629
+ const j = parent.children.indexOf(bId);
3630
+ if (i < 0 || j < 0) return false;
3631
+ parent.children[i] = bId;
3632
+ parent.children[j] = aId;
3633
+ // `sizes` IS PARALLEL TO `children` — `makeSplit` builds them together and
3634
+ // `TileTree.split` splices both at the same index. Swapping one and not the
3635
+ // other does not swap two panes, it makes them trade widths in place, which
3636
+ // looks like the drop landing on the correct side and then resizing itself
3637
+ // for no reason. `TileTree.moveDir` swaps both; so does this.
3638
+ const size = parent.sizes[i];
3639
+ parent.sizes[i] = parent.sizes[j];
3640
+ parent.sizes[j] = size;
3641
+ return true;
3642
+ }
3643
+