flexdesk 0.17.15 → 0.17.16

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 (42) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/css/base.css +224 -4
  3. package/css/flexdesk.css +314 -12
  4. package/dist/charts.js +104 -34
  5. package/dist/charts.js.map +3 -3
  6. package/dist/{chunk-2PM6ZMNS.js → chunk-2Z2ENHR3.js} +2 -2
  7. package/dist/{chunk-AD7FIA3Z.js → chunk-7JI7HHSY.js} +2 -2
  8. package/dist/{chunk-TLIV6QSU.js → chunk-7OL6F2AM.js} +3 -3
  9. package/dist/{chunk-MADMGRX2.js → chunk-PGZ4EHSE.js} +156 -9
  10. package/dist/chunk-PGZ4EHSE.js.map +7 -0
  11. package/dist/{chunk-OEG6NPAN.js → chunk-XJNADRGR.js} +2 -2
  12. package/dist/flexdesk.css +314 -12
  13. package/dist/flow.js +3 -3
  14. package/dist/tiles.js +84 -9
  15. package/dist/tiles.js.map +2 -2
  16. package/dist/widgets.js +448 -5
  17. package/dist/widgets.js.map +4 -4
  18. package/dist/wm.js +102 -14
  19. package/dist/wm.js.map +2 -2
  20. package/docs/README.md +1 -1
  21. package/docs/components/charts.md +70 -3
  22. package/docs/components/forms-and-dialogs.md +61 -0
  23. package/docs/components/menus.md +85 -1
  24. package/docs/components/shell.md +37 -0
  25. package/docs/components/tiles.md +37 -0
  26. package/package.json +1 -1
  27. package/src/charts/plot_config_editor.js +125 -29
  28. package/src/tiles/tile_base.js +48 -2
  29. package/src/tiles/tile_grid.js +42 -1
  30. package/src/tiling/kind_taxonomy.js +45 -0
  31. package/src/tiling/wm.js +86 -11
  32. package/src/ui/components/category_picker.js +556 -0
  33. package/src/ui/components/computing_status_window.js +3 -0
  34. package/src/ui/components/confirm_dialog.js +3 -0
  35. package/src/ui/components/managed_window.js +204 -9
  36. package/src/ui/components/wizard.js +4 -1
  37. package/widgets.js +2 -1
  38. package/dist/chunk-MADMGRX2.js.map +0 -7
  39. /package/dist/{chunk-2PM6ZMNS.js.map → chunk-2Z2ENHR3.js.map} +0 -0
  40. /package/dist/{chunk-AD7FIA3Z.js.map → chunk-7JI7HHSY.js.map} +0 -0
  41. /package/dist/{chunk-TLIV6QSU.js.map → chunk-7OL6F2AM.js.map} +0 -0
  42. /package/dist/{chunk-OEG6NPAN.js.map → chunk-XJNADRGR.js.map} +0 -0
@@ -47,10 +47,13 @@ export class TileBase {
47
47
  * @param {{resolve: Function}} [options.dataSource] - Where this tile asks for its
48
48
  * own rows. Handed down by `TileGrid`; absent for a grid whose data is
49
49
  * broadcast with `setData()`, and then nothing ever calls `loadData()`.
50
+ * @param {boolean} [options.configOnDoubleClick=true] - A double-click on the
51
+ * title strip (not on its buttons or an editable field in it) does what
52
+ * the cogwheel does. False leaves the cogwheel as the only way in.
50
53
  */
51
54
  constructor({ id, grid, eventBus = null, config = {}, readonly = false,
52
55
  headless = false, host = null, stateGuard = null,
53
- dataSource = null }) {
56
+ dataSource = null, configOnDoubleClick = true }) {
54
57
  this.host = host;
55
58
  // THE TILE PULLS; THE GRID DOES NOT PUSH.
56
59
  //
@@ -74,6 +77,9 @@ export class TileBase {
74
77
  this.config = { ...this.getDefaultConfig(), ...config };
75
78
  this.readonly = readonly;
76
79
  this.headless = headless;
80
+ this.configOnDoubleClick = configOnDoubleClick;
81
+ /** STORY-0128. See `setConfiguring`. */
82
+ this._configuring = false;
77
83
  this.element = null;
78
84
  this.contentElement = null;
79
85
  this.chartInstance = null;
@@ -288,7 +294,8 @@ export class TileBase {
288
294
  if (this._disposed) return;
289
295
 
290
296
  this.element = document.createElement('div');
291
- this.element.className = `tile${this.readonly ? ' tile--readonly' : ''}`;
297
+ this.element.className = `tile${this.readonly ? ' tile--readonly' : ''}`
298
+ + `${this._configuring ? ' tile--configuring' : ''}`;
292
299
 
293
300
  // Use StateGuard bypass if available to set dataset properties
294
301
  const stateGuard = this.stateGuard;
@@ -595,6 +602,17 @@ export class TileBase {
595
602
  this._onConfigClick();
596
603
  });
597
604
 
605
+ // STORY-0128. The title strip's double-click is a second door to
606
+ // the cogwheel, through the same `_onConfigClick`. Not on a button
607
+ // in the strip (each has its own verb) and not on an editable
608
+ // field, where a double-click selects a word.
609
+ header.addEventListener('dblclick', (e) => {
610
+ if (!this.configOnDoubleClick || !configBtn) return;
611
+ if (e.target.closest?.('button, input, textarea, select, [contenteditable=""], [contenteditable="true"], .tile-menu-dropdown')) return;
612
+ e.stopPropagation();
613
+ this._onConfigClick();
614
+ });
615
+
598
616
  // "..." menu button + dropdown
599
617
  const menuBtn = header.querySelector('.tile-menu-btn');
600
618
  const menuDropdown = header.querySelector('.tile-menu-dropdown');
@@ -705,6 +723,34 @@ export class TileBase {
705
723
  if (this._menuHome && menu.parentNode !== this._menuHome) this._menuHome.appendChild(menu);
706
724
  }
707
725
 
726
+ /**
727
+ * Mark this tile as being configured, or clear the mark (STORY-0128).
728
+ * A convenience for `grid.setConfiguring(tile.id, on)`, which is the API:
729
+ * the grid holds the mark by tile id, so it outlives this tile instance
730
+ * (`setLayout` disposes and rebuilds every tile). A tile with no grid
731
+ * holds the mark itself.
732
+ *
733
+ * @param {boolean} [on=true]
734
+ */
735
+ setConfiguring(on = true) {
736
+ if (typeof this.grid?.setConfiguring === 'function') {
737
+ this.grid.setConfiguring(this.id, on);
738
+ return;
739
+ }
740
+ this._applyConfiguring(on);
741
+ }
742
+
743
+ /** Draw (or clear) the configuring mark. Called by the grid.
744
+ * @param {boolean} on
745
+ * @private */
746
+ _applyConfiguring(on) {
747
+ this._configuring = !!on;
748
+ this.element?.classList.toggle('tile--configuring', this._configuring);
749
+ }
750
+
751
+ /** True while this tile is marked as being configured. */
752
+ get isConfiguring() { return this._configuring; }
753
+
708
754
  /**
709
755
  * Handle config button click — highlight tile and emit config request.
710
756
  * @private
@@ -27,6 +27,8 @@ export class TileGrid {
27
27
  * @param {*} [options.scope=null] - The board's scope, a filter every tile
28
28
  * follows (0.16.1); see `setScope`.
29
29
  * @param {Function} [options.onScopeChange] - `(scope)` after `setScope` changed it.
30
+ * @param {boolean} [options.configOnDoubleClick=true] - Whether a double-click
31
+ * on a tile's title strip opens its config, as its cogwheel does (0.17.16).
30
32
  */
31
33
  constructor({ container, columns = 12, rowHeight = 80, gap = 16, eventBus = null,
32
34
  showToolbar = true, readonly = false, onLayoutChange, onBeforeLayoutChange,
@@ -38,12 +40,19 @@ export class TileGrid {
38
40
  host = null, stateGuard = null,
39
41
  // Injected, never constructed here: the grid does not know what
40
42
  // a binding is, only that a tile may have somewhere to ask.
41
- dataSource = null, scope = null, onScopeChange = null }) {
43
+ dataSource = null, scope = null, onScopeChange = null,
44
+ // STORY-0128. Handed to every tile: false turns off the
45
+ // title strip's double-click to configure.
46
+ configOnDoubleClick = true }) {
42
47
  this.host = host;
43
48
  this.scope = scope;
44
49
  this._onScopeChangeCallback = onScopeChange;
45
50
  this.stateGuard = stateGuard;
46
51
  this.dataSource = dataSource;
52
+ this.configOnDoubleClick = configOnDoubleClick;
53
+ /** STORY-0128. Ids of the tiles marked as being configured; see
54
+ * `setConfiguring`. Kept by id so a rebuilt tile is marked again. */
55
+ this._configuringIds = new Set();
47
56
  this.container = container;
48
57
  this.columns = columns;
49
58
  this.rowHeight = rowHeight;
@@ -194,11 +203,14 @@ export class TileGrid {
194
203
  host: this.host,
195
204
  stateGuard: this.stateGuard,
196
205
  dataSource: this.dataSource,
206
+ configOnDoubleClick: this.configOnDoubleClick,
197
207
  });
198
208
 
199
209
  if (!tile) return null;
200
210
  // Before mount, so a tile's first render and first load already follow it.
201
211
  tile.scope = this.scope;
212
+ // STORY-0128. A tile rebuilt under a marked id (setLayout) is marked.
213
+ if (this._configuringIds.has(tileId)) tile._applyConfiguring(true);
202
214
 
203
215
  // Add to layout
204
216
  const layoutEntry = {
@@ -340,6 +352,35 @@ export class TileGrid {
340
352
  }));
341
353
  }
342
354
 
355
+ /**
356
+ * Mark a tile as being configured, or clear the mark (STORY-0128).
357
+ *
358
+ * A config request (cogwheel or title double-click) highlights the
359
+ * requesting tile with `tile--selected` and takes that highlight off every
360
+ * other tile: one selection per grid. An embedder that keeps a config
361
+ * window open per tile marks each such tile here instead. The mark
362
+ * (`tile--configuring`, drawn like the selection) is the embedder's alone:
363
+ * no config request clears it, and it is held by id, so a tile that
364
+ * `setLayout` rebuilds (or an id added later) is marked again. Clear it
365
+ * when that tile's config window closes.
366
+ *
367
+ * @param {string} tileId
368
+ * @param {boolean} [on=true]
369
+ */
370
+ setConfiguring(tileId, on = true) {
371
+ if (on) this._configuringIds.add(tileId);
372
+ else this._configuringIds.delete(tileId);
373
+ this.tiles.get(tileId)?._applyConfiguring(!!on);
374
+ }
375
+
376
+ /**
377
+ * @param {string} tileId
378
+ * @returns {boolean} Whether the tile is marked as being configured.
379
+ */
380
+ isConfiguring(tileId) {
381
+ return this._configuringIds.has(tileId);
382
+ }
383
+
343
384
  /**
344
385
  * Set layout and rebuild tiles.
345
386
  * @param {Array|Object} layout - Layout array or object with tiles property
@@ -62,6 +62,20 @@
62
62
  * a reason, and keeps the first of any duplicates a restored
63
63
  * layout holds. The root kind cannot be single: every new
64
64
  * pane, tab and desktop is seeded with it.
65
+ *
66
+ * window? `{ width?, height? }` — the initial size, in px, of a managed
67
+ * window this kind opens in (`navigate(…, { dest: 'window' })`,
68
+ * or always for `windowOnly`); clamped to the screen and to the
69
+ * window's minimum (400 × 300) as any window is. Absent: the
70
+ * window's default size (STORY-0127).
71
+ * windowOnly? `true`: the kind lives in a managed window, never in a tile
72
+ * — a tool, not a page. Every open path (navigate with any
73
+ * `dest`, openInPrimary, openInLeaf, the tab and split verbs)
74
+ * opens it in a window; its window never docks into a tile
75
+ * (no Back to tile, maximise is geometric, no drag-to-dock); a
76
+ * restored layout drops a tile tab of it, with one warning, and
77
+ * a tab's history entry of it opens a window. Neither the root
78
+ * nor a top-nav kind can be window-only (STORY-0127).
65
79
  */
66
80
 
67
81
  import { PANEL_OFFERED, PANEL_SIDES, readPanelOffer } from './panel_availability.js';
@@ -104,6 +118,26 @@ export function createTaxonomy({ kinds, root } = {}) {
104
118
  throw new TypeError(
105
119
  `createTaxonomy: '${kind}'.single must be true or a prop name`);
106
120
  }
121
+ if (def.window != null) {
122
+ const w = def.window;
123
+ const bad = (v) => v != null && !(typeof v === 'number' && Number.isFinite(v) && v > 0);
124
+ if (typeof w !== 'object' || Array.isArray(w) || bad(w.width) || bad(w.height)) {
125
+ throw new TypeError(
126
+ `createTaxonomy: '${kind}'.window must be { width?, height? } in positive px`);
127
+ }
128
+ }
129
+ if (def.windowOnly != null && typeof def.windowOnly !== 'boolean') {
130
+ throw new TypeError(`createTaxonomy: '${kind}'.windowOnly must be true or false`);
131
+ }
132
+ if (def.windowOnly && def.isTopNav) {
133
+ throw new Error(
134
+ `createTaxonomy: '${kind}' is a top-nav page and cannot be windowOnly — a page lives in tiles`);
135
+ }
136
+ }
137
+ if (kinds[root].windowOnly) {
138
+ throw new Error(
139
+ `createTaxonomy: the root kind '${root}' cannot be windowOnly — `
140
+ + 'every new pane, tab and desktop is seeded with it');
107
141
  }
108
142
  if (kinds[root].single) {
109
143
  throw new Error(
@@ -227,6 +261,15 @@ export function createTaxonomy({ kinds, root } = {}) {
227
261
  return `${kind}\u0000${v == null ? '' : String(v)}`;
228
262
  };
229
263
 
264
+ /** Whether `kind` lives in a managed window only (KindDef `windowOnly`). */
265
+ const isWindowOnly = (kind) => _kinds[kind]?.windowOnly === true;
266
+
267
+ /** The initial size of a window `kind` opens in (KindDef `window`), or null. */
268
+ const windowSize = (kind) => {
269
+ const w = _kinds[kind]?.window;
270
+ return w ? { width: w.width ?? null, height: w.height ?? null } : null;
271
+ };
272
+
230
273
  /** The sentence a refused copy verb shows for a single kind. */
231
274
  const singleReason = (kind) =>
232
275
  `${_kinds[kind]?.label || kind} is open once: use the one that is open`;
@@ -246,5 +289,7 @@ export function createTaxonomy({ kinds, root } = {}) {
246
289
  isSingle,
247
290
  instanceKey,
248
291
  singleReason,
292
+ isWindowOnly,
293
+ windowSize,
249
294
  });
250
295
  }
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();
@@ -581,20 +587,57 @@ export class WindowManager {
581
587
  return keys;
582
588
  }
583
589
 
590
+ /** Open a window-only kind (KindDef `windowOnly`) in its window — the one
591
+ * that is open, or a new one — and say so; any other kind: false, and the
592
+ * caller opens it where it asked (STORY-0127). */
593
+ _openWindowOnly(kind, props) {
594
+ if (!this.taxonomy.isWindowOnly?.(kind)) return false;
595
+ this._navigateWindow(kind, props);
596
+ return true;
597
+ }
598
+
599
+ /** Whether a window record holds a window-only kind: such a window never
600
+ * docks into a tile (Back to tile, maximize, drag-to-dock). */
601
+ _holdsWindowOnly(rec) {
602
+ return !!this.taxonomy.isWindowOnly
603
+ && _windowTabSpecs(rec).some((t) => this.taxonomy.isWindowOnly(t?.kind));
604
+ }
605
+
606
+ /** A restored layout never holds a window-only kind in a tile: its tabs,
607
+ * and its tabs archived under a page, go. Windows are not restored, so
608
+ * the content simply opens in a window the next time it is asked for.
609
+ * Returns the kinds of the tabs that went, one entry per tab. */
610
+ _dropWindowOnlyTabs() {
611
+ if (!this.taxonomy.isWindowOnly) return [];
612
+ const dropped = [];
613
+ this._pruneRestoredTabs((t) => {
614
+ if (!t || !this.taxonomy.isWindowOnly(t.kind)) return true;
615
+ dropped.push(t.kind);
616
+ return false;
617
+ });
618
+ return dropped;
619
+ }
620
+
584
621
  /** A restored layout keeps the FIRST instance of each single kind: later
585
622
  * live tabs are dropped (the tile shows the tab that took the dropped
586
623
  * one's place, or closes when it held nothing else), and so are later
587
624
  * archived ones. Answers whether anything was dropped. */
588
625
  _dropDuplicateSingles() {
589
626
  const seen = new Set();
590
- let dropped = false;
591
- const keep = (t) => {
627
+ return this._pruneRestoredTabs((t) => {
592
628
  const k = t ? this._instanceKey(t.kind, t.props) : null;
593
629
  if (k == null) return true;
594
630
  if (seen.has(k)) return false;
595
631
  seen.add(k);
596
632
  return true;
597
- };
633
+ });
634
+ }
635
+
636
+ /** Drop every tab of the restored layout `keep(tab)` refuses, in the
637
+ * tiles and in their archived pages; a tile left empty closes (seeding a
638
+ * content tile if none is left). Returns whether anything went. */
639
+ _pruneRestoredTabs(keep) {
640
+ let dropped = false;
598
641
  for (const d of this.desktops.desktops) {
599
642
  const tree = d.tree;
600
643
  for (const leaf of tree.leaves()) {
@@ -862,6 +905,9 @@ export class WindowManager {
862
905
  const tree = this._tree();
863
906
  const prior = tree.popActiveTabHistory?.(id);
864
907
  if (!prior || !prior.kind) return false;
908
+ // A window-only kind in a tab's history (a layout saved before the kind
909
+ // was one) goes back to its window, never into this tile.
910
+ if (this._openWindowOnly(prior.kind, prior.props || {})) return true;
865
911
  // A single kind opened elsewhere since this entry was pushed is
866
912
  // revealed there, not put back here as a second copy.
867
913
  if (this.revealInstance(prior.kind, prior.props || {})) return true;
@@ -973,6 +1019,7 @@ export class WindowManager {
973
1019
  * the leaf. Used by Backspace + breadcrumb segments + anything
974
1020
  * else that should walk WITHIN the tile rather than reset it. */
975
1021
  navigateActiveTab(leafId, kind, props = {}) {
1022
+ if (this._openWindowOnly(kind, props)) return;
976
1023
  if (this.revealInstance(kind, props)) return;
977
1024
  const tree = this._tree();
978
1025
  const leaf = tree.get(leafId);
@@ -1029,6 +1076,7 @@ export class WindowManager {
1029
1076
  * tab feature shipped), the behavior is identical to the old
1030
1077
  * setLeafContent: one tab in, one tab out. */
1031
1078
  openInLeaf(leafId, kind, props = {}) {
1079
+ if (this._openWindowOnly(kind, props)) return;
1032
1080
  if (this.revealInstance(kind, props)) return;
1033
1081
  const tree = this._tree();
1034
1082
  const leaf = tree.get(leafId);
@@ -1047,6 +1095,7 @@ export class WindowManager {
1047
1095
  * previous mount, mounts the new kind into the same body element,
1048
1096
  * and updates the window's title. */
1049
1097
  openInWindow(winId, kind, props = {}) {
1098
+ if (this._openWindowOnly(kind, props)) return;
1050
1099
  if (this.revealInstance(kind, props)) return;
1051
1100
  const rec = this._windowToLeaf.get(winId);
1052
1101
  if (!rec) { this.openInPrimary(kind, props); return; }
@@ -1089,6 +1138,7 @@ export class WindowManager {
1089
1138
  * If only panel tiles exist (or the tree is empty), spawn a new
1090
1139
  * content leaf and canonicalize so the panels wrap it. */
1091
1140
  openInPrimary(kind, props = {}) {
1141
+ if (this._openWindowOnly(kind, props)) return;
1092
1142
  if (this.revealInstance(kind, props)) return;
1093
1143
  const tree = this._tree();
1094
1144
  let leafId = tree.primaryLeafId();
@@ -1172,6 +1222,7 @@ export class WindowManager {
1172
1222
  * pane in a split never dismounts the original). Returns the new
1173
1223
  * leaf id, or null if the leaf can't be split. */
1174
1224
  splitLeafWith(leafId, dir, kind, props = {}, title = '') {
1225
+ if (this._openWindowOnly(kind, props)) return null;
1175
1226
  if (this.revealInstance(kind, props)) return null;
1176
1227
  const tree = this._tree();
1177
1228
  leafId = this._contentLeafId(leafId);
@@ -1661,6 +1712,9 @@ export class WindowManager {
1661
1712
  canMaximize: true,
1662
1713
  canResize: true,
1663
1714
  modal: false,
1715
+ // BUG-0053. The id is minted per window and never opens again, so
1716
+ // geometry saved under it could never be restored.
1717
+ persistGeometry: false,
1664
1718
  // C15. Dropping a promoted window on a tile PUTS IT BACK — as that
1665
1719
  // tile's content, as a split of it, or as one of its tabs. Off
1666
1720
  // unless the embedder asked, because it changes what a drag to an
@@ -2273,6 +2327,9 @@ export class WindowManager {
2273
2327
  // false lets the geometric maximise happen instead — the answer every
2274
2328
  // other window manager gives a window with nowhere to go back to.
2275
2329
  if (!rec) return false;
2330
+ // A window-only kind has no tile to go back to (STORY-0127): the
2331
+ // geometric maximise happens instead.
2332
+ if (this._holdsWindowOnly(rec)) return false;
2276
2333
  const tree = this.desktops.desktops[rec.desktopIdx]?.tree;
2277
2334
  const home = rec.homeLeafId ? tree?.get(rec.homeLeafId) : null;
2278
2335
  if (home && home.kind === 'leaf') {
@@ -3138,6 +3195,7 @@ export class WindowManager {
3138
3195
  dockWindowInto(windowId, target) {
3139
3196
  const rec = this._windowToLeaf.get(windowId);
3140
3197
  if (!rec || !target?.leafId) return false;
3198
+ if (this._holdsWindowOnly(rec)) return false; // never into a tile (STORY-0127)
3141
3199
  rec._dock = { ...target };
3142
3200
  rec._demoting = true;
3143
3201
  try { rec.window.close({ force: true }); }
@@ -3343,7 +3401,8 @@ export class WindowManager {
3343
3401
  e.preventDefault();
3344
3402
  const rec = this._windowToLeaf.get(winId);
3345
3403
  if (!rec) return;
3346
- const items = [
3404
+ // A window-only kind's window has no tile to go back to
3405
+ const items = this._holdsWindowOnly(rec) ? [] : [
3347
3406
  { label: 'Back to tile', icon: 'close_fullscreen', action: 'back' },
3348
3407
  ];
3349
3408
  // C40. Never onto a fixed desktop — `moveWindowToDesktop` refuses.
@@ -4223,6 +4282,9 @@ export class WindowManager {
4223
4282
 
4224
4283
  /** The routing behind `navigate`, for a kind known to be registered. */
4225
4284
  _route(kind, props, opts) {
4285
+ // A window-only kind (KindDef `windowOnly`) opens in a window, whatever
4286
+ // the destination asked for.
4287
+ if (this._openWindowOnly(kind, props)) return;
4226
4288
  const { ctx = null, transient = false } = opts;
4227
4289
  // Append the tab but stay where you are. Only meaningful with `newTab`.
4228
4290
  const background = !!opts.background;
@@ -4297,6 +4359,10 @@ export class WindowManager {
4297
4359
  * source leaf — closing the window just disposes the content. */
4298
4360
  _navigateWindow(kind, props) {
4299
4361
  if (this.revealInstance(kind, props)) return;
4362
+ const size = this.taxonomy.windowSize?.(kind);
4363
+ // A window-only kind's window maximises as any window does and never
4364
+ // snaps into a tile (STORY-0127)
4365
+ const dockable = !this.taxonomy.isWindowOnly?.(kind);
4300
4366
  const winId = `twm-mw-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 5)}`;
4301
4367
  const contentEl = document.createElement('div');
4302
4368
  contentEl.className = 'twm-window-content';
@@ -4309,17 +4375,22 @@ export class WindowManager {
4309
4375
  title: mountInfo?.title || title,
4310
4376
  icon: 'web_asset',
4311
4377
  content: contentEl,
4378
+ ...(size?.width ? { defaultWidth: size.width } : {}),
4379
+ ...(size?.height ? { defaultHeight: size.height } : {}),
4312
4380
  canMinimize: true,
4313
4381
  canMaximize: true,
4314
4382
  canResize: true,
4315
4383
  modal: false,
4384
+ // BUG-0053. The id is minted per window and never opens again, so
4385
+ // geometry saved under it could never be restored.
4386
+ persistGeometry: false,
4316
4387
  // C15, and this is the SECOND of the two places the WM builds a
4317
4388
  // window. Alt+N and "Open in new window" produce a window that is
4318
4389
  // every bit as dockable as a promoted one, and a window that can be
4319
4390
  // dragged onto a tile in one case and not the other is a rule
4320
4391
  // nobody can learn.
4321
- snap: this.snapPromotion,
4322
- snapController: this.snapPromotion ? this._snapController() : null,
4392
+ snap: dockable && this.snapPromotion,
4393
+ snapController: dockable && this.snapPromotion ? this._snapController() : null,
4323
4394
  // R1. THE PANE IS A BOX WITH `overflow: hidden`. A window contained
4324
4395
  // to one (C21) cannot be dragged a single pixel outside it, so
4325
4396
  // "drag a window from one tile to another" — the gesture all three
@@ -4337,9 +4408,11 @@ export class WindowManager {
4337
4408
  // harder. So the maximize button docks — and the separate demote
4338
4409
  // button the WM used to inject beside it is gone, because two
4339
4410
  // 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',
4411
+ ...(dockable ? {
4412
+ onMaximize: () => this.bringBackWindow(winId),
4413
+ maximizeIcon: 'close_fullscreen',
4414
+ maximizeTitle: 'Back to tile',
4415
+ } : {}),
4343
4416
  onClose: () => this._onManagedWindowClosed(winId, mountInfo),
4344
4417
  });
4345
4418
  // DECORATED ONCE, below, after the record exists. It was called here as
@@ -4425,6 +4498,7 @@ export class WindowManager {
4425
4498
  * routing) but uses `appendLeafTab` so the existing content
4426
4499
  * stays in place as a tab. */
4427
4500
  openInTabFromContext(ctx, kind, props = {}, transient = false, background = false) {
4501
+ if (this._openWindowOnly(kind, props)) return;
4428
4502
  if (this.revealInstance(kind, props)) return;
4429
4503
  // Managed-window content: just open in the window — managed
4430
4504
  // windows aren't tabbed (one window = one content).
@@ -4461,6 +4535,7 @@ export class WindowManager {
4461
4535
  * "Add row" button, which passes no ctx) reliably lands as a sibling
4462
4536
  * tab in the main tile rather than swapping its content. */
4463
4537
  openInTabInPrimary(kind, props = {}, transient = false, background = false) {
4538
+ if (this._openWindowOnly(kind, props)) return;
4464
4539
  if (this.revealInstance(kind, props)) return;
4465
4540
  const tree = this._tree();
4466
4541
  const leafId = tree.primaryLeafId();