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
@@ -11,8 +11,9 @@
11
11
  * It draws a layout picker, a tab per subplot slot (when the layout has more
12
12
  * than one) and, for the subplot in front: its title; its chart type, legend,
13
13
  * interpolation, NaN handling and data points; its x axis (time, or a series);
14
- * its y axes (label, scale, position, min, max, step) and their series (a
15
- * picker, label, colour, width, dash, stack group, the embedder's own fields
14
+ * its y axes (label, scale, position, min, max, step) and their series (each a
15
+ * block: a header line of picker, label and remove over one label column of
16
+ * colour, width, dash, stack group, the embedder's own fields
16
17
  * and, with `overlaySources`, an overlay source); its z axis while the chart
17
18
  * type is 3D; its reference lines and shaded bands; and the toggles of any
18
19
  * injected `seriesTransforms` (series_transforms.js).
@@ -101,15 +102,37 @@ function removeButton(title, onClick) {
101
102
  return b;
102
103
  }
103
104
 
105
+ let hintCount = 0;
106
+
104
107
  /**
105
- * A labelled control. A single input or select is wrapped in a `<label>`; a
106
- * compound one (a picker, a colour field) is not, so a click on its text does
107
- * not land on whichever of its inputs comes first.
108
+ * A labelled control on the editor's label column. A single input or select
109
+ * is wrapped in a `<label>`; a compound one (a picker, a colour field) is not,
110
+ * so a click on its text does not land on whichever of its inputs comes
111
+ * first. A `hint` is drawn under the control, in the control's column, and
112
+ * outside any `<label>`: the row is then a `<div>` whose label text is the
113
+ * `<label for>` of a single control, and the hint describes the control
114
+ * (`aria-describedby`) without being part of its name.
108
115
  */
109
- function field(label, control) {
116
+ function field(label, control, { hint = null } = {}) {
110
117
  const single = control.matches('input, select, textarea');
111
- const row = el(single ? 'label' : 'div', 'twm-plot-config__field');
112
- row.append(el('span', 'twm-plot-config__label', label), control);
118
+ const hinted = hint != null && hint !== '';
119
+ if (!hinted) {
120
+ const row = el(single ? 'label' : 'div', 'twm-plot-config__field');
121
+ row.append(el('span', 'twm-plot-config__label', label), control);
122
+ return row;
123
+ }
124
+ const row = el('div', 'twm-plot-config__field twm-plot-config__field--hinted');
125
+ const text = el(single ? 'label' : 'span', 'twm-plot-config__label', label);
126
+ const note = el('span', 'twm-plot-config__hint', hint);
127
+ note.id = `twm-plot-config-hint-${++hintCount}`;
128
+ if (single) {
129
+ if (!control.id) control.id = `${note.id}-control`;
130
+ text.htmlFor = control.id;
131
+ }
132
+ const target = single ? control : (control.querySelector('input, select, textarea') ?? control);
133
+ const describedBy = target.getAttribute('aria-describedby');
134
+ target.setAttribute('aria-describedby', describedBy ? `${describedBy} ${note.id}` : note.id);
135
+ row.append(text, control, note);
113
136
  return row;
114
137
  }
115
138
 
@@ -170,8 +193,13 @@ let editorCount = 0;
170
193
  * source, written to `series.overlay.source`. Absent: no overlay controls.
171
194
  * @param {Object} [options.seriesTransforms] - `{ [id]: transform }` (series_transforms.js);
172
195
  * their toggles are drawn per subplot.
173
- * @param {Function} [options.extraSeriesFields] - `(el, series, { subplot, axis, onChange })
174
- * => { dispose? } | void`: fields of the embedder's own in each series row.
196
+ * @param {Function} [options.extraSeriesFields] - `(el, series, { subplot, axis, onChange, field })
197
+ * => { dispose? } | specs[] | void`: fields of the embedder's own in each series
198
+ * block, on the series' label column. It may draw into `el` (with
199
+ * `field(label, control, { hint })` for rows that line up) and return a handle,
200
+ * or return an ARRAY of field specs `{ label: string, control: HTMLElement,
201
+ * hint? }` / `{ label: string, render(slot) => { dispose? } | void, hint? }`,
202
+ * laid out after anything drawn into `el`. A bad spec is skipped with a warning.
175
203
  * @param {Object} [options.logger] - Given to every AutocompleteField (provider errors);
176
204
  * its `warn` also hears a part (an `extraSeriesFields` handle, say) that throws on dispose.
177
205
  * @returns {{ dispose: Function, refresh: Function, selectSubplot: Function,
@@ -309,13 +337,14 @@ function renderPlotConfigEditor(container, config, options = {}) {
309
337
  return node;
310
338
  }
311
339
 
312
- function colorField(value, onSet, label) {
340
+ /** `keep` takes its teardown: the body's (track), or a narrower redraw's. */
341
+ function colorField(value, onSet, label, keep = track) {
313
342
  const f = new ColorField({
314
343
  value: value ?? '',
315
344
  label,
316
345
  onChange: (v) => { if (v !== null) { onSet(v || null); notify(); } },
317
346
  });
318
- track(() => f.dispose());
347
+ keep(() => f.dispose());
319
348
  return f.render();
320
349
  }
321
350
 
@@ -489,18 +518,17 @@ function renderPlotConfigEditor(container, config, options = {}) {
489
518
  return group;
490
519
  }
491
520
 
521
+ /**
522
+ * One series as a block: a header line (series picker, legend label,
523
+ * remove) over its fields on the editor's label column (colour, width,
524
+ * dash, stack group, the embedder's fields, overlay).
525
+ */
492
526
  function seriesRow(sp, axis, s, overlaySources) {
493
527
  const row = el('div', 'twm-plot-config__series');
494
- const main = el('div', 'twm-plot-config__series-main');
495
- main.append(
528
+ const head = el('div', 'twm-plot-config__series-head');
529
+ head.append(
496
530
  seriesPicker(s, 'variable', 'series', sp, 'Series'),
497
531
  textInput(s.label, (v) => { s.label = v; notify(); }, { placeholder: 'Legend label', label: 'Legend label' }),
498
- colorField(s.color, (v) => { if (v) s.color = v; else delete s.color; }, 'Colour'),
499
- numberInput(s.lineWidth ?? 2, (v) => { s.lineWidth = v ?? 2; notify(); },
500
- { placeholder: '2', label: 'Line width', min: 1, max: 10, step: 1 }),
501
- select(DASHES, s.lineStyle ?? 'solid', (v) => { s.lineStyle = v; notify(); }, { label: 'Dash' }),
502
- textInput(s.stackGroup, (v) => { s.stackGroup = v || null; notify(); },
503
- { placeholder: 'Stack group', label: 'Stack group' }),
504
532
  removeButton('Remove series', () => {
505
533
  const i = axis.series.indexOf(s);
506
534
  if (i >= 0) axis.series.splice(i, 1);
@@ -508,32 +536,100 @@ function renderPlotConfigEditor(container, config, options = {}) {
508
536
  notify();
509
537
  }),
510
538
  );
511
- row.appendChild(main);
512
539
 
513
- if (opts.extraSeriesFields) {
514
- const extra = el('div', 'twm-plot-config__series-extra');
515
- row.appendChild(extra);
516
- const handle = opts.extraSeriesFields(extra, s, { subplot: sp, axis, onChange: notify });
517
- if (typeof handle?.dispose === 'function') track(() => handle.dispose());
518
- }
519
- if (opts.overlaySources) row.appendChild(overlayEditor(s, overlaySources));
540
+ const fields = el('div', 'twm-plot-config__series-fields');
541
+ fields.append(
542
+ field('Colour', colorField(s.color, (v) => { if (v) s.color = v; else delete s.color; }, 'Colour')),
543
+ field('Width', numberInput(s.lineWidth ?? 2, (v) => { s.lineWidth = v ?? 2; notify(); },
544
+ { placeholder: '2', label: 'Line width', min: 1, max: 10, step: 1 })),
545
+ field('Dash', select(DASHES, s.lineStyle ?? 'solid', (v) => { s.lineStyle = v; notify(); }, { label: 'Dash' })),
546
+ field('Stack group', textInput(s.stackGroup, (v) => { s.stackGroup = v || null; notify(); },
547
+ { placeholder: 'None', label: 'Stack group' })),
548
+ );
549
+ if (opts.extraSeriesFields) fields.appendChild(extraFields(sp, axis, s));
550
+ if (opts.overlaySources) fields.appendChild(overlayEditor(s, overlaySources));
551
+ row.append(head, fields);
520
552
  return row;
521
553
  }
522
554
 
555
+ const warn = (...args) => (opts.logger?.warn ? opts.logger.warn(...args) : console.warn(...args));
556
+
557
+ /**
558
+ * The embedder's fields of a series: whatever `extraSeriesFields` draws
559
+ * into the box (which sits in the series' field column), then, when it
560
+ * returns an ARRAY, those field specs laid out by `field()`. Anything
561
+ * else it returns is a handle, disposed when the row goes (`{ dispose }`).
562
+ * A spec that is not `{ label, control: Element }` or
563
+ * `{ label, render(slot) }` is skipped with a warning, as is a `render`
564
+ * that throws: one bad field never leaves half a body.
565
+ */
566
+ function extraFields(sp, axis, s) {
567
+ const extra = el('div', 'twm-plot-config__series-extra');
568
+ const result = opts.extraSeriesFields(extra, s, { subplot: sp, axis, onChange: notify, field });
569
+ if (!Array.isArray(result)) {
570
+ if (typeof result?.dispose === 'function') track(() => result.dispose());
571
+ return extra;
572
+ }
573
+ for (const spec of result) {
574
+ const label = spec?.label;
575
+ let control = null;
576
+ if (typeof label !== 'string') {
577
+ warn('[plot config editor] an extraSeriesFields spec has no label; skipped', spec);
578
+ continue;
579
+ }
580
+ if (spec.control != null) {
581
+ if (!(spec.control instanceof Element)) {
582
+ warn('[plot config editor] an extraSeriesFields spec control is not an Element; skipped', spec);
583
+ continue;
584
+ }
585
+ control = spec.control;
586
+ } else if (typeof spec.render === 'function') {
587
+ control = el('div', 'twm-plot-config__slot');
588
+ try {
589
+ const handle = spec.render(control);
590
+ if (typeof handle?.dispose === 'function') track(() => handle.dispose());
591
+ } catch (error) {
592
+ warn('[plot config editor] an extraSeriesFields spec failed to render; skipped', error);
593
+ continue;
594
+ }
595
+ } else {
596
+ warn('[plot config editor] an extraSeriesFields spec has neither control nor render; skipped', spec);
597
+ continue;
598
+ }
599
+ extra.appendChild(field(label, control, { hint: spec.hint }));
600
+ }
601
+ return extra;
602
+ }
603
+
523
604
  function overlayEditor(s, overlaySources) {
524
605
  const wrap = el('div', 'twm-plot-config__overlay');
525
606
  const head = el('div', 'twm-plot-config__overlay-source', 'Loading overlay sources…');
526
607
  const details = el('div', 'twm-plot-config__overlay-fields');
527
608
  wrap.append(head, details);
528
609
 
610
+ // The details' colour field is torn down on each redraw of the
611
+ // details, not left for the body's next redraw.
612
+ let detailDisposers = [];
613
+ const disposeDetails = () => {
614
+ const fns = detailDisposers;
615
+ detailDisposers = [];
616
+ for (const fn of fns) {
617
+ try { fn(); } catch (error) {
618
+ opts.logger?.warn?.('[plot config editor] a field failed to dispose', error);
619
+ }
620
+ }
621
+ };
622
+ track(disposeDetails);
623
+ const keep = (fn) => { detailDisposers.push(fn); };
529
624
  const drawDetails = () => {
625
+ disposeDetails();
530
626
  details.replaceChildren();
531
627
  const ov = s.overlay;
532
628
  if (!ov) return;
533
629
  details.append(
534
630
  field('Label', textInput(ov.label, (v) => { ov.label = v; notify(); },
535
631
  { placeholder: `${s.label || s.variable || 'Series'} (actual)` })),
536
- field('Colour', colorField(ov.color, (v) => { if (v) ov.color = v; else delete ov.color; }, 'Overlay colour')),
632
+ field('Colour', colorField(ov.color, (v) => { if (v) ov.color = v; else delete ov.color; }, 'Overlay colour', keep)),
537
633
  field('Dash', select(DASHES, ov.lineStyle ?? 'dashed', (v) => { ov.lineStyle = v; notify(); })),
538
634
  field('Width', numberInput(ov.lineWidth ?? 2, (v) => { ov.lineWidth = v ?? 2; notify(); },
539
635
  { placeholder: '2', min: 1, max: 10, step: 1 })),
@@ -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
@@ -53,15 +53,31 @@
53
53
  * not inserted and their toggles are disabled, showing
54
54
  * `reason`.
55
55
  *
56
- * single? `true`, or the name of a prop. The kind exists ONCE: content
57
- * that holds heavy app-wide state and must not be mounted
58
- * twice. With `true` there is one instance of the kind; with a
59
- * prop name there is one per value of that prop (`'id'`: one
60
- * tile per record). The window manager makes every open path
61
- * reveal the instance that exists, refuses the copy verbs with
62
- * a reason, and keeps the first of any duplicates a restored
63
- * layout holds. The root kind cannot be single: every new
64
- * pane, tab and desktop is seeded with it.
56
+ * single? `true`, `'desktop'`, or the name of a prop. The kind exists
57
+ * ONCE: content that holds heavy app-wide state and must not be
58
+ * mounted twice. With `true` there is one instance of the kind;
59
+ * with `'desktop'` there is one per virtual desktop, found and
60
+ * opened on the active one (so `'desktop'` is not a prop name
61
+ * here); with a prop name there is one per value of that prop
62
+ * (`'id'`: one tile per record). The window manager makes every
63
+ * open path reveal the instance that exists, refuses the copy
64
+ * verbs with a reason, and keeps the first of any duplicates a
65
+ * restored layout holds. The root kind cannot be single: every
66
+ * new pane, tab and desktop is seeded with it.
67
+ *
68
+ * window? `{ width?, height? }` — the initial size, in px, of a managed
69
+ * window this kind opens in (`navigate(…, { dest: 'window' })`,
70
+ * or always for `windowOnly`); clamped to the screen and to the
71
+ * window's minimum (400 × 300) as any window is. Absent: the
72
+ * window's default size (STORY-0127).
73
+ * windowOnly? `true`: the kind lives in a managed window, never in a tile
74
+ * — a tool, not a page. Every open path (navigate with any
75
+ * `dest`, openInPrimary, openInLeaf, the tab and split verbs)
76
+ * opens it in a window; its window never docks into a tile
77
+ * (no Back to tile, maximise is geometric, no drag-to-dock); a
78
+ * restored layout drops a tile tab of it, with one warning, and
79
+ * a tab's history entry of it opens a window. Neither the root
80
+ * nor a top-nav kind can be window-only (STORY-0127).
65
81
  */
66
82
 
67
83
  import { PANEL_OFFERED, PANEL_SIDES, readPanelOffer } from './panel_availability.js';
@@ -104,6 +120,26 @@ export function createTaxonomy({ kinds, root } = {}) {
104
120
  throw new TypeError(
105
121
  `createTaxonomy: '${kind}'.single must be true or a prop name`);
106
122
  }
123
+ if (def.window != null) {
124
+ const w = def.window;
125
+ const bad = (v) => v != null && !(typeof v === 'number' && Number.isFinite(v) && v > 0);
126
+ if (typeof w !== 'object' || Array.isArray(w) || bad(w.width) || bad(w.height)) {
127
+ throw new TypeError(
128
+ `createTaxonomy: '${kind}'.window must be { width?, height? } in positive px`);
129
+ }
130
+ }
131
+ if (def.windowOnly != null && typeof def.windowOnly !== 'boolean') {
132
+ throw new TypeError(`createTaxonomy: '${kind}'.windowOnly must be true or false`);
133
+ }
134
+ if (def.windowOnly && def.isTopNav) {
135
+ throw new Error(
136
+ `createTaxonomy: '${kind}' is a top-nav page and cannot be windowOnly — a page lives in tiles`);
137
+ }
138
+ }
139
+ if (kinds[root].windowOnly) {
140
+ throw new Error(
141
+ `createTaxonomy: the root kind '${root}' cannot be windowOnly — `
142
+ + 'every new pane, tab and desktop is seeded with it');
107
143
  }
108
144
  if (kinds[root].single) {
109
145
  throw new Error(
@@ -215,18 +251,32 @@ export function createTaxonomy({ kinds, root } = {}) {
215
251
  return s === true || (typeof s === 'string' && s !== '');
216
252
  };
217
253
 
254
+ /** Whether `kind` exists once per virtual desktop (`single: 'desktop'`)
255
+ * rather than once in the app. */
256
+ const isSinglePerDesktop = (kind) => _kinds[kind]?.single === 'desktop';
257
+
218
258
  /** The identity of one instance of a single kind: two tab specs with the
219
- * same key are the same instance. `null` for a kind that is not single,
220
- * which is how callers ask both questions at once. Keyed by the kind and,
221
- * for `single: '<prop>'`, that prop's value as a string. */
259
+ * same key are the same instance (on one desktop, for
260
+ * `single: 'desktop'`). `null` for a kind that is not single, which is
261
+ * how callers ask both questions at once. Keyed by the kind and, for
262
+ * `single: '<prop>'`, that prop's value as a string. */
222
263
  const instanceKey = (kind, props) => {
223
264
  if (!isSingle(kind)) return null;
224
265
  const by = _kinds[kind].single;
225
- if (by === true) return String(kind);
266
+ if (by === true || by === 'desktop') return String(kind);
226
267
  const v = props?.[by];
227
268
  return `${kind}\u0000${v == null ? '' : String(v)}`;
228
269
  };
229
270
 
271
+ /** Whether `kind` lives in a managed window only (KindDef `windowOnly`). */
272
+ const isWindowOnly = (kind) => _kinds[kind]?.windowOnly === true;
273
+
274
+ /** The initial size of a window `kind` opens in (KindDef `window`), or null. */
275
+ const windowSize = (kind) => {
276
+ const w = _kinds[kind]?.window;
277
+ return w ? { width: w.width ?? null, height: w.height ?? null } : null;
278
+ };
279
+
230
280
  /** The sentence a refused copy verb shows for a single kind. */
231
281
  const singleReason = (kind) =>
232
282
  `${_kinds[kind]?.label || kind} is open once: use the one that is open`;
@@ -244,7 +294,10 @@ export function createTaxonomy({ kinds, root } = {}) {
244
294
  panelOffer,
245
295
  topNavEntries,
246
296
  isSingle,
297
+ isSinglePerDesktop,
247
298
  instanceKey,
248
299
  singleReason,
300
+ isWindowOnly,
301
+ windowSize,
249
302
  });
250
303
  }
@@ -712,8 +712,11 @@ function _tileContextMenu(wm, leafId, x, y) {
712
712
  // `isPanel` above) never a panel: `moveFocusedToDesktop` refuses all
713
713
  // three too, because the keyboard reaches it without this menu.
714
714
  if (i === wm.desktops.activeIdx || d.fixed) continue;
715
+ // A desktop holds a `single: 'desktop'` kind once: greyed, saying so.
716
+ const refusal = wm.desktopMoveRefusal({ leafId }, i);
715
717
  items.push({ label: `Move to desktop ${d.label}`, icon: 'sweep',
716
- action: `move:${i}` });
718
+ action: `move:${i}`, disabled: !!refusal,
719
+ title: refusal || undefined });
717
720
  }
718
721
  }
719
722
  items.push({ separator: true });