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/css/flexdesk.css CHANGED
@@ -551,6 +551,17 @@
551
551
  color: #bcbcbc;
552
552
  line-height: 1.4;
553
553
  }
554
+ /* The text of a toast and of a history entry can be selected and copied (STORY-0131);
555
+ * the body's `user-select: none` (reset.css) would otherwise hold it. */
556
+ .twm-notification__title,
557
+ .twm-notification__body,
558
+ .twm-notification__description,
559
+ .twm-notification__status,
560
+ .twm-notification-history__item-title,
561
+ .twm-notification-history__item-body {
562
+ user-select: text;
563
+ cursor: text;
564
+ }
554
565
  .twm-notification-history__empty {
555
566
  text-align: center;
556
567
  color: #6c6c6c;
@@ -2314,7 +2325,7 @@ body.twm-dt-col-resizing {
2314
2325
  border: 1px solid var(--surface-3);
2315
2326
  border-radius: var(--radius-md);
2316
2327
  box-shadow: 0 6px 24px rgba(0,0,0,0.45);
2317
- z-index: 5000; /* Higher than nodes and selection rectangle */
2328
+ z-index: var(--z-context-menu);
2318
2329
  display: none;
2319
2330
  pointer-events: auto;
2320
2331
  min-width: 180px;
@@ -3869,20 +3880,25 @@ body.twm-window-maximized .twm-resize-handle {
3869
3880
  }
3870
3881
 
3871
3882
  /* Selected — set by `_onConfigClick()` while the config dialog is open, and
3872
- cleared from every sibling by the same handler. It reads `--accent-lighter`
3883
+ cleared from every sibling by the same handler. `--configuring` is the same
3884
+ look set by the embedder through `setConfiguring()` (0.17.16), one per open
3885
+ config window, and no handler clears it. It reads `--accent-lighter`
3873
3886
  (BUG-0045): `--accent-bright` is a border colour on this background and not
3874
3887
  a text one. It was #78b9ff, which no mix of `--accent-bright` with white
3875
3888
  gives exactly; `--accent-lighter` (about #73b6e3) is the nearest. */
3876
- .tile.tile--selected {
3889
+ .tile.tile--selected,
3890
+ .tile.tile--configuring {
3877
3891
  border-color: var(--accent-lighter);
3878
3892
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-lighter) 30%, transparent), var(--shadow-md);
3879
3893
  }
3880
3894
 
3881
- .tile.tile--selected .tile-header {
3895
+ .tile.tile--selected .tile-header,
3896
+ .tile.tile--configuring .tile-header {
3882
3897
  background: color-mix(in srgb, var(--accent-lighter) 10%, transparent);
3883
3898
  }
3884
3899
 
3885
- .tile.tile--selected .tile-title {
3900
+ .tile.tile--selected .tile-title,
3901
+ .tile.tile--configuring .tile-title {
3886
3902
  color: var(--accent-lighter);
3887
3903
  }
3888
3904
 
@@ -4609,6 +4625,215 @@ body.twm-window-maximized .twm-resize-handle {
4609
4625
  text-overflow: ellipsis;
4610
4626
  }
4611
4627
 
4628
+ /* ══ CATEGORY PICKER (STORY-0126) ══════════════════════════════════════
4629
+ `CategoryPicker` (category_picker.js): a search field over three columns —
4630
+ categories, the active category's items, the highlighted item's detail.
4631
+ Placed by `ActionDropdown.position` (inline top/left/max-height), so it is
4632
+ `position: fixed` on --z-over-windows: over the managed window it opened
4633
+ from. It shrinks to the inline max-height and its columns scroll inside.
4634
+ A category's colour arrives as `--twm-category-picker-color` on its rows. */
4635
+ .twm-category-picker {
4636
+ position: fixed;
4637
+ z-index: var(--z-over-windows);
4638
+ display: flex;
4639
+ flex-direction: column;
4640
+ box-sizing: border-box;
4641
+ width: 640px;
4642
+ max-width: calc(100vw - 2 * var(--space-md));
4643
+ height: 440px;
4644
+ background: var(--surface-1);
4645
+ border: 1px solid var(--border-strong);
4646
+ border-radius: var(--radius-lg);
4647
+ box-shadow: var(--shadow-lg);
4648
+ color: var(--text-primary);
4649
+ font-family: inherit;
4650
+ font-size: var(--font-size-md);
4651
+ overflow: hidden;
4652
+ }
4653
+ .twm-category-picker[hidden] {
4654
+ display: none;
4655
+ }
4656
+ .twm-category-picker__search {
4657
+ flex: none;
4658
+ padding: var(--space-md);
4659
+ border-bottom: 1px solid var(--border-default);
4660
+ }
4661
+ .twm-category-picker__input {
4662
+ width: 100%;
4663
+ box-sizing: border-box;
4664
+ padding: var(--space-sm) var(--space-md);
4665
+ background: var(--surface-0);
4666
+ border: 1px solid var(--border-strong);
4667
+ border-radius: var(--radius-sm);
4668
+ color: var(--text-primary);
4669
+ font-family: inherit;
4670
+ font-size: var(--font-size-md);
4671
+ }
4672
+ .twm-category-picker__input:focus {
4673
+ outline: none;
4674
+ border-color: var(--accent-bright);
4675
+ }
4676
+ .twm-category-picker__body {
4677
+ flex: 1 1 auto;
4678
+ min-height: 0;
4679
+ display: flex;
4680
+ }
4681
+ .twm-category-picker__column {
4682
+ flex: none;
4683
+ box-sizing: border-box;
4684
+ padding: var(--space-sm) 0;
4685
+ border-right: 1px solid var(--border-default);
4686
+ overflow-y: auto;
4687
+ }
4688
+ .twm-category-picker__categories {
4689
+ width: 170px;
4690
+ }
4691
+ .twm-category-picker__items {
4692
+ width: 220px;
4693
+ }
4694
+ .twm-category-picker__category,
4695
+ .twm-category-picker__item {
4696
+ display: flex;
4697
+ align-items: center;
4698
+ gap: var(--space-md);
4699
+ width: 100%;
4700
+ box-sizing: border-box;
4701
+ padding: var(--space-sm) var(--space-lg);
4702
+ background: transparent;
4703
+ border: none;
4704
+ color: var(--text-primary);
4705
+ font-family: inherit;
4706
+ font-size: var(--font-size-md);
4707
+ text-align: left;
4708
+ cursor: pointer;
4709
+ }
4710
+ .twm-category-picker__category:hover,
4711
+ .twm-category-picker__item:hover {
4712
+ background: var(--state-hover);
4713
+ }
4714
+ .twm-category-picker__category--active,
4715
+ .twm-category-picker__item--highlighted,
4716
+ .twm-category-picker__category--active:hover,
4717
+ .twm-category-picker__item--highlighted:hover {
4718
+ background: var(--state-selected);
4719
+ }
4720
+ /* The column the arrow keys move in marks its row with an accent edge. */
4721
+ .twm-category-picker__column--current .twm-category-picker__category--active,
4722
+ .twm-category-picker__column--current .twm-category-picker__item--highlighted {
4723
+ box-shadow: inset 2px 0 0 var(--accent-bright);
4724
+ }
4725
+ .twm-category-picker__category--empty {
4726
+ color: var(--text-dim);
4727
+ }
4728
+ .twm-category-picker__dot {
4729
+ flex: none;
4730
+ width: 8px;
4731
+ height: 8px;
4732
+ border-radius: var(--radius-circle);
4733
+ /* background-color, not the shorthand: the colour is the caller's, and a
4734
+ url() in the shorthand would be fetched */
4735
+ background-color: var(--twm-category-picker-color, var(--text-muted));
4736
+ }
4737
+ .twm-category-picker__category-label,
4738
+ .twm-category-picker__item-label {
4739
+ flex: 1 1 auto;
4740
+ min-width: 0;
4741
+ overflow: hidden;
4742
+ white-space: nowrap;
4743
+ text-overflow: ellipsis;
4744
+ }
4745
+ .twm-category-picker__count {
4746
+ flex: none;
4747
+ color: var(--text-muted);
4748
+ font-size: var(--font-size-xs);
4749
+ font-variant-numeric: tabular-nums;
4750
+ }
4751
+ .twm-category-picker__badge,
4752
+ .twm-category-picker__item .twm-category-picker__icon {
4753
+ flex: none;
4754
+ display: flex;
4755
+ align-items: center;
4756
+ justify-content: center;
4757
+ width: 22px;
4758
+ height: 22px;
4759
+ box-sizing: border-box;
4760
+ color: var(--twm-category-picker-color, var(--text-muted));
4761
+ }
4762
+ .twm-category-picker__badge {
4763
+ border: 1px solid var(--twm-category-picker-color, var(--text-muted));
4764
+ border-radius: var(--radius-sm);
4765
+ font-family: var(--font-family-mono);
4766
+ font-size: var(--font-size-xs);
4767
+ font-weight: var(--font-weight-semibold);
4768
+ overflow: hidden;
4769
+ white-space: nowrap;
4770
+ }
4771
+ .twm-category-picker__item .twm-category-picker__icon {
4772
+ font-size: 18px;
4773
+ }
4774
+ /* One category's hits while a query is set: role="group", named by its header. */
4775
+ .twm-category-picker__hits {
4776
+ display: flex;
4777
+ flex-direction: column;
4778
+ }
4779
+ .twm-category-picker__group {
4780
+ display: flex;
4781
+ align-items: center;
4782
+ gap: var(--space-sm);
4783
+ padding: var(--space-md) var(--space-lg) var(--space-xs);
4784
+ color: var(--text-muted);
4785
+ font-size: var(--font-size-xs);
4786
+ text-transform: uppercase;
4787
+ letter-spacing: 0.06em;
4788
+ }
4789
+ .twm-category-picker__empty,
4790
+ .twm-category-picker__detail-empty {
4791
+ padding: var(--space-md) var(--space-lg);
4792
+ color: var(--text-muted);
4793
+ }
4794
+ .twm-category-picker__detail {
4795
+ flex: 1 1 auto;
4796
+ min-width: 0;
4797
+ display: flex;
4798
+ flex-direction: column;
4799
+ gap: var(--space-md);
4800
+ padding: var(--space-xl);
4801
+ overflow-y: auto;
4802
+ }
4803
+ .twm-category-picker__title {
4804
+ color: var(--text-bright);
4805
+ font-size: var(--font-size-xl);
4806
+ font-weight: var(--font-weight-semibold);
4807
+ }
4808
+ .twm-category-picker__description {
4809
+ color: var(--text-secondary);
4810
+ line-height: var(--line-height-normal);
4811
+ }
4812
+ .twm-category-picker__preview-label {
4813
+ margin-top: var(--space-sm);
4814
+ color: var(--text-muted);
4815
+ font-size: var(--font-size-xs);
4816
+ text-transform: uppercase;
4817
+ letter-spacing: 0.06em;
4818
+ }
4819
+ .twm-category-picker__preview {
4820
+ margin: 0;
4821
+ padding: var(--space-md);
4822
+ background: var(--surface-0);
4823
+ border: 1px solid var(--border-default);
4824
+ border-radius: var(--radius-sm);
4825
+ color: var(--text-body);
4826
+ font-family: var(--font-family-mono);
4827
+ font-size: var(--font-size-sm);
4828
+ white-space: pre-wrap;
4829
+ overflow-wrap: anywhere;
4830
+ }
4831
+ .twm-category-picker__add {
4832
+ margin-top: auto;
4833
+ align-self: flex-end;
4834
+ }
4835
+ /* ══ end CATEGORY PICKER ══ */
4836
+
4612
4837
  /* ── from code.css ───────────────────────── */
4613
4838
  /* Code language tabs */
4614
4839
  .twm-code-tabs-header {
@@ -4825,6 +5050,12 @@ body.twm-window-maximized .twm-resize-handle {
4825
5050
  .twm-managed-window:focus-within {
4826
5051
  border-color: #505050;
4827
5052
  }
5053
+ /* BUG-0053. The frame is focusable (tabindex -1) so a press on it keeps focus
5054
+ in the window; the focus-within border above already shows that, so the
5055
+ frame draws no outline of its own. */
5056
+ .twm-managed-window:focus {
5057
+ outline: none;
5058
+ }
4828
5059
  /* Minimize animation class — targets taskbar item via CSS custom properties */
4829
5060
  .twm-managed-window--minimizing {
4830
5061
  transform: translate(var(--mw-target-x, 0px), var(--mw-target-y, 100vh)) scale(var(--mw-target-scale, 0.1));
@@ -11084,6 +11315,11 @@ button.twm-alerts__ref:hover { text-decoration: underline; }
11084
11315
  flex: 1 1 140px;
11085
11316
  min-width: 0;
11086
11317
  }
11318
+ /* A picker has no helper text: its empty helper takes no room (it held a
11319
+ * 28px gap under every picker, and the series header line off its centre). */
11320
+ .twm-plot-config__picker > .twm-autocomplete-field__helper:empty {
11321
+ display: none;
11322
+ }
11087
11323
  .twm-plot-config__axes,
11088
11324
  .twm-plot-config__list,
11089
11325
  .twm-plot-config__series-list {
@@ -11117,37 +11353,114 @@ button.twm-alerts__ref:hover { text-decoration: underline; }
11117
11353
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
11118
11354
  gap: var(--space-xs) var(--space-md);
11119
11355
  }
11356
+ /* A series is one block (BUG-0052): a header line (picker, legend label,
11357
+ * remove) over its fields, one per line on the editor's 96px label column —
11358
+ * colour, width, dash, stack group, the embedder's `extraSeriesFields` (their
11359
+ * box is `display: contents`, so its rows are rows of the column) and the
11360
+ * overlay. Until 0.17.15 all of it was one wrapped line. */
11120
11361
  .twm-plot-config__series {
11121
11362
  display: flex;
11122
11363
  flex-direction: column;
11123
- gap: var(--space-xxs);
11124
- padding: var(--space-xs) 0;
11364
+ gap: var(--space-xs);
11365
+ padding: var(--space-sm) 0;
11125
11366
  border-top: 1px solid var(--border-subtle);
11367
+ min-width: 0;
11368
+ }
11369
+ .twm-plot-config__series-head {
11370
+ display: flex;
11371
+ flex-wrap: wrap;
11372
+ align-items: center;
11373
+ gap: var(--space-xs);
11374
+ min-width: 0;
11375
+ }
11376
+ .twm-plot-config__series-head > .twm-plot-config__picker {
11377
+ flex: 2 1 140px;
11378
+ }
11379
+ .twm-plot-config__series-head > .twm-plot-config__input {
11380
+ flex: 1 1 100px;
11381
+ }
11382
+ .twm-plot-config__series-fields {
11383
+ display: flex;
11384
+ flex-direction: column;
11385
+ gap: var(--space-xxs);
11386
+ min-width: 0;
11387
+ }
11388
+ .twm-plot-config__series-extra {
11389
+ display: contents;
11126
11390
  }
11127
- .twm-plot-config__series-main,
11128
11391
  .twm-plot-config__row {
11129
11392
  display: flex;
11130
11393
  flex-wrap: wrap;
11131
11394
  align-items: center;
11132
11395
  gap: var(--space-xs);
11133
11396
  }
11134
- .twm-plot-config__series-main > .twm-plot-config__input:not(.twm-plot-config__input--number):not(.twm-plot-config__input--select),
11135
11397
  .twm-plot-config__row > .twm-plot-config__input:not(.twm-plot-config__input--number):not(.twm-plot-config__input--select) {
11136
11398
  flex: 1 1 100px;
11137
11399
  }
11138
- .twm-plot-config__series-main > .twm-plot-config__input--select,
11139
11400
  .twm-plot-config__row > .twm-plot-config__input--select {
11140
11401
  flex: 0 1 auto;
11141
11402
  }
11142
- .twm-plot-config__series-extra:empty {
11143
- display: none;
11403
+ /* On a field line a colour is the swatch and a hex box sized to `#rrggbb`. */
11404
+ .twm-plot-config__field > .twm-color-field {
11405
+ flex: 0 1 auto;
11406
+ flex-wrap: nowrap;
11407
+ }
11408
+ .twm-plot-config__field > .twm-color-field > .twm-color-field__hex {
11409
+ flex: 0 1 auto;
11410
+ width: 10ch;
11411
+ }
11412
+ /* A colour's hex box is a well like the editor's other inputs. */
11413
+ .twm-plot-config .twm-color-field__hex {
11414
+ box-sizing: border-box;
11415
+ padding: var(--space-xxs) var(--space-xs);
11416
+ background: var(--surface-3);
11417
+ color: var(--text-primary);
11418
+ border: 1px solid var(--border-strong);
11419
+ border-radius: var(--radius-sm);
11420
+ }
11421
+ /* ... and still shows a hex that is not a colour (the well's border above
11422
+ * would otherwise hide ColorField's invalid border). */
11423
+ .twm-plot-config .twm-color-field--invalid .twm-color-field__hex {
11424
+ border-color: var(--color-danger);
11425
+ }
11426
+ /* A field line's select keeps to its options, as the number boxes do. */
11427
+ .twm-plot-config__series-fields .twm-plot-config__field > .twm-plot-config__input--select {
11428
+ flex: 0 1 auto;
11429
+ max-width: 100%;
11430
+ }
11431
+ /* An embedder's control drawn by a field spec's `render(slot)`. */
11432
+ .twm-plot-config__slot {
11433
+ flex: 1 1 auto;
11434
+ min-width: 0;
11144
11435
  }
11436
+ /* A field's hint, under its control: past the label column. Only a field
11437
+ * with a hint wraps, so a number box never drops under its label. */
11438
+ .twm-plot-config__field--hinted {
11439
+ flex-wrap: wrap;
11440
+ }
11441
+ .twm-plot-config__hint {
11442
+ box-sizing: border-box;
11443
+ flex: 1 0 100%;
11444
+ padding-left: calc(96px + var(--space-sm));
11445
+ font-size: var(--font-size-xs);
11446
+ color: var(--text-muted);
11447
+ }
11448
+ /* The overlay's own fields, under its source: set in by a rule, their label
11449
+ * column shortened by as much, so their controls stay on the series' column. */
11145
11450
  .twm-plot-config__overlay,
11146
11451
  .twm-plot-config__overlay-fields {
11147
11452
  display: flex;
11148
11453
  flex-direction: column;
11149
11454
  gap: var(--space-xxs);
11150
- padding-left: var(--space-md);
11455
+ min-width: 0;
11456
+ }
11457
+ .twm-plot-config__overlay-fields:not(:empty) {
11458
+ margin-left: var(--space-sm);
11459
+ padding-left: calc(var(--space-sm) - 1px);
11460
+ border-left: 1px solid var(--border-subtle);
11461
+ }
11462
+ .twm-plot-config__overlay-fields > .twm-plot-config__field > .twm-plot-config__label {
11463
+ flex-basis: calc(96px - 2 * var(--space-sm));
11151
11464
  }
11152
11465
  .twm-plot-config__overlay-source {
11153
11466
  color: var(--text-muted);
package/css/tokens.css CHANGED
@@ -137,6 +137,9 @@
137
137
  --z-popover: 200;
138
138
  --z-modal: 1000;
139
139
  --z-toast: 10000;
140
+ /* A context menu is what the pointer just asked for: over everything,
141
+ toasts and managed windows included (a right-click on a toast). */
142
+ --z-context-menu: 10100;
140
143
  /* What opens FROM a managed window and must draw over it: managed windows
141
144
  stack from 6000 to 6999 (managed_window.js), so a popover at
142
145
  --z-popover would open behind the window that opened it. */
package/dist/charts.js CHANGED
@@ -55,17 +55,17 @@ import {
55
55
  strideIndices,
56
56
  uid,
57
57
  updateChart
58
- } from "./chunk-TLIV6QSU.js";
58
+ } from "./chunk-7OL6F2AM.js";
59
59
  import {
60
60
  AutocompleteField,
61
61
  createRafResizeObserver
62
- } from "./chunk-2PM6ZMNS.js";
62
+ } from "./chunk-2Z2ENHR3.js";
63
63
  import {
64
64
  ColorField,
65
65
  ManagedWindow,
66
66
  ToggleField,
67
67
  selectBox
68
- } from "./chunk-MADMGRX2.js";
68
+ } from "./chunk-PGZ4EHSE.js";
69
69
  import "./chunk-HNU6MM2B.js";
70
70
  import "./chunk-N7HC3ADV.js";
71
71
  import "./chunk-WUFLGETN.js";
@@ -758,10 +758,27 @@ function removeButton(title, onClick) {
758
758
  b.appendChild(el("span", "material-symbols-outlined", "close"));
759
759
  return b;
760
760
  }
761
- function field(label, control) {
761
+ var hintCount = 0;
762
+ function field(label, control, { hint = null } = {}) {
762
763
  const single = control.matches("input, select, textarea");
763
- const row = el(single ? "label" : "div", "twm-plot-config__field");
764
- row.append(el("span", "twm-plot-config__label", label), control);
764
+ const hinted = hint != null && hint !== "";
765
+ if (!hinted) {
766
+ const row2 = el(single ? "label" : "div", "twm-plot-config__field");
767
+ row2.append(el("span", "twm-plot-config__label", label), control);
768
+ return row2;
769
+ }
770
+ const row = el("div", "twm-plot-config__field twm-plot-config__field--hinted");
771
+ const text = el(single ? "label" : "span", "twm-plot-config__label", label);
772
+ const note = el("span", "twm-plot-config__hint", hint);
773
+ note.id = `twm-plot-config-hint-${++hintCount}`;
774
+ if (single) {
775
+ if (!control.id) control.id = `${note.id}-control`;
776
+ text.htmlFor = control.id;
777
+ }
778
+ const target = single ? control : control.querySelector("input, select, textarea") ?? control;
779
+ const describedBy = target.getAttribute("aria-describedby");
780
+ target.setAttribute("aria-describedby", describedBy ? `${describedBy} ${note.id}` : note.id);
781
+ row.append(text, control, note);
765
782
  return row;
766
783
  }
767
784
  function textInput(value, onSet, { placeholder = "", label = null } = {}) {
@@ -920,7 +937,7 @@ function renderPlotConfigEditor(container, config, options = {}) {
920
937
  node.classList.add("twm-plot-config__picker");
921
938
  return node;
922
939
  }
923
- function colorField(value, onSet, label) {
940
+ function colorField(value, onSet, label, keep = track) {
924
941
  const f = new ColorField({
925
942
  value: value ?? "",
926
943
  label,
@@ -931,7 +948,7 @@ function renderPlotConfigEditor(container, config, options = {}) {
931
948
  }
932
949
  }
933
950
  });
934
- track(() => f.dispose());
951
+ keep(() => f.dispose());
935
952
  return f.render();
936
953
  }
937
954
  function toggle(label, checked, onSet) {
@@ -1154,60 +1171,113 @@ function renderPlotConfigEditor(container, config, options = {}) {
1154
1171
  }
1155
1172
  function seriesRow(sp, axis, s, overlaySources) {
1156
1173
  const row = el("div", "twm-plot-config__series");
1157
- const main = el("div", "twm-plot-config__series-main");
1158
- main.append(
1174
+ const head = el("div", "twm-plot-config__series-head");
1175
+ head.append(
1159
1176
  seriesPicker(s, "variable", "series", sp, "Series"),
1160
1177
  textInput(s.label, (v) => {
1161
1178
  s.label = v;
1162
1179
  notify();
1163
1180
  }, { placeholder: "Legend label", label: "Legend label" }),
1164
- colorField(s.color, (v) => {
1181
+ removeButton("Remove series", () => {
1182
+ const i = axis.series.indexOf(s);
1183
+ if (i >= 0) axis.series.splice(i, 1);
1184
+ renderBody();
1185
+ notify();
1186
+ })
1187
+ );
1188
+ const fields = el("div", "twm-plot-config__series-fields");
1189
+ fields.append(
1190
+ field("Colour", colorField(s.color, (v) => {
1165
1191
  if (v) s.color = v;
1166
1192
  else delete s.color;
1167
- }, "Colour"),
1168
- numberInput(
1193
+ }, "Colour")),
1194
+ field("Width", numberInput(
1169
1195
  s.lineWidth ?? 2,
1170
1196
  (v) => {
1171
1197
  s.lineWidth = v ?? 2;
1172
1198
  notify();
1173
1199
  },
1174
1200
  { placeholder: "2", label: "Line width", min: 1, max: 10, step: 1 }
1175
- ),
1176
- select(DASHES, s.lineStyle ?? "solid", (v) => {
1201
+ )),
1202
+ field("Dash", select(DASHES, s.lineStyle ?? "solid", (v) => {
1177
1203
  s.lineStyle = v;
1178
1204
  notify();
1179
- }, { label: "Dash" }),
1180
- textInput(
1205
+ }, { label: "Dash" })),
1206
+ field("Stack group", textInput(
1181
1207
  s.stackGroup,
1182
1208
  (v) => {
1183
1209
  s.stackGroup = v || null;
1184
1210
  notify();
1185
1211
  },
1186
- { placeholder: "Stack group", label: "Stack group" }
1187
- ),
1188
- removeButton("Remove series", () => {
1189
- const i = axis.series.indexOf(s);
1190
- if (i >= 0) axis.series.splice(i, 1);
1191
- renderBody();
1192
- notify();
1193
- })
1212
+ { placeholder: "None", label: "Stack group" }
1213
+ ))
1194
1214
  );
1195
- row.appendChild(main);
1196
- if (opts.extraSeriesFields) {
1197
- const extra = el("div", "twm-plot-config__series-extra");
1198
- row.appendChild(extra);
1199
- const handle = opts.extraSeriesFields(extra, s, { subplot: sp, axis, onChange: notify });
1200
- if (typeof handle?.dispose === "function") track(() => handle.dispose());
1201
- }
1202
- if (opts.overlaySources) row.appendChild(overlayEditor(s, overlaySources));
1215
+ if (opts.extraSeriesFields) fields.appendChild(extraFields(sp, axis, s));
1216
+ if (opts.overlaySources) fields.appendChild(overlayEditor(s, overlaySources));
1217
+ row.append(head, fields);
1203
1218
  return row;
1204
1219
  }
1220
+ const warn = (...args) => opts.logger?.warn ? opts.logger.warn(...args) : console.warn(...args);
1221
+ function extraFields(sp, axis, s) {
1222
+ const extra = el("div", "twm-plot-config__series-extra");
1223
+ const result = opts.extraSeriesFields(extra, s, { subplot: sp, axis, onChange: notify, field });
1224
+ if (!Array.isArray(result)) {
1225
+ if (typeof result?.dispose === "function") track(() => result.dispose());
1226
+ return extra;
1227
+ }
1228
+ for (const spec of result) {
1229
+ const label = spec?.label;
1230
+ let control = null;
1231
+ if (typeof label !== "string") {
1232
+ warn("[plot config editor] an extraSeriesFields spec has no label; skipped", spec);
1233
+ continue;
1234
+ }
1235
+ if (spec.control != null) {
1236
+ if (!(spec.control instanceof Element)) {
1237
+ warn("[plot config editor] an extraSeriesFields spec control is not an Element; skipped", spec);
1238
+ continue;
1239
+ }
1240
+ control = spec.control;
1241
+ } else if (typeof spec.render === "function") {
1242
+ control = el("div", "twm-plot-config__slot");
1243
+ try {
1244
+ const handle = spec.render(control);
1245
+ if (typeof handle?.dispose === "function") track(() => handle.dispose());
1246
+ } catch (error) {
1247
+ warn("[plot config editor] an extraSeriesFields spec failed to render; skipped", error);
1248
+ continue;
1249
+ }
1250
+ } else {
1251
+ warn("[plot config editor] an extraSeriesFields spec has neither control nor render; skipped", spec);
1252
+ continue;
1253
+ }
1254
+ extra.appendChild(field(label, control, { hint: spec.hint }));
1255
+ }
1256
+ return extra;
1257
+ }
1205
1258
  function overlayEditor(s, overlaySources) {
1206
1259
  const wrap = el("div", "twm-plot-config__overlay");
1207
1260
  const head = el("div", "twm-plot-config__overlay-source", "Loading overlay sources\u2026");
1208
1261
  const details = el("div", "twm-plot-config__overlay-fields");
1209
1262
  wrap.append(head, details);
1263
+ let detailDisposers = [];
1264
+ const disposeDetails = () => {
1265
+ const fns = detailDisposers;
1266
+ detailDisposers = [];
1267
+ for (const fn of fns) {
1268
+ try {
1269
+ fn();
1270
+ } catch (error) {
1271
+ opts.logger?.warn?.("[plot config editor] a field failed to dispose", error);
1272
+ }
1273
+ }
1274
+ };
1275
+ track(disposeDetails);
1276
+ const keep = (fn) => {
1277
+ detailDisposers.push(fn);
1278
+ };
1210
1279
  const drawDetails = () => {
1280
+ disposeDetails();
1211
1281
  details.replaceChildren();
1212
1282
  const ov = s.overlay;
1213
1283
  if (!ov) return;
@@ -1223,7 +1293,7 @@ function renderPlotConfigEditor(container, config, options = {}) {
1223
1293
  field("Colour", colorField(ov.color, (v) => {
1224
1294
  if (v) ov.color = v;
1225
1295
  else delete ov.color;
1226
- }, "Overlay colour")),
1296
+ }, "Overlay colour", keep)),
1227
1297
  field("Dash", select(DASHES, ov.lineStyle ?? "dashed", (v) => {
1228
1298
  ov.lineStyle = v;
1229
1299
  notify();