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.
- package/README.md +1036 -301
- package/css/base.css +2958 -2
- package/css/flexdesk.css +2962 -4
- package/css/overrides.css +4 -2
- package/css/tokens.css +86 -0
- package/dist/canvas.js +390 -0
- package/dist/canvas.js.map +7 -0
- package/dist/charts.js +782 -782
- package/dist/{chunk-LH5TSOZW.js → chunk-6JSOVNID.js} +42 -2
- package/dist/{chunk-LH5TSOZW.js.map → chunk-6JSOVNID.js.map} +3 -3
- package/dist/{chunk-ELXVW542.js → chunk-6NG7YVBG.js} +3 -471
- package/dist/chunk-6NG7YVBG.js.map +7 -0
- package/dist/{chunk-F5FYDCOA.js → chunk-CNXP3SIN.js} +2373 -2373
- package/dist/{chunk-F5FYDCOA.js.map → chunk-CNXP3SIN.js.map} +7 -7
- package/dist/chunk-H4LT43RS.js +475 -0
- package/dist/chunk-H4LT43RS.js.map +7 -0
- package/dist/{chunk-WMGOJ3I6.js → chunk-KH2GJTKR.js} +3702 -2628
- package/dist/chunk-KH2GJTKR.js.map +7 -0
- package/dist/chunk-SN4LSOAR.js +532 -0
- package/dist/chunk-SN4LSOAR.js.map +7 -0
- package/dist/flexdesk.css +2962 -4
- package/dist/flow.js +10030 -0
- package/dist/flow.js.map +7 -0
- package/dist/tiles.js +2195 -2195
- package/dist/tokens.css +86 -0
- package/dist/widgets.js +5824 -5796
- package/dist/widgets.js.map +1 -1
- package/dist/wm.js +8077 -8074
- package/dist/wm.js.map +7 -7
- package/package.json +89 -81
- package/src/canvas/canvas_adapter.js +523 -0
- package/src/canvas/edge_router.js +392 -0
- package/src/canvas/graph/connector_router.js +189 -0
- package/src/canvas/graph/history_service.js +170 -0
- package/src/canvas/graph/node_platform.js +116 -0
- package/src/canvas/index.js +16 -0
- package/src/flow/canvas_editor/editor.js +1081 -0
- package/src/flow/canvas_editor/index.js +16 -0
- package/src/flow/canvas_editor/layout.js +118 -0
- package/src/flow/canvas_editor/strings.js +37 -0
- package/src/flow/kit/always_before.js +325 -0
- package/src/flow/kit/catalogue.js +170 -0
- package/src/flow/kit/chip_input.js +461 -0
- package/src/flow/kit/dom.js +205 -0
- package/src/flow/kit/findings.js +129 -0
- package/src/flow/kit/history.js +173 -0
- package/src/flow/kit/index.js +38 -0
- package/src/flow/kit/keys.js +127 -0
- package/src/flow/kit/logic_graph.js +210 -0
- package/src/flow/kit/references.js +199 -0
- package/src/flow/kit/settings_panel.js +357 -0
- package/src/flow/kit/settings_schema.js +175 -0
- package/src/flow/kit/step_picker.js +209 -0
- package/src/flow/kit/strings.js +102 -0
- package/src/flow/kit/value_picker.js +238 -0
- package/src/flow/kit/widgets.js +763 -0
- package/src/flow/lanes/editor.js +1469 -0
- package/src/flow/lanes/index.js +26 -0
- package/src/flow/lanes/layout.js +525 -0
- package/src/flow/lanes/pipeline.js +327 -0
- package/src/flow/lanes/preview.js +109 -0
- package/src/flow/lanes/strings.js +98 -0
- package/src/flow/outline/editor.js +1511 -0
- package/src/flow/outline/index.js +20 -0
- package/src/flow/outline/mapping.js +286 -0
- package/src/flow/outline/menu.js +154 -0
- package/src/flow/outline/operations.js +738 -0
- package/src/flow/outline/recognise.js +755 -0
- package/src/flow/outline/strings.js +135 -0
- package/src/tiling/landing_table.js +13 -5
- package/src/tiling/wm.js +3916 -3916
- package/src/ui/components/data_table.js +4205 -3294
- package/src/ui/components/row_activation.js +70 -0
- package/src/ui/components/table_values.js +423 -0
- package/dist/chunk-ELXVW542.js.map +0 -7
- 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
|
-
##
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
as
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
**
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
`
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
`
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
})
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
the
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
| `
|
|
280
|
-
| `
|
|
281
|
-
| `
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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).
|