flexdesk 0.16.1 → 0.17.1

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 (262) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/LICENSE +0 -0
  3. package/README.md +2435 -42
  4. package/THIRD_PARTY_LICENSES.md +0 -0
  5. package/canvas.js +18 -0
  6. package/charts.js +21 -0
  7. package/core.js +14 -0
  8. package/css/base.css +1834 -75
  9. package/css/flexdesk.css +3165 -159
  10. package/css/overrides.css +16 -0
  11. package/css/reset.css +0 -0
  12. package/css/tokens.css +0 -0
  13. package/dist/canvas.js +6 -239
  14. package/dist/canvas.js.map +3 -3
  15. package/dist/charts.js +707 -18
  16. package/dist/charts.js.map +4 -4
  17. package/dist/chunk-3PHCPZHT.js +0 -0
  18. package/dist/chunk-3PHCPZHT.js.map +0 -0
  19. package/dist/chunk-4A7SJC3T.js +503 -0
  20. package/dist/chunk-4A7SJC3T.js.map +7 -0
  21. package/dist/{chunk-6JSOVNID.js → chunk-CVMCZZX2.js} +635 -10
  22. package/dist/chunk-CVMCZZX2.js.map +7 -0
  23. package/dist/chunk-D3COLZZ2.js +1311 -0
  24. package/dist/chunk-D3COLZZ2.js.map +7 -0
  25. package/dist/chunk-FOOS3T5L.js +0 -0
  26. package/dist/chunk-FOOS3T5L.js.map +0 -0
  27. package/dist/{chunk-SN4LSOAR.js → chunk-GOQLR5WR.js} +452 -2
  28. package/dist/chunk-GOQLR5WR.js.map +7 -0
  29. package/dist/{chunk-FL5KFNQH.js → chunk-HNU6MM2B.js} +52 -17
  30. package/dist/chunk-HNU6MM2B.js.map +7 -0
  31. package/dist/chunk-HYJYYEPK.js +375 -0
  32. package/dist/chunk-HYJYYEPK.js.map +7 -0
  33. package/dist/chunk-JKZLNKG6.js +2041 -0
  34. package/dist/chunk-JKZLNKG6.js.map +7 -0
  35. package/dist/chunk-JYWURG5T.js +0 -0
  36. package/dist/chunk-JYWURG5T.js.map +0 -0
  37. package/dist/{chunk-FBGP5KN5.js → chunk-LWV23YVE.js} +5073 -532
  38. package/dist/chunk-LWV23YVE.js.map +7 -0
  39. package/dist/chunk-N7HC3ADV.js +30 -0
  40. package/dist/chunk-N7HC3ADV.js.map +7 -0
  41. package/dist/{chunk-RQM5Z2MU.js → chunk-RMTRWWEO.js} +633 -311
  42. package/dist/chunk-RMTRWWEO.js.map +7 -0
  43. package/dist/{chunk-NLTOY57M.js → chunk-UURZ37SD.js} +28 -48
  44. package/dist/chunk-UURZ37SD.js.map +7 -0
  45. package/dist/{chunk-B5Q53LAJ.js → chunk-UWO43BZU.js} +2 -2
  46. package/dist/chunk-UWO43BZU.js.map +7 -0
  47. package/dist/chunk-WVFGV5FT.js +0 -0
  48. package/dist/chunk-WVFGV5FT.js.map +0 -0
  49. package/dist/core.js +170 -118
  50. package/dist/core.js.map +2 -2
  51. package/dist/editor.js +156 -70
  52. package/dist/editor.js.map +2 -2
  53. package/dist/flexdesk.css +3165 -159
  54. package/dist/flow.js +2789 -306
  55. package/dist/flow.js.map +4 -4
  56. package/dist/host.js +109 -3
  57. package/dist/host.js.map +3 -3
  58. package/dist/reset.css +0 -0
  59. package/dist/tile_registry-6WZPTUZV.js +0 -0
  60. package/dist/tile_registry-6WZPTUZV.js.map +0 -0
  61. package/dist/tiles.js +657 -234
  62. package/dist/tiles.js.map +4 -4
  63. package/dist/tokens.css +0 -0
  64. package/dist/widgets.js +3290 -2124
  65. package/dist/widgets.js.map +4 -4
  66. package/dist/wm.js +1227 -347
  67. package/dist/wm.js.map +4 -4
  68. package/editor.js +20 -0
  69. package/flow.js +21 -0
  70. package/host.js +14 -0
  71. package/package.json +12 -1
  72. package/src/canvas/canvas_adapter.js +5 -0
  73. package/src/canvas/edge_router.js +0 -0
  74. package/src/canvas/graph/connector_router.js +0 -0
  75. package/src/canvas/graph/history_service.js +0 -0
  76. package/src/canvas/graph/node_platform.js +0 -0
  77. package/src/canvas/index.js +1 -0
  78. package/src/canvas/layered_layout.js +244 -0
  79. package/src/charts/chart_types.js +0 -0
  80. package/src/charts/data_series_plot_window.js +0 -0
  81. package/src/charts/downsample.js +2 -2
  82. package/src/charts/plot_config.js +53 -29
  83. package/src/charts/plot_config_editor.js +627 -0
  84. package/src/charts/plot_grid.js +449 -0
  85. package/src/charts/plot_popout_window.js +103 -317
  86. package/src/charts/plotly_wrapper.js +22 -2
  87. package/src/charts/series_transforms.js +206 -0
  88. package/src/core/event_bus.js +0 -0
  89. package/src/core/logging.js +1 -1
  90. package/src/core/settings.js +105 -19
  91. package/src/core/state_guard.js +93 -51
  92. package/src/core/state_guard_config.js +85 -74
  93. package/src/core/state_machine.js +0 -0
  94. package/src/editor/editor_pane.js +3 -3
  95. package/src/editor/monaco_editor_factory.js +124 -60
  96. package/src/editor/monaco_loader.js +103 -28
  97. package/src/editor/notebook_search.js +0 -0
  98. package/src/editor/notebook_tab_bar.js +38 -52
  99. package/src/editor/notebook_undo_manager.js +0 -0
  100. package/src/editor/split_pane_container.js +6 -3
  101. package/src/flow/canvas_editor/connections.js +90 -0
  102. package/src/flow/canvas_editor/editor.js +2351 -153
  103. package/src/flow/canvas_editor/index.js +2 -1
  104. package/src/flow/canvas_editor/layout.js +119 -12
  105. package/src/flow/canvas_editor/strings.js +80 -0
  106. package/src/flow/kit/always_before.js +0 -0
  107. package/src/flow/kit/catalogue.js +97 -3
  108. package/src/flow/kit/chip_input.js +0 -0
  109. package/src/flow/kit/dom.js +0 -0
  110. package/src/flow/kit/findings.js +0 -0
  111. package/src/flow/kit/history.js +0 -0
  112. package/src/flow/kit/index.js +3 -3
  113. package/src/flow/kit/keys.js +7 -1
  114. package/src/flow/kit/logic_graph.js +97 -15
  115. package/src/flow/kit/references.js +0 -0
  116. package/src/flow/kit/settings_panel.js +0 -0
  117. package/src/flow/kit/settings_schema.js +2 -1
  118. package/src/flow/kit/step_picker.js +0 -0
  119. package/src/flow/kit/strings.js +3 -1
  120. package/src/flow/kit/value_picker.js +0 -0
  121. package/src/flow/kit/widgets.js +86 -109
  122. package/src/flow/lanes/editor.js +136 -14
  123. package/src/flow/lanes/index.js +3 -1
  124. package/src/flow/lanes/layout.js +0 -0
  125. package/src/flow/lanes/pipeline.js +44 -2
  126. package/src/flow/lanes/preview.js +0 -0
  127. package/src/flow/lanes/strings.js +1 -0
  128. package/src/flow/outline/editor.js +0 -0
  129. package/src/flow/outline/index.js +0 -0
  130. package/src/flow/outline/mapping.js +0 -0
  131. package/src/flow/outline/menu.js +0 -0
  132. package/src/flow/outline/operations.js +0 -0
  133. package/src/flow/outline/recognise.js +0 -0
  134. package/src/flow/outline/strings.js +0 -0
  135. package/src/help/help_modal.js +168 -42
  136. package/src/help/help_registry.js +12 -1
  137. package/src/host/external_links.js +0 -0
  138. package/src/host/file_picker.js +117 -0
  139. package/src/host/host.js +10 -2
  140. package/src/host/photino_host.js +9 -0
  141. package/src/host/photino_host.md +21 -3
  142. package/src/host/pywebview_host.js +10 -0
  143. package/src/tiles/chart_tiles.js +53 -245
  144. package/src/tiles/config_schema.js +8 -0
  145. package/src/tiles/figure_tile.js +322 -0
  146. package/src/tiles/layout_persistence.js +3 -3
  147. package/src/tiles/list_tiles.js +7 -7
  148. package/src/tiles/scope_picker.js +0 -0
  149. package/src/tiles/tile_base.js +1 -1
  150. package/src/tiles/tile_config_form.js +442 -0
  151. package/src/tiles/tile_grid.js +2 -0
  152. package/src/tiles/tile_registry.js +0 -0
  153. package/src/tiling/bottom_panel_tabs.js +67 -99
  154. package/src/tiling/command_palette.js +64 -19
  155. package/src/tiling/content_registry.js +4 -1
  156. package/src/tiling/desktops.js +11 -1
  157. package/src/tiling/entity_sources.js +0 -0
  158. package/src/tiling/keymap.js +403 -128
  159. package/src/tiling/kind_taxonomy.js +78 -0
  160. package/src/tiling/landing_table.js +0 -0
  161. package/src/tiling/loading_overlay.js +0 -0
  162. package/src/tiling/nav_panel.js +59 -10
  163. package/src/tiling/page_factory.js +5 -5
  164. package/src/tiling/panel_availability.js +49 -0
  165. package/src/tiling/panel_keys.js +0 -0
  166. package/src/tiling/shell.js +114 -12
  167. package/src/tiling/tab_strip.js +0 -0
  168. package/src/tiling/tile_breadcrumb.js +0 -0
  169. package/src/tiling/tile_renderer.js +105 -9
  170. package/src/tiling/tile_tab_menu.js +0 -0
  171. package/src/tiling/tile_tree.js +0 -0
  172. package/src/tiling/wm.js +562 -30
  173. package/src/tiling/zoom.js +0 -0
  174. package/src/ui/base/component_base.js +0 -0
  175. package/src/ui/base/controller_base.js +0 -0
  176. package/src/ui/base/page_base.js +0 -0
  177. package/src/ui/components/about_dialog.js +0 -0
  178. package/src/ui/components/action_dropdown.js +30 -2
  179. package/src/ui/components/alert_log.js +0 -0
  180. package/src/ui/components/alerts_pane.js +0 -0
  181. package/src/ui/components/attribute_list_editor.js +0 -0
  182. package/src/ui/components/autocomplete_field.js +0 -0
  183. package/src/ui/components/cell_editor_host.js +293 -0
  184. package/src/ui/components/cell_editors.js +480 -0
  185. package/src/ui/components/cell_grid.js +761 -0
  186. package/src/ui/components/cell_paste.js +243 -0
  187. package/src/ui/components/cell_selection.js +220 -0
  188. package/src/ui/components/choice_controls.js +351 -0
  189. package/src/ui/components/color_field.js +211 -0
  190. package/src/ui/components/color_list_field.js +210 -0
  191. package/src/ui/components/column_fit.js +0 -0
  192. package/src/ui/components/column_groups.js +314 -0
  193. package/src/ui/components/column_order.js +142 -0
  194. package/src/ui/components/computing_status_window.js +98 -25
  195. package/src/ui/components/confirm_dialog.js +81 -38
  196. package/src/ui/components/context_menu.js +2 -2
  197. package/src/ui/components/conversation_view.js +440 -0
  198. package/src/ui/components/crosstab.js +289 -0
  199. package/src/ui/components/data_table.js +696 -71
  200. package/src/ui/components/detail_header.js +22 -11
  201. package/src/ui/components/document_outline.js +397 -0
  202. package/src/ui/components/drag_reorder.js +0 -0
  203. package/src/ui/components/fact_table.js +0 -0
  204. package/src/ui/components/fill_handle.js +322 -0
  205. package/src/ui/components/gallery_picker.js +19 -8
  206. package/src/ui/components/header_actions.js +55 -0
  207. package/src/ui/components/inline_renamer.js +0 -0
  208. package/src/ui/components/job_progress.js +51 -0
  209. package/src/ui/components/managed_window.js +27 -4
  210. package/src/ui/components/menu_bar.js +745 -0
  211. package/src/ui/components/modal.js +449 -75
  212. package/src/ui/components/notification_history.js +3 -3
  213. package/src/ui/components/progress.js +483 -0
  214. package/src/ui/components/record_list.js +0 -0
  215. package/src/ui/components/row_activation.js +0 -0
  216. package/src/ui/components/section_stack.js +298 -0
  217. package/src/ui/components/series.js +173 -0
  218. package/src/ui/components/settings_view.js +634 -0
  219. package/src/ui/components/slide_out_panel.js +43 -6
  220. package/src/ui/components/slider_field.js +275 -18
  221. package/src/ui/components/sortable_list.js +0 -0
  222. package/src/ui/components/staged_edits.js +361 -0
  223. package/src/ui/components/staged_history.js +172 -0
  224. package/src/ui/components/table_filter_chips.js +0 -0
  225. package/src/ui/components/table_state_store.js +0 -0
  226. package/src/ui/components/table_structure.js +182 -0
  227. package/src/ui/components/table_values.js +0 -0
  228. package/src/ui/components/tabs.js +497 -0
  229. package/src/ui/components/tag_input.js +215 -0
  230. package/src/ui/components/toast.js +25 -8
  231. package/src/ui/components/toggle_field.js +175 -0
  232. package/src/ui/components/tree_view.js +55 -34
  233. package/src/ui/components/tsv.js +74 -0
  234. package/src/ui/components/window_taskbar.js +0 -0
  235. package/src/ui/components/wizard.js +563 -0
  236. package/src/ui/controllers/window_chrome_controller.js +2 -2
  237. package/src/ui/notification_center.js +375 -119
  238. package/src/ui/utils/auto_scrollbars.js +0 -0
  239. package/src/ui/utils/markdown.js +709 -0
  240. package/src/ui/utils/native_context_menu.js +0 -0
  241. package/src/ui/utils/overlay_scrollbar.js +16 -12
  242. package/src/ui/utils/raf_resize_observer.js +0 -0
  243. package/src/ui/utils/scroll_lock.js +0 -0
  244. package/src/ui/utils/strip_scroll.js +55 -0
  245. package/src/ui/utils/tooltip_service.js +0 -8
  246. package/tiles.js +27 -0
  247. package/widgets.js +69 -0
  248. package/wm.js +28 -0
  249. package/dist/chunk-6JSOVNID.js.map +0 -7
  250. package/dist/chunk-B5Q53LAJ.js.map +0 -7
  251. package/dist/chunk-FBGP5KN5.js.map +0 -7
  252. package/dist/chunk-FL5KFNQH.js.map +0 -7
  253. package/dist/chunk-NLTOY57M.js.map +0 -7
  254. package/dist/chunk-NNFTETK3.js +0 -633
  255. package/dist/chunk-NNFTETK3.js.map +0 -7
  256. package/dist/chunk-O5OHMWBB.js +0 -284
  257. package/dist/chunk-O5OHMWBB.js.map +0 -7
  258. package/dist/chunk-RQM5Z2MU.js.map +0 -7
  259. package/dist/chunk-SN4LSOAR.js.map +0 -7
  260. package/dist/chunk-W6FBJLPW.js +0 -367
  261. package/dist/chunk-W6FBJLPW.js.map +0 -7
  262. package/src/ui/controllers/panel_state_machine.js +0 -634
@@ -0,0 +1,298 @@
1
+ /**
2
+ * ══ A STACK OF COLLAPSIBLE SECTIONS ══════════════════════════════════════
3
+ *
4
+ * Inspector-style panel content: titled sections, one above the other, each
5
+ * opened and closed from its header (FlexDesk STORY-0036). Until this, only a
6
+ * tree's categories could collapse, and every embedder that wanted a panel of
7
+ * "Notes / Assistant / Parameters" built the headers, the arrows and the
8
+ * remembering itself.
9
+ *
10
+ * const stack = createSectionStack(panel, {
11
+ * sections: [
12
+ * { id: 'notes', title: 'Notes', content: notesEl },
13
+ * { id: 'chat', title: 'Assistant', fill: true,
14
+ * actions: [{ id: 'clear', icon: 'delete', label: 'Clear', onClick: clear }] },
15
+ * { id: 'params', title: 'Parameters', defaultExpanded: false },
16
+ * ],
17
+ * key: 'inspector.sections',
18
+ * stateStore: { read: (key) => saved[key], write: (key, map) => { saved[key] = map; } },
19
+ * });
20
+ * stack.body('chat').appendChild(chatEl);
21
+ * const off = stack.on('toggled', ({ id, expanded }) => …);
22
+ *
23
+ * A SECTION is `{ id, title, icon?, content?, actions?, defaultExpanded?, fill? }`.
24
+ * `content` is a Node put into the section's body at mount; `body(id)` hands
25
+ * the same element over for anything mounted later. `defaultExpanded` is true
26
+ * unless it says otherwise.
27
+ *
28
+ * THE HEADER TOGGLES; ITS ACTIONS DO NOT. A click anywhere on the header opens
29
+ * or closes the section and turns its arrow — except on one of its `actions`
30
+ * (`{ id?, icon, label, title?, onClick(sectionId, event) }`), which runs its
31
+ * own `onClick` and leaves the section as it was.
32
+ *
33
+ * A FILL SECTION (`fill: true`) takes the height the others leave, and its
34
+ * body scrolls inside it (`min-height: 0`), so a long conversation scrolls in
35
+ * its section instead of growing the panel past the window. Give the stack's
36
+ * container a height — it is `height: 100%` of it.
37
+ *
38
+ * REMEMBERING is the embedder's: `stateStore` is `{ read(key), write(key, map) }`
39
+ * and `map` is `{ [sectionId]: expanded }`, written whenever a section opens or
40
+ * closes. `read` may answer a promise; until it does, sections show their
41
+ * `defaultExpanded`, and a section somebody toggled meanwhile keeps their
42
+ * choice. Such a toggle is written once the read has settled, merged over
43
+ * the stored map, so the defaults on screen never overwrite it. A store that throws, rejects, or answers something that is not a map
44
+ * leaves every section on its `defaultExpanded` — remembering is a convenience,
45
+ * never a reason for a panel not to draw. `ready` resolves once the read has
46
+ * been applied (at once without a store).
47
+ *
48
+ * `setVisible(id, false)` takes a section off the stack without forgetting it:
49
+ * `setVisible(id, true)` brings it back open or closed exactly as it was.
50
+ *
51
+ * The `toggled` event carries `{ id, expanded, source }`, `source` being
52
+ * `'user'` (a header click), `'api'` (expand / collapse / toggle) or
53
+ * `'restore'` (a stored state that arrived after mount). It fires only on a
54
+ * change. `on()` answers the function that stops listening.
55
+ *
56
+ * `dispose()` removes the one listener the stack adds and every node it made;
57
+ * `content` the embedder handed in goes with its section.
58
+ *
59
+ * Styled by `.twm-collapsible-*` (the tree's primitive) plus `.twm-section-stack*`.
60
+ */
61
+
62
+ /**
63
+ * @param {HTMLElement} container
64
+ * @param {object} opts
65
+ * @param {Array} opts.sections `{ id, title, icon?, content?, actions?, defaultExpanded?, fill? }`
66
+ * @param {object} [opts.stateStore] `{ read(key): map|Promise<map>, write(key, map) }`
67
+ * @param {string} [opts.key] the key the expanded states live under; required with a store
68
+ */
69
+ export function createSectionStack(container, { sections = [], stateStore = null, key = null } = {}) {
70
+ if (stateStore && !key) throw new Error('createSectionStack: a stateStore needs a { key }');
71
+ const doc = container.ownerDocument;
72
+
73
+ const root = doc.createElement('div');
74
+ root.className = 'twm-section-stack';
75
+
76
+ /** id -> { def, box, toggle, arrow, body, expanded, visible, touched } */
77
+ const byId = new Map();
78
+ /** action button -> { sectionId, action } */
79
+ const actionOf = new Map();
80
+ /** header -> section id */
81
+ const headerOf = new Map();
82
+ const listeners = new Set();
83
+ let disposed = false;
84
+ // While a promised read is out, the sections nobody touched still show
85
+ // their defaults, not what the store holds: writing them then would
86
+ // overwrite the stored states with defaults. A toggle meanwhile only marks
87
+ // the map as owed; it is written once the read has settled.
88
+ let readPending = false;
89
+ let writeOwed = false;
90
+
91
+ for (const def of sections) {
92
+ if (!def || def.id == null || byId.has(def.id)) {
93
+ throw new Error(`createSectionStack: every section needs a unique id (got ${def?.id})`);
94
+ }
95
+ const expanded = def.defaultExpanded !== false;
96
+
97
+ const box = doc.createElement('section');
98
+ box.className = 'twm-collapsible-box twm-section-stack__section'
99
+ + (def.fill ? ' twm-section-stack__section--fill' : '');
100
+
101
+ const header = doc.createElement('div');
102
+ header.className = 'twm-collapsible-header twm-section-stack__header';
103
+
104
+ const toggle = doc.createElement('button');
105
+ toggle.type = 'button';
106
+ toggle.className = 'twm-section-stack__toggle';
107
+ const arrow = doc.createElement('span');
108
+ arrow.className = 'twm-collapsible-arrow material-symbols-outlined';
109
+ arrow.setAttribute('aria-hidden', 'true');
110
+ arrow.textContent = 'expand_more';
111
+ toggle.appendChild(arrow);
112
+ if (def.icon) {
113
+ const icon = doc.createElement('span');
114
+ icon.className = 'twm-section-stack__icon material-symbols-outlined';
115
+ icon.setAttribute('aria-hidden', 'true');
116
+ icon.textContent = def.icon;
117
+ toggle.appendChild(icon);
118
+ }
119
+ const title = doc.createElement('span');
120
+ title.className = 'twm-section-stack__title';
121
+ title.textContent = def.title ?? String(def.id);
122
+ toggle.appendChild(title);
123
+ header.appendChild(toggle);
124
+
125
+ if (Array.isArray(def.actions) && def.actions.length) {
126
+ const bar = doc.createElement('div');
127
+ bar.className = 'twm-section-stack__actions';
128
+ for (const action of def.actions) {
129
+ const btn = doc.createElement('button');
130
+ btn.type = 'button';
131
+ btn.className = 'twm-section-stack__action';
132
+ if (action.label) btn.setAttribute('aria-label', action.label);
133
+ if (action.title ?? action.label) btn.setAttribute('title', action.title ?? action.label);
134
+ const glyph = doc.createElement('span');
135
+ glyph.className = 'material-symbols-outlined';
136
+ glyph.setAttribute('aria-hidden', 'true');
137
+ glyph.textContent = action.icon ?? '';
138
+ btn.appendChild(glyph);
139
+ bar.appendChild(btn);
140
+ actionOf.set(btn, { sectionId: def.id, action });
141
+ }
142
+ header.appendChild(bar);
143
+ }
144
+
145
+ const body = doc.createElement('div');
146
+ body.className = 'twm-collapsible-content twm-section-stack__body';
147
+ if (def.content) body.appendChild(def.content);
148
+
149
+ box.append(header, body);
150
+ root.appendChild(box);
151
+ headerOf.set(header, def.id);
152
+ const entry = { def, box, toggle, arrow, body, expanded, visible: true, touched: false };
153
+ byId.set(def.id, entry);
154
+ paint(entry);
155
+ }
156
+
157
+ function paint(entry) {
158
+ entry.box.classList.toggle('twm-section-stack__section--expanded', entry.expanded);
159
+ entry.body.classList.toggle('visible', entry.expanded);
160
+ entry.arrow.classList.toggle('collapsed', !entry.expanded);
161
+ entry.toggle.setAttribute('aria-expanded', String(entry.expanded));
162
+ entry.box.hidden = !entry.visible;
163
+ }
164
+
165
+ function emit(detail) {
166
+ for (const fn of [...listeners]) {
167
+ try { fn(detail); } catch (err) { console.error('[section-stack] toggled listener failed', err); }
168
+ }
169
+ }
170
+
171
+ function persist() {
172
+ if (!stateStore?.write) return;
173
+ if (readPending) { writeOwed = true; return; }
174
+ const map = {};
175
+ for (const [id, entry] of byId) map[id] = entry.expanded;
176
+ write(map);
177
+ }
178
+
179
+ function write(map) {
180
+ try {
181
+ const pending = stateStore.write(key, map);
182
+ pending?.catch?.(() => { /* storage refused: not ours to fix */ });
183
+ } catch { /* storage refused: not ours to fix */ }
184
+ }
185
+
186
+ /** Set one section's state; true when it changed. */
187
+ function set(id, expanded, source) {
188
+ const entry = byId.get(id);
189
+ if (disposed || !entry || entry.expanded === expanded) return false;
190
+ entry.expanded = expanded;
191
+ if (source !== 'restore') entry.touched = true;
192
+ paint(entry);
193
+ if (source !== 'restore') persist();
194
+ emit({ id, expanded, source });
195
+ return true;
196
+ }
197
+
198
+ const isMap = (saved) => !!saved && typeof saved === 'object' && !Array.isArray(saved);
199
+
200
+ function restore(saved, source) {
201
+ if (disposed || !isMap(saved)) return;
202
+ for (const [id, entry] of byId) {
203
+ if (entry.touched || typeof saved[id] !== 'boolean') continue;
204
+ if (source === 'restore') set(id, saved[id], 'restore');
205
+ else { entry.expanded = saved[id]; paint(entry); }
206
+ }
207
+ }
208
+
209
+ /** A promised read has settled (`saved` is null when it rejected). */
210
+ function settle(saved) {
211
+ restore(saved, 'restore');
212
+ readPending = false;
213
+ if (!writeOwed) return;
214
+ writeOwed = false;
215
+ // The touched sections over what the store holds — also after a
216
+ // dispose, which must not lose a choice made before the read answered.
217
+ const map = {};
218
+ for (const [id, entry] of byId) {
219
+ map[id] = !entry.touched && isMap(saved) && typeof saved[id] === 'boolean'
220
+ ? saved[id] : entry.expanded;
221
+ }
222
+ write(map);
223
+ }
224
+
225
+ function onClick(event) {
226
+ const target = event.target;
227
+ const btn = target?.closest?.('.twm-section-stack__action');
228
+ if (btn && actionOf.has(btn)) {
229
+ const { sectionId, action } = actionOf.get(btn);
230
+ event.stopPropagation();
231
+ try { action.onClick?.(sectionId, event); } catch (err) {
232
+ console.error(`[section-stack] action on "${sectionId}" failed`, err);
233
+ }
234
+ return;
235
+ }
236
+ const header = target?.closest?.('.twm-section-stack__header');
237
+ if (!header || !headerOf.has(header)) return;
238
+ const id = headerOf.get(header);
239
+ set(id, !byId.get(id).expanded, 'user');
240
+ }
241
+ root.addEventListener('click', onClick);
242
+
243
+ // Synchronous stored state is applied before the stack is on screen, so it
244
+ // never flashes the defaults; a promise is applied when it answers.
245
+ let ready = Promise.resolve();
246
+ if (stateStore?.read) {
247
+ let saved = null;
248
+ try { saved = stateStore.read(key); } catch { saved = null; }
249
+ if (saved && typeof saved.then === 'function') {
250
+ readPending = true;
251
+ ready = Promise.resolve(saved)
252
+ .then((value) => settle(value), () => settle(null));
253
+ } else {
254
+ restore(saved, 'mount');
255
+ }
256
+ }
257
+
258
+ container.appendChild(root);
259
+
260
+ const need = (id) => {
261
+ if (!byId.has(id)) throw new Error(`createSectionStack: no section "${id}"`);
262
+ return byId.get(id);
263
+ };
264
+
265
+ return {
266
+ root,
267
+ ready,
268
+ /** The section's body element, for content mounted after the stack. */
269
+ body: (id) => need(id).body,
270
+ expand(id) { need(id); set(id, true, 'api'); },
271
+ collapse(id) { need(id); set(id, false, 'api'); },
272
+ toggle(id) { set(id, !need(id).expanded, 'api'); },
273
+ isExpanded: (id) => need(id).expanded,
274
+ /** Off the stack, or back on it — its expanded state is kept either way. */
275
+ setVisible(id, visible) {
276
+ const entry = need(id);
277
+ entry.visible = !!visible;
278
+ paint(entry);
279
+ },
280
+ isVisible: (id) => need(id).visible,
281
+ /** `on('toggled', fn)` — answers the function that stops listening. */
282
+ on(event, fn) {
283
+ if (event !== 'toggled') throw new Error(`createSectionStack: no "${event}" event`);
284
+ if (disposed) return () => {};
285
+ listeners.add(fn);
286
+ return () => listeners.delete(fn);
287
+ },
288
+ dispose() {
289
+ if (disposed) return;
290
+ disposed = true;
291
+ root.removeEventListener('click', onClick);
292
+ listeners.clear();
293
+ actionOf.clear();
294
+ headerOf.clear();
295
+ root.remove();
296
+ },
297
+ };
298
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * series.js — STORY-0089. What a FILL HANDLE writes: `detectSeries` reads the
3
+ * block a fill starts from, and `projectSeries` continues it. Pure functions,
4
+ * no DOM, so the rules are tested on their own (tests/series.test.mjs) and a
5
+ * consumer that builds its own grid fills the way DataTable does.
6
+ *
7
+ * The rules, as a spreadsheet has them:
8
+ * - one value -> repeat it
9
+ * - two or more numbers a constant
10
+ * step apart -> continue the arithmetic series
11
+ * - two or more dates (`YYYY-MM-DD`
12
+ * or `Date`) a constant number of
13
+ * days apart -> continue them that many days apart
14
+ * - two or more texts with one shared
15
+ * prefix and suffix around a number
16
+ * a constant step apart ("Item 1") -> continue the number, zero-padded
17
+ * as the first is
18
+ * - anything else (equal values
19
+ * included) -> repeat the block, in a cycle
20
+ * - `forceCopy` (Ctrl held) -> repeat the block, in a cycle
21
+ *
22
+ * `values` is the seed IN FILL ORDER: a fill up or left hands the block in
23
+ * reverse, so the value next to the gap comes last and the series runs away
24
+ * from the block.
25
+ */
26
+
27
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
28
+ const TRAILING_NUMBER = /^(.*?)(\d+)(\D*)$/;
29
+ const NUMERIC_TEXT = /^[-+]?(\d+\.?\d*|\.\d+)$/;
30
+ const DAY_MS = 86400000;
31
+
32
+ const isBlank = (v) => v === null || v === undefined || v === '';
33
+
34
+ /**
35
+ * What the seed is: `{kind, step}` with `kind` one of 'copy', 'number',
36
+ * 'date' (step in days), 'text-number' (also `prefix`, `suffix`, `pad`) or
37
+ * 'cycle' — or null for an empty seed or one of blanks only.
38
+ *
39
+ * @param {any[]} values
40
+ * @returns {{kind: string, step: number, prefix?: string, suffix?: string, pad?: number}|null}
41
+ */
42
+ export function detectSeries(values) {
43
+ if (!Array.isArray(values) || values.length === 0) return null;
44
+ if (values.every(isBlank)) return null;
45
+ if (values.length === 1) return { kind: 'copy', step: 0 };
46
+
47
+ const numbers = values.map(toNumber);
48
+ if (numbers.every((n) => n !== null)) {
49
+ const step = numbers[1] - numbers[0];
50
+ const constant = numbers.every((n, i) => i === 0 || nearlyEqual(n - numbers[i - 1], step));
51
+ if (constant && step !== 0) return { kind: 'number', step };
52
+ if (constant) return { kind: 'copy', step: 0 };
53
+ return { kind: 'cycle', step: 0 };
54
+ }
55
+
56
+ const days = values.map(toDayMs);
57
+ if (days.every((d) => d !== null)) {
58
+ const step = Math.round((days[1] - days[0]) / DAY_MS);
59
+ const constant = days.every((d, i) => i === 0 || Math.round((d - days[i - 1]) / DAY_MS) === step);
60
+ if (constant && step !== 0) return { kind: 'date', step };
61
+ if (constant) return { kind: 'copy', step: 0 };
62
+ return { kind: 'cycle', step: 0 };
63
+ }
64
+
65
+ // "Item 1", "Item 2": one prefix and suffix around a number.
66
+ const parts = values.map((v) => (typeof v === 'string' ? TRAILING_NUMBER.exec(v) : null));
67
+ if (parts.every(Boolean)) {
68
+ const prefix = parts[0][1];
69
+ const suffix = parts[0][3];
70
+ if (parts.every((p) => p[1] === prefix && p[3] === suffix)) {
71
+ const nums = parts.map((p) => Number(p[2]));
72
+ const step = nums[1] - nums[0];
73
+ const constant = nums.every((n, i) => i === 0 || n - nums[i - 1] === step);
74
+ if (constant && step !== 0) {
75
+ return { kind: 'text-number', step, prefix, suffix, pad: parts[0][2].length };
76
+ }
77
+ }
78
+ }
79
+
80
+ return { kind: 'cycle', step: 0 };
81
+ }
82
+
83
+ /**
84
+ * The `count` values a fill writes after `values` (the seed, in fill order).
85
+ * A series keeps its seed's type: number seeds give numbers, text seeds text
86
+ * (with the seed's decimals), `Date` seeds `Date`s and `YYYY-MM-DD` seeds
87
+ * `YYYY-MM-DD`. A repeat hands back the seed's own values.
88
+ *
89
+ * @param {any[]} values
90
+ * @param {number} count
91
+ * @param {{forceCopy?: boolean}} [options] `forceCopy`: repeat the block
92
+ * even when it is a series (Ctrl held).
93
+ * @returns {any[]}
94
+ */
95
+ export function projectSeries(values, count, { forceCopy = false } = {}) {
96
+ const out = [];
97
+ const n = Math.max(0, Math.floor(Number(count) || 0));
98
+ if (!Array.isArray(values) || values.length === 0 || n === 0) return out;
99
+ const series = forceCopy ? null : detectSeries(values);
100
+ const last = values[values.length - 1];
101
+ if (series?.kind === 'number') {
102
+ const base = toNumber(last);
103
+ const decimals = Math.min(15, Math.max(...values.map(decimalsOf)));
104
+ const allNumbers = values.every((v) => typeof v === 'number');
105
+ for (let i = 1; i <= n; i++) {
106
+ const next = base + series.step * i;
107
+ out.push(allNumbers ? Number(next.toFixed(decimals)) : next.toFixed(decimals));
108
+ }
109
+ return out;
110
+ }
111
+ if (series?.kind === 'date') {
112
+ // Calendar days, not 24-hour blocks: a `Date` keeps its wall-clock
113
+ // time across a daylight-saving change (local calendar), and a
114
+ // YYYY-MM-DD text stays on the UTC calendar it was parsed on.
115
+ const asDate = last instanceof Date;
116
+ for (let i = 1; i <= n; i++) {
117
+ if (asDate) {
118
+ const next = new Date(last.getTime());
119
+ next.setDate(next.getDate() + series.step * i);
120
+ out.push(next);
121
+ } else {
122
+ const next = new Date(toDayMs(last));
123
+ next.setUTCDate(next.getUTCDate() + series.step * i);
124
+ out.push(next.toISOString().slice(0, 10));
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+ if (series?.kind === 'text-number') {
130
+ const base = Number(TRAILING_NUMBER.exec(last)[2]);
131
+ for (let i = 1; i <= n; i++) {
132
+ const next = base + series.step * i;
133
+ const digits = String(Math.abs(next)).padStart(series.pad, '0');
134
+ out.push(`${series.prefix}${next < 0 ? '-' : ''}${digits}${series.suffix}`);
135
+ }
136
+ return out;
137
+ }
138
+ for (let i = 0; i < n; i++) out.push(values[i % values.length]);
139
+ return out;
140
+ }
141
+
142
+ /** A number, or a text that is exactly one; never NaN. */
143
+ function toNumber(value) {
144
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
145
+ if (typeof value !== 'string') return null;
146
+ const text = value.trim();
147
+ return NUMERIC_TEXT.test(text) ? Number(text) : null;
148
+ }
149
+
150
+ /** A `Date`, or a `YYYY-MM-DD` text (read as UTC midnight), in ms. */
151
+ function toDayMs(value) {
152
+ if (value instanceof Date) {
153
+ const t = value.getTime();
154
+ return Number.isNaN(t) ? null : t;
155
+ }
156
+ if (typeof value !== 'string' || !ISO_DATE.test(value.trim())) return null;
157
+ const text = value.trim();
158
+ const t = new Date(`${text}T00:00:00Z`).getTime();
159
+ // 2026-02-30 parses to March 2nd in some engines: not a date.
160
+ if (Number.isNaN(t) || new Date(t).toISOString().slice(0, 10) !== text) return null;
161
+ return t;
162
+ }
163
+
164
+ /** The decimals a seed value is written with ("2.50" → 2, 0.1 → 1). */
165
+ function decimalsOf(value) {
166
+ const text = typeof value === 'number' ? String(value) : String(value).trim();
167
+ if (/e/i.test(text)) return 15;
168
+ return (text.split('.')[1] || '').length;
169
+ }
170
+
171
+ function nearlyEqual(a, b) {
172
+ return Math.abs(a - b) < 1e-9;
173
+ }