flexdesk 0.4.7 → 0.6.0

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 (76) hide show
  1. package/README.md +1036 -301
  2. package/css/base.css +2958 -2
  3. package/css/flexdesk.css +2962 -4
  4. package/css/overrides.css +4 -2
  5. package/css/tokens.css +86 -0
  6. package/dist/canvas.js +390 -0
  7. package/dist/canvas.js.map +7 -0
  8. package/dist/charts.js +782 -782
  9. package/dist/{chunk-LH5TSOZW.js → chunk-6JSOVNID.js} +42 -2
  10. package/dist/{chunk-LH5TSOZW.js.map → chunk-6JSOVNID.js.map} +3 -3
  11. package/dist/{chunk-ELXVW542.js → chunk-6NG7YVBG.js} +3 -471
  12. package/dist/chunk-6NG7YVBG.js.map +7 -0
  13. package/dist/{chunk-F5FYDCOA.js → chunk-CNXP3SIN.js} +2373 -2373
  14. package/dist/{chunk-F5FYDCOA.js.map → chunk-CNXP3SIN.js.map} +7 -7
  15. package/dist/chunk-H4LT43RS.js +475 -0
  16. package/dist/chunk-H4LT43RS.js.map +7 -0
  17. package/dist/{chunk-WMGOJ3I6.js → chunk-KH2GJTKR.js} +3702 -2628
  18. package/dist/chunk-KH2GJTKR.js.map +7 -0
  19. package/dist/chunk-SN4LSOAR.js +532 -0
  20. package/dist/chunk-SN4LSOAR.js.map +7 -0
  21. package/dist/flexdesk.css +2962 -4
  22. package/dist/flow.js +10030 -0
  23. package/dist/flow.js.map +7 -0
  24. package/dist/tiles.js +2195 -2195
  25. package/dist/tokens.css +86 -0
  26. package/dist/widgets.js +5824 -5796
  27. package/dist/widgets.js.map +1 -1
  28. package/dist/wm.js +8077 -8074
  29. package/dist/wm.js.map +7 -7
  30. package/package.json +89 -81
  31. package/src/canvas/canvas_adapter.js +523 -0
  32. package/src/canvas/edge_router.js +392 -0
  33. package/src/canvas/graph/connector_router.js +189 -0
  34. package/src/canvas/graph/history_service.js +170 -0
  35. package/src/canvas/graph/node_platform.js +116 -0
  36. package/src/canvas/index.js +16 -0
  37. package/src/flow/canvas_editor/editor.js +1081 -0
  38. package/src/flow/canvas_editor/index.js +16 -0
  39. package/src/flow/canvas_editor/layout.js +118 -0
  40. package/src/flow/canvas_editor/strings.js +37 -0
  41. package/src/flow/kit/always_before.js +325 -0
  42. package/src/flow/kit/catalogue.js +170 -0
  43. package/src/flow/kit/chip_input.js +461 -0
  44. package/src/flow/kit/dom.js +205 -0
  45. package/src/flow/kit/findings.js +129 -0
  46. package/src/flow/kit/history.js +173 -0
  47. package/src/flow/kit/index.js +38 -0
  48. package/src/flow/kit/keys.js +127 -0
  49. package/src/flow/kit/logic_graph.js +210 -0
  50. package/src/flow/kit/references.js +199 -0
  51. package/src/flow/kit/settings_panel.js +357 -0
  52. package/src/flow/kit/settings_schema.js +175 -0
  53. package/src/flow/kit/step_picker.js +209 -0
  54. package/src/flow/kit/strings.js +102 -0
  55. package/src/flow/kit/value_picker.js +238 -0
  56. package/src/flow/kit/widgets.js +763 -0
  57. package/src/flow/lanes/editor.js +1469 -0
  58. package/src/flow/lanes/index.js +26 -0
  59. package/src/flow/lanes/layout.js +525 -0
  60. package/src/flow/lanes/pipeline.js +327 -0
  61. package/src/flow/lanes/preview.js +109 -0
  62. package/src/flow/lanes/strings.js +98 -0
  63. package/src/flow/outline/editor.js +1511 -0
  64. package/src/flow/outline/index.js +20 -0
  65. package/src/flow/outline/mapping.js +286 -0
  66. package/src/flow/outline/menu.js +154 -0
  67. package/src/flow/outline/operations.js +738 -0
  68. package/src/flow/outline/recognise.js +755 -0
  69. package/src/flow/outline/strings.js +135 -0
  70. package/src/tiling/landing_table.js +13 -5
  71. package/src/tiling/wm.js +3916 -3916
  72. package/src/ui/components/data_table.js +4205 -3294
  73. package/src/ui/components/row_activation.js +70 -0
  74. package/src/ui/components/table_values.js +423 -0
  75. package/dist/chunk-ELXVW542.js.map +0 -7
  76. package/dist/chunk-WMGOJ3I6.js.map +0 -7
package/README.md CHANGED
@@ -1,301 +1,1036 @@
1
- # FlexDesk
2
-
3
- A tiling window manager and widget set for desktop-class web applications.
4
-
5
- Tiles that split and tab, a command palette, dockable panels, virtual desktops, a
6
- DataTable with sticky headers and CSV export, managed windows, modals, toasts,
7
- trees and context menus.
8
-
9
- **Zero runtime dependencies.** No React, no framework, nothing. If you install
10
- FlexDesk, you install FlexDesk.
11
-
12
- FlexDesk was extracted from [EcoAgent](https://github.com/nor-os/EcoAgent), a
13
- macroeconomic simulation platform, and it knows nothing about economics: no
14
- application vocabulary, no import of anything application-specific, in any
15
- shipped file.
16
-
17
- ```bash
18
- npm install flexdesk
19
- ```
20
-
21
- Or vendor `dist/` and load it through an import map — no bundler, no build step.
22
- That is exactly what EcoAgent does:
23
-
24
- ```html
25
- <script type="importmap">
26
- { "imports": {
27
- "@flexdesk/core": "./vendor/flexdesk/core.js",
28
- "@flexdesk/host": "./vendor/flexdesk/host.js",
29
- "@flexdesk/wm": "./vendor/flexdesk/wm.js",
30
- "@flexdesk/widgets": "./vendor/flexdesk/widgets.js"
31
- } }
32
- </script>
33
- <link href="./vendor/flexdesk/flexdesk.css" rel="stylesheet" />
34
- ```
35
-
36
- ## Seven entry points
37
-
38
- Because someone who wants a DataTable should not have to download a window
39
- manager, and neither of them should drag in a plotting engine or a code editor.
40
-
41
- | Entry | Size | What is in it |
42
- |---|---|---|
43
- | `@flexdesk/core` | 30 kB | Event bus, settings store, logging, state machine, state guard |
44
- | `@flexdesk/host` | 5 kB | The host port (below) |
45
- | `@flexdesk/wm` | 166 kB | Tiling window manager: tabs, splits, desktops, command palette, taxonomy, `createShell()` |
46
- | `@flexdesk/widgets` | 192 kB | DataTable, modals, managed windows, toasts, trees, panels, context menus |
47
- | `@flexdesk/charts` | 26 kB | Plotly wrapper, chart types, subplot layouts, downsampling, plot windows |
48
- | `@flexdesk/tiles` | 67 kB | Draggable/resizable widget grid, tile base class, widget registry, layout persistence |
49
- | `@flexdesk/editor` | 62 kB | Monaco loader + factory, multi-file tab bar, search, split panes, undo manager |
50
-
51
- They share code through chunks, so importing two of them does **not** give you two
52
- event buses — and therefore not two copies of every module-level singleton. A test
53
- asserts each kernel symbol is defined exactly once across the whole `dist/`.
54
-
55
- Two different things here are called "tiles", which is unfortunate but honest to
56
- the code: `@flexdesk/wm` tiles the *window*; `@flexdesk/tiles` is a dashboard
57
- *widget grid*.
58
-
59
- ## Hello, shell
60
-
61
- ```js
62
- import { createShell, createTaxonomy, createEntityCatalog } from '@flexdesk/wm';
63
- import { createHost } from '@flexdesk/host';
64
- import { DataTable } from '@flexdesk/widgets';
65
-
66
- const store = new Map();
67
-
68
- const shell = await createShell({
69
- root: document.getElementById('app'),
70
-
71
- // YOUR ontology. FlexDesk has none.
72
- taxonomy: createTaxonomy({
73
- root: 'cosmos',
74
- kinds: {
75
- cosmos: { label: 'Cosmos', isTopNav: true, sources: ['planet'] },
76
- planet: { label: 'Planet', icon: 'circle' },
77
- moon: {
78
- label: 'Moon',
79
- // "jupiter/europa" — your naming convention, not the library's.
80
- ancestors: ({ id }) => {
81
- const i = String(id).indexOf('/');
82
- return i > 0 ? [{ kind: 'planet', id: String(id).slice(0, i) }] : [];
83
- },
84
- },
85
- },
86
- }),
87
-
88
- entities: createEntityCatalog({ sources: [/* what the palette searches */] }),
89
- content: { planet: (hostEl, props) => { /* render a tile */ } },
90
-
91
- // Where persistence goes. An in-memory Map is a perfectly good host.
92
- host: createHost({
93
- state: {
94
- read: async (k) => store.get(k) ?? null,
95
- write: async (k, v) => (store.set(k, v), true),
96
- },
97
- }),
98
-
99
- // Chrome is ELEMENTS, not selectors. The library never guesses at your markup.
100
- chrome: { topNav: document.getElementById('nav') },
101
- });
102
- ```
103
-
104
- A working version of exactly this — planets and moons, a mock host, no backend —
105
- lives in `demo/`.
106
-
107
- ### Opt-in shell features
108
-
109
- These change what the user sees, so all of them are off until you ask:
110
-
111
- ```js
112
- await createShell({
113
- // …everything above…
114
- snapPromotion: true, // drag a floating window onto a tile: it snaps, and docks back in
115
- promoteInPlace: true, // a window floated out of a tile stays inside that tile's pane
116
- backToOpenList: true, // Back in a record opened beside its list closes onto that list
117
- floatActiveTab: true, // floating a tile takes the tab on screen, not the whole pane
118
- chrome: {
119
- topNav: document.getElementById('nav'),
120
- zoom: document.getElementById('status-bar'), // content zoom, 50–200%
121
- },
122
- });
123
- ```
124
-
125
- **`chrome.zoom`** paints a − / track / + / readout control into the element you
126
- pass, restores the saved zoom through the host's `state` capability, and scales
127
- the content of tiles and floating windows. It deliberately scales *content only*
128
- — never the root, a tile's frame or a window's frame — because CSS `zoom`
129
- establishes a scaled coordinate space and every window drag, resize and snap
130
- measures in real pixels. `shell.chrome.zoom` exposes `get()` and `set(percent)`
131
- for a settings pane. An embedder that builds its own `WindowManager` instead of
132
- calling `createShell` mounts the same control with `mountZoomControl(el, { root,
133
- host })`.
134
-
135
- **`backToOpenList`** is for apps that open a record from a list in a new tab.
136
- Without it, Back in that record has no history to pop, so it walks the taxonomy
137
- up and rewrites the record into its parent, and the tile now shows the list
138
- twice. With it, Back closes the record tab and activates the list it came from:
139
- the nearest tab to the left, else to the right, that is not a record (no
140
- `props.id`) and belongs to the same top-nav section. Per-tab history still comes
141
- first, and a record with no such list beside it still walks up in place.
142
-
143
- **`floatActiveTab`** is for apps whose tabs are separate records rather than
144
- views of one pane. The float button, Alt+F and the tile menu take only the tab
145
- on screen and leave its siblings in the tile, so the window holds one tab and
146
- draws no strip. Without it the whole pane floats, strip and all.
147
-
148
- **Back and the breadcrumb are one rule.** `wm.navigateBack()` (Backspace) and
149
- `wm.navigateUp(kind, props, { ctx })` (every breadcrumb crumb) share it. With
150
- `backToOpenList`, a record whose section has a list open closes onto that list;
151
- otherwise Back walks the taxonomy up and a crumb navigates to its level, in
152
- place. A crumb naming an entity, such as an epic above a story, opens that
153
- entity. `navigate` stays the verb for going to exactly a target; use
154
- `navigateUp` for anything that means "up to this level".
155
-
156
- A filter or other props passed to `openInPrimary` for a list that is already
157
- showing are applied to it. A bare open, such as a top-nav click, keeps the tab
158
- as you left it.
159
-
160
- **Back in a floating window** always acts on that window, whichever option is
161
- set. The last click decides: inside a window, Backspace walks that window up;
162
- anywhere else, it walks the focused tile as before. With `backToOpenList`, a
163
- record in a window closes onto an open list of its section, first among the
164
- window's own tabs and then in any tile of its desktop.
165
-
166
- **A page's own levels come first** (0.4.7). A page can have levels that are not
167
- tabs, such as a section whose list opens one member in place of itself. Return
168
- `navigateBack()` from the content factory's mount, and Backspace asks it before
169
- anything above: it climbs one of the page's own levels and returns `true`. Only
170
- a strict `true` counts. `false`, any other value, a missing function or a throw
171
- (logged as a warning) all let Back walk the tab history, the open list and the
172
- taxonomy exactly as before. It is asked in the same place as the rest of Back:
173
- the window you last clicked in, else the focused tile. Return
174
- `canNavigateBack()` as well, and `wm.canNavigateBack()` reports the page's own
175
- levels, for that same window or tile. `TileRenderer.contentOf(leafId)` reads
176
- what a tile's factory returned. To draw the page's levels, call
177
- `renderBreadcrumb(container, segments)` from `@flexdesk/wm`, with
178
- `segments = [{ label, icon?, onClick? }]`. It uses the tile breadcrumb's
179
- markup, so it looks the same, and each `onClick` is yours: the last segment,
180
- and any without `onClick`, is drawn as plain text.
181
-
182
- ### Column sizing
183
-
184
- `fitColumns(columns, avail)` (from `@flexdesk/widgets`) turns per-column
185
- measurements into widths. Pass each column's widest cell (`natural`), the width
186
- most cells fit (`typical`), the header label (`header`) and any user-dragged
187
- width (`pinned`). Short columns such as ids, dates and statuses keep their full
188
- values, and free-text columns (`text`, or anything 240px and wider) take the
189
- rest. When space runs short, short columns lose their rare outliers first,
190
- then text shrinks to a floor. The widths are fractional and add up to `avail`
191
- exactly whenever the floors fit. `DataTable` does not use it yet; an embedder
192
- that measures its own columns can.
193
-
194
- ### Sorting a paged DataTable
195
-
196
- A client-paged `DataTable` (no `onPageChange`) keeps its page when it is
197
- sorted, so a sort on page 3 shows rows 201–300 of the new order. Pass
198
- `resetPageOnSort: true` (0.4.7) to start every new order at the first page, a
199
- header click included, the way a new filter already does. It is off by default
200
- because keeping the page is what every earlier release did. Server-side paging
201
- is not affected: its page is your `offset`, which a sort never changes.
202
-
203
- ### Scrollbars
204
-
205
- `installAutoScrollbars(root)` (from `@flexdesk/widgets`) replaces the browser's
206
- scrollbars under `root` with thin overlay bars that show while an element is
207
- hovered or scrolling and fade out afterwards. Native bars are hidden, so no
208
- element reserves layout width for one. The overlay bars live in one fixed
209
- layer, so a container that redraws its contents cannot lose its bar, and
210
- textareas get one too. Containers that already use `installOverlayScrollbar`
211
- keep their own.
212
-
213
- ## The host port
214
-
215
- FlexDesk never touches `pywebview`, `fetch`, `localStorage` or the filesystem. It
216
- asks a **Host**, and you supply one. `createHost({...})` validates the capabilities
217
- you pass and freezes the result; anything you leave out is simply absent, and the
218
- library degrades rather than throwing.
219
-
220
- ```js
221
- createHost({
222
- state: { read, write }, // layout persistence
223
- dialogs: { saveFile }, // optional — CSV export falls back to a Blob
224
- window: { minimize, maximize, ... }, // optional — no native chrome buttons without it
225
- })
226
- ```
227
-
228
- `NULL_HOST` is a valid host with nothing behind it.
229
-
230
- ## Peer dependencies
231
-
232
- `@flexdesk/charts` needs Plotly (4.4 MB). `@flexdesk/editor` needs Monaco (14 MB).
233
- Neither is bundled, and **neither is guessed at**:
234
-
235
- ```js
236
- import { setPlotlySource } from '@flexdesk/charts';
237
- import { setMonacoBasePath, setDefaultLanguage } from '@flexdesk/editor';
238
-
239
- setPlotlySource('/vendor/plotly/plotly.min.js');
240
- setMonacoBasePath('/vendor/monaco');
241
- setDefaultLanguage('my-dsl'); // default: 'plaintext'
242
- ```
243
-
244
- There is deliberately **no default path**. A hardcoded `'vendor/monaco'` is one
245
- application's directory layout, and for everyone else it is a 404 that surfaces as
246
- an editor which silently never appears. `initMonaco()` throws instead.
247
-
248
- If you already load Plotly yourself (a `<script>` tag that sets `window.Plotly`),
249
- the chart loader short-circuits and you can skip `setPlotlySource`.
250
-
251
- ## Layouts are keyed by whatever you key them by
252
-
253
- `@flexdesk/tiles` stores layouts under keys **you** choose and resolves a starting
254
- layout through a callback **you** supply:
255
-
256
- ```js
257
- import { configureLayoutPersistence } from '@flexdesk/tiles';
258
-
259
- configureLayoutPersistence({
260
- layoutsKey: 'myapp.layouts', // default: 'twm.tiles.layouts'
261
- resolveTemplate: (ctx) => pickTemplate(ctx) ?? null, // null => empty grid
262
- });
263
- ```
264
-
265
- **Configure it before anything reads it.** The store is a singleton; built before
266
- you supply your key, it is built with the default, and every layout your users have
267
- saved becomes silently invisible. That is a data-loss bug that raises no error, so
268
- the configuration step is explicit rather than lazy.
269
-
270
- A layout is keyed by *something* — a document, a user, a run — and the library has
271
- no opinion about which. It calls that a `contextId`.
272
-
273
- ## Styling
274
-
275
- Three stylesheets, and that is the whole CSS contract:
276
-
277
- | | |
278
- |---|---|
279
- | `dist/tokens.css` | Every rule reads `var(--…)` from here. Swap this one file and the shell restyles. |
280
- | `dist/reset.css` | The reset. |
281
- | `dist/flexdesk.css` | The library. Every class is `twm-` prefixed (the original working name; the prefix is stable and not worth a breaking rename). |
282
-
283
- Icons are rendered by applying the `material-symbols-outlined` class. FlexDesk does
284
- not ship the font — supply it from Google Fonts or self-host. Without it, icons fall
285
- back to their text names and nothing breaks.
286
-
287
- ## Build
288
-
289
- ```bash
290
- npm install
291
- npm run build # -> dist/
292
- ```
293
-
294
- `dist/` is committed. The packaging decision is *prebuilt dist*: this repo owns the
295
- build so that a consumer with no bundler can load the library through a plain import
296
- map. See `.gitignore` — it says so there too, because it is the kind of thing someone
297
- "tidies up" later.
298
-
299
- ## Licence
300
-
301
- MIT. See [`LICENSE`](LICENSE) and [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
1
+ # FlexDesk
2
+
3
+ A tiling window manager and widget set for desktop-class web applications.
4
+
5
+ Tiles that split and tab, a command palette, dockable panels, virtual desktops, a
6
+ DataTable with sticky headers and CSV export, managed windows, modals, toasts,
7
+ trees and context menus.
8
+
9
+ **Zero runtime dependencies.** No React, no framework, nothing. If you install
10
+ FlexDesk, you install FlexDesk.
11
+
12
+ FlexDesk was extracted from [EcoAgent](https://github.com/nor-os/EcoAgent), a
13
+ macroeconomic simulation platform, and it knows nothing about economics: no
14
+ application vocabulary, no import of anything application-specific, in any
15
+ shipped file.
16
+
17
+ ```bash
18
+ npm install flexdesk
19
+ ```
20
+
21
+ Or vendor `dist/` and load it through an import map — no bundler, no build step.
22
+ That is exactly what EcoAgent does:
23
+
24
+ ```html
25
+ <script type="importmap">
26
+ { "imports": {
27
+ "@flexdesk/core": "./vendor/flexdesk/core.js",
28
+ "@flexdesk/host": "./vendor/flexdesk/host.js",
29
+ "@flexdesk/wm": "./vendor/flexdesk/wm.js",
30
+ "@flexdesk/widgets": "./vendor/flexdesk/widgets.js"
31
+ } }
32
+ </script>
33
+ <link href="./vendor/flexdesk/flexdesk.css" rel="stylesheet" />
34
+ ```
35
+
36
+ ## Nine entry points
37
+
38
+ Because someone who wants a DataTable should not have to download a window
39
+ manager, and neither of them should drag in a plotting engine or a code editor.
40
+
41
+ | Entry | Size | What is in it |
42
+ |---|---|---|
43
+ | `@flexdesk/core` | 30 kB | Event bus, settings store, logging, state machine, state guard |
44
+ | `@flexdesk/host` | 5 kB | The host port (below) |
45
+ | `@flexdesk/wm` | 166 kB | Tiling window manager: tabs, splits, desktops, command palette, taxonomy, `createShell()` |
46
+ | `@flexdesk/widgets` | 192 kB | DataTable, modals, managed windows, toasts, trees, panels, context menus |
47
+ | `@flexdesk/charts` | 26 kB | Plotly wrapper, chart types, subplot layouts, downsampling, plot windows |
48
+ | `@flexdesk/tiles` | 67 kB | Draggable/resizable widget grid, tile base class, widget registry, layout persistence |
49
+ | `@flexdesk/editor` | 62 kB | Monaco loader + factory, multi-file tab bar, search, split panes, undo manager |
50
+ | `@flexdesk/canvas` | 14 kB | The canvas kernel: a pannable, zoomable surface, the edge router, a generic graph layer ([Flow editors](#flow-editors)) |
51
+ | `@flexdesk/flow` | 381 kB | The flow-editor kit and the canvas, outline and lane editors ([Flow editors](#flow-editors)) |
52
+
53
+ The sizes of `@flexdesk/canvas` and `@flexdesk/flow` are the entry files as
54
+ `node build.mjs` reported them for the flow editors' integration build; like
55
+ every entry they also import shared chunks. The other rows are older figures.
56
+
57
+ They share code through chunks, so importing two of them does **not** give you two
58
+ event buses — and therefore not two copies of every module-level singleton. A test
59
+ asserts each kernel symbol is defined exactly once across the whole `dist/`.
60
+
61
+ Two different things here are called "tiles", which is unfortunate but honest to
62
+ the code: `@flexdesk/wm` tiles the *window*; `@flexdesk/tiles` is a dashboard
63
+ *widget grid*.
64
+
65
+ ## Hello, shell
66
+
67
+ ```js
68
+ import { createShell, createTaxonomy, createEntityCatalog } from '@flexdesk/wm';
69
+ import { createHost } from '@flexdesk/host';
70
+ import { DataTable } from '@flexdesk/widgets';
71
+
72
+ const store = new Map();
73
+
74
+ const shell = await createShell({
75
+ root: document.getElementById('app'),
76
+
77
+ // YOUR ontology. FlexDesk has none.
78
+ taxonomy: createTaxonomy({
79
+ root: 'cosmos',
80
+ kinds: {
81
+ cosmos: { label: 'Cosmos', isTopNav: true, sources: ['planet'] },
82
+ planet: { label: 'Planet', icon: 'circle' },
83
+ moon: {
84
+ label: 'Moon',
85
+ // "jupiter/europa" — your naming convention, not the library's.
86
+ ancestors: ({ id }) => {
87
+ const i = String(id).indexOf('/');
88
+ return i > 0 ? [{ kind: 'planet', id: String(id).slice(0, i) }] : [];
89
+ },
90
+ },
91
+ },
92
+ }),
93
+
94
+ entities: createEntityCatalog({ sources: [/* what the palette searches */] }),
95
+ content: { planet: (hostEl, props) => { /* render a tile */ } },
96
+
97
+ // Where persistence goes. An in-memory Map is a perfectly good host.
98
+ host: createHost({
99
+ state: {
100
+ read: async (k) => store.get(k) ?? null,
101
+ write: async (k, v) => (store.set(k, v), true),
102
+ },
103
+ }),
104
+
105
+ // Chrome is ELEMENTS, not selectors. The library never guesses at your markup.
106
+ chrome: { topNav: document.getElementById('nav') },
107
+ });
108
+ ```
109
+
110
+ A working version of exactly this — planets and moons, a mock host, no backend —
111
+ lives in `demo/`.
112
+
113
+ ### Opt-in shell features
114
+
115
+ These change what the user sees, so all of them are off until you ask:
116
+
117
+ ```js
118
+ await createShell({
119
+ // …everything above…
120
+ snapPromotion: true, // drag a floating window onto a tile: it snaps, and docks back in
121
+ promoteInPlace: true, // a window floated out of a tile stays inside that tile's pane
122
+ backToOpenList: true, // Back in a record opened beside its list closes onto that list
123
+ floatActiveTab: true, // floating a tile takes the tab on screen, not the whole pane
124
+ chrome: {
125
+ topNav: document.getElementById('nav'),
126
+ zoom: document.getElementById('status-bar'), // content zoom, 50–200%
127
+ },
128
+ });
129
+ ```
130
+
131
+ **`chrome.zoom`** paints a − / track / + / readout control into the element you
132
+ pass, restores the saved zoom through the host's `state` capability, and scales
133
+ the content of tiles and floating windows. It deliberately scales *content only*
134
+ — never the root, a tile's frame or a window's frame — because CSS `zoom`
135
+ establishes a scaled coordinate space and every window drag, resize and snap
136
+ measures in real pixels. `shell.chrome.zoom` exposes `get()` and `set(percent)`
137
+ for a settings pane. An embedder that builds its own `WindowManager` instead of
138
+ calling `createShell` mounts the same control with `mountZoomControl(el, { root,
139
+ host })`.
140
+
141
+ **`backToOpenList`** is for apps that open a record from a list in a new tab.
142
+ Without it, Back in that record has no history to pop, so it walks the taxonomy
143
+ up and rewrites the record into its parent, and the tile now shows the list
144
+ twice. With it, Back closes the record tab and activates the list it came from:
145
+ the nearest tab to the left, else to the right, that is not a record (no
146
+ `props.id`) and belongs to the same top-nav section. Per-tab history still comes
147
+ first, and a record with no such list beside it still walks up in place.
148
+
149
+ **`floatActiveTab`** is for apps whose tabs are separate records rather than
150
+ views of one pane. The float button, Alt+F and the tile menu take only the tab
151
+ on screen and leave its siblings in the tile, so the window holds one tab and
152
+ draws no strip. Without it the whole pane floats, strip and all.
153
+
154
+ **Back and the breadcrumb are one rule.** `wm.navigateBack()` (Backspace) and
155
+ `wm.navigateUp(kind, props, { ctx })` (every breadcrumb crumb) share it. With
156
+ `backToOpenList`, a record whose section has a list open closes onto that list;
157
+ otherwise Back walks the taxonomy up and a crumb navigates to its level, in
158
+ place. A crumb naming an entity, such as an epic above a story, opens that
159
+ entity. `navigate` stays the verb for going to exactly a target; use
160
+ `navigateUp` for anything that means "up to this level".
161
+
162
+ A filter or other props passed to `openInPrimary` for a list that is already
163
+ showing are applied to it. A bare open, such as a top-nav click, keeps the tab
164
+ as you left it.
165
+
166
+ **Back in a floating window** always acts on that window, whichever option is
167
+ set. The last click decides: inside a window, Backspace walks that window up;
168
+ anywhere else, it walks the focused tile as before. With `backToOpenList`, a
169
+ record in a window closes onto an open list of its section, first among the
170
+ window's own tabs and then in any tile of its desktop.
171
+
172
+ **A page's own levels come first** (0.4.7). A page can have levels that are not
173
+ tabs, such as a section whose list opens one member in place of itself. Return
174
+ `navigateBack()` from the content factory's mount, and Backspace asks it before
175
+ anything above: it climbs one of the page's own levels and returns `true`. Only
176
+ a strict `true` counts. `false`, any other value, a missing function or a throw
177
+ (logged as a warning) all let Back walk the tab history, the open list and the
178
+ taxonomy exactly as before. It is asked in the same place as the rest of Back:
179
+ the window you last clicked in, else the focused tile. Return
180
+ `canNavigateBack()` as well, and `wm.canNavigateBack()` reports the page's own
181
+ levels, for that same window or tile. `TileRenderer.contentOf(leafId)` reads
182
+ what a tile's factory returned. To draw the page's levels, call
183
+ `renderBreadcrumb(container, segments)` from `@flexdesk/wm`, with
184
+ `segments = [{ label, icon?, onClick? }]`. It uses the tile breadcrumb's
185
+ markup, so it looks the same, and each `onClick` is yours: the last segment,
186
+ and any without `onClick`, is drawn as plain text.
187
+
188
+ ### Column sizing
189
+
190
+ `fitColumns(columns, avail)` (from `@flexdesk/widgets`) turns per-column
191
+ measurements into widths. Pass each column's widest cell (`natural`), the width
192
+ most cells fit (`typical`), the header label (`header`) and any user-dragged
193
+ width (`pinned`). Short columns such as ids, dates and statuses keep their full
194
+ values, and free-text columns (`text`, or anything 240px and wider) take the
195
+ rest. When space runs short, short columns lose their rare outliers first,
196
+ then text shrinks to a floor. The widths are fractional and add up to `avail`
197
+ exactly whenever the floors fit. `DataTable` does not use it yet; an embedder
198
+ that measures its own columns can.
199
+
200
+ ### Sorting a paged DataTable
201
+
202
+ A client-paged `DataTable` (no `onPageChange`) keeps its page when it is
203
+ sorted, so a sort on page 3 shows rows 201–300 of the new order. Pass
204
+ `resetPageOnSort: true` (0.4.7) to start every new order at the first page, a
205
+ header click included, the way a new filter already does. It is off by default
206
+ because keeping the page is what every earlier release did. Server-side paging
207
+ is not affected: its page is your `offset`, which a sort never changes.
208
+
209
+ ### DataTable options in 0.5.0: lists that need no help
210
+
211
+ 0.5.0 adds options for the ordinary list in an application: one that sits in a
212
+ page, opens a row, marks the open one, and sorts dates as dates. Every option
213
+ is off until you set it. A table that sets none of them looks and behaves as it
214
+ did in 0.4, apart from the few changes listed at the end of this section. This
215
+ was measured in headless Edge: 64 cells in 7 default tables, an empty and a
216
+ loading one among them, computed the same under 0.4.7 and 0.5.0.
217
+
218
+ ```js
219
+ import { DataTable } from '@flexdesk/widgets';
220
+
221
+ // House rules once, then one call per list: build AND draw.
222
+ const ListTable = DataTable.withDefaults({
223
+ mode: 'compact', sortable: true, pagination: false,
224
+ fitContent: true, firstColumn: 'plain', autoDispose: true,
225
+ });
226
+
227
+ const table = ListTable.mount(host, {
228
+ headers: ['Name', 'Role', 'Last seen', 'Took'],
229
+ rows, // values, not formatted text
230
+ columnTypes: [null, null, 'datetime', 'duration'],
231
+ getRowKey: (row) => row[0],
232
+ activeRow: openName,
233
+ onRowActivate: (i, row) => open(row), // one click opens
234
+ rowClass: (row) => (row[1] === 'Suspended' ? 'is-off' : null),
235
+ emptyState: 'Nobody yet.',
236
+ });
237
+ ```
238
+
239
+ **Layout**
240
+
241
+ | Option | What it does |
242
+ |---|---|
243
+ | `fitContent: true` | Makes the table as tall as its rows. By default the wrapper is `height: 100%`. In a parent without a height (an ordinary block in a page that scrolls) that resolves to nothing, so the header draws and the rows do not. |
244
+ | `maxHeight: 240` | Implies `fitContent` and caps the table at this height. A number is px; a string is used as CSS. Past the cap, the body scrolls under its header. |
245
+ | `firstColumn: 'plain'` | Styles the first column like the others. By default it is styled as a time column: 120–150px wide, grey, weight 500. |
246
+ | `--twm-dt-cell-pad-block`, `--twm-dt-cell-pad-inline`, `--twm-dt-cell-line-height` | Three CSS tokens. Set them on `:root` or on any ancestor to give both densities one row height. They are unset by default, and then each density keeps its own 0.4 values. In Edge, with the tokens set to `2px`, `6px` and `1.35`, normal and compact rows both measured 21.19px. Without them, normal rows measured 27px. The normal density reads the line-height token on the table's `thead`/`tbody` and the cells inherit it, so your own line-height for the cells, the rows or the table still wins, from a cascade layer too. |
247
+
248
+ **Opening a row, and which one is open**
249
+
250
+ | Option | What it does |
251
+ |---|---|
252
+ | `onRowActivate(rowIdx, row, ev)` | Opens a row. It fires after the selection has been updated (`onRowClick` still fires before it, as in 0.4). It does not fire for a press on a control in the row (a button, link, field, label, `<summary>`, editable text, or `[data-twm-action]`), for a click that ends a drag selecting text in the table, for the second click of a double-click, or for the Ctrl click that takes a `selectable: 'single'` table's row off the selection. `ev.type` tells you which gesture fired it. If your `onSelectionChange` or `onRowClick` redraws the table (`setData`, `render`), the row that was pressed still opens; `row` is that row as it was drawn. |
253
+ | `activateOn` | Which gestures open a row: `'click'` (the default), `'dblclick'`, `'enter'`, or an array of them. A pick list, where one click selects, uses `['dblclick', 'enter']`. Enter opens the selected row (the one row selected, or the selection's anchor), which is the row a double-click on it would open. If nothing is selected, Enter opens the active row. |
254
+ | `clickable` | Adds `twm-dt--clickable` to the table, which gives rows a pointer cursor and a hover, readonly tables included. It defaults to on exactly when `onRowActivate` is set. |
255
+ | `selectable: 'single'` | Lets a click select exactly one row. Shift and Ctrl do not extend the selection, Ctrl+A selects nothing, and `setSelection` keeps the last index it is given. |
256
+ | `getRowKey(row, idx)`, `activeRow`, `setActiveRow(key)`, `getActiveRow()` | Mark the open row: `twm-dt-row--active` and `aria-current="true"`, drawn as an accent on the left edge. The mark is not the selection. It survives `setData`, sorting, filtering and paging, and a right-click does not move it. |
257
+
258
+ The landing helper `attachLandingTableBehavior` now uses the same rule as
259
+ `onRowActivate` to decide whether a click opens a row. That changes three
260
+ things, with no option (see the list at the end of this section).
261
+
262
+ **Row state**
263
+
264
+ | Option | What it does |
265
+ |---|---|
266
+ | `rowClass(row, idx)`, `rowAttrs(row, idx)` | Add classes and attributes to the row's `<tr>`. They follow the row through sorts and filters, and `updateRow` asks again, so a class the row no longer earns is removed. |
267
+ | `cellClass(value, colIdx, row, idx)` | Adds classes to a cell, after its type class. |
268
+ | `rowIcon(row, idx)` | Draws an icon (`'name'` or `{ icon, title, tone }`) at the start of the first cell. It is drawn from an attribute, so the icon's name is not part of the cell's text, its tooltip or a copy. |
269
+ | `emptyState` | Text, a node, or a function, shown in place of `emptyMessage`. It is hidden while `setLoading(true)` is on. |
270
+ | `setError(message \| node \| Error \| null)` | Shows a failure inside the table. If there are rows on screen, they stay, under a `role="alert"` banner. If there are none, the failure takes the place of the empty row. `setData({ rows })` and `setError(null)` clear it. |
271
+ | `showRowNumbers: 'position'` | Numbers rows 1, 2, 3 in the order shown, after a sort, a filter, or on a later page. `true` still numbers rows by their original index. |
272
+ | `nullDisplay: 'NULL'` | Draws `null` and `undefined` as this text, adds `twm-dt-cell--null` to the cell, and copies the text as well. |
273
+
274
+ **Values, not text**
275
+
276
+ | Option | What it does |
277
+ |---|---|
278
+ | `columnTypes: [..., 'date' \| 'datetime' \| 'duration']` | Sorts and filters the column by its value: an ISO 8601 string, a `Date` or epoch ms for dates, and milliseconds or text such as `'3.2 s'` for durations. A date-only value such as `2026-10-05` is a calendar date, so the time zone never moves it. Any other text, and a date that does not exist (`2026-02-31`), is shown as it came and sorts last. Non-ISO text is not passed to `Date.parse`, because browsers read it differently (V8 reads `'12'` as a date in 2001). Only `columnTypes` makes a column a typed column. A `getColumnType` that returns `'date'` still sets only the cell's class name, as in 0.4. |
279
+ | `dateFormat`, `dateTimeFormat` | **Default ISO: `YYYY-MM-DD` and `YYYY-MM-DD HH:mm`.** Pass a pattern (`DD.MM.YYYY`, `D MMM YYYY, h:mm a`, with `[literal]` text) or a function `(date) => string`, for every column or per column index. |
280
+ | `durationFormat` | `'auto'` (`850 ms`, `3.2 s`, `2m 5s`, `1h 2m`), `'clock'` (`1:02:05`), or a function. |
281
+ | `dateTimeZone: 'UTC'` | Reads and draws typed dates in UTC instead of the local zone. |
282
+ | `sortValue(value, colIdx, row)` | Sets what a column sorts by, for every column or (as an object or array) per column. Return `undefined` to fall back to the column's own rule. |
283
+
284
+ The column filters work on values. On a date column, `2026-10` matches October,
285
+ `>2026-10` matches after October, `<=2026-10-05` includes the 5th, and
286
+ `2026-01..2026-03` matches January through the end of March. A date operand is
287
+ ISO with two-digit parts, so an operator over a half-typed one (`>20`,
288
+ `>2026-1`) filters nothing until it is complete. Text without an operator that
289
+ is not such a date (`5 Oct`, `Oct`, `2026-1`) is matched against the cell as
290
+ drawn, so a filter narrows as you type it. On a duration column, `>1s`,
291
+ `<500ms` and `1s..1m` work. `formatDate`, `formatDuration`, `parseDateValue`
292
+ and `parseDuration` are exported, so text drawn elsewhere can use the same
293
+ words as the table.
294
+
295
+ **Housekeeping**
296
+
297
+ | Option | What it does |
298
+ |---|---|
299
+ | `DataTable.mount(host, config)` | Builds the table and draws it. The constructor still draws nothing until `render()`. |
300
+ | `DataTable.withDefaults(defaults)` | Returns a subclass whose `defaults` sit under every config it is given. `instanceof DataTable` holds, and it can be narrowed again. |
301
+ | `autoDispose: true` | Disposes the table once it has been taken out of the document and is still out a task later. It lets go of everything `dispose()` does, but it removes only the table's own elements and never empties the host. By then you may have mounted the next list into the host or written your own message there, and that stays. Do not set it on a table you detach and attach again later, such as a cached tab, because a disposed table comes back empty. |
302
+ | `resetColumnWidths()` | Forgets every dragged, fitted or restored width, the persisted ones included, and measures the columns again. Use it after a zoom or a font change. |
303
+
304
+ **Changes that need no option.** A table that sets none of the options above
305
+ behaves as it did in 0.4, except for these:
306
+
307
+ - The copy menu's document listeners (click, scroll, Escape, resize) now exist
308
+ only while the menu is open. Before 0.5.0, a table that had ever been
309
+ right-clicked kept them until `dispose()`.
310
+ - `attachLandingTableBehavior` judges a click by the `onRowActivate` rule:
311
+ - A double-click opens the row once, not twice.
312
+ - A click while text in the host is selected does not open the row. That is
313
+ the click that ends a drag across a cell.
314
+ - A click on a `<label>`, a `<summary>` or editable (`contenteditable`) text
315
+ in a row does not open the row either. 0.4.7 skipped only buttons, links
316
+ and form fields. A `[data-twm-action]` control still runs its action.
317
+ - The CSS reads the new tokens with the 0.4 values as fallbacks, so the
318
+ computed styles are the same while the tokens are unset. One exception: the
319
+ normal density's line-height token is declared on the `thead` and `tbody`.
320
+ A line-height rule of yours aimed at those two elements themselves now loses
321
+ to it if it is in a cascade layer, or has zero specificity and loads before
322
+ `flexdesk.css`. A rule for the cells, the rows or the table does not.
323
+
324
+ ### Scrollbars
325
+
326
+ `installAutoScrollbars(root)` (from `@flexdesk/widgets`) replaces the browser's
327
+ scrollbars under `root` with thin overlay bars that show while an element is
328
+ hovered or scrolling and fade out afterwards. Native bars are hidden, so no
329
+ element reserves layout width for one. The overlay bars live in one fixed
330
+ layer, so a container that redraws its contents cannot lose its bar, and
331
+ textareas get one too. Containers that already use `installOverlayScrollbar`
332
+ keep their own.
333
+
334
+ ## Flow editors
335
+
336
+ *New in 0.6.0.* Both entries first ship in FlexDesk 0.6.0.
337
+
338
+ `@flexdesk/flow` holds three editors for flows — a graph of steps, each with
339
+ settings — and the kit they share: a **canvas** (nodes and lines), an
340
+ **outline** (the graph drawn as a list of blocks) and **lanes** (data flows,
341
+ left to right). All three are domain-neutral. An editor never fetches, never
342
+ saves and never starts a timer that writes: the step types, the reference
343
+ grammar, every finding and every byte of I/O are the consumer's, handed in as
344
+ options and handed back through callbacks. `@flexdesk/canvas` is the kernel
345
+ under the canvas editor (and under any other diagram), on its own so that a
346
+ diagram does not download the editors.
347
+
348
+ Nothing here changes an existing entry. Both are new entry points, and their
349
+ CSS is four new sections at the end of `flexdesk.css`, read only by the classes
350
+ these modules set.
351
+
352
+ ```js
353
+ import {
354
+ createStepCatalogue, createWidgetRegistry, createSettingsPanel,
355
+ TEMPLATE_REFERENCES, FlowHistory, bindFlowKeys,
356
+ } from '@flexdesk/flow';
357
+ ```
358
+
359
+ ### The kit
360
+
361
+ The parts every editor stands on, exported for a consumer that builds its own.
362
+ A test (`tests/flow_kit_vocabulary.test.mjs`) fails when a file under
363
+ `src/flow/` or `src/canvas/` names a consumer's vocabulary, calls `fetch` or
364
+ touches browser storage — and is shown to fail on a planted one.
365
+
366
+ | Part | What it does |
367
+ |---|---|
368
+ | `createStepCatalogue(types, {categories, idBase})` | The step types the consumer passes in, read as they arrive (`type_id`, `label`, `category`, `role`, `description`, `icon`, `ports`, `config_schema`, `unavailable`; any other key is kept). `get`, `list`, `byCategory`, `role`, `tone`, `inputs(typeId, {flow})`, `outputs`, `port`, `idBase`, and `problems` — a broken type is listed there, never thrown. **Ports keep their declared order**: a lane reads the first input as its own and an outline draws the other outputs as arms in order, so nothing sorts them |
369
+ | `checkSettingsSchema(schema)` | The JSON-Schema subset a step's settings may use: `type`, `enum`, `const`, `required`, `properties`, `additionalProperties` (a boolean), `items`, `minItems`/`maxItems`, `minimum`/`maximum`, `minLength`/`maxLength`, `format: "uuid"`, the annotations and any `x-` hint. Returns the first refusal as the server's own sentence, or `null`. `oneOf`, `anyOf`, `$ref` and `pattern` are refused. `tests/fixtures/flow_kit_settings_schema.json` holds 30 schemas and their verdicts. Tables' own `check_schema` gave the same 30 verdicts when the file was written, and is meant to read this file in a test of its own |
370
+ | `createWidgetRegistry()` | The controls, by name. The ten generic widgets are registered already; `register(name, widget)` throws for a name that exists, and replacing one takes `{replace: true}`. A widget is `(spec, value, ctx) => {el, destroy?, focus?, caret?, restoreCaret?}` and reports with `ctx.set(value)`; `undefined` removes the setting |
371
+ | `createSettingsPanel(options)` | One step's settings, or the flow's own, drawn from the schema (below) |
372
+ | `createReferenceSyntax({name, pattern, format, describe, stepScope})` | What a chip is. Three are ready: `TEMPLATE_REFERENCES` (`${a.b[0]}`), `FORMULA_REFERENCES` (`[a.b]`), `PARAMETER_REFERENCES` (`{{name}}`). `find`, `format`, `rename(text, map)` and the consumer's `describe(match, ctx) → {label, tone, known}` |
373
+ | `createChipInput({value, syntaxes, multiline, readOnly, onInput})` | Text with references drawn as chips. **The value is always the plain text**; a chip is a view of it |
374
+ | `openStepPicker({anchor, entries, categories, where, paste})` | What "+" opens: grouped, searchable, ↑ ↓ and Enter; a refused entry is shown greyed with its reason. Resolves `{entry}`, `{paste: true}` or `null` |
375
+ | `openValuePicker({anchor, groups, note, onPick})` | *Insert a value*: the consumer's groups of values, a level down for a value with children (`body ›`), searchable across levels |
376
+ | `alwaysBefore(graph, catalogue, stepId, {loopPorts, waitsForAll})` | The steps that run before this one on every path, nearest first: the only ones *Insert a value* should offer. A parallel whose merge waits for every branch counts each branch's steps; a loop's body never counts for a step after the loop. `enclosingLoops` lists the loops around a step |
377
+ | `groupFindings(list)`, `createFindingsStrip({onGoTo, nameOf})` | The consumer's findings (`{code, message, severity, node_id?, field?}`) by step, and the strip: *"N things to fix before this can be published."*, a line each, *Go to it* |
378
+ | `new FlowHistory({actions, restore, onState, limit, mergeMs})` | Undo and redo over whole snapshots. The editor passes its own list of actions and `commit` throws on any other, so an edit nobody named fails its first test. Loading and publishing are `baseline`s, never entries. Keyed commits within `mergeMs` merge (typing a name is one entry), never across an undo. `keep()` and `adopt(kept, state)` carry the stacks to a successor that loaded the same bytes |
379
+ | `bindFlowKeys(root, handlers)`, `ownsUndo(target)` | An editor's keys, bound on its root, never on `window` (below) |
380
+ | `emptyGraph`, `normalise`, `serialise`, `nextId`, `addNode`, `removeNode`, `connect`, `canConnect`, `disconnect`, `inputNamesOf` | A logic flow's graph — `{nodes: [{id, type, label?, config, position}], connections: [{source, target, sourcePort, targetPort}]}`, the shape the canvas and the outline both save. `serialise` is byte-stable: a graph opened and saved with no edit is the same text |
381
+ | `createStrings(...layers)`, `FLOW_STRINGS` | Every word drawn, English and domain-free by default; an editor lays its own words over these, and the consumer lays its words over both |
382
+
383
+ **The settings panel.**
384
+
385
+ ```js
386
+ const panel = createSettingsPanel({
387
+ widgets, references: { template: TEMPLATE_REFERENCES },
388
+ onChange: (stepId, key, value) => apply(stepId, key, value), // you write it into your graph
389
+ onRename: (stepId, label) => rename(stepId, label),
390
+ onFocusLost: () => surface.focus(),
391
+ values: async ({ stepId, field }) => groupsFor(stepId), // Insert a value; absent, no "{ }"
392
+ columns: (step) => inputColumnsOf(step), // upstream-column(s); null in a logic flow
393
+ host,
394
+ });
395
+ panel.show({ step, type, title, typeLabel, where, description, idLine, icon, tone,
396
+ rename: true, extra: [...], actions: [...], slots: { before, after } });
397
+ panel.setFindings(findings); // under each field, the rest at the top; rebuilds nothing
398
+ panel.focusField('retry.max_attempts');
399
+ panel.repaintSlots(); // your areas only; no field is rebuilt
400
+ ```
401
+
402
+ It never writes into the step: a change is reported to `onChange`, and the
403
+ consumer writes it. A redraw puts focus back on the rebuilt control of the same
404
+ field, with the caret where it was, or hands it to `onFocusLost`, never to
405
+ `<body>`. Findings and slot redraws rebuild no field, so a run overlay that
406
+ refreshes every second leaves a name being typed alone. A control that cannot
407
+ be used is disabled and its reason is shown next to it, not only in a tooltip.
408
+ `extra` holds the editor's own fields (a port shown as a setting) and notes,
409
+ placed with `after: '<key>'` or `at: 'start'`.
410
+
411
+ **The widgets.** A schema type with no `x-ui-widget` draws its plain control: an
412
+ `enum` a select (writing the enum's own value, so a number stays a number), a
413
+ string a text box, a number a number box, a boolean a checkbox, an object with
414
+ properties a group of its own fields. A control never writes a value the reader
415
+ did not give it. An untouched setting stays absent, and emptying one removes it.
416
+ A list or map entry keeps its stored type when another entry is edited. A widget
417
+ name nobody registered is refused on screen, by name, and its value is kept.
418
+
419
+ **A widget is built before it is in the document.** The panel calls the widget,
420
+ then attaches what it returns, and a field inside a folded (`x-ui-fold`) group
421
+ is built inside a closed `<details>`. A widget that measures itself — a Monaco
422
+ editor registered as `expression` — must create that editor once its element is
423
+ connected and has a size (a `ResizeObserver` on `el` will tell it), never inside
424
+ the widget call: an editor built into a box with no size measures a zero
425
+ character width and paints a viewport of no lines, and nothing repaints it.
426
+
427
+ | Generic widget | Draws |
428
+ |---|---|
429
+ | `template` | Text with chips and *Insert a value*. A setting that may also be a list or an object is shown as JSON and written back as one when the text parses |
430
+ | `expression` | A formula: monospace, with chips. Replace it with your own editor (`{replace: true}`) |
431
+ | `json-body` | JSON when the text parses, text otherwise |
432
+ | `key-value-map` | Names to values |
433
+ | `key-value-list` | Ordered assignments: `{variable, value}` or `{variable, expression}` |
434
+ | `string-list` | Short strings |
435
+ | `enum-chips` | A multi-select of `items.enum`, as chips, in the enum's order |
436
+ | `choice-cards` | One of N as radio rows, each with its words |
437
+ | `upstream-column`, `upstream-columns` | One or several columns of the step's input, by name, from the panel's `columns` |
438
+
439
+ | Schema hint | Meaning |
440
+ |---|---|
441
+ | `x-ui-widget` | The widget that draws the field |
442
+ | `x-ui-references` | The syntaxes its chips are recognised in, by name. Without it, `template`, `json-body` and the two key-value widgets use `template`, and `expression` uses `formula` |
443
+ | `x-ui-when` | `{field, in}`: drawn only while that sibling holds one of those values (its stored value, else its default) |
444
+ | `x-ui-placeholder`, `x-ui-multiline` | A placeholder; a string as a growing textarea |
445
+ | `x-ui-enum-labels`, `x-ui-enum-descriptions` | An enum's words, and a sentence under each choice card |
446
+ | `x-ui-fold` | An object drawn folded, with a one-line summary of what is set in it |
447
+ | `x-ui-add-label` | The words on a list's *Add* button |
448
+ | `x-ui-formula-references` | `key-value-list`: the syntaxes of a formula row (default `formula`) |
449
+
450
+ **References and chips.** A syntax recognises references in order to draw them.
451
+ It is not a grammar. A reference your server would refuse is still drawn as a chip
452
+ (`describe` can mark it `known: false`) and kept as text. Each syntax has a
453
+ default tone for its chips (`PARAMETER_REFERENCES` is violet, the others blue),
454
+ so two syntaxes in one field read as two kinds of chip. In the chip input the
455
+ browser does the typing, so an IME works. After every input the text is read
456
+ back and re-tokenised. Everything else the control does on the text itself:
457
+ ← and → step over a chip, one Backspace removes a whole chip, a paste is plain
458
+ text, a copy puts the exact text on the clipboard, and Enter adds a line only in
459
+ a multi-line field. It keeps its own undo, because a script that redraws a
460
+ `contenteditable` breaks the browser's. `rename(text, map)` rewrites only the
461
+ path segment that names a step, and only once the syntax is told which segment
462
+ that is (`stepScope: 'steps'`, or a `stepSegment` function). A syntax nobody
463
+ configured renames nothing.
464
+
465
+ **Keys.** `bindFlowKeys(root, handlers)` binds Ctrl/⌘+Z (`undo`), Ctrl/⌘+Y and
466
+ Ctrl/⌘+Shift+Z (`redo`), Delete (`remove`), Ctrl/⌘+D, C, X and V (`duplicate`,
467
+ `copy`, `cut`, `paste`), F2 (`rename`), Alt+↑, Alt+↓ and Alt+← (`moveUp`,
468
+ `moveDown`, `moveOut`), the arrows (`up`, `down`, `left`, `right`), Enter
469
+ (`open`) and Escape (`escape`), and returns an unbind. A key whose handler is
470
+ absent, or returns `false`, is not taken. An editor's own row or card marks
471
+ itself `data-twm-flow-item`, so Enter on it opens it. Inside a text field Ctrl+Z is the field's own, but it is
472
+ still stopped at the editor's root, so a tile beside the editor never hears it.
473
+ In a field every other key belongs to the field, and Enter on a button belongs
474
+ to the button. **Backspace is never an editor key.** It is not handled and not
475
+ stopped, because a host may use it to go back a level.
476
+
477
+ **Tones and strings.** A category names a tone (`violet`, `teal`, `amber`,
478
+ `grey`, `blue`, `indigo`), and `tokens.css` maps each tone to colours:
479
+ `--twm-flow-<tone>-bg`, `-fg`, `-line`, `-icon-bg`, `-text-bg` and `-dot`.
480
+
481
+ **What it does not do.** It does not validate a value against its schema, decide
482
+ whether a reference is valid, save, fetch, or keep anything between page loads.
483
+ Those belong to the consumer.
484
+
485
+ **Where each claim was checked.** jsdom computes no layout and does no editing,
486
+ so the suites (`tests/flow_kit_*.test.mjs`) assert the DOM, the events and the
487
+ text. The rest was checked in headless Edge with real input over the DevTools
488
+ protocol: `demo/flow_kit_probe.mjs` serves the repository, opens
489
+ `demo/flow_kit.html` and drives it (`demo/flow_cdp.mjs` is the small driver it
490
+ uses, for the editors' pages too). The demo pages load the source barrels;
491
+ `FLOW_DIST=1 node demo/flow_<kit|canvas|outline|lanes>_probe.mjs` redirects
492
+ `flow.js`, `canvas.js` and the three sheets to `dist/`, so the same checks run
493
+ against the built bundle. All four probes passed both ways on the integration
494
+ build. In the chip input, typing on either side of
495
+ a chip, ← and → over it, one Backspace removing it, a real paste of `${a.b}`
496
+ becoming a chip, a real copy giving the exact text, an IME composition leaving
497
+ the text alone until it committed, and the field's own Ctrl+Z all passed. The
498
+ same Backspace in a plain `contenteditable` whose chips are not
499
+ `contenteditable="false"` split its chip. A pointer drag across a chip started
500
+ no native drag. `elementFromPoint` at the centre of Undo, "+", the panel's
501
+ first field, its title, a field's "{ }", an action, a choice and the strip's
502
+ *Go to it* returned each one. Both pickers opened inside the viewport, their
503
+ search box focused, and gave the focus back to "+" on Escape.
504
+
505
+ ### The canvas editor
506
+
507
+ A flow drawn as nodes and lines: a palette of step types, a pannable canvas and
508
+ the settings of whatever is picked, under a toolbar. It stands on the
509
+ `@flexdesk/canvas` kernel, which is also usable on its own for any diagram.
510
+
511
+ **The kernel** (`@flexdesk/canvas`).
512
+
513
+ | Part | What it does |
514
+ |---|---|
515
+ | `new CanvasAdapter({container, gridSize = 16, onViewportChange, onBackgroundMenu, nodeSelector})` | A surface you put nodes and lines on: right-, middle- or Shift-drag pans, Ctrl + the wheel zooms about the pointer, a left press on the background draws a marquee (`onMarquee(box)`, `onSelectionChange(selection)`). Fields `root`, `surface`, `edgeLayer` (an SVG), `nodeLayer`, `marquee`, `selection`, `scale`, `tx`, `ty`, `gridSize`. Methods `clientToCanvas`, `canvasToClient`, `snap`, `setViewport`, `viewport`, `zoomAt`, `fit`, `visibleBox`, `contains`, `bringIntoView` (pans and never zooms, and does nothing when the box is already in view), `consumeRightDrag`, `clear`, `destroy`. **A node is whatever you put in `nodeLayer`**: a press inside one starts no pan and no marquee, and a right-click on one keeps your menu (or the browser's). Pass `nodeSelector` when the node layer holds other things too. `onViewportChange` fires once, synchronously, from the constructor, so declare what it reads before you build the adapter |
516
+ | `routeEdge(from, to, {obstacles, simple})`, `selfLoop`, `returnEdge(from, to, {floor})` | SVG path data between two anchors `{x, y, side}`: a line with horizontal lead-outs (bent round obstacles up to `OBSTACLE_ROUTING_NODE_LIMIT` nodes), a node's line to itself, and a loop's line back, which runs under both nodes |
517
+ | `crowsFoot(point, side, kind)`, `footAnchor(point, side, kind)`, `cardinality({kind, optional})` | Crow's-foot ends (`many`, `one`, `zero-or-many`, `one-or-many`, `zero-or-one`, `exactly-one`) as an SVG group `twm-edge__marker twm-edge__marker--<kind>`, where the line should start so it meets the mark, and the two ends a reference implies |
518
+ | `arrange(nodes, edges, {columnGap, rowGap})` | A layered layout. **Two quirks, kept on purpose:** an edge points from child to parent (a node sits one column right of what it points at), and each column is sorted by `label`, which every node must have |
519
+ | `new NodePlatform({collapsedPorts})` | The nodes and their ports: `hydrate(nodes)`, `get`, `all`, `port`, `setPosition`, `setCollapsed`, `visiblePorts(id)`, `on(event, fn)` (`hydrated`, `moved`, `collapsed`). A node is `{id, position, size, collapsed, ports, …}`; any other key is kept |
520
+ | `new ConnectorRouter({platform, selfReference, policy, endsOf, disabledEnds, strings})` | Edges and whether a drop may become one. `canConnect(source, target, {pendingEdges})` refuses, in order, a node that is gone, a port that is gone, a disabled port (with its `disabledReason`), a self-reference (unless `selfReference`), and a port at its `maxConnections`. Only then does it ask your `policy(ctx)`, which may refuse, allow, or answer `{ok: true, offer}`. Also `hydrate`, `edges` (a Map), `edgesFor`, `stage`, `unstage`, `remapNodeConnections(nodeId, portIds)` (keeps the edges whose ports survive and reports the rest) |
521
+ | `new HistoryService({actions, apply, onState, limit, requires, refusal})` | Undo over recorded edits (deltas), behind a closed action list: `record` throws on any action you did not declare. Nothing is recorded before `hydrated()`, and a batch (`startBatch`/`endBatch`) is one entry. Also `undo`, `redo`, `reset`, `adopt`, `undoStack`, `redoStack`, `canUndo`, `canRedo`. A missing `actions`, `apply` or anything named in `requires` throws at construction, not at the first silent no-op |
522
+
523
+ A diagram with its own rules subclasses the three and passes them in. For
524
+ example, a schema diagram's router answers the generic refusals in its own
525
+ words and keeps its foreign-key rules in the policy:
526
+
527
+ ```js
528
+ class ConnectorRouter extends Base.ConnectorRouter {
529
+ constructor({ platform }) {
530
+ super({ platform, selfReference: true, disabledEnds: ['source'],
531
+ endsOf: (e) => ({ from: `col:${e.from_col}`, to: `col:${e.to_col}` }),
532
+ strings: { nodeGone: 'That table is no longer on the diagram.' },
533
+ policy: (ctx) => myForeignKeyRules(ctx) });
534
+ }
535
+ }
536
+ ```
537
+
538
+ `tests/canvas_graph.test.mjs` §7 builds such a diagram on the generic layer.
539
+
540
+ **The editor.**
541
+
542
+ ```js
543
+ import { createCanvasEditor, createStepCatalogue } from '@flexdesk/flow';
544
+
545
+ const editor = createCanvasEditor(host, {
546
+ catalogue: createStepCatalogue(types, { categories }),
547
+ flowSettings: { title: 'Sync', schema, value }, // the flow's own settings, shown when nothing is picked
548
+ actions: [{ id: 'publish', label: 'Publish', icon: 'publish', primary: true, run: (ed) => publish(ed) }],
549
+ onChange: ({ graph, flowSettings, text, action, key, via }) => autosave(text),
550
+ values: async ({ stepId, field, before, loops }) => groups, // Insert a value
551
+ dataPortRead: (node, port) => `\${steps.${node.id}.${port.name}}`,
552
+ summariseReads: (node) => `Later steps read it as \${steps.${node.id}.…}`,
553
+ });
554
+ editor.load({ graph }); // a baseline: there is nothing to undo after it
555
+ editor.setFindings(validatorResult); // on the nodes, under the fields, in the strip
556
+ editor.setRunOverlay({ steps, ports, banner });
557
+ ```
558
+
559
+ | Option | Meaning |
560
+ |---|---|
561
+ | `catalogue` | `createStepCatalogue(...)`, or the raw list of types. The palette shows them by category, except start-role types (`paletteTypes(type)` decides). A type with `unavailable` is shown greyed, with its sentence under it |
562
+ | `widgets`, `references`, `strings`, `services` | The kit's widget registry, the reference syntaxes by name, your words (`CANVAS_STRINGS` and the kit's), and your bag, handed to every widget unchanged |
563
+ | `values` | *Insert a value*: called with `{graph, stepId, field, key, before, loops}`, where `before` is the steps that always run before this one (`alwaysBefore`, nearest first) and `loops` the loops around it. Returns groups, or `{groups, note}` |
564
+ | `summarise(node, type)` | A line under a node's ports. `summariseReads(node, type)` is the panel's id line |
565
+ | `dataPortRead(node, port)` | How a data port is read, in your syntax. It is shown on the port, which cannot be connected |
566
+ | `flowSettings` | `{title, schema, value}` — the flow's own settings, edited as `flow:settings` |
567
+ | `readOnly` | `false`, or `{reason}`, which is shown above the editor. Every verb is then refused with that reason, and no gesture writes |
568
+ | `actions` | Your toolbar verbs, `[{id, label, icon, primary, danger, title, run(editor)}]`, after Undo, Redo, Arrange and Fit |
569
+ | `slots` | `toolbarEnd(el)`, `flowPanel(el, editor)` and `stepPanel(el, node, editor)`, drawn by you; `setRunOverlay` redraws the panel slots and nothing else |
570
+ | `onChange`, `onSelect` | After every committed edit. An undo or a redo is a change too (`action: null`, `via: 'undo' \| 'redo'`), because you autosave what is undone. `onSelect({kind, id})`: `step`, `flow`, `connection` (with `connection`) or `null` |
571
+ | `loop`, `waitsForAll` | The loop's port names (`{entry: 'in', next, body, done}`): a line into a loop-role step on any input but `entry` is its body going back round, drawn dashed under both steps. `waitsForAll(join)` goes to `alwaysBefore` |
572
+ | `removable(node, type)` | Whether a step may be removed. By default every step except a start step |
573
+ | `node` | `{width: 208, header: 34, row: 22}` (`CANVAS_NODE`). Lines are anchored by arithmetic over these numbers, and the stylesheet is held to them |
574
+ | `history`, `gridSize`, `label` | `{limit, mergeMs}`, the snap grid, and the canvas's accessible name |
575
+
576
+ | Method | Does |
577
+ |---|---|
578
+ | `load({graph, flowSettings}, {baseline = true, fit = true})` | Replaces the content. A baseline clears both stacks. With `baseline: false` the stacks are kept, and the loaded content becomes the present they undo from |
579
+ | `getGraph()`, `serialise()`, `snapshot()`, `getFlowSettings()` | A copy of the graph; its byte-stable text; the text the history holds (graph and settings); the settings |
580
+ | `setFindings(list)`, `setRunOverlay(overlay \| null)`, `setReadOnly(false \| {reason})` | — |
581
+ | `setActionState(id, {disabled, reason, busy})`, `setStatus(text)`, `setMessage(text, tone)` | A verb refused in place, with its reason printed beside the toolbar; the status line; the gesture line |
582
+ | `select(id \| 'flow' \| null)`, `repaintPanel()`, `focus()`, `undo()`, `redo()`, `arrange()`, `fit({whole})` | — |
583
+ | `history`, `canvas` | The editor's `FlowHistory` (`keep()`, `adopt(kept, editor.snapshot())`, `baseline(editor.snapshot())` for your publish) and its `CanvasAdapter` |
584
+ | `destroy()` | Removes every listener, the ghost of a drag and any picker it opened. It saves nothing, because the editor holds nothing |
585
+
586
+ Its edits are the closed list `CANVAS_ACTIONS`: `flow:node:add`,
587
+ `flow:node:remove`, `flow:node:move` (one per drag), `flow:edge:connect`,
588
+ `flow:edge:disconnect`, `flow:node:label`, `flow:node:config`,
589
+ `flow:settings`, `flow:arrange` (one entry for the whole layout). Typing a name
590
+ or a setting is one entry. The geometry under it is exported as well, all of it
591
+ pure: `CANVAS_NODE`, `arrangeCanvasGraph(graph, catalogue, {loopEntry})` (where
592
+ Arrange puts each step: a Map of id → `{x, y}` from the origin),
593
+ `canvasPortAnchor`, `canvasNodeBottom` and `isCanvasReturnEdge`.
594
+
595
+ **Gestures.** A node's header moves it. A connection is made by clicking an
596
+ output and then an input, and Escape cancels one in progress. A line is picked
597
+ by clicking it, and Delete removes it or the picked step. A palette step is
598
+ added by clicking it (at the centre of the view) or by dragging it onto the
599
+ canvas (where it is dropped). The drag uses pointer events and a ghost on
600
+ `document.body`. It never uses `draggable`, which cancels the pointer after
601
+ about 5px, and never `setPointerCapture`, which would send the release, and so
602
+ a second click, to the palette button. No press rebuilds a node, so a click lands
603
+ on the node it went down on. Arrange lays the run out left to right, a branch's
604
+ arms in their declared order, from the graph's own top-left corner. Fit frames
605
+ the graph at natural size or smaller; pressing Fit frames all of it.
606
+
607
+ **Keys** (bound on the editor's root): Ctrl/⌘+Z and Ctrl/⌘+Y or Ctrl/⌘+Shift+Z,
608
+ stopped at the root even inside a text field; Delete; F2 renames the picked
609
+ step; Enter on a focused node opens its settings; Escape. Backspace is never
610
+ the editor's.
611
+
612
+ **What it does not do.** It does not load, save, validate, publish or run
613
+ anything, and it does not keep the stacks across page loads. Those belong to
614
+ the consumer. Nor does it add anything a flow must have: a consumer whose new
615
+ flow opens on a start step adds that step to the graph before `load`, so it is
616
+ part of the baseline and the first Ctrl+Z does not take it away.
617
+
618
+ **Where each claim was checked.** `tests/canvas_adapter.test.mjs`,
619
+ `canvas_edge_router`, `canvas_graph`, `flow_canvas_layout`, `flow_canvas_editor`
620
+ and `flow_canvas_css` check the DOM, the events, the geometry and the stylesheet
621
+ as text. In headless Edge, `demo/flow_canvas_probe.mjs` drives
622
+ `demo/flow_canvas.html` with real input:
623
+ - `elementFromPoint` at the centre of Undo, Arrange, Fit, a consumer verb, a
624
+ palette item, a node's header, a port, and the panel's name box, first field
625
+ and Remove step returned each one, and the panel was not covered.
626
+ - Every line began and ended within 1px of its ports' dots, measured.
627
+ - The loop's line back ran below both of its steps.
628
+ - A real pointer drag from the palette added exactly one step, under the
629
+ pointer, with a `dragstart` counter of 0. A drag ending off the canvas added
630
+ nothing.
631
+ - A real click-then-click connected two steps.
632
+ - A header drag moved its node, and a real Ctrl+Z put it back. Backspace removed
633
+ nothing, Delete removed the step, and the focus stayed on the canvas.
634
+ - At 700px the canvas took the editor's whole width.
635
+
636
+ ### The outline editor
637
+
638
+ `createOutlineEditor` draws a logic flow as a list of blocks: *If … otherwise*,
639
+ *For each row* with its *Next row* foot, *At the same time* with its *Then wait
640
+ for every branch* foot, and a step's other ports (an error port, an empty-result
641
+ port) as arms under it. **The outline is the graph.** It reads the same
642
+ `{nodes, connections}` JSON the canvas edits, holds no second model, and every
643
+ edit is an operation on that graph. A graph opened and saved with no edit is the
644
+ same text, byte for byte, and node positions are kept and never used. A graph
645
+ that is not block-shaped is refused, never repaired: it opens read-only, as a
646
+ flat list in the order it runs, with the reason above it.
647
+
648
+ ```js
649
+ import { createOutlineEditor, createStepCatalogue, createWidgetRegistry, TEMPLATE_REFERENCES } from '@flexdesk/flow';
650
+
651
+ const editor = createOutlineEditor(host, {
652
+ catalogue, widgets: createWidgetRegistry(), references: { template: TEMPLATE_REFERENCES },
653
+ blocks, // which roles are blocks, and their words (below)
654
+ values: async ({ stepId, field, before, loops }) => groupsFor(before, loops), // Insert a value
655
+ valuesNote: 'Only steps that always run before this one are listed.',
656
+ summarise: (node, type) => oneLine(node), // a row's line; {text, mono: true} for code
657
+ summariseReads: (node, type) => howLaterStepsRead(node), // the panel's id line
658
+ flowSettings: { schema, value, title, typeLabel, where, description, idLine },
659
+ actions: [{ id: 'publish', label: 'Publish', icon: 'publish', primary: true, run: () => publish() }],
660
+ slots: { toolbarEnd: (el) => {}, flowPanel: (el) => {}, stepPanel: (el, node) => {} },
661
+ initialFolds: saved, onFoldChange: (ids) => keep(ids),
662
+ onChange: ({ graph, flowSettings, text, action, key }) => autosave(text),
663
+ onSelect: ({ kind, id }) => {}, // kind: 'flow' or 'step'
664
+ readOnly: false, // or {reason}
665
+ history: { limit: 100, mergeMs: 1000 },
666
+ });
667
+ editor.load({ graph, flowSettings }); // a baseline: nothing to undo
668
+ ```
669
+
670
+ | Method | Does |
671
+ |---|---|
672
+ | `load({graph, flowSettings}, {baseline = true})` | Replaces the content; a baseline clears both undo stacks and emits nothing |
673
+ | `getGraph()`, `serialise()`, `getFlowSettings()` | A copy of the graph; its byte-stable text; the flow's own settings |
674
+ | `setFindings(list)` | A finding with a `node_id` replaces that row's line with its message, in the error or warning tone, marks the row, and is drawn under its field; the others go to the strip, with *Go to it* |
675
+ | `setRunOverlay({steps, ports, banner} \| null)` | A run drawn on the outline (below) |
676
+ | `setReadOnly(false \| {reason})`, `setActionState(id, {disabled, reason, busy})`, `setStatus(text)` | A refused verb is disabled with its reason beside it |
677
+ | `select(id \| 'flow' \| null)`, `focus()`, `undo()`, `redo()`, `history`, `folds`, `setFolds(ids)` | `select` unfolds the blocks around the step |
678
+ | `describe()`, `refusal` | The outline as text (`describeOutline`), or why the graph could not be drawn as one |
679
+ | `destroy()` | Removes every listener, popover and ghost. It flushes nothing, because it holds nothing |
680
+
681
+ **The block mapping.** The consumer says which catalogue roles are blocks and
682
+ what their parts are called. Nothing here guesses a domain:
683
+
684
+ ```js
685
+ const blocks = {
686
+ start: { role: 'start', label: 'When it runs' }, // the flow row
687
+ end: { role: 'end', entry: { label: 'End the run', sub: 'End' } },
688
+ step: { input: 'in', continue: 'out' }, // an ordinary step's two ports
689
+ branch: { role: 'branch', arms: { true: 'Then', false: 'Otherwise' },
690
+ entry: { label: 'If … otherwise', sub: 'Condition' }, note: 'Either arm may be empty.' },
691
+ fanout: { role: 'fanout', join: { role: 'join', type: 'merge', field: 'join',
692
+ foot: { all: 'Then wait for every branch', any: 'Then go on when the first finishes' } },
693
+ arm: 'Branch {n}', addArm: 'Add a branch', entry: { label: 'At the same time', sub: 'Parallel + Merge' } },
694
+ loop: { role: 'loop', ports: { entry: 'in', next: 'next', body: 'body', done: 'done' },
695
+ foot: 'Next row', skip: 'Go on with the next row', addInside: 'Add a step to the loop',
696
+ entry: { label: 'For each row', sub: 'Loop over rows' } },
697
+ arms: { error: { label: (step) => `If ${step.label} fails`, unconnected: 'fail',
698
+ setting: { label: 'If it fails', unconnected: 'Fail the run', after: 'timeout_seconds',
699
+ connected: (arm) => `Run the steps under “${arm}”` } } },
700
+ types: { 'http-request': { arms: { error: { label: 'If the request fails' } } } },
701
+ };
702
+ ```
703
+
704
+ `unconnected` is what your runtime does with a port nothing is connected to,
705
+ `'fail'` or `'stop'`, and only you can say it. An ordinary step's other flow
706
+ outputs are its arms, in the order the type declares them. **Ports become
707
+ settings** in the panel. An arm is a choice between "Fail the run" (or "Stop
708
+ here") and "Run the steps under …", which connects or disconnects the port. An
709
+ arm inside a loop also chooses between carrying on with the next step and going
710
+ on with the next row. A parallel step carries its merge's *When the branches
711
+ finish*, written to the merge step's config.
712
+
713
+ **What "block-shaped" means.** From the start step, every step is placed exactly
714
+ once: as a step, a branch, a parallel closed by one merge, a loop whose body
715
+ ends at its Next, or an end. An arm ends by rejoining what follows its block,
716
+ by going on with the loop's next row, by ending, or by leaving its port
717
+ unconnected. Anything else is refused with a code and the step it names:
718
+ `outline_no_start`, `outline_two_starts`, `outline_unknown_type`,
719
+ `outline_unknown_port`, `outline_broken_line`, `outline_unreachable`,
720
+ `outline_shared_step`, `outline_jump_out`, `outline_two_continuations`,
721
+ `outline_parallel_unjoined`, `outline_merge_unpaired`, `outline_loop_shape`,
722
+ `outline_after_end`, `outline_cycle`. Where a branch's arms meet nowhere
723
+ (every arm but one ends), the arm that goes on holds the rest of its list. The
724
+ lines cannot say more than that, and the outline does not pretend they do.
725
+
726
+ **Edits.** "+" between rows (shown on hover and on focus) and the add rows open
727
+ the kit's step picker. It says where the step will land (*"inside For each new
728
+ application, after Check eligibility"*) and offers the blocks as blocks: *At
729
+ the same time* is one entry and two steps. A step's menu (its "…", a
730
+ right-click, or the ContextMenu key) moves it up, down or out, wraps it in a
731
+ block, duplicates, copies, cuts, pastes after, renames and removes it. An entry
732
+ or a verb that would leave a graph the outline cannot draw is shown greyed with
733
+ its reason: *"Move down — already last in For each new application"*. A step is
734
+ dragged by its grip, with pointer events and a ghost after 4 px, never
735
+ `draggable` and never `setPointerCapture`. Its drop line names the place
736
+ (*"Into Branch 2 · after Write a summary"*) or says why the place is refused.
737
+ Each edit is one history entry from a closed list: `flow:step:add`, `remove`,
738
+ `move`, `wrap`, `duplicate`, `paste`, `label` and `config`, `flow:arm`,
739
+ `flow:branch:add`, `flow:branch:remove` and `flow:settings`.
740
+
741
+ **Keys.** ↑ and ↓ move the selection, ← and → fold and unfold, and Enter opens
742
+ the settings at their first field. Alt+↑, Alt+↓ and Alt+← move a step, Ctrl+D
743
+ duplicates, Ctrl+C, Ctrl+X and Ctrl+V copy, cut and paste after the selection,
744
+ F2 renames, Delete removes, and Ctrl+Z and Ctrl+Y undo and redo. All of them are
745
+ bound on the editor's root. Backspace is never the editor's.
746
+
747
+ ***Insert a value*.** A field's "{ }" calls your `values` provider with
748
+ `before`, the steps that always run before this one, nearest first
749
+ (`alwaysBefore`), and `loops`, the loops the step is inside, innermost first. A
750
+ step on the other arm of a condition is never in `before`, and neither is a
751
+ loop's body for a step after the loop.
752
+
753
+ **A run drawn on the outline.** `setRunOverlay({steps: {[id]: {state, line,
754
+ tone}}, ports: {[id]: {[port]: {taken, count}}}, banner})` puts a pill beside
755
+ each step. The pill shows `line` as you give it, toned by `state`
756
+ (`completed`, `failed`, `skipped`, `running`, `cancelled`, `not-reached`). Each
757
+ arm says *taken*, *not taken* or a count, an untaken arm and its steps are
758
+ dimmed, a loop's *Next row* shows its count (`ports[loop].next.count`), and
759
+ your banner is shown above the list. It draws only what you pass, and it
760
+ rebuilds no field in the panel. A redraw you did not ask for (findings, a run
761
+ refresh, a read-only switch) waits while a press is held in the editor, because
762
+ a browser fires a click only when the press and the release land on the same
763
+ element.
764
+
765
+ **The pure parts** are exported for a consumer that reads or checks an outline
766
+ without drawing one. `outlineFromGraph(graph, catalogue, blocks)` returns
767
+ `{ok: true, tree}` or `{ok: false, code, message, node_id}`, and
768
+ `graphFromOutline(tree)` is its inverse (`outlineGraphsEqual(a, b)` compares
769
+ two graphs as sets). `describeOutline(tree)` gives the tree as text.
770
+ `createOutlineOperations({catalogue, blocks, references})` gives every edit as
771
+ a function from a graph to `{ok, graph, select}` or `{ok: false, reason}`. Also
772
+ exported: `createOutlineBlocks`, `outlineRunOrder`, `outlineFlatOrder`,
773
+ `indexOutline`, `openOutlineStepMenu`, `OUTLINE_STRINGS`, `OUTLINE_ACTIONS` and
774
+ `OUTLINE_INDENT` (22 px a level, which the stylesheet also uses).
775
+
776
+ **What it does not do.** It does not fetch, save, validate or start a timer
777
+ that writes. Your validator's findings are drawn, never made. The folds are
778
+ yours to keep (`initialFolds`, `onFoldChange`).
779
+
780
+ **Where each claim was checked.** The round trip is tested over a corpus
781
+ (`tests/fixtures/flow_outline_corpus.json`). Every block-shaped graph is
782
+ recognised as the tree the corpus states and written back equal as sets (I1).
783
+ Opened and saved with no edit, it is the same text (I2). Random sequences of
784
+ operations keep it block-shaped (I3). Every refusal names a code and a step and
785
+ leaves the graph untouched (I4). `tests/flow_outline_editor.test.mjs` mounts
786
+ the editor in jsdom, and `tests/flow_outline_css.test.mjs` checks that every
787
+ class the editor sets has a rule and every rule names a class it sets. jsdom
788
+ computes no layout and starts no drag, so `demo/flow_outline_probe.mjs` drives
789
+ `demo/flow_outline.html` in headless Edge with real input. `elementFromPoint`
790
+ at the centre of Undo, an action, Fold all, the find box, the flow row, a row,
791
+ its menu, its "+" (with its row hovered), an add row, the panel's first field,
792
+ a port drawn as a setting and the strip's *Go to it* returned each one. A "+"
793
+ whose row was not hovered caught no press, and a level measured 22 px. The step
794
+ picker and the step menu opened inside the viewport on a real press, real keys
795
+ filtered and chose, and Escape gave the focus back. Real Alt+↑, Ctrl+Z, Ctrl+D
796
+ and Delete edited the flow, and Backspace reached the window unprevented. A real
797
+ grip drag drew its ghost and a drop line naming the place, moved the step as
798
+ one edit and started no native drag (a `dragstart` counter at 0). A refused
799
+ place said why and moved nothing, and Escape cancelled the drag.
800
+
801
+ ### The lane editor
802
+
803
+ `createLaneEditor(host, options)` draws a data flow as lanes, left to right.
804
+ Every source starts a lane, and each step follows the one before it. A join
805
+ brings another lane in from below, and its line is laid out for you. Cards sit
806
+ where the layout puts them: **nothing is dragged and no line is drawn by
807
+ hand.** A step is added with the "+" on a card's right tip, which splices it in
808
+ between the card and what the card led to (`A → new → B`). *Add a source* starts
809
+ a new lane. A join's other lanes are settings (*Joined with*), not lines. Under
810
+ the lanes are a tab per step and a dock: the kit's settings panel on the left,
811
+ and on the right what the step gives — **Preview**, **Columns** and, for a sink,
812
+ **Would be rejected**. A toolbar toggle, **Steps show: The preview | The last
813
+ run**, picks what each card's status line says.
814
+
815
+ ```js
816
+ import { createLaneEditor, createStepCatalogue, createWidgetRegistry, PARAMETER_REFERENCES } from '@flexdesk/flow';
817
+
818
+ const editor = createLaneEditor(host, {
819
+ catalogue: createStepCatalogue(types, { categories }), // roles: source | transform | operation | sink
820
+ widgets: createWidgetRegistry(),
821
+ references: { parameter: PARAMETER_REFERENCES },
822
+ summarise: (step, type) => '…', // a card's second line
823
+ preview: async ({ pipeline, text, parameters, signal }) => ({ nodes: { [id]: {
824
+ status, rows, total, columns: [{ name, type }], head: [[…]], caption, rejects, error } } }),
825
+ describe: async ({ pipeline, text, parameters, signal }) => ({ nodes: { [id]: {
826
+ columns: [{ name, type, origin, change }] } } }),
827
+ lastRun: async ({ pipeline, signal }) => runOverlay, // asked when Steps show turns to The last run
828
+ actions: [{ id: 'publish', label: 'Publish', primary: true, run }],
829
+ onChange: ({ pipeline, text, action, key }) => save(text), // after every edit, undo and redo
830
+ });
831
+ editor.load({ graph: pipeline });
832
+ ```
833
+
834
+ It edits the data flow's JSON — `{nodes: [{id, type, label?, config, position?}],
835
+ connections: [{id, sourceId, sourcePort, targetId, targetPort}], parameters:
836
+ {name: {type, default?, description?}}}` — with two rules added. A step's name
837
+ is a top-level `label`, never a key inside `config`. Connection ids are
838
+ deterministic: `c1`, `c2`, … the lowest free number. `serialisePipeline` is
839
+ byte-stable, so a flow opened and saved with no edit is the same text. Keys the
840
+ editor does not know are kept, and a node's `position` is kept and never used.
841
+ **A step's ports keep their declared order**: the first FLOW input is the lane
842
+ input, and every further input takes another lane. The first output is the
843
+ continuation.
844
+
845
+ **The layout** is `layoutLanes(pipeline, catalogue)` and `placeLanes(layout,
846
+ size)`. Both are pure, and the wires are computed from the grid, never measured
847
+ from the page. A step belongs to the lane that feeds its lane input, not to
848
+ whichever lane reaches it first. An output that feeds two steps' lane inputs
849
+ forks a new lane below. A feeder lane is moved right until its last card sits
850
+ one column before the join it feeds, and never so far that the join moves.
851
+ Under each lane come its feeders, then its forks. A wire that would run through a card that is not one of its ends (a feeder that goes on past its feed, a fork drawn under a feeder) goes round by the gutters, the gaps between columns and between lanes, which hold no card. A step two lines feed on one
852
+ input (`lanes_two_inputs`), a cycle (`lanes_cycle`) or two steps with one id
853
+ (`lanes_duplicate_step`) cannot be drawn honestly as lanes. Such a flow opens
854
+ read only with the sentence, is drawn as well as it can be, and is never
855
+ repaired. The sizes are `LANE_GEOMETRY`: `regular` is 200 × 62 (the editor),
856
+ `small` is 160 × 50 (two lines a card, for a narrower host) and `strip` is
857
+ 92 × 26 (`compact`). The pure edits — splice, add a source, remove, set an
858
+ input, the input candidates, parameters — are `lanePipeline`.
859
+
860
+ | Option | Meaning |
861
+ |---|---|
862
+ | `catalogue` | The kit's step catalogue (or a list of types and `categories`) |
863
+ | `widgets`, `references`, `services`, `strings` | As for the kit's panel. `references.parameter` decides how the parameters strip spells a reference |
864
+ | `values` | *Insert a value*: `({pipeline, stepId, field, key, upstream, parameters}) => groups`. `upstream` is every step whose output reaches this one, nearest first |
865
+ | `summarise(step, type)`, `summariseReads(step, type)` | A card's second line; the panel's id line |
866
+ | `preview`, `describe` | The providers (above). Absent, the dock has no Preview, or no Columns |
867
+ | `previewDelayMs` | The debounce, 800 by default |
868
+ | `lastRun` | Asked once, when *Steps show* first turns to *The last run* and no overlay is set |
869
+ | `flowSettings` | `{schema, value}`: the flow's own settings, under a first tab, *The flow* |
870
+ | `readOnly` | `false`, or `{reason}`, drawn above the editor |
871
+ | `actions` | Toolbar verbs after Undo and Redo: `[{id, label, icon?, primary?, run}]` |
872
+ | `slots` | `toolbarStart(el)`, `toolbarEnd(el)`, `flowPanel(el)`, `stepPanel(el, step)` |
873
+ | `size` | `'regular'` or `'small'` |
874
+ | `stepTabs`, `parameters` | `false` leaves out the step tabs or the parameters strip |
875
+ | `height` | `'fill'` (the default: the editor fills its host, which must have a height) or `'auto'` (the editor flows, and the dock is `dockHeight`, 360 by default) |
876
+ | `compact` | `true` (or `{size: 'small'}`): the lanes alone, read only, nothing to press. This is what a step that runs a data flow shows as *What it does, left to right* |
877
+ | `parameterTypes` | The parameter types a parameter may be: `text`, `number`, `integer`, `boolean`, `date` by default |
878
+ | `select` | The step chosen on load (the first step, left to right, by default) |
879
+ | `history` | `{limit, mergeMs}` |
880
+ | `onChange`, `onSelect({kind, id})`, `onStepsShow(mode)` | Callbacks |
881
+
882
+ | Method | Does |
883
+ |---|---|
884
+ | `load({graph, flowSettings}, {baseline = true})` | Replaces the content. A baseline clears both undo stacks, and a preview is asked for at once |
885
+ | `getGraph()`, `serialise()`, `getFlowSettings()` | A copy of the flow; its byte-stable text |
886
+ | `setFindings(list)` | On the card (its status line becomes the message), under the field, and in the strip with *Go to it* |
887
+ | `setRunOverlay({steps: {[id]: {state, line, tone}}, banner})` | The last run, shown when *Steps show* is *The last run*. `line` is drawn as given |
888
+ | `setStepsShow('preview' \| 'run')`, `stepsShow` | — |
889
+ | `setReadOnly(false \| {reason})`, `readOnly` | — |
890
+ | `setActionState(id, {disabled, reason, busy})`, `setStatus(text)` | A refused verb shows its reason beside it |
891
+ | `select(id \| 'flow' \| null)`, `selected`, `focus()` | — |
892
+ | `undo()`, `redo()`, `history` | Over whole snapshots. The actions are `LANE_ACTIONS` (`flow:step:add`, `:remove`, `:label`, `:config`, `flow:join:set`, `flow:source:add`, `flow:parameter:add`, `:change`, `:remove`, `flow:settings`) |
893
+ | `refreshPreview()` | Asks the providers now, without the debounce |
894
+ | `inputColumns(stepId, port?)` | The columns of the frame a step reads, from the last description, else the last preview. The `upstream-column(s)` widgets read the same |
895
+ | `layout`, `placed` | The current layout and its coordinates |
896
+ | `destroy()` | Removes every listener, popover and table, and aborts the preview in flight |
897
+
898
+ **The preview** is one request for the whole flow to each provider. It is sent
899
+ 800 ms after the last edit and once after a load. A rename does not send one,
900
+ because it changes no row. Every request carries a sequence number and an
901
+ `AbortSignal`. A newer edit aborts the request in flight, and an answer that is
902
+ not the newest is dropped even when it arrives. A failure is shown as the
903
+ provider's own sentence in the dock, and nothing else changes. The rows are a
904
+ FlexDesk `DataTable` in a box with a definite height. How many rows a preview
905
+ reads, as whom it reads them and that it writes nothing are all the provider's
906
+ business; the editor knows only what it is handed.
907
+
908
+ **Keys.** These are the kit's, on the editor's root. ← → ↑ ↓ move between cards
909
+ and between the step tabs. Enter opens a step's settings on their first field,
910
+ and Escape goes back to the card. Delete removes the step and F2 renames it.
911
+ Ctrl/⌘+Z and Ctrl/⌘+Y undo and redo, and are stopped at the editor even inside
912
+ a field. The context-menu key or a right press opens a card's menu: *Open its
913
+ settings*, *Add a step after it*, *Remove step*. Backspace is never the
914
+ editor's. The cards are one Tab stop, the chosen one, and its "+" comes next in
915
+ the Tab order. The "+" shows on hover, on focus and on the chosen card, never
916
+ on hover alone.
917
+
918
+ **What it does not do.** It does not fetch, save, validate, propagate a schema,
919
+ coerce a value or run anything. Steps are never dragged and lines are never
920
+ drawn by hand. The editor does not draw a page trail, a breadcrumb or a "you
921
+ are editing the shared flow" banner either: a host draws those around it.
922
+
923
+ **Where each claim was checked.** The layout, the JSON, the edits and the
924
+ preview runner are pure, and `tests/flow_lanes_layout.test.mjs`,
925
+ `flow_lanes_pipeline.test.mjs` and `flow_lanes_preview.test.mjs` test them under
926
+ plain node. The layout suite reproduces the mock and shows that the earlier
927
+ layout (EcoSim's `buildLanes`, copied verbatim) puts the join in the feeder's
928
+ lane when the feeder is listed first. The preview suite has a provider that
929
+ answers out of order, and the stale answer is shown to be dropped.
930
+ `flow_lanes_editor.test.mjs` mounts the editor in jsdom: choosing a card
931
+ rebuilds no node, every edit is one undo entry, and a flow that cannot be drawn
932
+ is never written. `flow_lanes_css.test.mjs` holds the classes and the rules to
933
+ each other and the sheet's card sizes to `LANE_GEOMETRY`. The rest was checked
934
+ in headless Edge with real input (`node demo/flow_lanes_probe.mjs`, which opens
935
+ `demo/flow_lanes.html` at all three sizes). In a laid-out page every wire
936
+ starts at its card's tip and ends in the next card's notch, and the join's line
937
+ and its port dot meet the join's bottom centre. `elementFromPoint` at the centre
938
+ of Undo, a card, a "+", *Add a source*, a step tab, *Steps show*, a parameter,
939
+ the panel's first field, its name, *Remove step*, a dock tab and a preview cell
940
+ returns each one, and the settings are covered by nothing. The preview's
941
+ `DataTable` draws its rows with a height, and every card's words stay inside
942
+ the card. A real press on "+" opens the picker inside the viewport with its
943
+ search focused, and real keys filter it and add the step. A real drag across a
944
+ card starts no native drag (a `dragstart` counter at 0) and moves nothing. Real
945
+ arrows, Delete, Ctrl+Z and Backspace do what is said above, and three real
946
+ keystrokes ask the preview once.
947
+
948
+ ## The host port
949
+
950
+ FlexDesk never touches `pywebview`, `fetch`, `localStorage` or the filesystem. It
951
+ asks a **Host**, and you supply one. `createHost({...})` validates the capabilities
952
+ you pass and freezes the result; anything you leave out is simply absent, and the
953
+ library degrades rather than throwing.
954
+
955
+ ```js
956
+ createHost({
957
+ state: { read, write }, // layout persistence
958
+ dialogs: { saveFile }, // optional — CSV export falls back to a Blob
959
+ window: { minimize, maximize, ... }, // optional — no native chrome buttons without it
960
+ })
961
+ ```
962
+
963
+ `NULL_HOST` is a valid host with nothing behind it.
964
+
965
+ ## Peer dependencies
966
+
967
+ `@flexdesk/charts` needs Plotly (4.4 MB). `@flexdesk/editor` needs Monaco (14 MB).
968
+ Neither is bundled, and **neither is guessed at**:
969
+
970
+ ```js
971
+ import { setPlotlySource } from '@flexdesk/charts';
972
+ import { setMonacoBasePath, setDefaultLanguage } from '@flexdesk/editor';
973
+
974
+ setPlotlySource('/vendor/plotly/plotly.min.js');
975
+ setMonacoBasePath('/vendor/monaco');
976
+ setDefaultLanguage('my-dsl'); // default: 'plaintext'
977
+ ```
978
+
979
+ There is deliberately **no default path**. A hardcoded `'vendor/monaco'` is one
980
+ application's directory layout, and for everyone else it is a 404 that surfaces as
981
+ an editor which silently never appears. `initMonaco()` throws instead.
982
+
983
+ If you already load Plotly yourself (a `<script>` tag that sets `window.Plotly`),
984
+ the chart loader short-circuits and you can skip `setPlotlySource`.
985
+
986
+ ## Layouts are keyed by whatever you key them by
987
+
988
+ `@flexdesk/tiles` stores layouts under keys **you** choose and resolves a starting
989
+ layout through a callback **you** supply:
990
+
991
+ ```js
992
+ import { configureLayoutPersistence } from '@flexdesk/tiles';
993
+
994
+ configureLayoutPersistence({
995
+ layoutsKey: 'myapp.layouts', // default: 'twm.tiles.layouts'
996
+ resolveTemplate: (ctx) => pickTemplate(ctx) ?? null, // null => empty grid
997
+ });
998
+ ```
999
+
1000
+ **Configure it before anything reads it.** The store is a singleton; built before
1001
+ you supply your key, it is built with the default, and every layout your users have
1002
+ saved becomes silently invisible. That is a data-loss bug that raises no error, so
1003
+ the configuration step is explicit rather than lazy.
1004
+
1005
+ A layout is keyed by *something* — a document, a user, a run — and the library has
1006
+ no opinion about which. It calls that a `contextId`.
1007
+
1008
+ ## Styling
1009
+
1010
+ Three stylesheets, and that is the whole CSS contract:
1011
+
1012
+ | | |
1013
+ |---|---|
1014
+ | `dist/tokens.css` | Every rule reads `var(--…)` from here. Swap this one file and the shell restyles. |
1015
+ | `dist/reset.css` | The reset. |
1016
+ | `dist/flexdesk.css` | The library. Every class is `twm-` prefixed (the original working name; the prefix is stable and not worth a breaking rename). |
1017
+
1018
+ Icons are rendered by applying the `material-symbols-outlined` class. FlexDesk does
1019
+ not ship the font — supply it from Google Fonts or self-host. Without it, icons fall
1020
+ back to their text names and nothing breaks.
1021
+
1022
+ ## Build
1023
+
1024
+ ```bash
1025
+ npm install
1026
+ npm run build # -> dist/
1027
+ ```
1028
+
1029
+ `dist/` is committed. The packaging decision is *prebuilt dist*: this repo owns the
1030
+ build so that a consumer with no bundler can load the library through a plain import
1031
+ map. See `.gitignore` — it says so there too, because it is the kind of thing someone
1032
+ "tidies up" later.
1033
+
1034
+ ## Licence
1035
+
1036
+ MIT. See [`LICENSE`](LICENSE) and [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).