flexdesk 0.17.15 → 0.17.17

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 (49) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/css/base.css +236 -5
  3. package/css/flexdesk.css +326 -13
  4. package/css/tokens.css +3 -0
  5. package/dist/charts.js +104 -34
  6. package/dist/charts.js.map +3 -3
  7. package/dist/{chunk-2PM6ZMNS.js → chunk-2Z2ENHR3.js} +2 -2
  8. package/dist/{chunk-AD7FIA3Z.js → chunk-7JI7HHSY.js} +2 -2
  9. package/dist/{chunk-TLIV6QSU.js → chunk-7OL6F2AM.js} +3 -3
  10. package/dist/{chunk-MADMGRX2.js → chunk-PGZ4EHSE.js} +156 -9
  11. package/dist/chunk-PGZ4EHSE.js.map +7 -0
  12. package/dist/{chunk-OEG6NPAN.js → chunk-XJNADRGR.js} +2 -2
  13. package/dist/flexdesk.css +326 -13
  14. package/dist/flow.js +3 -3
  15. package/dist/tiles.js +84 -9
  16. package/dist/tiles.js.map +2 -2
  17. package/dist/tokens.css +3 -0
  18. package/dist/widgets.js +4559 -4091
  19. package/dist/widgets.js.map +4 -4
  20. package/dist/wm.js +212 -40
  21. package/dist/wm.js.map +2 -2
  22. package/docs/README.md +1 -1
  23. package/docs/components/charts.md +70 -3
  24. package/docs/components/forms-and-dialogs.md +61 -0
  25. package/docs/components/menus.md +85 -1
  26. package/docs/components/notifications.md +23 -1
  27. package/docs/components/panels-and-desktops.md +22 -0
  28. package/docs/components/shell.md +91 -0
  29. package/docs/components/tiles.md +37 -0
  30. package/package.json +1 -1
  31. package/src/charts/plot_config_editor.js +125 -29
  32. package/src/tiles/tile_base.js +48 -2
  33. package/src/tiles/tile_grid.js +42 -1
  34. package/src/tiling/kind_taxonomy.js +66 -13
  35. package/src/tiling/shell.js +4 -1
  36. package/src/tiling/wm.js +205 -35
  37. package/src/ui/components/category_picker.js +556 -0
  38. package/src/ui/components/computing_status_window.js +3 -0
  39. package/src/ui/components/confirm_dialog.js +3 -0
  40. package/src/ui/components/managed_window.js +204 -9
  41. package/src/ui/components/notification_history.js +11 -1
  42. package/src/ui/components/wizard.js +4 -1
  43. package/src/ui/notification_center.js +47 -1
  44. package/widgets.js +2 -1
  45. package/dist/chunk-MADMGRX2.js.map +0 -7
  46. /package/dist/{chunk-2PM6ZMNS.js.map → chunk-2Z2ENHR3.js.map} +0 -0
  47. /package/dist/{chunk-AD7FIA3Z.js.map → chunk-7JI7HHSY.js.map} +0 -0
  48. /package/dist/{chunk-TLIV6QSU.js.map → chunk-7OL6F2AM.js.map} +0 -0
  49. /package/dist/{chunk-OEG6NPAN.js.map → chunk-XJNADRGR.js.map} +0 -0
package/src/tiling/wm.js CHANGED
@@ -303,8 +303,14 @@ export class WindowManager {
303
303
  }
304
304
  }
305
305
  }
306
- // A single kind is open once, whatever the saved layout says.
307
- const deduped = this._dropDuplicateSingles();
306
+ // A window-only kind is never in a tile, and a single kind is open
307
+ // once, whatever the saved layout says.
308
+ const windowOnlyDropped = this._dropWindowOnlyTabs();
309
+ if (windowOnlyDropped.length) {
310
+ (this.logger ?? console).warn?.(`[wm] the saved layout held ${windowOnlyDropped.length} tile tab(s) `
311
+ + `of window-only kind(s) ${[...new Set(windowOnlyDropped)].join(', ')}; they open in a window when asked for`);
312
+ }
313
+ const deduped = this._dropDuplicateSingles() || windowOnlyDropped.length > 0;
308
314
  for (const d of this.desktops.desktops) this._canonicalize(d.tree, d);
309
315
  this.renderer.tree = this.desktops.active().tree;
310
316
  this.renderer.render();
@@ -338,6 +344,29 @@ export class WindowManager {
338
344
  //
339
345
  // MOVE verbs (float, dock, drag a tab, move to desktop) carry the one
340
346
  // instance somewhere else and are untouched.
347
+ //
348
+ // `single: 'desktop'` keeps the same promises per virtual desktop: a
349
+ // desktop is the tiles and panels the user arranged on it, so opening the
350
+ // kind looks only at the ACTIVE desktop — its tiles, their archived pages
351
+ // and the windows on it — and opens it there when that desktop has none.
352
+ // A window is on the desktop it was opened on or moved to (`desktopIdx`):
353
+ // a free-floating one is drawn over every desktop, but it is that one
354
+ // desktop's instance, so it never stands in for another desktop's. The
355
+ // move verbs refuse to carry the kind onto a desktop that already holds
356
+ // it (`desktopMoveRefusal`). Navigating inside a window looks on the
357
+ // window's own desktop (`openInWindow`). Removing a desktop brings its
358
+ // windows across, a window never being destroyed by a tile operation, so
359
+ // the active desktop can then hold the kind twice until the next reload
360
+ // keeps the first.
361
+
362
+ /** The key a tab's instance is unique under on desktop `desktopIdx`: its
363
+ * instance key, scoped to that desktop for a `single: 'desktop'` kind;
364
+ * null for a tab that is not single. */
365
+ _scopedKey(t, desktopIdx) {
366
+ const k = t ? this._instanceKey(t.kind, t.props) : null;
367
+ if (k == null || !this.taxonomy.isSinglePerDesktop(t.kind)) return k;
368
+ return `${desktopIdx}\u0000${k}`;
369
+ }
341
370
 
342
371
  /** The instance key of a tab spec, or null when its kind is not single. */
343
372
  _instanceKey(kind, props) {
@@ -350,27 +379,38 @@ export class WindowManager {
350
379
  }
351
380
 
352
381
  /** Where the one instance of a single kind is open, or null — also null
353
- * for a kind that is not single. One of:
382
+ * for a kind that is not single. A `single: 'desktop'` kind is looked for
383
+ * on the active desktop only. One of:
354
384
  * `{ where: 'window', windowId, idx, desktopIdx }`
355
385
  * `{ where: 'tab', desktopIdx, leafId, idx }`
356
386
  * `{ where: 'archive', desktopIdx, leafId, page, idx }` — a tab of a
357
387
  * top-nav page the tile has switched away from (`pageTabs`). */
358
388
  findInstance(kind, props = {}) {
389
+ const on = this.taxonomy.isSinglePerDesktop(kind) ? this.desktops.activeIdx : null;
390
+ return this._findInstance(kind, props, on);
391
+ }
392
+
393
+ /** `findInstance`, looking only at desktop `on` when it is a number, and
394
+ * past the window `exceptWindowId`. */
395
+ _findInstance(kind, props, on, exceptWindowId = null) {
359
396
  const key = this._instanceKey(kind, props);
360
397
  if (key == null) return null;
361
398
  const same = (t) => !!t && this._instanceKey(t.kind, t.props) === key;
399
+ const desktops = [...this.desktops.desktops.entries()]
400
+ .filter(([desktopIdx]) => on == null || desktopIdx === on);
362
401
  for (const [windowId, rec] of this._windowToLeaf) {
402
+ if (windowId === exceptWindowId) continue;
403
+ if (on != null && rec.desktopIdx !== on) continue;
363
404
  const idx = _windowTabSpecs(rec).findIndex(same);
364
405
  if (idx >= 0) return { where: 'window', windowId, idx, desktopIdx: rec.desktopIdx };
365
406
  }
366
- const desktops = this.desktops.desktops;
367
- for (const [desktopIdx, d] of desktops.entries()) {
407
+ for (const [desktopIdx, d] of desktops) {
368
408
  for (const leaf of d.tree.leaves()) {
369
409
  const idx = (leaf.tabs || []).findIndex(same);
370
410
  if (idx >= 0) return { where: 'tab', desktopIdx, leafId: leaf.id, idx };
371
411
  }
372
412
  }
373
- for (const [desktopIdx, d] of desktops.entries()) {
413
+ for (const [desktopIdx, d] of desktops) {
374
414
  for (const leaf of d.tree.leaves()) {
375
415
  for (const [page, arc] of this._archivedPages(leaf)) {
376
416
  const idx = arc.tabs.findIndex(same);
@@ -381,6 +421,31 @@ export class WindowManager {
381
421
  return null;
382
422
  }
383
423
 
424
+ /** Why moving a pane's or a window's tabs onto desktop `idx` is refused,
425
+ * or null: that desktop already holds one of their `single: 'desktop'`
426
+ * kinds (in a tile, an archived page or a window), and a desktop holds
427
+ * such a kind once. `source` is `{ leafId }` (a tile of the active
428
+ * desktop) or `{ windowId }`. The move verbs ask it, and their menu rows
429
+ * are greyed with its sentence. */
430
+ desktopMoveRefusal(source, idx) {
431
+ let specs = [];
432
+ if (source?.windowId) {
433
+ const rec = this._windowToLeaf.get(source.windowId);
434
+ specs = rec ? _windowTabSpecs(rec) : [];
435
+ } else {
436
+ const leaf = this._tree().get(source?.leafId);
437
+ specs = leaf?.tabs?.length ? leaf.tabs : (leaf?.content ? [leaf.content] : []);
438
+ }
439
+ const d = this.desktops.desktops[idx];
440
+ if (!d) return null;
441
+ for (const t of specs) {
442
+ if (!t || !this.taxonomy.isSinglePerDesktop(t.kind)) continue;
443
+ if (!this._findInstance(t.kind, t.props, idx, source.windowId ?? null)) continue;
444
+ return `${this.taxonomy.kinds[t.kind]?.label || t.kind} is open on desktop ${d.label} already`;
445
+ }
446
+ return null;
447
+ }
448
+
384
449
  /** Reveal the instance of a single kind that is open: answers whether
385
450
  * there was one. Every open path asks this first.
386
451
  *
@@ -394,7 +459,12 @@ export class WindowManager {
394
459
  * open names no props at all — a top-nav button, `openInPrimary(kind)` —
395
460
  * which asks to SEE the instance, not to empty it. */
396
461
  revealInstance(kind, props = {}) {
397
- const loc = this.findInstance(kind, props);
462
+ return this._revealFound(this.findInstance(kind, props), kind, props);
463
+ }
464
+
465
+ /** Reveal the instance at `loc` (a `findInstance` result, or null) and
466
+ * hand it `props`. Answers whether there was one to reveal. */
467
+ _revealFound(loc, kind, props) {
398
468
  if (!loc || !this._revealAt(loc)) return false;
399
469
  if (props && Object.keys(props).length) this._deliverProps(kind, props);
400
470
  return true;
@@ -510,7 +580,8 @@ export class WindowManager {
510
580
  * soon as the tile leaves the page by a path other than `swapToPage`
511
581
  * (`navigateActiveTab`). So a restored tab whose instance is live
512
582
  * anywhere else is dropped from the tile and from the restored archive;
513
- * the instance stays where it is. */
583
+ * the instance stays where it is. For a `single: 'desktop'` kind,
584
+ * anywhere else on the tile's desktop. */
514
585
  _swapToPage(tree, leafId, target, currentTopNav, targetTopNav, opts) {
515
586
  const done = tree.swapToPage(leafId, target, currentTopNav, targetTopNav, opts);
516
587
  if (done) this._dropRestoredCopies(tree, leafId, targetTopNav, target);
@@ -521,11 +592,12 @@ export class WindowManager {
521
592
  const leaf = tree.get(leafId);
522
593
  const tabs = Array.isArray(leaf?.tabs) ? leaf.tabs : null;
523
594
  if (!tabs) return;
595
+ const desktopIdx = this.desktops.desktops.findIndex((d) => d.tree === tree);
524
596
  const elsewhere = this._liveInstanceKeys(tree, leafId);
525
597
  const keeper = () => {
526
598
  const seen = new Set();
527
599
  return (t) => {
528
- const k = t ? this._instanceKey(t.kind, t.props) : null;
600
+ const k = this._scopedKey(t, desktopIdx);
529
601
  if (k == null) return true;
530
602
  if (elsewhere.has(k) || seen.has(k)) return false;
531
603
  seen.add(k);
@@ -563,16 +635,23 @@ export class WindowManager {
563
635
  tree.setActiveLeafTab(leafId, Math.max(0, Math.min(tabs.length - 1, active)));
564
636
  }
565
637
 
566
- /** The instance keys of every LIVE tab — windows, and every leaf of every
567
- * desktop but `leafId` of `tree`. Archived pages are not live. */
638
+ /** The scoped keys (`_scopedKey`, for `tree`'s desktop) of every LIVE
639
+ * tab — windows, and every leaf of every desktop but `leafId` of `tree`;
640
+ * a `single: 'desktop'` tab counts only when it is on `tree`'s desktop.
641
+ * Archived pages are not live. */
568
642
  _liveInstanceKeys(tree, leafId) {
643
+ const here = this.desktops.desktops.findIndex((d) => d.tree === tree);
569
644
  const keys = new Set();
570
- const add = (t) => {
571
- const k = t ? this._instanceKey(t.kind, t.props) : null;
645
+ const adder = (onHere) => (t) => {
646
+ if (!onHere && t && this.taxonomy.isSinglePerDesktop(t.kind)) return;
647
+ const k = this._scopedKey(t, here);
572
648
  if (k != null) keys.add(k);
573
649
  };
574
- for (const [, rec] of this._windowToLeaf) _windowTabSpecs(rec).forEach(add);
650
+ for (const [, rec] of this._windowToLeaf) {
651
+ _windowTabSpecs(rec).forEach(adder(rec.desktopIdx === here));
652
+ }
575
653
  for (const d of this.desktops.desktops) {
654
+ const add = adder(d.tree === tree);
576
655
  for (const leaf of d.tree.leaves()) {
577
656
  if (d.tree === tree && leaf.id === leafId) continue;
578
657
  (leaf.tabs || []).forEach(add);
@@ -581,28 +660,67 @@ export class WindowManager {
581
660
  return keys;
582
661
  }
583
662
 
584
- /** A restored layout keeps the FIRST instance of each single kind: later
585
- * live tabs are dropped (the tile shows the tab that took the dropped
586
- * one's place, or closes when it held nothing else), and so are later
587
- * archived ones. Answers whether anything was dropped. */
663
+ /** Open a window-only kind (KindDef `windowOnly`) in its window — the one
664
+ * that is open, or a new one — and say so; any other kind: false, and the
665
+ * caller opens it where it asked (STORY-0127). */
666
+ _openWindowOnly(kind, props) {
667
+ if (!this.taxonomy.isWindowOnly?.(kind)) return false;
668
+ this._navigateWindow(kind, props);
669
+ return true;
670
+ }
671
+
672
+ /** Whether a window record holds a window-only kind: such a window never
673
+ * docks into a tile (Back to tile, maximize, drag-to-dock). */
674
+ _holdsWindowOnly(rec) {
675
+ return !!this.taxonomy.isWindowOnly
676
+ && _windowTabSpecs(rec).some((t) => this.taxonomy.isWindowOnly(t?.kind));
677
+ }
678
+
679
+ /** A restored layout never holds a window-only kind in a tile: its tabs,
680
+ * and its tabs archived under a page, go. Windows are not restored, so
681
+ * the content simply opens in a window the next time it is asked for.
682
+ * Returns the kinds of the tabs that went, one entry per tab. */
683
+ _dropWindowOnlyTabs() {
684
+ if (!this.taxonomy.isWindowOnly) return [];
685
+ const dropped = [];
686
+ this._pruneRestoredTabs((t) => {
687
+ if (!t || !this.taxonomy.isWindowOnly(t.kind)) return true;
688
+ dropped.push(t.kind);
689
+ return false;
690
+ });
691
+ return dropped;
692
+ }
693
+
694
+ /** A restored layout keeps the FIRST instance of each single kind (of a
695
+ * `single: 'desktop'` kind, on each desktop): later live tabs are
696
+ * dropped (the tile shows the tab that took the dropped one's place, or
697
+ * closes when it held nothing else), and so are later archived ones.
698
+ * Answers whether anything was dropped. */
588
699
  _dropDuplicateSingles() {
589
700
  const seen = new Set();
590
- let dropped = false;
591
- const keep = (t) => {
592
- const k = t ? this._instanceKey(t.kind, t.props) : null;
701
+ return this._pruneRestoredTabs((t, desktopIdx) => {
702
+ const k = this._scopedKey(t, desktopIdx);
593
703
  if (k == null) return true;
594
704
  if (seen.has(k)) return false;
595
705
  seen.add(k);
596
706
  return true;
597
- };
598
- for (const d of this.desktops.desktops) {
707
+ });
708
+ }
709
+
710
+ /** Drop every tab of the restored layout `keep(tab, desktopIdx)` refuses,
711
+ * in the tiles and in their archived pages; a tile left empty closes
712
+ * (seeding a content tile if none is left). Returns whether anything
713
+ * went. */
714
+ _pruneRestoredTabs(keep) {
715
+ let dropped = false;
716
+ for (const [desktopIdx, d] of this.desktops.desktops.entries()) {
599
717
  const tree = d.tree;
600
718
  for (const leaf of tree.leaves()) {
601
719
  const tabs = Array.isArray(leaf.tabs) ? leaf.tabs : [];
602
720
  let active = leaf.activeTabIdx || 0;
603
721
  let changed = false;
604
722
  for (let i = 0; i < tabs.length;) {
605
- if (keep(tabs[i])) { i++; continue; }
723
+ if (keep(tabs[i], desktopIdx)) { i++; continue; }
606
724
  tabs.splice(i, 1);
607
725
  if (i < active) active -= 1;
608
726
  changed = true;
@@ -621,10 +739,10 @@ export class WindowManager {
621
739
  }
622
740
  }
623
741
  }
624
- for (const d of this.desktops.desktops) {
742
+ for (const [desktopIdx, d] of this.desktops.desktops.entries()) {
625
743
  for (const leaf of d.tree.leaves()) {
626
744
  for (const [page, arc] of this._archivedPages(leaf)) {
627
- const kept = arc.tabs.filter(keep);
745
+ const kept = arc.tabs.filter((t) => keep(t, desktopIdx));
628
746
  if (kept.length === arc.tabs.length) continue;
629
747
  dropped = true;
630
748
  if (!kept.length) { delete leaf.pageTabs[page]; continue; }
@@ -862,6 +980,9 @@ export class WindowManager {
862
980
  const tree = this._tree();
863
981
  const prior = tree.popActiveTabHistory?.(id);
864
982
  if (!prior || !prior.kind) return false;
983
+ // A window-only kind in a tab's history (a layout saved before the kind
984
+ // was one) goes back to its window, never into this tile.
985
+ if (this._openWindowOnly(prior.kind, prior.props || {})) return true;
865
986
  // A single kind opened elsewhere since this entry was pushed is
866
987
  // revealed there, not put back here as a second copy.
867
988
  if (this.revealInstance(prior.kind, prior.props || {})) return true;
@@ -973,6 +1094,7 @@ export class WindowManager {
973
1094
  * the leaf. Used by Backspace + breadcrumb segments + anything
974
1095
  * else that should walk WITHIN the tile rather than reset it. */
975
1096
  navigateActiveTab(leafId, kind, props = {}) {
1097
+ if (this._openWindowOnly(kind, props)) return;
976
1098
  if (this.revealInstance(kind, props)) return;
977
1099
  const tree = this._tree();
978
1100
  const leaf = tree.get(leafId);
@@ -1029,6 +1151,7 @@ export class WindowManager {
1029
1151
  * tab feature shipped), the behavior is identical to the old
1030
1152
  * setLeafContent: one tab in, one tab out. */
1031
1153
  openInLeaf(leafId, kind, props = {}) {
1154
+ if (this._openWindowOnly(kind, props)) return;
1032
1155
  if (this.revealInstance(kind, props)) return;
1033
1156
  const tree = this._tree();
1034
1157
  const leaf = tree.get(leafId);
@@ -1047,9 +1170,16 @@ export class WindowManager {
1047
1170
  * previous mount, mounts the new kind into the same body element,
1048
1171
  * and updates the window's title. */
1049
1172
  openInWindow(winId, kind, props = {}) {
1050
- if (this.revealInstance(kind, props)) return;
1173
+ if (this._openWindowOnly(kind, props)) return;
1051
1174
  const rec = this._windowToLeaf.get(winId);
1052
1175
  if (!rec) { this.openInPrimary(kind, props); return; }
1176
+ // A window is on its own desktop, even a free-floating one drawn over
1177
+ // every desktop: a `single: 'desktop'` kind navigated to in it is
1178
+ // looked for on the window's desktop, not the one on screen.
1179
+ const loc = this.taxonomy.isSinglePerDesktop(kind)
1180
+ ? this._findInstance(kind, props, rec.desktopIdx)
1181
+ : this.findInstance(kind, props);
1182
+ if (this._revealFound(loc, kind, props)) return;
1053
1183
  try { rec.mountInfo?.destroy?.(); } catch {}
1054
1184
  // R8. THE BODY, NOT THE CONTENT ELEMENT. A floated pane's window holds
1055
1185
  // a tab strip above its body, and both are children of `contentEl` —
@@ -1089,6 +1219,7 @@ export class WindowManager {
1089
1219
  * If only panel tiles exist (or the tree is empty), spawn a new
1090
1220
  * content leaf and canonicalize so the panels wrap it. */
1091
1221
  openInPrimary(kind, props = {}) {
1222
+ if (this._openWindowOnly(kind, props)) return;
1092
1223
  if (this.revealInstance(kind, props)) return;
1093
1224
  const tree = this._tree();
1094
1225
  let leafId = tree.primaryLeafId();
@@ -1172,6 +1303,7 @@ export class WindowManager {
1172
1303
  * pane in a split never dismounts the original). Returns the new
1173
1304
  * leaf id, or null if the leaf can't be split. */
1174
1305
  splitLeafWith(leafId, dir, kind, props = {}, title = '') {
1306
+ if (this._openWindowOnly(kind, props)) return null;
1175
1307
  if (this.revealInstance(kind, props)) return null;
1176
1308
  const tree = this._tree();
1177
1309
  leafId = this._contentLeafId(leafId);
@@ -1661,6 +1793,9 @@ export class WindowManager {
1661
1793
  canMaximize: true,
1662
1794
  canResize: true,
1663
1795
  modal: false,
1796
+ // BUG-0053. The id is minted per window and never opens again, so
1797
+ // geometry saved under it could never be restored.
1798
+ persistGeometry: false,
1664
1799
  // C15. Dropping a promoted window on a tile PUTS IT BACK — as that
1665
1800
  // tile's content, as a split of it, or as one of its tabs. Off
1666
1801
  // unless the embedder asked, because it changes what a drag to an
@@ -2273,6 +2408,9 @@ export class WindowManager {
2273
2408
  // false lets the geometric maximise happen instead — the answer every
2274
2409
  // other window manager gives a window with nowhere to go back to.
2275
2410
  if (!rec) return false;
2411
+ // A window-only kind has no tile to go back to (STORY-0127): the
2412
+ // geometric maximise happens instead.
2413
+ if (this._holdsWindowOnly(rec)) return false;
2276
2414
  const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
2277
2415
  const home = rec.homeLeafId ? tree?.get(rec.homeLeafId) : null;
2278
2416
  if (home && home.kind === 'leaf') {
@@ -3092,6 +3230,11 @@ export class WindowManager {
3092
3230
  // The window's home follows the drop. Without this the dock is applied
3093
3231
  // to the tree of the desktop the window was PROMOTED from, and the tile
3094
3232
  // the user aimed at is on the one they are looking at.
3233
+ // A free-floating window is drawn over every desktop, so a drop can
3234
+ // carry it to another one: refused, as the move verbs are, when that
3235
+ // desktop holds its `single: 'desktop'` kind already.
3236
+ if (typeof desktopIdx === 'number' && desktopIdx !== record.desktopIdx
3237
+ && this.desktopMoveRefusal({ windowId: winId }, desktopIdx)) return false;
3095
3238
  if (typeof desktopIdx === 'number') record.desktopIdx = desktopIdx;
3096
3239
 
3097
3240
  // THE DRAG ALREADY ANSWERED, so there is nothing to ask — the probe
@@ -3138,6 +3281,7 @@ export class WindowManager {
3138
3281
  dockWindowInto(windowId, target) {
3139
3282
  const rec = this._windowToLeaf.get(windowId);
3140
3283
  if (!rec || !target?.leafId) return false;
3284
+ if (this._holdsWindowOnly(rec)) return false; // never into a tile (STORY-0127)
3141
3285
  rec._dock = { ...target };
3142
3286
  rec._demoting = true;
3143
3287
  try { rec.window.close({ force: true }); }
@@ -3178,6 +3322,8 @@ export class WindowManager {
3178
3322
  if (rec.desktopIdx === targetIdx) return;
3179
3323
  // C40. A fixed desktop is the embedder's, and nothing is moved onto it.
3180
3324
  if (this.desktops.desktops[targetIdx]?.fixed) return;
3325
+ // A desktop holds a `single: 'desktop'` kind once.
3326
+ if (this.desktopMoveRefusal({ windowId }, targetIdx)) return;
3181
3327
  this.desktops.ensureCount(targetIdx + 1);
3182
3328
  rec.desktopIdx = targetIdx;
3183
3329
  if (!rec.homeContainer && rec.homeLeafId) {
@@ -3343,7 +3489,8 @@ export class WindowManager {
3343
3489
  e.preventDefault();
3344
3490
  const rec = this._windowToLeaf.get(winId);
3345
3491
  if (!rec) return;
3346
- const items = [
3492
+ // A window-only kind's window has no tile to go back to
3493
+ const items = this._holdsWindowOnly(rec) ? [] : [
3347
3494
  { label: 'Back to tile', icon: 'close_fullscreen', action: 'back' },
3348
3495
  ];
3349
3496
  // C40. Never onto a fixed desktop — `moveWindowToDesktop` refuses.
@@ -3352,9 +3499,11 @@ export class WindowManager {
3352
3499
  if (targets.length) {
3353
3500
  items.push({ separator: true });
3354
3501
  for (const [i, d] of targets) {
3502
+ const refusal = this.desktopMoveRefusal({ windowId: winId }, i);
3355
3503
  items.push({
3356
3504
  label: `Move to desktop ${d.label}`, icon: 'sweep',
3357
- action: `move:${i}`,
3505
+ action: `move:${i}`, disabled: !!refusal,
3506
+ title: refusal || undefined,
3358
3507
  });
3359
3508
  }
3360
3509
  }
@@ -3929,7 +4078,9 @@ export class WindowManager {
3929
4078
  * ruling is that a tile operation may move a window and never destroy it,
3930
4079
  * and removing a desktop is the largest tile operation there is: they come
3931
4080
  * across to the desktop that ends up active, re-homed onto its ground by
3932
- * `_rehomeContainedWindows` on the render below.
4081
+ * `_rehomeContainedWindows` on the render below. One holding a
4082
+ * `single: 'desktop'` kind the active desktop already has makes it hold
4083
+ * the kind twice, until a reload keeps the first.
3933
4084
  */
3934
4085
  removeDesktop(idx) {
3935
4086
  const m = this.desktops;
@@ -4007,6 +4158,9 @@ export class WindowManager {
4007
4158
  // Already there. Without this, `tree.close` below removes the tile from
4008
4159
  // the same tree its tabs were just appended to, and it is gone.
4009
4160
  if (idx === this.desktops.activeIdx) return;
4161
+ // A desktop holds a `single: 'desktop'` kind once: a pane carrying one
4162
+ // the target already has stays where it is.
4163
+ if (this.desktopMoveRefusal({ leafId: focused.id }, idx)) return;
4010
4164
  this.desktops.ensureCount(idx + 1);
4011
4165
  const target = this.desktops.desktops[idx];
4012
4166
  // C40. THE PANE'S TABS JOIN THE TARGET'S PRIMARY LEAF, ALL OF THEM. It
@@ -4223,6 +4377,9 @@ export class WindowManager {
4223
4377
 
4224
4378
  /** The routing behind `navigate`, for a kind known to be registered. */
4225
4379
  _route(kind, props, opts) {
4380
+ // A window-only kind (KindDef `windowOnly`) opens in a window, whatever
4381
+ // the destination asked for.
4382
+ if (this._openWindowOnly(kind, props)) return;
4226
4383
  const { ctx = null, transient = false } = opts;
4227
4384
  // Append the tab but stay where you are. Only meaningful with `newTab`.
4228
4385
  const background = !!opts.background;
@@ -4297,6 +4454,10 @@ export class WindowManager {
4297
4454
  * source leaf — closing the window just disposes the content. */
4298
4455
  _navigateWindow(kind, props) {
4299
4456
  if (this.revealInstance(kind, props)) return;
4457
+ const size = this.taxonomy.windowSize?.(kind);
4458
+ // A window-only kind's window maximises as any window does and never
4459
+ // snaps into a tile (STORY-0127)
4460
+ const dockable = !this.taxonomy.isWindowOnly?.(kind);
4300
4461
  const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
4301
4462
  const contentEl = document.createElement('div');
4302
4463
  contentEl.className = 'twm-window-content';
@@ -4309,17 +4470,22 @@ export class WindowManager {
4309
4470
  title: mountInfo?.title || title,
4310
4471
  icon: 'web_asset',
4311
4472
  content: contentEl,
4473
+ ...(size?.width ? { defaultWidth: size.width } : {}),
4474
+ ...(size?.height ? { defaultHeight: size.height } : {}),
4312
4475
  canMinimize: true,
4313
4476
  canMaximize: true,
4314
4477
  canResize: true,
4315
4478
  modal: false,
4479
+ // BUG-0053. The id is minted per window and never opens again, so
4480
+ // geometry saved under it could never be restored.
4481
+ persistGeometry: false,
4316
4482
  // C15, and this is the SECOND of the two places the WM builds a
4317
4483
  // window. Alt+N and "Open in new window" produce a window that is
4318
4484
  // every bit as dockable as a promoted one, and a window that can be
4319
4485
  // dragged onto a tile in one case and not the other is a rule
4320
4486
  // nobody can learn.
4321
- snap: this.snapPromotion,
4322
- snapController: this.snapPromotion ? this._snapController() : null,
4487
+ snap: dockable && this.snapPromotion,
4488
+ snapController: dockable && this.snapPromotion ? this._snapController() : null,
4323
4489
  // R1. THE PANE IS A BOX WITH `overflow: hidden`. A window contained
4324
4490
  // to one (C21) cannot be dragged a single pixel outside it, so
4325
4491
  // "drag a window from one tile to another" — the gesture all three
@@ -4337,9 +4503,11 @@ export class WindowManager {
4337
4503
  // harder. So the maximize button docks — and the separate demote
4338
4504
  // button the WM used to inject beside it is gone, because two
4339
4505
  // buttons for one verb is how you get a chrome nobody reads.
4340
- onMaximize: () => this.bringBackWindow(winId),
4341
- maximizeIcon: 'close_fullscreen',
4342
- maximizeTitle: 'Back to tile',
4506
+ ...(dockable ? {
4507
+ onMaximize: () => this.bringBackWindow(winId),
4508
+ maximizeIcon: 'close_fullscreen',
4509
+ maximizeTitle: 'Back to tile',
4510
+ } : {}),
4343
4511
  onClose: () => this._onManagedWindowClosed(winId, mountInfo),
4344
4512
  });
4345
4513
  // DECORATED ONCE, below, after the record exists. It was called here as
@@ -4425,6 +4593,7 @@ export class WindowManager {
4425
4593
  * routing) but uses `appendLeafTab` so the existing content
4426
4594
  * stays in place as a tab. */
4427
4595
  openInTabFromContext(ctx, kind, props = {}, transient = false, background = false) {
4596
+ if (this._openWindowOnly(kind, props)) return;
4428
4597
  if (this.revealInstance(kind, props)) return;
4429
4598
  // Managed-window content: just open in the window — managed
4430
4599
  // windows aren't tabbed (one window = one content).
@@ -4461,6 +4630,7 @@ export class WindowManager {
4461
4630
  * "Add row" button, which passes no ctx) reliably lands as a sibling
4462
4631
  * tab in the main tile rather than swapping its content. */
4463
4632
  openInTabInPrimary(kind, props = {}, transient = false, background = false) {
4633
+ if (this._openWindowOnly(kind, props)) return;
4464
4634
  if (this.revealInstance(kind, props)) return;
4465
4635
  const tree = this._tree();
4466
4636
  const leafId = tree.primaryLeafId();