flexdesk 0.17.14 → 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 (47) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/css/base.css +237 -8
  3. package/css/flexdesk.css +344 -17
  4. package/css/overrides.css +6 -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-IG74S3ZH.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-ZMH3OC7H.js → chunk-XJNADRGR.js} +4 -2
  13. package/dist/chunk-XJNADRGR.js.map +7 -0
  14. package/dist/flexdesk.css +344 -17
  15. package/dist/flow.js +3 -3
  16. package/dist/tiles.js +84 -9
  17. package/dist/tiles.js.map +2 -2
  18. package/dist/widgets.js +448 -5
  19. package/dist/widgets.js.map +4 -4
  20. package/dist/wm.js +110 -19
  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 +80 -2
  25. package/docs/components/menus.md +85 -1
  26. package/docs/components/shell.md +52 -0
  27. package/docs/components/slide-out-panel.md +15 -0
  28. package/docs/components/tiles.md +37 -0
  29. package/package.json +1 -1
  30. package/src/charts/plot_config_editor.js +125 -29
  31. package/src/tiles/tile_base.js +48 -2
  32. package/src/tiles/tile_grid.js +42 -1
  33. package/src/tiling/kind_taxonomy.js +45 -0
  34. package/src/tiling/tile_breadcrumb.js +12 -4
  35. package/src/tiling/wm.js +86 -11
  36. package/src/ui/components/category_picker.js +556 -0
  37. package/src/ui/components/computing_status_window.js +3 -0
  38. package/src/ui/components/confirm_dialog.js +3 -0
  39. package/src/ui/components/managed_window.js +204 -9
  40. package/src/ui/components/modal.js +11 -2
  41. package/src/ui/components/wizard.js +4 -1
  42. package/widgets.js +2 -1
  43. package/dist/chunk-MADMGRX2.js.map +0 -7
  44. package/dist/chunk-ZMH3OC7H.js.map +0 -7
  45. /package/dist/{chunk-2PM6ZMNS.js.map → chunk-2Z2ENHR3.js.map} +0 -0
  46. /package/dist/{chunk-IG74S3ZH.js.map → chunk-7JI7HHSY.js.map} +0 -0
  47. /package/dist/{chunk-TLIV6QSU.js.map → chunk-7OL6F2AM.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
@@ -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
  }
@@ -24,14 +24,19 @@
24
24
  /** Build a breadcrumb strip element + render initial state. Returns
25
25
  * `{ el, destroy }`. The strip is wired to the WM (clicks open content
26
26
  * in the primary tile) and, when the embedder supplies a `rootCrumb`
27
- * with a `subscribe` hook, to whatever keeps the root label fresh. */
27
+ * with a `subscribe` hook, to whatever keeps the root label fresh.
28
+ * While the path is a single segment (the tile itself) the strip is
29
+ * `hidden` and takes no room; `refresh()` shows it once there are more. */
28
30
  export function mountTileBreadcrumb(kind, props, ctx) {
29
31
  const eventBus = ctx?.eventBus;
30
32
  const taxonomy = ctx?.taxonomy;
31
33
  const rootCrumb = ctx?.rootCrumb || null;
32
34
  if (!taxonomy) {
33
35
  console.error('[breadcrumb] no taxonomy in ctx — cannot render');
34
- return { el: document.createElement('nav'), destroy: () => {} };
36
+ const el = document.createElement('nav');
37
+ el.className = 'twm-topbar-breadcrumb twm-tile-breadcrumb';
38
+ el.hidden = true; // nothing to draw: no strip, as for a one-segment path
39
+ return { el, refresh: () => {}, destroy: () => {} };
35
40
  }
36
41
  const root = document.createElement('nav');
37
42
  root.className = 'twm-topbar-breadcrumb twm-tile-breadcrumb';
@@ -82,9 +87,12 @@ export function mountTileBreadcrumb(kind, props, ctx) {
82
87
  try { return ctx.trailSegments() || []; }
83
88
  catch (err) { console.warn('[breadcrumb] trailSegments threw', err); return []; }
84
89
  };
90
+ // One segment is the tile itself, which its title already names: the strip
91
+ // is hidden (and its page-shell slot with it) until there is a level above.
85
92
  const render = () => {
86
- _renderInto(root,
87
- _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate, trailOf()));
93
+ const segments = _segments(kind, props, taxonomy, rootCrumb, rootLabel, navigate, trailOf());
94
+ _renderInto(root, segments);
95
+ root.hidden = segments.length < 2;
88
96
  };
89
97
  render();
90
98