flexdesk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/css/base.css +1484 -181
  2. package/css/flexdesk.css +1311 -18
  3. package/css/overrides.css +44 -0
  4. package/css/tokens.css +45 -0
  5. package/dist/charts.js +5 -3
  6. package/dist/charts.js.map +1 -1
  7. package/dist/{chunk-DVU44T77.js → chunk-ELXVW542.js} +196 -75
  8. package/dist/chunk-ELXVW542.js.map +7 -0
  9. package/dist/chunk-LH5TSOZW.js +1237 -0
  10. package/dist/chunk-LH5TSOZW.js.map +7 -0
  11. package/dist/{chunk-TLZUUFOE.js → chunk-O5OHMWBB.js} +10 -2
  12. package/dist/chunk-O5OHMWBB.js.map +7 -0
  13. package/dist/{chunk-CT4YXXLP.js → chunk-QIU5S2RU.js} +371 -73
  14. package/dist/chunk-QIU5S2RU.js.map +7 -0
  15. package/dist/chunk-QNQHQ24V.js +408 -0
  16. package/dist/chunk-QNQHQ24V.js.map +7 -0
  17. package/dist/{chunk-DRYCDMEG.js → chunk-XKDTIT4Q.js} +168 -12
  18. package/dist/chunk-XKDTIT4Q.js.map +7 -0
  19. package/dist/editor.js +3 -380
  20. package/dist/editor.js.map +3 -3
  21. package/dist/flexdesk.css +1311 -18
  22. package/dist/tiles.js +168 -41
  23. package/dist/tiles.js.map +2 -2
  24. package/dist/tokens.css +45 -0
  25. package/dist/widgets.js +44 -14
  26. package/dist/widgets.js.map +2 -2
  27. package/dist/wm.js +2983 -142
  28. package/dist/wm.js.map +4 -4
  29. package/package.json +3 -2
  30. package/src/charts/chart_types.js +167 -0
  31. package/src/charts/plotly_wrapper.js +178 -10
  32. package/src/editor/notebook_tab_bar.js +39 -3
  33. package/src/tiles/tile_base.js +143 -35
  34. package/src/tiles/tile_grid.js +52 -1
  35. package/src/tiling/command_palette.js +71 -18
  36. package/src/tiling/desktops.js +36 -12
  37. package/src/tiling/keymap.js +24 -4
  38. package/src/tiling/shell.js +135 -24
  39. package/src/tiling/tab_strip.js +184 -0
  40. package/src/tiling/tile_breadcrumb.js +34 -2
  41. package/src/tiling/tile_renderer.js +1386 -21
  42. package/src/tiling/tile_tab_menu.js +101 -0
  43. package/src/tiling/tile_tree.js +82 -0
  44. package/src/tiling/wm.js +2352 -74
  45. package/src/ui/components/action_dropdown.js +34 -3
  46. package/src/ui/components/autocomplete_field.js +65 -13
  47. package/src/ui/components/context_menu.js +79 -8
  48. package/src/ui/components/data_table.js +508 -84
  49. package/src/ui/components/managed_window.js +928 -36
  50. package/src/ui/components/modal.js +214 -8
  51. package/dist/chunk-CT4YXXLP.js.map +0 -7
  52. package/dist/chunk-DRYCDMEG.js.map +0 -7
  53. package/dist/chunk-DVU44T77.js.map +0 -7
  54. package/dist/chunk-TLZUUFOE.js.map +0 -7
  55. package/dist/chunk-UCJ2WD4D.js +0 -625
  56. package/dist/chunk-UCJ2WD4D.js.map +0 -7
@@ -30,6 +30,10 @@ const MAX_Z_INDEX = 6999;
30
30
  const TOP_BAR_HEIGHT = 35;
31
31
  const BOTTOM_BAR_HEIGHT = 22;
32
32
 
33
+ /** C11. How close to an edge the POINTER must come for a snap zone to arm.
34
+ * Small enough that it takes intent, large enough to hit without aiming. */
35
+ const SNAP_EDGE = 12;
36
+
33
37
  // Default icon for windows
34
38
  const DEFAULT_ICON = 'web_asset';
35
39
 
@@ -113,6 +117,24 @@ export class ManagedWindow {
113
117
  * @param {Function} [options.onClose] - Callback when window is closed
114
118
  * @param {Function} [options.beforeClose] - Guard called before close. Return false (or a Promise resolving to false) to prevent closing.
115
119
  * @param {Function} [options.onMinimize] - Callback when window is minimized
120
+ * @param {HTMLElement} [options.container] - Mount point. Defaults to
121
+ * `document.body` (every call site that exists today). When given, the
122
+ * window is positioned and CLAMPED inside that element instead of the
123
+ * viewport, and its taskbar events carry the container so a per-panel
124
+ * taskbar can filter on them.
125
+ *
126
+ * The container MUST establish a containing block with
127
+ * `position: relative` or `position: absolute` — and with NOTHING else.
128
+ * `transform`, `filter`, `contain` and `will-change` also create a
129
+ * containing block, and they additionally trap `position: fixed`
130
+ * descendants. DataTable's filter dropdown (`position: fixed;
131
+ * z-index: 10001`) and the autocomplete dropdown deliberately ESCAPE
132
+ * their tile to the viewport; under a transformed ancestor they become
133
+ * container-relative and get clipped by `overflow: hidden`. The symptom
134
+ * is "the filter dropdown is cut in half" and the cause is three files
135
+ * away.
136
+ * @param {Array} [options.titlebarButtons] - Extra buttons left of
137
+ * minimize: `{icon, title, onClick}`.
116
138
  */
117
139
  constructor(options) {
118
140
  this.id = options.id;
@@ -140,6 +162,103 @@ export class ManagedWindow {
140
162
  this.onClose = options.onClose;
141
163
  this.beforeClose = options.beforeClose || null;
142
164
  this.onMinimize = options.onMinimize;
165
+ // C1. `document.body` is the default and the legacy path; every bounds
166
+ // computation below reduces to today's expression by substitution when
167
+ // it is in force. See `_bounds`.
168
+ this.container = options.container || null;
169
+ /** C11. AERO SNAP — drag to an edge, release, the window takes that
170
+ * region. Opt-in and default OFF, so no existing consumer changes
171
+ * behaviour by upgrading. Meaningful only for a draggable window that
172
+ * is allowed to change size. */
173
+ this.snap = (options.snap ?? false) && this.canDrag && this.canResize;
174
+ /** C15. SNAP AS A DELEGATED DECISION.
175
+ *
176
+ * C11's snap answers "which rectangle" out of three it computes itself
177
+ * from `_bounds()`. That is the whole and correct answer for a window
178
+ * floating over a region nothing else owns, and only half of it under a
179
+ * TILING window manager: there, dropping on an edge does not move a
180
+ * window, it PROMOTES one — the window stops being a ManagedWindow and
181
+ * becomes a leaf in a tree this component knows nothing about.
182
+ *
183
+ * So the decision is delegated. A `snapController` is:
184
+ *
185
+ * probe(pointerEvent, win) -> { key, rect } | null
186
+ * `rect` is {left, top, width, height} in VIEWPORT pixels —
187
+ * the space a hit-test against other people's DOM naturally
188
+ * produces. `key` is an opaque identity for the zone; the
189
+ * preview only re-renders when it changes.
190
+ * commit(probe, win) -> falsy | true | Promise
191
+ * Called once on release. Falsy means "not mine" and the
192
+ * window keeps the position the drag left it in. Anything
193
+ * truthy means the controller took the window AND the preview:
194
+ * a controller that opens a menu needs the preview to outlive
195
+ * the pointer-up, so it clears it through `clearSnapPreview()`.
196
+ *
197
+ * Absent, every line below reduces to C11 exactly. */
198
+ this.snapController = options.snapController || null;
199
+ /** R1. DRAG ACROSS THE CONTAINER BOUNDARY.
200
+ *
201
+ * A contained window lives inside a box with `overflow: hidden` — for
202
+ * the WM that box is a tile's leaf wrap — so at rest it cannot show a
203
+ * single pixel outside it, and a drag that leaves it is a drag that
204
+ * disappears. `dragHost` names the WIDER box the window is re-parented
205
+ * into for the duration of a drag: an element or a `(win) => element`
206
+ * callback, resolved on every pointer-down because the WM's root
207
+ * outlives any particular tile and a captured tile does not.
208
+ *
209
+ * Absent — every consumer today — nothing re-parents and the drag is
210
+ * clamped to the container exactly as before. The host must CONTAIN
211
+ * the current container: escaping is meant to widen the box the window
212
+ * may cross, not to move it somewhere it has no business being. */
213
+ this.dragHost = options.dragHost || null;
214
+ /** R12. THE BOX AN ESCAPED WINDOW MAY OCCUPY, in the drag host's own
215
+ * coordinates: `() => {minX, minY, width, height}`.
216
+ *
217
+ * R1 widened the drag from one pane to the whole root, and the root is
218
+ * not all tiles — the docked panels are inside it too. Without this a
219
+ * window can be dragged down over the bottom panel and left there,
220
+ * which is the one edge of the four that stopped feeling like an edge.
221
+ * The host knows which of its children are panels and this component
222
+ * never will, so it answers rather than guesses.
223
+ *
224
+ * Consulted only while a drag has escaped: at rest the container is
225
+ * the box, exactly as before, and a window that never escapes never
226
+ * reads this. */
227
+ this.dragBounds = options.dragBounds || null;
228
+ /** R7. MAXIMISE IS A GESTURE, NOT NECESSARILY A RECTANGLE.
229
+ *
230
+ * `(win) => truthy` claims the maximise gesture: `toggleMaximize`
231
+ * returns without touching the geometry, and the consumer does
232
+ * whatever it decided maximise means. The WM decides it means "back
233
+ * into the tree", which is why the separate demote button it used to
234
+ * inject into this chrome is gone. Falsy — and absent — leaves the
235
+ * ordinary maximise, so no existing window changes behaviour. */
236
+ this.onMaximize = options.onMaximize || null;
237
+ /** A Material Symbol name replacing the maximize button's square, and
238
+ * its tooltip. They exist because `onMaximize` can change what the
239
+ * button DOES, and a button that does something else while drawing a
240
+ * square is a lie the user only discovers by pressing it. */
241
+ this.maximizeIcon = options.maximizeIcon || null;
242
+ this.maximizeTitle = options.maximizeTitle || 'Maximize';
243
+ this._snapZone = null;
244
+ this._snapProbe = null;
245
+ /** The container a drag escaped FROM, for as long as that drag lasts.
246
+ * Null at every other moment, including for a window that never
247
+ * escapes. Read by the consumer's snap controller (`dragOrigin`), which
248
+ * needs to know which pane is "home" to stay silent inside it. */
249
+ this._escapeOrigin = null;
250
+ this._preSnapState = null;
251
+ this._snapPreviewEl = null;
252
+ // C27. THE TWO ANIMATION TIMERS, HELD SO THEY CAN BE CANCELLED.
253
+ // Each of `minimize`/`_restore` ends in a `setTimeout` that finishes
254
+ // its animation, and each finishes it by writing state the OTHER one
255
+ // owns — `display`, and the three `--mw-target-*` properties. Fired
256
+ // after the opposite gesture has already run, that write is not a late
257
+ // tidy-up, it is a corruption. See `_restore` for the report.
258
+ this._minimizeTimer = null;
259
+ this._restoreTimer = null;
260
+ this.titlebarButtons = Array.isArray(options.titlebarButtons)
261
+ ? options.titlebarButtons : [];
143
262
 
144
263
  this.element = null;
145
264
  this.backdropElement = null;
@@ -149,13 +268,12 @@ export class ManagedWindow {
149
268
  this.isMaximized = false;
150
269
  this.zIndex = BASE_Z_INDEX;
151
270
 
152
- // Position/size state - clamp to viewport bounds
271
+ // Position/size state - clamp to the bounds rectangle
153
272
  this.x = 0;
154
273
  this.y = 0;
155
- const maxWidth = window.innerWidth;
156
- const maxHeight = window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT;
157
- this.width = Math.min(this.defaultWidth, maxWidth);
158
- this.height = Math.min(this.defaultHeight, maxHeight);
274
+ const initial = this._bounds();
275
+ this.width = Math.min(this.defaultWidth, initial.width);
276
+ this.height = Math.min(this.defaultHeight, initial.height);
159
277
 
160
278
  // State before maximize (for restore)
161
279
  this._preMaximizeState = null;
@@ -178,6 +296,16 @@ export class ManagedWindow {
178
296
  */
179
297
  show() {
180
298
  if (this.isVisible && !this.isMinimized) {
299
+ // C27. `show()` IS THE REPAIR PATH, so it repairs. A window that is
300
+ // visible by its own flags and hidden by an inline `display: none`
301
+ // is the state the minimise/restore race used to leave behind, and
302
+ // a consumer holding such a window had nothing to call: this branch
303
+ // raised a window nobody could see. Clearing the property is a
304
+ // no-op for every window that was not in that state — a shown
305
+ // window's `display` is already `''`.
306
+ if (this.element && this.element.style.display === 'none') {
307
+ this.element.style.display = '';
308
+ }
181
309
  this.bringToFront();
182
310
  return;
183
311
  }
@@ -190,11 +318,24 @@ export class ManagedWindow {
190
318
  if (this.isMinimized) {
191
319
  this._restore();
192
320
  } else {
321
+ // A window whose PERSISTED state was maximised is maximised before
322
+ // anyone touches the button, so the modifier has to be written here
323
+ // too and not only in `toggleMaximize`.
324
+ this.element?.classList.toggle(
325
+ 'twm-managed-window--maximized', this.isMaximized);
193
326
  this._applyPosition();
194
- document.body.appendChild(this.element);
327
+ const mount = this.container || document.body;
328
+ // C3. `--contained` switches `position: fixed` to `absolute`; the
329
+ // backdrop follows the same rule so a modal inside a panel dims the
330
+ // panel rather than the page.
331
+ this.element.classList.toggle('twm-managed-window--contained', !!this.container);
332
+ mount.appendChild(this.element);
195
333
  if (this.modal && this.backdropElement) {
196
- document.body.appendChild(this.backdropElement);
334
+ this.backdropElement.classList.toggle(
335
+ 'twm-managed-window__backdrop--contained', !!this.container);
336
+ mount.appendChild(this.backdropElement);
197
337
  }
338
+ this._installContainerResizeObserver();
198
339
  this.isVisible = true;
199
340
  }
200
341
 
@@ -242,10 +383,25 @@ export class ManagedWindow {
242
383
 
243
384
  this.isVisible = false;
244
385
  this.isMinimized = false;
386
+ // C27. A window can be closed mid-animation — "back to tile" from the
387
+ // taskbar's menu is exactly that, and it closes a MINIMISED window. The
388
+ // pending timer would then write `display: none` and strip the target
389
+ // properties from an element that has been detached, or, for a window
390
+ // `show()` puts back inside the same 200ms, from a live one.
391
+ this._cancelMinimizeAnimation();
392
+ this._cancelRestoreAnimation();
393
+ this._clearTargetProperties();
245
394
  document.removeEventListener('keydown', this._boundOnKeyDown);
246
395
 
247
396
  // Notify taskbar
248
- window.dispatchEvent(new CustomEvent('managed-window-closed', { detail: { id: this.id } }));
397
+ this._teardownContainerResizeObserver();
398
+ // C5. `container` rides on all three window events so a NON-SINGLETON
399
+ // taskbar can filter on receipt: an in-panel taskbar shows only the
400
+ // windows mounted in its own panel, and the viewport taskbar shows only
401
+ // the ones with no container.
402
+ window.dispatchEvent(new CustomEvent('managed-window-closed', {
403
+ detail: { id: this.id, container: this.container }
404
+ }));
249
405
  }
250
406
 
251
407
  /**
@@ -267,11 +423,21 @@ export class ManagedWindow {
267
423
 
268
424
  // Dispatch event first so the taskbar button is created synchronously
269
425
  window.dispatchEvent(new CustomEvent('managed-window-minimized', {
270
- detail: { id: this.id, title: this.title, icon: this.icon }
426
+ detail: {
427
+ id: this.id, title: this.title, icon: this.icon,
428
+ container: this.container,
429
+ }
271
430
  }));
272
431
 
273
432
  if (!this.element) return;
274
433
 
434
+ // C27. The mirror of the cancellation in `_restore`. A restore in
435
+ // flight owns `--restoring` and the target properties, and its timer
436
+ // would strip both out from under the minimise that replaced it —
437
+ // leaving a window that shrinks toward the taskbar and then snaps back
438
+ // to full size for the rest of the 200ms.
439
+ this._cancelRestoreAnimation();
440
+
275
441
  if (!getSetting('window.animateMinimize', true)) {
276
442
  this.element.style.display = 'none';
277
443
  return;
@@ -281,7 +447,8 @@ export class ManagedWindow {
281
447
  this._setMinimizeTargetProperties();
282
448
  this.element.classList.add('twm-managed-window--minimizing');
283
449
 
284
- setTimeout(() => {
450
+ this._minimizeTimer = setTimeout(() => {
451
+ this._minimizeTimer = null;
285
452
  if (this.element) {
286
453
  this.element.style.display = 'none';
287
454
  this.element.classList.remove('twm-managed-window--minimizing');
@@ -290,8 +457,63 @@ export class ManagedWindow {
290
457
  }, 200);
291
458
  }
292
459
 
460
+ /** C27. Abandon a minimise animation that has not landed yet.
461
+ *
462
+ * The timer is dropped AND the class is removed, because the class is half
463
+ * the damage: `--minimizing` is `opacity: 0` plus a transform that parks
464
+ * the window over the taskbar plus `pointer-events: none`, so a window
465
+ * that keeps it is invisible and unclickable for the rest of the 200ms
466
+ * even before the timer hides it outright. */
467
+ _cancelMinimizeAnimation() {
468
+ if (this._minimizeTimer !== null) {
469
+ clearTimeout(this._minimizeTimer);
470
+ this._minimizeTimer = null;
471
+ }
472
+ this.element?.classList.remove('twm-managed-window--minimizing');
473
+ }
474
+
475
+ /** C27. Abandon a restore animation that has not landed yet. */
476
+ _cancelRestoreAnimation() {
477
+ if (this._restoreTimer !== null) {
478
+ clearTimeout(this._restoreTimer);
479
+ this._restoreTimer = null;
480
+ }
481
+ this.element?.classList.remove('twm-managed-window--restoring');
482
+ }
483
+
293
484
  /**
294
485
  * Restore from minimized state with animation.
486
+ *
487
+ * ══ C27. THE WINDOW THAT COULD NOT BE BROUGHT BACK ══════════════════
488
+ *
489
+ * `minimize` hides the element inside a `setTimeout(..., 200)` so the
490
+ * shrink-toward-the-taskbar animation has time to play, and this method
491
+ * cleared the `display` IMMEDIATELY. Minimise a window and restore it from
492
+ * the taskbar inside those 200ms — which is not a stress test, it is what
493
+ * "I clicked the wrong button" looks like — and the sequence ran:
494
+ *
495
+ * minimize() isMinimized = true, timer armed for +200ms
496
+ * _restore() display = '', isMinimized = FALSE, taskbar button gone
497
+ * +200ms the timer fires and writes `display: none`
498
+ *
499
+ * leaving a window that is off screen with `isMinimized === false`. Every
500
+ * route back is closed at once: `static restore` and `show` both funnel
501
+ * through the `isMinimized` guard above and return without doing anything,
502
+ * and a taskbar built from `ManagedWindow.all().filter(isMinimized)` —
503
+ * which is how `syncTaskbars` builds it — has no button for it either. The
504
+ * window is live, holding its content and its staged edits, and there is no
505
+ * gesture in the product that can reach it. Reported twice.
506
+ *
507
+ * The `--minimizing` CLASS is the same defect one layer up and it bites
508
+ * even before the timer does: it is `opacity: 0` with a transform parking
509
+ * the window over the taskbar and `pointer-events: none`, and it was left
510
+ * on for the remainder of the animation, so the restored window was
511
+ * invisible and unclickable for up to 200ms before disappearing outright.
512
+ *
513
+ * Both are cancelled here rather than worked around in a consumer. A
514
+ * consumer cannot see either one: nothing throws, no state is inconsistent
515
+ * at any moment a caller can observe, and the corruption is written by a
516
+ * timer with no name.
295
517
  */
296
518
  _restore() {
297
519
  if (!this.isMinimized) return;
@@ -299,19 +521,34 @@ export class ManagedWindow {
299
521
  // Capture taskbar item rect BEFORE dispatching the event (which removes it)
300
522
  const taskbarRect = this._getTaskbarItemRect();
301
523
 
524
+ // C27. FIRST, before `display` is cleared: the pending timer would
525
+ // otherwise undo this whole method 200ms from now.
526
+ this._cancelMinimizeAnimation();
527
+
302
528
  if (this.element) {
303
529
  this.element.style.display = '';
304
530
 
305
531
  const animate = getSetting('window.animateMinimize', true);
306
532
  if (animate) {
533
+ // A restore that interrupts a restore — two clicks on one
534
+ // taskbar button — would otherwise have the first timer strip
535
+ // the second one's class and properties at ITS deadline.
536
+ this._cancelRestoreAnimation();
307
537
  this._setRestoreTargetProperties(taskbarRect);
308
538
  this.element.classList.add('twm-managed-window--restoring');
309
- setTimeout(() => {
539
+ this._restoreTimer = setTimeout(() => {
540
+ this._restoreTimer = null;
310
541
  if (this.element) {
311
542
  this.element.classList.remove('twm-managed-window--restoring');
312
543
  this._clearTargetProperties();
313
544
  }
314
545
  }, 250);
546
+ } else {
547
+ // No animation means no timer to clear the properties, and a
548
+ // minimise that was cancelled mid-flight left three of them
549
+ // set. Harmless while no class reads them and wrong the moment
550
+ // one does.
551
+ this._clearTargetProperties();
315
552
  }
316
553
  }
317
554
  if (this.modal && this.backdropElement) {
@@ -322,15 +559,64 @@ export class ManagedWindow {
322
559
  this.bringToFront();
323
560
 
324
561
  // Notify taskbar (removes the taskbar button)
325
- window.dispatchEvent(new CustomEvent('managed-window-restored', { detail: { id: this.id } }));
562
+ window.dispatchEvent(new CustomEvent('managed-window-restored', {
563
+ detail: { id: this.id, container: this.container }
564
+ }));
326
565
  }
327
566
 
328
567
  /**
329
568
  * Toggle maximize state.
330
569
  */
331
- toggleMaximize() {
570
+ /**
571
+ * @param {{claimable?: boolean}} [opts] `claimable: false` performs the
572
+ * GEOMETRIC maximise even when a consumer has claimed the gesture. NOTHING
573
+ * INSIDE THE LIBRARY PASSES IT — this docstring used to say the topbar's
574
+ * double-click did, and the binding at `:890` has never passed anything
575
+ * (C26 settled the other way; see the note there). It has had two callers
576
+ * outside it and it now has none: the window manager's aero-snap top edge
577
+ * used it for R13's maximise-onto-the-layer, and Tables' window menu drew
578
+ * a "Maximize" beside "Back to tile" and got its rectangle from here. R14
579
+ * collapsed both into the dock — maximise means back to tile, everywhere
580
+ * — so the escape hatch is now a LIBRARY API with no caller in this repo
581
+ * rather than a shared secret between two.
582
+ *
583
+ * IT IS KEPT, and deliberately. A window that came out of a tile has a
584
+ * tile to go back to; a window that never did has only the rectangle, and
585
+ * `openModal`'s dialogs are exactly that case (`modal.js`, `maximizable`)
586
+ * — they reach the same rectangle through the ordinary claimless path
587
+ * because they set no `onMaximize` at all. Removing this would leave a
588
+ * consumer that HAS claimed the gesture with no way to ask for the other
589
+ * verb, which is the situation the flag was added to fix.
590
+ *
591
+ * A doc that names a caller that does not exist is worse than no doc — it
592
+ * is the reason a reader concludes the double-click is already handled.
593
+ */
594
+ toggleMaximize({ claimable = true } = {}) {
332
595
  if (!this.canMaximize) return;
333
596
 
597
+ // R7. THE CONSUMER MAY OWN THIS GESTURE, and under the window manager
598
+ // it does: `adoptWindow` rewrites `onMaximize` to `bringBackWindow`, so
599
+ // both doors — the button and the topbar's double-click — DOCK.
600
+ //
601
+ // C28. A CLAIM THAT DECLINES MUST FALL THROUGH, which is what makes
602
+ // "expand this window into a tile" a gesture rather than a dead zone.
603
+ // `bringBackWindow` returns false for a window the WM never adopted, so
604
+ // this runs the geometric maximise for it — the answer every other
605
+ // window manager gives a window with nowhere to go back to. When the
606
+ // wrappers returned `true` regardless, the gesture was claimed, nothing
607
+ // docked and nothing maximised: reported twice, as a title bar whose
608
+ // double-click did nothing at all.
609
+ //
610
+ // Guarded like every other consumer callback here: an exception must not
611
+ // leave the window in a half-maximised state — and a `throw` is a
612
+ // DECLINE, not a claim, so it falls through to the rectangle too.
613
+ if (claimable && this.onMaximize) {
614
+ let handled = false;
615
+ try { handled = this.onMaximize(this) ?? false; }
616
+ catch (err) { console.error('[managed-window] onMaximize threw', err); }
617
+ if (handled) return;
618
+ }
619
+
334
620
  if (this.isMaximized) {
335
621
  // Restore
336
622
  if (this._preMaximizeState) {
@@ -344,15 +630,35 @@ export class ManagedWindow {
344
630
  } else {
345
631
  // Maximize - respect top and bottom bars
346
632
  this._preMaximizeState = { x: this.x, y: this.y, width: this.width, height: this.height };
347
- this.x = 0;
348
- this.y = TOP_BAR_HEIGHT;
349
- this.width = window.innerWidth;
350
- this.height = window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT;
633
+ const bounds = this._bounds();
634
+ this.x = bounds.minX;
635
+ this.y = bounds.minY;
636
+ this.width = bounds.width;
637
+ this.height = bounds.height;
351
638
  this.isMaximized = true;
352
639
  }
353
640
 
641
+ // A CLASS THE STYLESHEET HAS ALWAYS KNOWN AND NOTHING EVER SET.
642
+ // `.twm-managed-window--maximized .twm-managed-window__resize
643
+ // { display: none }` is in `css/base.css` — "a maximised window is not
644
+ // resized by dragging its edges" — and it has never matched anything,
645
+ // because no code path adds the modifier. So a maximised window keeps
646
+ // eight live resize handles hanging 3px outside its edges. Exactly the
647
+ // shape of the `.twm-collapsible-content.visible` and
648
+ // `managed-window__resize--${dir}` bugs: a rule for a class that is
649
+ // never written.
650
+ this.element?.classList.toggle('twm-managed-window--maximized', this.isMaximized);
354
651
  this._applyPosition();
355
652
  this._saveCurrentState();
653
+ // C16. Maximise is a STATE, and until now nothing outside this class
654
+ // could see it change. A consumer that wants to say "a maximised window
655
+ // has no minimize" — the WM does, for the windows it promotes out of
656
+ // tiles — had no edge to hang the rule on and would have had to poll.
657
+ // Same detail shape as the other three window events, `container`
658
+ // included, so a per-region consumer can filter on receipt.
659
+ window.dispatchEvent(new CustomEvent('managed-window-maximized', {
660
+ detail: { id: this.id, maximized: this.isMaximized, container: this.container },
661
+ }));
356
662
  }
357
663
 
358
664
  /**
@@ -490,10 +796,44 @@ export class ManagedWindow {
490
796
  const buttons = document.createElement('div');
491
797
  buttons.className = 'twm-managed-window__buttons';
492
798
 
799
+ // C6. Extra titlebar buttons, immediately LEFT of minimize — which is
800
+ // exactly where the concept asks for the table cogwheel. Added before
801
+ // the built-in buttons so the ordering is positional rather than
802
+ // something each caller has to get right.
803
+ for (const spec of this.titlebarButtons) {
804
+ const btn = document.createElement('button');
805
+ btn.className = 'twm-managed-window__btn twm-managed-window__btn--custom';
806
+ btn.type = 'button';
807
+ btn.title = spec.title || '';
808
+ if (spec.icon) {
809
+ const glyph = document.createElement('span');
810
+ glyph.className = 'material-symbols-outlined';
811
+ glyph.textContent = spec.icon;
812
+ btn.appendChild(glyph);
813
+ } else {
814
+ btn.textContent = spec.label || '';
815
+ }
816
+ btn.addEventListener('click', (e) => {
817
+ e.stopPropagation();
818
+ spec.onClick?.(this, e);
819
+ });
820
+ buttons.appendChild(btn);
821
+ }
822
+
493
823
  // Minimize button (optional)
494
824
  if (this.canMinimize) {
495
825
  const minBtn = document.createElement('button');
496
- minBtn.className = 'twm-managed-window__btn managed-window__btn--minimize';
826
+ // BOTH SPELLINGS. The `twm-` prefix was missed on this modifier and
827
+ // on `--maximize` when the component was vendored — the same slip
828
+ // C13 found on the resize handles, where it meant no rule matched
829
+ // either spelling. Nothing styles these two today, so nothing is
830
+ // broken by it, but a consumer that wants to reach for one (the WM
831
+ // hides minimize on a maximised window it promoted) should not have
832
+ // to know which of the two conventions this particular button
833
+ // landed on. The unprefixed name stays for whoever already queries
834
+ // it; the prefixed one is the one to use.
835
+ minBtn.className = 'twm-managed-window__btn '
836
+ + 'twm-managed-window__btn--minimize managed-window__btn--minimize';
497
837
  minBtn.type = 'button';
498
838
  minBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10"><path d="M1 5h8" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
499
839
  minBtn.title = 'Minimize';
@@ -504,10 +844,23 @@ export class ManagedWindow {
504
844
  // Maximize button (optional)
505
845
  if (this.canMaximize) {
506
846
  const maxBtn = document.createElement('button');
507
- maxBtn.className = 'twm-managed-window__btn managed-window__btn--maximize';
847
+ maxBtn.className = 'twm-managed-window__btn '
848
+ + 'twm-managed-window__btn--maximize managed-window__btn--maximize';
508
849
  maxBtn.type = 'button';
509
- maxBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10"><rect x="1" y="1" width="8" height="8" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
510
- maxBtn.title = 'Maximize';
850
+ // R7. The square is the default and stays the default. A consumer
851
+ // that redefined the gesture with `onMaximize` draws its own glyph
852
+ // here rather than reaching into this markup afterwards — which is
853
+ // what the WM used to do for its "back to tile" button, with four
854
+ // internal class names and a silent failure if any of them moved.
855
+ if (this.maximizeIcon) {
856
+ const glyph = document.createElement('span');
857
+ glyph.className = 'material-symbols-outlined';
858
+ glyph.textContent = this.maximizeIcon;
859
+ maxBtn.appendChild(glyph);
860
+ } else {
861
+ maxBtn.innerHTML = '<svg width="10" height="10" viewBox="0 0 10 10"><rect x="1" y="1" width="8" height="8" stroke="currentColor" stroke-width="1.5" fill="none"/></svg>';
862
+ }
863
+ maxBtn.title = this.maximizeTitle;
511
864
  maxBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleMaximize(); });
512
865
  buttons.appendChild(maxBtn);
513
866
  }
@@ -549,6 +902,20 @@ export class ManagedWindow {
549
902
  // Event listeners
550
903
  topbar.addEventListener('pointerdown', (e) => this._onTopbarPointerDown(e));
551
904
  if (this.canMaximize) {
905
+ // C26. THE CLAIMED VERB, which under the window manager is "back to
906
+ // tile". This went the other way first, on a reading of *"double
907
+ // click ... always to maximize window"* as the geometric maximise —
908
+ // the product owner then said *"double click on a managed window
909
+ // showing a table needs to maximize to tile"*, which is the claim.
910
+ // `claimable: false` stays on the method for a consumer that wants
911
+ // the rectangle on a window whose maximise is claimed; nothing
912
+ // INSIDE the library passes it, and this binding never has.
913
+ //
914
+ // C28 is what makes this safe for a window with no tile to go back
915
+ // to: the claim may DECLINE — `bringBackWindow` returns false for a
916
+ // window the WM never adopted — and `toggleMaximize` then falls
917
+ // through to the geometric maximise. So the gesture always does
918
+ // something, which is the whole complaint it was reported under.
552
919
  topbar.addEventListener('dblclick', () => this.toggleMaximize());
553
920
  }
554
921
  this.element.addEventListener('pointerdown', () => this.bringToFront());
@@ -558,28 +925,106 @@ export class ManagedWindow {
558
925
  const directions = ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'];
559
926
  for (const dir of directions) {
560
927
  const handle = document.createElement('div');
561
- handle.className = `twm-managed-window__resize managed-window__resize--${dir}`;
928
+ // BOTH classes carry the `twm-` prefix. The per-direction one was
929
+ // left unprefixed when the framework was namespaced, and no rule for
930
+ // either spelling exists — so all eight handles were
931
+ // `position: absolute` with no size and no placement, and every
932
+ // window in every consumer was unresizable. The legacy unprefixed
933
+ // class is kept alongside for any embedder still selecting on it.
934
+ handle.className = `twm-managed-window__resize `
935
+ + `twm-managed-window__resize--${dir} managed-window__resize--${dir}`;
562
936
  handle.addEventListener('pointerdown', (e) => this._onResizePointerDown(e, dir));
563
937
  this.element.appendChild(handle);
564
938
  }
565
939
  }
566
940
 
941
+ /** C2. The bounds rectangle this window is clamped inside.
942
+ *
943
+ * ONE computation replacing four inline copies. The `document.body` case —
944
+ * every call site that exists today — reduces to the previous expression
945
+ * BY SUBSTITUTION, not by "should be equivalent":
946
+ *
947
+ * minX = 0
948
+ * minY = TOP_BAR_HEIGHT
949
+ * width = window.innerWidth
950
+ * height = window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT
951
+ *
952
+ * so, substituting into the clamps below:
953
+ *
954
+ * maxWidth = width = window.innerWidth ✓
955
+ * maxHeight = height = innerHeight - TOP - BOTTOM ✓
956
+ * maxX = max(minX, minX + width - w) = max(0, innerWidth - w) ✓
957
+ * maxY = max(minY, minY + height - h) = max(TOP, innerHeight - BOTTOM - h) ✓
958
+ * x = max(minX, min(x, maxX)) = max(0, min(x, maxX)) ✓
959
+ * y = max(minY, min(y, maxY)) = max(TOP, min(y, maxY)) ✓
960
+ *
961
+ * EcoAgent's and EcoSim's modals depend on that arithmetic; prove the
962
+ * equivalence in review by substitution rather than by testing.
963
+ */
964
+ _bounds() {
965
+ // R12. Mid-escape the container is the drag HOST — the root, which
966
+ // contains the panels as well as the tiles. `dragBounds` narrows it to
967
+ // the part a window belongs in; without it the bottom edge is the
968
+ // root's, and the bottom panel is inside that.
969
+ //
970
+ // R13. AND FOR AS LONG AS THE WINDOW IS LEFT THERE. A drop the snap
971
+ // controller CLAIMS ends the escape WITHOUT putting the window back
972
+ // (`_endDragEscape({ taken: true })` returns before re-parenting), and
973
+ // the window manager's aero-snap maximise is exactly that case: the
974
+ // window stays a child of the host, filling the part of it
975
+ // `dragBounds` describes. `_escapeOrigin` is null by then, so the test
976
+ // as it stood stopped applying the moment the drag ended — and the next
977
+ // re-clamp, which the container's ResizeObserver performs on any
978
+ // splitter drag, would re-read the bounds as the WHOLE ROOT and grow
979
+ // the window out over the docked panels.
980
+ //
981
+ // What the two cases share is not "mid-drag", it is THE CONTAINER IS
982
+ // THE DRAG HOST — never true of a window sitting in its own pane, and
983
+ // true of every window the host is currently holding. `dragHost` is
984
+ // resolved rather than remembered for the same reason `_beginDragEscape`
985
+ // resolves it: it is a function so that it can outlive any one tile.
986
+ if (this.dragBounds) {
987
+ const host = typeof this.dragHost === 'function'
988
+ ? this.dragHost(this) : this.dragHost;
989
+ if (this._escapeOrigin || (host && host === this.container)) {
990
+ const b = this.dragBounds(this);
991
+ if (b && b.width > 0 && b.height > 0) return b;
992
+ }
993
+ }
994
+ if (this.container) {
995
+ // Contained: coordinates are relative to the container, which is a
996
+ // positioned ancestor, so the bars do not apply.
997
+ return {
998
+ minX: 0,
999
+ minY: 0,
1000
+ width: this.container.clientWidth,
1001
+ height: this.container.clientHeight,
1002
+ };
1003
+ }
1004
+ return {
1005
+ minX: 0,
1006
+ minY: TOP_BAR_HEIGHT,
1007
+ width: window.innerWidth,
1008
+ height: window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT,
1009
+ };
1010
+ }
1011
+
567
1012
  _applyPosition() {
568
1013
  if (!this.element) return;
569
1014
 
570
- // Calculate max available dimensions
571
- const maxWidth = window.innerWidth;
572
- const maxHeight = window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT;
1015
+ const bounds = this._bounds();
1016
+ const maxWidth = bounds.width;
1017
+ const maxHeight = bounds.height;
573
1018
 
574
- // Clamp width and height to viewport (but respect minWidth/minHeight)
1019
+ // Clamp width and height to the bounds (but respect minWidth/minHeight)
575
1020
  this.width = Math.max(this.minWidth, Math.min(this.width, maxWidth));
576
1021
  this.height = Math.max(this.minHeight, Math.min(this.height, maxHeight));
577
1022
 
578
- // Ensure window is within viewport (respect top and bottom bars)
579
- const maxX = Math.max(0, maxWidth - this.width);
580
- const maxY = Math.max(TOP_BAR_HEIGHT, window.innerHeight - this.height - BOTTOM_BAR_HEIGHT);
581
- this.x = Math.max(0, Math.min(this.x, maxX));
582
- this.y = Math.max(TOP_BAR_HEIGHT, Math.min(this.y, maxY));
1023
+ // Ensure the window is within the bounds rectangle
1024
+ const maxX = Math.max(bounds.minX, bounds.minX + maxWidth - this.width);
1025
+ const maxY = Math.max(bounds.minY, bounds.minY + maxHeight - this.height);
1026
+ this.x = Math.max(bounds.minX, Math.min(this.x, maxX));
1027
+ this.y = Math.max(bounds.minY, Math.min(this.y, maxY));
583
1028
 
584
1029
  this.element.style.left = `${this.x}px`;
585
1030
  this.element.style.top = `${this.y}px`;
@@ -587,10 +1032,215 @@ export class ManagedWindow {
587
1032
  this.element.style.height = `${this.height}px`;
588
1033
  }
589
1034
 
1035
+ /** C12. Re-clamp on demand.
1036
+ *
1037
+ * The ResizeObserver below covers a container that changes size on its
1038
+ * own. A container that changes size because a SIBLING did — a splitter
1039
+ * drag moves two panels at once — needs the caller to say so, and a
1040
+ * private `_applyPosition` is not something a caller may reach for. */
1041
+ reclamp() {
1042
+ this._applyPosition();
1043
+ }
1044
+
1045
+ /** C12. Move this window into a different container.
1046
+ *
1047
+ * Re-parents the element and re-clamps against the new bounds, because a
1048
+ * window carried into a narrower region would otherwise keep coordinates
1049
+ * that put it outside and out of reach. The ResizeObserver follows the new
1050
+ * container, or the old one would keep driving the clamp.
1051
+ */
1052
+ moveTo(container) {
1053
+ if (!container || container === this.container) return false;
1054
+ this.container = container;
1055
+ if (this.element) {
1056
+ container.appendChild(this.element);
1057
+ this.element.classList.toggle('twm-managed-window--contained', true);
1058
+ if (this.backdropElement) container.appendChild(this.backdropElement);
1059
+ }
1060
+ this._resizeObserver?.disconnect();
1061
+ this._resizeObserver = null;
1062
+ this._installContainerResizeObserver();
1063
+ // A snapped window's rectangle belonged to the old container, so the
1064
+ // snap does not survive the move; its pre-snap size does.
1065
+ this._preSnapState = null;
1066
+ this.element?.classList.remove('twm-managed-window--snapped');
1067
+ if (this.isMaximized) {
1068
+ const bounds = this._bounds();
1069
+ this.x = bounds.minX; this.y = bounds.minY;
1070
+ this.width = bounds.width; this.height = bounds.height;
1071
+ }
1072
+ this._applyPosition();
1073
+ window.dispatchEvent(new CustomEvent('managed-window-moved', {
1074
+ detail: { id: this.id, container },
1075
+ }));
1076
+ return true;
1077
+ }
1078
+
1079
+ /** R1/R3. The container this drag started in, or null when the drag did
1080
+ * not have to escape one (an uncontained window, or no `dragHost`).
1081
+ *
1082
+ * Public because the decision that needs it is not this component's: a snap
1083
+ * controller has to know which pane is HOME so that moving a window around
1084
+ * inside the pane it already lives in arms nothing. That was the complaint
1085
+ * about the old behaviour — in-pane, virtually any movement was a dock. */
1086
+ get dragOrigin() { return this._escapeOrigin; }
1087
+
1088
+ /** R1. Take the window out of its container for the duration of a drag.
1089
+ *
1090
+ * A contained window is clipped by its container (`.twm-leaf` is
1091
+ * `overflow: hidden`), so without this it cannot be dragged one pixel past
1092
+ * the pane it lives in — the gesture the tiling model is built on is not
1093
+ * merely awkward, it is invisible. The window is re-parented into the
1094
+ * wider `dragHost` and its coordinates are converted so the rectangle on
1095
+ * screen does not move: same viewport pixels, different reference frame.
1096
+ *
1097
+ * Deliberately NOT `moveTo` (C12), which is the same re-parent for a
1098
+ * different purpose. `moveTo` re-CLAMPS into the new container without
1099
+ * converting anything, which is right when a window is carried between
1100
+ * regions by a menu and wrong here — the window would jump out from under
1101
+ * the pointer at the first millimetre of every drag. It also drops the snap
1102
+ * and announces `managed-window-moved`, and an escape is neither a move the
1103
+ * user asked for nor one anybody should hear about: it is undone on
1104
+ * release, either by `_endDragEscape` or by the drop taking the window.
1105
+ */
1106
+ _beginDragEscape() {
1107
+ if (this._escapeOrigin || !this.container || !this.dragHost) return false;
1108
+ const host = typeof this.dragHost === 'function'
1109
+ ? this.dragHost(this) : this.dragHost;
1110
+ // CONTAINS, not merely "different". The host is meant to be the wider
1111
+ // box the window may now cross; anything else would teleport it.
1112
+ if (!host || host === this.container || !host.contains(this.container)) return false;
1113
+ const origin = this.container;
1114
+ this._escapeOrigin = origin;
1115
+ this._reparentPreservingPosition(host);
1116
+ return true;
1117
+ }
1118
+
1119
+ /** R1. Put the window back into a container when the drag ends.
1120
+ *
1121
+ * `taken` means the drop was claimed by the snap controller: the window is
1122
+ * being docked into a tree and closed, so re-parenting it into a pane it is
1123
+ * about to leave would be work done for a frame nobody sees.
1124
+ *
1125
+ * Otherwise it goes back where the drag started — including when the drag
1126
+ * ended over nothing (the rail, the gap between two panes, off the edge).
1127
+ * A window that lives in a pane has to end every drag in SOME pane, and the
1128
+ * one it came from is the only answer that never surprises anyone. The one
1129
+ * case that cannot be honoured is an origin that stopped being in the
1130
+ * document mid-drag — a repaint rebuilt the leaf wrap — and there the
1131
+ * window stays on the host rather than being orphaned into a detached
1132
+ * node; the WM's own re-home pass adopts it on the next render.
1133
+ */
1134
+ _endDragEscape({ taken = false } = {}) {
1135
+ const origin = this._escapeOrigin;
1136
+ this._escapeOrigin = null;
1137
+ this.element?.classList.remove('twm-managed-window--detached');
1138
+ if (!origin || taken) return false;
1139
+ if (!origin.isConnected) return false;
1140
+ this._reparentPreservingPosition(origin);
1141
+ return true;
1142
+ }
1143
+
1144
+ /** Move the element into `next` and rewrite `x`/`y` so it occupies the same
1145
+ * viewport rectangle it did a moment ago.
1146
+ *
1147
+ * `x`/`y` are written against the containing block, which for an absolutely
1148
+ * positioned child is the PADDING box — hence `clientLeft`/`clientTop`,
1149
+ * which are the border widths `getBoundingClientRect` includes and the
1150
+ * offset does not. `scrollLeft`/`scrollTop` are zero for every box either
1151
+ * side of this today (panes and the WM root both clip rather than scroll)
1152
+ * and are in the expression anyway: the day one of them scrolls, this is
1153
+ * the line that would be silently half a screen out.
1154
+ *
1155
+ * The container's ResizeObserver is deliberately NOT re-pointed. It exists
1156
+ * to re-clamp (C4), it re-clamps against whichever container is current
1157
+ * because `_applyPosition` reads `_bounds()` afresh, and an escape is
1158
+ * transient by construction — undone on release, or ended by the window
1159
+ * closing into a tree. `moveTo`, which is a permanent move, does re-point
1160
+ * it, and that difference is the reason these are two methods.
1161
+ */
1162
+ _reparentPreservingPosition(next) {
1163
+ const prev = this.container;
1164
+ if (!next || next === prev) return false;
1165
+ if (this.element && prev) {
1166
+ const from = prev.getBoundingClientRect();
1167
+ const to = next.getBoundingClientRect();
1168
+ this.x += (from.left + prev.clientLeft - prev.scrollLeft)
1169
+ - (to.left + next.clientLeft - next.scrollLeft);
1170
+ this.y += (from.top + prev.clientTop - prev.scrollTop)
1171
+ - (to.top + next.clientTop - next.scrollTop);
1172
+ }
1173
+ this.container = next;
1174
+ if (this.element) {
1175
+ next.appendChild(this.element);
1176
+ // Still contained — a drag host is another box on the page, not the
1177
+ // page — so the `position: absolute` modifier stays exactly as it
1178
+ // was. Toggled rather than assumed, because a window whose container
1179
+ // was null cannot reach here but a future caller might.
1180
+ this.element.classList.toggle('twm-managed-window--contained', !!next);
1181
+ if (this.backdropElement) next.appendChild(this.backdropElement);
1182
+ }
1183
+ this._applyPosition();
1184
+ return true;
1185
+ }
1186
+
1187
+ /** R3. HALF TRANSPARENT THE MOMENT IT LEAVES THE TILE IT CAME FROM.
1188
+ *
1189
+ * The signal that releasing now will dock the window somewhere, and the
1190
+ * complement of the rule that keeps the origin pane silent: inside it, this
1191
+ * is just a window being moved and it stays opaque. Only for a window with
1192
+ * a snap controller — nothing else on the page can be docked, so nothing
1193
+ * else has anything to promise.
1194
+ *
1195
+ * A window that never had a pane (opened with Alt+N, floating over the
1196
+ * whole root) has no "inside" to be in, so it reads as detached for the
1197
+ * whole drag. That is not a special case being papered over: every pane
1198
+ * under it genuinely is foreign, and every drop on one genuinely docks.
1199
+ */
1200
+ _syncDragTransparency(e) {
1201
+ if (!this.snapController || !this.element) return;
1202
+ const origin = this._escapeOrigin;
1203
+ let outside = true;
1204
+ if (origin && origin.isConnected) {
1205
+ const r = origin.getBoundingClientRect();
1206
+ outside = e.clientX < r.left || e.clientX > r.right
1207
+ || e.clientY < r.top || e.clientY > r.bottom;
1208
+ }
1209
+ this.element.classList.toggle('twm-managed-window--detached', outside);
1210
+ }
1211
+
1212
+ /** C4. Re-clamp when the container resizes.
1213
+ *
1214
+ * `managed_window.js` has NO resize listener at all today: a viewport
1215
+ * resize simply leaves windows where they were until the next pointer
1216
+ * move re-clamps them. That is survivable for the viewport, which resizes
1217
+ * rarely, and not for a panel, which resizes every time someone drags a
1218
+ * splitter — a window would end up outside its own panel and unreachable.
1219
+ */
1220
+ _installContainerResizeObserver() {
1221
+ if (!this.container || this._resizeObserver) return;
1222
+ this._resizeObserver = new ResizeObserver(() => {
1223
+ if (this.isMaximized) {
1224
+ const bounds = this._bounds();
1225
+ this.x = bounds.minX;
1226
+ this.y = bounds.minY;
1227
+ this.width = bounds.width;
1228
+ this.height = bounds.height;
1229
+ }
1230
+ this._applyPosition();
1231
+ });
1232
+ this._resizeObserver.observe(this.container);
1233
+ }
1234
+
1235
+ _teardownContainerResizeObserver() {
1236
+ this._resizeObserver?.disconnect();
1237
+ this._resizeObserver = null;
1238
+ }
1239
+
590
1240
  _restoreState() {
591
- // Calculate max available dimensions
592
- const maxWidth = window.innerWidth;
593
- const maxHeight = window.innerHeight - TOP_BAR_HEIGHT - BOTTOM_BAR_HEIGHT;
1241
+ const bounds = this._bounds();
1242
+ const maxWidth = bounds.width;
1243
+ const maxHeight = bounds.height;
594
1244
 
595
1245
  // Modal windows always center on screen - never restore saved position
596
1246
  const saved = this.modal ? null : _getWindowState(this.id);
@@ -604,8 +1254,8 @@ export class ManagedWindow {
604
1254
 
605
1255
  if (this.isMaximized && this.canMaximize) {
606
1256
  this._preMaximizeState = { x: saved.x, y: saved.y, width: saved.width, height: saved.height };
607
- this.x = 0;
608
- this.y = TOP_BAR_HEIGHT;
1257
+ this.x = bounds.minX;
1258
+ this.y = bounds.minY;
609
1259
  this.width = maxWidth;
610
1260
  this.height = maxHeight;
611
1261
  }
@@ -638,6 +1288,48 @@ export class ManagedWindow {
638
1288
  if (!this.canDrag) return;
639
1289
 
640
1290
  e.preventDefault();
1291
+ // Picking up a SNAPPED window restores the size it had before, centred
1292
+ // under the pointer — the same gesture Windows uses, and the reason a
1293
+ // snap does not have to be undone through a menu.
1294
+ if (this.snap && this._preSnapState) {
1295
+ const grabRatio = this.width ? (e.clientX - this.x) / this.width : 0.5;
1296
+ this.unsnap();
1297
+ this.x = Math.round(e.clientX - this.width * grabRatio);
1298
+ this._applyPosition();
1299
+ }
1300
+ // ══ C28. THE ESCAPE WAITS FOR MOVEMENT, AND THAT IS THE WHOLE OF
1301
+ // WHY DOUBLE-CLICKING A TITLE BAR DID NOTHING ══════════════════
1302
+ //
1303
+ // R1 escaped the pane HERE, on pointerdown, before the pointer had
1304
+ // moved a pixel — so every press on a title bar tore the window element
1305
+ // out of `.tbl-canvas-pane`, appended it to the WM root, and put it
1306
+ // back on release. Two DOM removals per click, for a click.
1307
+ //
1308
+ // Blink and Gecko both drop the pending click when the element the
1309
+ // press landed on leaves the document between press and release:
1310
+ // `MouseEventManager::NodeWillBeRemoved` clears `mouse_down_element_`,
1311
+ // and `click` is dispatched to the common ancestor of that element and
1312
+ // the release target — with it null, no `click` is dispatched at all,
1313
+ // and `dblclick`, which counts clicks, never comes. So the `dblclick`
1314
+ // listener eleven lines below `_addResizeHandles` has never once fired
1315
+ // on a CONTAINED window in a real browser. Reported twice as *"double
1316
+ // click does not maximize to tile"*, and re-reading the listener could
1317
+ // not find it: the listener is correct, the gesture never reaches it.
1318
+ //
1319
+ // jsdom cannot see this — it has no click-count model and a test
1320
+ // dispatches `dblclick` directly — which is why every existing check
1321
+ // says the gesture works. The assertion that CAN see it is the one
1322
+ // about the mechanism: a press with no movement must leave the element
1323
+ // where it found it, and `web/js/shell/window_lifecycle.test.mjs`
1324
+ // asserts exactly that.
1325
+ //
1326
+ // Deferring costs nothing the escape was buying. R1 exists so a
1327
+ // contained window can be dragged past the pane that clips it, and
1328
+ // nothing is clipped until something moves; `_onPointerMove` performs
1329
+ // it on the first movement and corrects the drag origin by the same
1330
+ // delta `_reparentPreservingPosition` applied, so the window still
1331
+ // tracks the pointer exactly. `_onPointerUp` already tolerates a drag
1332
+ // that never escaped — `_endDragEscape` returns early with no origin.
641
1333
  this._dragState = {
642
1334
  startX: e.clientX,
643
1335
  startY: e.clientY,
@@ -647,21 +1339,80 @@ export class ManagedWindow {
647
1339
 
648
1340
  document.addEventListener('pointermove', this._boundOnPointerMove);
649
1341
  document.addEventListener('pointerup', this._boundOnPointerUp);
1342
+ // A cancelled pointer never produces a `pointerup` — a touch turning
1343
+ // into a browser gesture, the tab losing the pointer, a device
1344
+ // disconnecting. Before C15 that left a window mid-drag, which the next
1345
+ // pointerdown corrected; now it can also leave a `position: fixed`
1346
+ // preview rectangle painted over the page with nothing to remove it.
1347
+ document.addEventListener('pointercancel', this._boundOnPointerUp);
650
1348
  }
651
1349
 
652
1350
  _onPointerMove(e) {
653
1351
  if (this._dragState) {
1352
+ // C28. THE ESCAPE, at the first movement rather than at the press —
1353
+ // see `_onTopbarPointerDown` for the click it used to eat.
1354
+ //
1355
+ // `_reparentPreservingPosition` rewrites `this.x`/`this.y` into the
1356
+ // host's reference frame so the rectangle on screen does not move.
1357
+ // `_dragState.startWinX` was recorded in the PANE's frame a moment
1358
+ // ago and is the origin every subsequent delta is added to, so it
1359
+ // has to make the same journey — otherwise the window jumps by the
1360
+ // offset between the two boxes at the first millimetre of the drag,
1361
+ // which is the exact failure R1's "out of the pane FIRST" comment
1362
+ // was written to prevent. Same correction, applied where the escape
1363
+ // now happens.
1364
+ if (!this._escapeOrigin) {
1365
+ const fromX = this.x;
1366
+ const fromY = this.y;
1367
+ if (this._beginDragEscape()) {
1368
+ this._dragState.startWinX += this.x - fromX;
1369
+ this._dragState.startWinY += this.y - fromY;
1370
+ }
1371
+ }
654
1372
  const dx = e.clientX - this._dragState.startX;
655
1373
  const dy = e.clientY - this._dragState.startY;
656
1374
  this.x = this._dragState.startWinX + dx;
657
1375
  this.y = this._dragState.startWinY + dy;
658
1376
  this._applyPosition();
1377
+ this._syncDragTransparency(e);
1378
+ if (this.snap) this._updateSnapZone(e);
659
1379
  } else if (this._resizeState) {
660
1380
  this._handleResize(e);
661
1381
  }
662
1382
  }
663
1383
 
664
1384
  _onPointerUp() {
1385
+ // The snap is applied BEFORE the state is saved, so what is persisted is
1386
+ // where the window ended up rather than where it was let go.
1387
+ let taken = false;
1388
+ if (this._dragState && this.snap && this._snapZone) {
1389
+ // C15. A controller answers instead of `_applySnap` when there is
1390
+ // one. It may answer asynchronously (a three-way choice is a menu),
1391
+ // which is why a truthy return also transfers ownership of the
1392
+ // preview — clearing it here would blank the affordance the menu is
1393
+ // still describing.
1394
+ if (this.snapController) {
1395
+ // Guarded, exactly as `probe` is. `commit` is not a leaf call —
1396
+ // it reaches all the way into somebody else's tree mutation and
1397
+ // back out through a window close — and an exception escaping
1398
+ // here would leave the drag listeners attached and the window
1399
+ // following the pointer for ever.
1400
+ try {
1401
+ taken = this.snapController.commit?.(this._snapProbe, this) ?? false;
1402
+ } catch (err) {
1403
+ console.error('[managed-window] snap commit threw', err);
1404
+ taken = false;
1405
+ }
1406
+ } else {
1407
+ this._applySnap(this._snapZone);
1408
+ }
1409
+ }
1410
+ if (!taken) this.clearSnapPreview();
1411
+ this._snapZone = null;
1412
+ // R1. Back into a container before `_saveCurrentState`, or what is
1413
+ // persisted is a rectangle in the drag host's space that will be read
1414
+ // back as if it were the pane's.
1415
+ if (this._dragState) this._endDragEscape({ taken });
665
1416
  if (this._dragState || this._resizeState) {
666
1417
  this._saveCurrentState();
667
1418
  }
@@ -669,6 +1420,132 @@ export class ManagedWindow {
669
1420
  this._resizeState = null;
670
1421
  document.removeEventListener('pointermove', this._boundOnPointerMove);
671
1422
  document.removeEventListener('pointerup', this._boundOnPointerUp);
1423
+ document.removeEventListener('pointercancel', this._boundOnPointerUp);
1424
+ }
1425
+
1426
+ // ========== C11. Aero Snap ==========
1427
+
1428
+ /** The rectangle a zone would give this window, in the SAME coordinate
1429
+ * space `_applyPosition` writes — container-relative when contained,
1430
+ * viewport-relative otherwise. One source for the preview and the apply,
1431
+ * so the preview cannot promise a rectangle the drop does not deliver. */
1432
+ _snapRect(zone) {
1433
+ const b = this._bounds();
1434
+ const half = Math.round(b.width / 2);
1435
+ switch (zone) {
1436
+ case 'top': return { x: b.minX, y: b.minY, width: b.width, height: b.height };
1437
+ case 'left': return { x: b.minX, y: b.minY, width: half, height: b.height };
1438
+ case 'right': return { x: b.minX + b.width - half, y: b.minY,
1439
+ width: half, height: b.height };
1440
+ default: return null;
1441
+ }
1442
+ }
1443
+
1444
+ /** Which zone the POINTER is in — not the window. Using the window's own
1445
+ * edge would make a wide window snap the moment it is picked up, because
1446
+ * it is already touching the edge it did not move towards. */
1447
+ _zoneFor(e) {
1448
+ const host = this.container || document.documentElement;
1449
+ const rect = this.container
1450
+ ? host.getBoundingClientRect()
1451
+ : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
1452
+ const x = e.clientX - rect.left;
1453
+ const y = e.clientY - rect.top;
1454
+ // Outside the host entirely: no zone. Dragging a contained window over
1455
+ // the page chrome must not snap it to the container's edge.
1456
+ if (x < 0 || y < 0 || x > rect.width || y > rect.height) return null;
1457
+ if (y <= SNAP_EDGE) return 'top';
1458
+ if (x <= SNAP_EDGE) return 'left';
1459
+ if (x >= rect.width - SNAP_EDGE) return 'right';
1460
+ return null;
1461
+ }
1462
+
1463
+ _updateSnapZone(e) {
1464
+ if (this.snapController) { this._updateControlledSnapZone(e); return; }
1465
+ const zone = this._zoneFor(e);
1466
+ if (zone === this._snapZone) return;
1467
+ this._snapZone = zone;
1468
+ if (!zone) { this.clearSnapPreview(); return; }
1469
+ this.showSnapPreview(this._snapRect(zone));
1470
+ }
1471
+
1472
+ /** C15. The controller's half of `_updateSnapZone`. The probe runs on every
1473
+ * move because the rectangle can change while the KEY does not — a tile
1474
+ * resized underneath the pointer, a menu re-previewing the same zone — but
1475
+ * the DOM is only touched when something actually differs. */
1476
+ _updateControlledSnapZone(e) {
1477
+ let probe = null;
1478
+ try { probe = this.snapController.probe?.(e, this) ?? null; }
1479
+ catch (err) { console.warn('[managed-window] snap probe threw', err); }
1480
+ this._snapProbe = probe;
1481
+ this._snapZone = probe?.key ?? null;
1482
+ if (!probe?.rect) { this.clearSnapPreview(); return; }
1483
+ this.showSnapPreview(probe.rect, { viewport: true });
1484
+ }
1485
+
1486
+ /** Paint the drag affordance. `rect` is container-relative by default —
1487
+ * the same space `_applyPosition` writes — and viewport-relative for a
1488
+ * controller, whose rectangles come from hit-testing other people's DOM.
1489
+ * Public because a controller that survives the pointer-up owns it. */
1490
+ showSnapPreview(rect, { viewport = false } = {}) {
1491
+ if (!rect) { this.clearSnapPreview(); return; }
1492
+ const host = viewport ? document.body : (this.container || document.body);
1493
+ if (!this._snapPreviewEl) {
1494
+ this._snapPreviewEl = document.createElement('div');
1495
+ // `aria-hidden`: it is a drag affordance, not content.
1496
+ this._snapPreviewEl.setAttribute('aria-hidden', 'true');
1497
+ }
1498
+ this._snapPreviewEl.className =
1499
+ `twm-snap-preview${viewport ? ' twm-snap-preview--viewport' : ''}`;
1500
+ const left = rect.left ?? rect.x;
1501
+ const top = rect.top ?? rect.y;
1502
+ Object.assign(this._snapPreviewEl.style, {
1503
+ left: `${left}px`, top: `${top}px`,
1504
+ width: `${rect.width}px`, height: `${rect.height}px`,
1505
+ });
1506
+ if (this._snapPreviewEl.parentNode !== host) host.appendChild(this._snapPreviewEl);
1507
+ }
1508
+
1509
+ clearSnapPreview() {
1510
+ this._snapPreviewEl?.remove();
1511
+ this._snapProbe = null;
1512
+ }
1513
+
1514
+ /** @deprecated retained so nothing inside this file has to change spelling
1515
+ * in the same commit that adds the public one. */
1516
+ _clearSnapPreview() { this.clearSnapPreview(); }
1517
+
1518
+ /** Applied on release. The pre-snap geometry is remembered so dragging the
1519
+ * window off an edge restores the size it had — a snap that eats the
1520
+ * original size makes the gesture one-way and people stop using it. */
1521
+ _applySnap(zone) {
1522
+ const rect = this._snapRect(zone);
1523
+ if (!rect) return;
1524
+ if (!this._preSnapState) {
1525
+ this._preSnapState = { x: this._dragState.startWinX, y: this._dragState.startWinY,
1526
+ width: this.width, height: this.height };
1527
+ }
1528
+ this.x = rect.x;
1529
+ this.y = rect.y;
1530
+ this.width = rect.width;
1531
+ this.height = rect.height;
1532
+ this._applyPosition();
1533
+ this.element?.classList.add('twm-managed-window--snapped');
1534
+ window.dispatchEvent(new CustomEvent('managed-window-snapped', {
1535
+ detail: { id: this.id, zone, container: this.container },
1536
+ }));
1537
+ }
1538
+
1539
+ /** Restore the geometry a snap replaced. Called when a snapped window is
1540
+ * picked up again, which is the gesture that means "un-snap". */
1541
+ unsnap() {
1542
+ if (!this._preSnapState) return false;
1543
+ const { x, y, width, height } = this._preSnapState;
1544
+ this._preSnapState = null;
1545
+ this.x = x; this.y = y; this.width = width; this.height = height;
1546
+ this._applyPosition();
1547
+ this.element?.classList.remove('twm-managed-window--snapped');
1548
+ return true;
672
1549
  }
673
1550
 
674
1551
  // ========== Resize Handling ==========
@@ -791,6 +1668,21 @@ export class ManagedWindow {
791
1668
  return _activeWindows.get(id) || null;
792
1669
  }
793
1670
 
1671
+ /**
1672
+ * Every window this class currently holds, newest last.
1673
+ *
1674
+ * `get`/`restore` answer about a window whose id you already have, which is
1675
+ * enough for a consumer reacting to an EVENT — it carries the id. It is not
1676
+ * enough for one that has to repaint from scratch: a taskbar rebuilt with
1677
+ * its pane has missed every event that came before it existed, and the only
1678
+ * honest source for "which windows are minimised right now" is the registry
1679
+ * itself. Returned as an array rather than the live map, so a consumer
1680
+ * iterating it cannot mutate what it is iterating.
1681
+ */
1682
+ static all() {
1683
+ return [..._activeWindows.values()];
1684
+ }
1685
+
794
1686
  /**
795
1687
  * Restore a minimized window by ID.
796
1688
  */