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
@@ -0,0 +1,327 @@
1
+ /**
2
+ * A DATA FLOW'S JSON, AND EVERY EDIT THE LANE EDITOR MAKES TO IT (36 §7.1).
3
+ * PURE: no DOM; runs under plain node.
4
+ *
5
+ * { nodes: [{ id, type, label?, config, position? }],
6
+ * connections: [{ id, sourceId, sourcePort, targetId, targetPort }],
7
+ * parameters: { name: { type, default?, description? } } }
8
+ *
9
+ * Two rules on top of the shape, each for a reason:
10
+ *
11
+ * - **A step's name is a top-level `label`**, never a key inside `config`:
12
+ * a consumer may hash the config (a cache key), and renaming a step must
13
+ * not change what it computes.
14
+ * - **Connection ids are deterministic** — `c1`, `c2`, … the lowest free
15
+ * number — never a clock-derived id, so the same edits give the same bytes.
16
+ *
17
+ * A node's `position`, when present, is kept and never used; a new node has
18
+ * none. Keys this module does not know — on the flow, a step or a connection —
19
+ * are KEPT, never dropped: the editor is a view over the JSON, not a repair of
20
+ * it.
21
+ *
22
+ * `serialisePipeline` is byte-stable: known keys in a fixed order, `config`
23
+ * keys sorted (all the way down), any other key sorted after the known ones,
24
+ * steps, connections and parameters in their stored order. A flow opened and
25
+ * saved with no edit is the identical text.
26
+ */
27
+
28
+ import { stepPorts } from './layout.js';
29
+
30
+ /** The edits the lane editor records — anything else is refused by its history. */
31
+ export const LANE_ACTIONS = Object.freeze([
32
+ 'flow:step:add', 'flow:step:remove', 'flow:step:label', 'flow:step:config', 'flow:join:set',
33
+ 'flow:source:add', 'flow:parameter:add', 'flow:parameter:change', 'flow:parameter:remove', 'flow:settings',
34
+ ]);
35
+
36
+ /** A parameter's name: what a reference to it can spell. */
37
+ export const PARAMETER_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
38
+
39
+ /** The step ids a server accepts: letters, digits, `-` and `_`, at most 64. */
40
+ const ID_OK = /^[A-Za-z0-9_-]{1,64}$/;
41
+
42
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
43
+ const clone = (v) => (v === undefined ? undefined : structuredClone(v));
44
+
45
+ const NODE_KEYS = ['id', 'type', 'label', 'config', 'position'];
46
+ const CONN_KEYS = ['id', 'sourceId', 'sourcePort', 'targetId', 'targetPort'];
47
+ const PARAM_KEYS = ['type', 'default', 'description'];
48
+ const TOP_KEYS = ['nodes', 'connections', 'parameters'];
49
+
50
+ export function emptyPipeline() {
51
+ return { nodes: [], connections: [], parameters: {} };
52
+ }
53
+
54
+ /** A deep copy with every field the flow carries, defaults filled in. */
55
+ export function normalisePipeline(pipeline) {
56
+ const p = isObject(pipeline) ? pipeline : emptyPipeline();
57
+ const out = {
58
+ nodes: (Array.isArray(p.nodes) ? p.nodes : []).filter(isObject).map((n) => {
59
+ const node = { id: String(n.id), type: String(n.type ?? '') };
60
+ if (n.label !== undefined && n.label !== null && String(n.label) !== '') node.label = String(n.label);
61
+ node.config = isObject(n.config) ? clone(n.config) : {};
62
+ if (n.position !== undefined && n.position !== null) node.position = clone(n.position);
63
+ for (const k of Object.keys(n)) if (!NODE_KEYS.includes(k)) node[k] = clone(n[k]);
64
+ return node;
65
+ }),
66
+ connections: (Array.isArray(p.connections) ? p.connections : []).filter(isObject).map((c) => {
67
+ const conn = {};
68
+ if (c.id !== undefined && c.id !== null) conn.id = String(c.id);
69
+ conn.sourceId = String(c.sourceId);
70
+ conn.sourcePort = String(c.sourcePort ?? 'out');
71
+ conn.targetId = String(c.targetId);
72
+ conn.targetPort = String(c.targetPort ?? 'in');
73
+ for (const k of Object.keys(c)) if (!CONN_KEYS.includes(k)) conn[k] = clone(c[k]);
74
+ return conn;
75
+ }),
76
+ parameters: isObject(p.parameters) ? clone(p.parameters) : {},
77
+ };
78
+ for (const k of Object.keys(p)) if (!TOP_KEYS.includes(k)) out[k] = clone(p[k]);
79
+ return out;
80
+ }
81
+
82
+ function sortedKeys(value) {
83
+ if (Array.isArray(value)) return value.map(sortedKeys);
84
+ if (isObject(value)) return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortedKeys(value[k])]));
85
+ return value;
86
+ }
87
+
88
+ /** Known keys first, in their order; every other key sorted after them. */
89
+ function ordered(obj, known, transform = {}) {
90
+ const out = {};
91
+ for (const k of known) {
92
+ if (obj[k] === undefined) continue;
93
+ out[k] = transform[k] ? transform[k](obj[k]) : obj[k];
94
+ }
95
+ for (const k of Object.keys(obj).filter((x) => !known.includes(x)).sort()) out[k] = sortedKeys(obj[k]);
96
+ return out;
97
+ }
98
+
99
+ /** The text a save sends and a reload must reproduce exactly. */
100
+ export function serialisePipeline(pipeline) {
101
+ const p = normalisePipeline(pipeline);
102
+ const top = {
103
+ nodes: p.nodes.map((n) => ordered(n, NODE_KEYS, { config: sortedKeys, position: sortedKeys })),
104
+ connections: p.connections.map((c) => ordered(c, CONN_KEYS)),
105
+ parameters: Object.fromEntries(Object.entries(p.parameters)
106
+ .map(([k, v]) => [k, isObject(v) ? ordered(v, PARAM_KEYS, { default: sortedKeys }) : sortedKeys(v)])),
107
+ };
108
+ for (const k of Object.keys(p).filter((x) => !TOP_KEYS.includes(x)).sort()) top[k] = sortedKeys(p[k]);
109
+ return JSON.stringify(top);
110
+ }
111
+
112
+ /** The lowest free `cN`. */
113
+ export function nextConnectionId(pipeline) {
114
+ const taken = new Set((pipeline?.connections || []).map((c) => String(c.id)));
115
+ for (let i = 1; ; i += 1) if (!taken.has(`c${i}`)) return `c${i}`;
116
+ }
117
+
118
+ /** A new step id for `typeId`: the catalogue's stem, then `stem-2`, `stem-3`, … */
119
+ export function nextStepId(pipeline, typeId, catalogue = null) {
120
+ const base = catalogue?.idBase
121
+ ? catalogue.idBase(typeId)
122
+ : (String(typeId || 'step').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 40) || 'step');
123
+ const taken = new Set((pipeline?.nodes || []).map((n) => String(n.id)));
124
+ if (!taken.has(base)) return base;
125
+ for (let i = 2; ; i += 1) if (!taken.has(`${base}-${i}`)) return `${base}-${i}`;
126
+ }
127
+
128
+ const findNode = (p, id) => p.nodes.find((n) => n.id === id) ?? null;
129
+
130
+ /** A step's lane ports (`stepPorts` over this flow's connections). */
131
+ export function portsOf(pipeline, catalogue, id) {
132
+ const node = findNode(pipeline, id);
133
+ return node ? stepPorts(catalogue, node, pipeline.connections) : null;
134
+ }
135
+
136
+ /** The connection that feeds a step's lane input (the first, in connection order), or null. */
137
+ export function laneFeedOf(pipeline, catalogue, id) {
138
+ const ports = portsOf(pipeline, catalogue, id);
139
+ if (!ports || ports.laneInput === null) return null;
140
+ return pipeline.connections.find((c) => c.targetId === id && c.targetPort === ports.laneInput) ?? null;
141
+ }
142
+
143
+ /** The connection into `id` at `port`, or null. */
144
+ export function inputOf(pipeline, id, port) {
145
+ return pipeline.connections.find((c) => c.targetId === id && c.targetPort === port) ?? null;
146
+ }
147
+
148
+ /** Every step `id` leads to, by any line (not `id` itself, unless a cycle comes back to it). */
149
+ export function downstreamOf(pipeline, id) {
150
+ const seen = new Set();
151
+ const todo = [id];
152
+ while (todo.length) {
153
+ const at = todo.shift();
154
+ for (const c of pipeline.connections) {
155
+ if (c.sourceId !== at || seen.has(c.targetId)) continue;
156
+ seen.add(c.targetId);
157
+ todo.push(c.targetId);
158
+ }
159
+ }
160
+ return seen;
161
+ }
162
+
163
+ /** Every step whose output reaches `id`, NEAREST FIRST (then in `nodes` order). */
164
+ export function upstreamOf(pipeline, id) {
165
+ const dist = new Map();
166
+ const todo = [[id, 0]];
167
+ while (todo.length) {
168
+ const [at, d] = todo.shift();
169
+ for (const c of pipeline.connections) {
170
+ if (c.targetId !== at || dist.has(c.sourceId) || c.sourceId === id) continue;
171
+ dist.set(c.sourceId, d + 1);
172
+ todo.push([c.sourceId, d + 1]);
173
+ }
174
+ }
175
+ const order = new Map(pipeline.nodes.map((n, i) => [n.id, i]));
176
+ return [...dist.keys()].sort((a, b) => (dist.get(a) - dist.get(b)) || ((order.get(a) ?? 0) - (order.get(b) ?? 0)));
177
+ }
178
+
179
+ function newNode(pipeline, catalogue, typeId, { id = null, label = null, config = null } = {}) {
180
+ const nid = id && ID_OK.test(id) && !findNode(pipeline, id) ? id : nextStepId(pipeline, typeId, catalogue);
181
+ return { id: nid, type: typeId, ...(label ? { label: String(label) } : {}),
182
+ config: isObject(config) ? clone(config) : {} };
183
+ }
184
+
185
+ /**
186
+ * Add a step of `typeId` after `afterId`, SPLICED between it and what it led
187
+ * to (`A → new → B`): the line that went on in A's lane (else A's first line
188
+ * out of its continuation) now leaves the new step, and a new line joins A to
189
+ * the new step's lane input — put where the moved line was, so the new step
190
+ * goes on in A's lane rather than starting a fork. A step with no output (a
191
+ * sink) is not spliced: it is joined to A, beside what A already leads to.
192
+ * Returns the new node, or null when `afterId` is not a step.
193
+ */
194
+ export function addStepAfter(pipeline, catalogue, afterId, typeId, opts = {}) {
195
+ const after = findNode(pipeline, afterId);
196
+ if (!after) return null;
197
+ const node = newNode(pipeline, catalogue, typeId, opts);
198
+ pipeline.nodes.push(node);
199
+ const a = stepPorts(catalogue, after, pipeline.connections);
200
+ const n = stepPorts(catalogue, node, pipeline.connections);
201
+ if (a.continuation === null || n.laneInput === null) return node;
202
+ const outs = pipeline.connections.filter((c) => c.sourceId === afterId && c.sourcePort === a.continuation);
203
+ const isLaneFeed = (c) => laneFeedOf(pipeline, catalogue, c.targetId) === c;
204
+ const splice = n.continuation === null ? null : (outs.find(isLaneFeed) ?? outs[0] ?? null);
205
+ const join = { id: nextConnectionId(pipeline), sourceId: afterId, sourcePort: a.continuation,
206
+ targetId: node.id, targetPort: n.laneInput };
207
+ if (splice) {
208
+ const at = pipeline.connections.indexOf(splice);
209
+ splice.sourceId = node.id;
210
+ splice.sourcePort = n.continuation;
211
+ pipeline.connections.splice(at, 0, join);
212
+ } else {
213
+ pipeline.connections.push(join);
214
+ }
215
+ return node;
216
+ }
217
+
218
+ /** Add a source: a new step at the end of `nodes`, which starts the last lane. */
219
+ export function addSource(pipeline, catalogue, typeId, opts = {}) {
220
+ const node = newNode(pipeline, catalogue, typeId, opts);
221
+ pipeline.nodes.push(node);
222
+ return node;
223
+ }
224
+
225
+ /**
226
+ * Remove a step. Its LANE INPUT is reconnected to everything it led to — each
227
+ * of its lines out keeps its id and its target, and now leaves the step that
228
+ * fed this one — and every other line into it goes. A step nothing fed (a
229
+ * source) leaves the steps after it unconnected, each the start of a lane
230
+ * flagged so. Returns whether anything was removed.
231
+ */
232
+ export function removeStep(pipeline, catalogue, id) {
233
+ if (!findNode(pipeline, id)) return false;
234
+ const feed = laneFeedOf(pipeline, catalogue, id);
235
+ const kept = [];
236
+ for (const c of pipeline.connections) {
237
+ if (c.targetId === id) continue;
238
+ if (c.sourceId === id) {
239
+ if (!feed || feed.sourceId === c.targetId) continue;
240
+ const twin = kept.some((k) => k.sourceId === feed.sourceId && k.sourcePort === feed.sourcePort
241
+ && k.targetId === c.targetId && k.targetPort === c.targetPort);
242
+ if (twin) continue;
243
+ c.sourceId = feed.sourceId;
244
+ c.sourcePort = feed.sourcePort;
245
+ }
246
+ kept.push(c);
247
+ }
248
+ pipeline.connections = kept;
249
+ pipeline.nodes = pipeline.nodes.filter((n) => n.id !== id);
250
+ return true;
251
+ }
252
+
253
+ /** May `sourceId` feed `stepId` without making a cycle? */
254
+ export function mayFeed(pipeline, stepId, sourceId) {
255
+ if (!sourceId || sourceId === stepId) return false;
256
+ return !downstreamOf(pipeline, stepId).has(sourceId);
257
+ }
258
+
259
+ /**
260
+ * Feed `stepId`'s input `port` from `sourceId`'s continuation, or (`null`)
261
+ * leave it unconnected. The line already there keeps its id; a new one takes
262
+ * the lowest free. Refuses (returns false) a source that would make a cycle.
263
+ */
264
+ export function setInput(pipeline, catalogue, stepId, port, sourceId) {
265
+ if (!findNode(pipeline, stepId)) return false;
266
+ const existing = pipeline.connections.filter((c) => c.targetId === stepId && c.targetPort === port);
267
+ if (!sourceId) {
268
+ if (!existing.length) return false;
269
+ pipeline.connections = pipeline.connections.filter((c) => !existing.includes(c));
270
+ return true;
271
+ }
272
+ const source = findNode(pipeline, sourceId);
273
+ if (!source || !mayFeed(pipeline, stepId, sourceId)) return false;
274
+ const out = stepPorts(catalogue, source, pipeline.connections).continuation ?? 'out';
275
+ if (existing.length === 1 && existing[0].sourceId === sourceId && existing[0].sourcePort === out) return false;
276
+ if (existing.length) {
277
+ existing[0].sourceId = sourceId;
278
+ existing[0].sourcePort = out;
279
+ pipeline.connections = pipeline.connections.filter((c) => c === existing[0] || !existing.includes(c));
280
+ } else {
281
+ pipeline.connections.push({ id: nextConnectionId(pipeline), sourceId, sourcePort: out, targetId: stepId,
282
+ targetPort: port });
283
+ }
284
+ return true;
285
+ }
286
+
287
+ /**
288
+ * The steps that may feed `stepId`'s input — every step with an output that
289
+ * would not make a cycle — in LANE order (lane by lane, left to right).
290
+ */
291
+ export function inputCandidates(pipeline, catalogue, layout, stepId) {
292
+ const below = downstreamOf(pipeline, stepId);
293
+ const ok = (id) => id !== stepId && !below.has(id)
294
+ && portsOf(pipeline, catalogue, id)?.continuation !== null;
295
+ const out = [];
296
+ for (const lane of layout?.lanes || []) {
297
+ for (const s of lane.steps) if (ok(s.id)) out.push({ id: s.id, lane: lane.index, column: s.column });
298
+ }
299
+ return out;
300
+ }
301
+
302
+ // ── parameters ───────────────────────────────────────────────────────────
303
+
304
+ /**
305
+ * Set a parameter, keeping its place among the others; `rename` gives it a new
306
+ * name in the same place. `definition` is `{type, default?, description?}`.
307
+ */
308
+ export function setParameter(pipeline, name, definition, { rename = null } = {}) {
309
+ const def = {};
310
+ if (definition?.type !== undefined) def.type = definition.type;
311
+ if (definition?.default !== undefined) def.default = clone(definition.default);
312
+ if (definition?.description !== undefined && definition.description !== '') def.description = definition.description;
313
+ const from = rename ?? name;
314
+ const entries = Object.entries(pipeline.parameters || {});
315
+ const at = entries.findIndex(([k]) => k === from);
316
+ if (at >= 0) entries.splice(at, 1, [name, def]);
317
+ else entries.push([name, def]);
318
+ pipeline.parameters = Object.fromEntries(entries.filter(([k], i) => k !== name || entries.findIndex(([x]) => x === k) === i));
319
+ return pipeline.parameters[name];
320
+ }
321
+
322
+ export function removeParameter(pipeline, name) {
323
+ if (!pipeline.parameters || !(name in pipeline.parameters)) return false;
324
+ const { [name]: _gone, ...rest } = pipeline.parameters;
325
+ pipeline.parameters = rest;
326
+ return true;
327
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * THE PREVIEW RUNNER — when the lane editor ASKS the consumer's providers, and
3
+ * which answer it believes (36 §7.7). It is the one timer an editor starts,
4
+ * and it only ever asks: a provider decides what a preview reads, how many
5
+ * rows, as whom, and that it writes nothing.
6
+ *
7
+ * - **Debounced.** `schedule()` (after a committed edit) waits `delayMs` —
8
+ * 800 by default — from the LAST edit, then sends ONE request for the
9
+ * whole flow to each provider (`preview`, `describe`) it has.
10
+ * - **The newest answer wins.** Every request carries a sequence number and
11
+ * an `AbortSignal`. A newer edit aborts the request in flight, and an
12
+ * answer that is not the newest is DROPPED even when it arrives — a
13
+ * provider that ignores its signal and answers late changes nothing.
14
+ * - **A failure is the provider's sentence**, handed to `onError`; the last
15
+ * answer that was believed stays where it is.
16
+ *
17
+ * PURE apart from the timer, which can be injected (`clock`) so a test can
18
+ * drive it.
19
+ */
20
+
21
+ export function createPreviewRunner({ preview = null, describe = null, delayMs = 800, request,
22
+ onAnswer = null, onError = null, onState = null, onDropped = null,
23
+ clock = null } = {}) {
24
+ const timers = {
25
+ set: clock?.setTimeout ?? ((fn, ms) => setTimeout(fn, ms)),
26
+ clear: clock?.clearTimeout ?? ((h) => clearTimeout(h)),
27
+ };
28
+ const providers = [['preview', preview], ['describe', describe]].filter(([, fn]) => typeof fn === 'function');
29
+ let timer = null;
30
+ let seq = 0;
31
+ let live = null; // { seq, controller, pending: Set<kind> }
32
+ let destroyed = false;
33
+
34
+ const state = () => ({
35
+ enabled: providers.length > 0,
36
+ waiting: timer !== null,
37
+ busy: Boolean(live && live.pending.size > 0),
38
+ seq,
39
+ });
40
+ const told = () => onState?.(state());
41
+
42
+ function send() {
43
+ timer = null;
44
+ if (destroyed) return;
45
+ const mine = seq;
46
+ const controller = new AbortController();
47
+ const pending = new Set(providers.map(([kind]) => kind));
48
+ live = { seq: mine, controller, pending };
49
+ const input = { ...(request?.() ?? {}), signal: controller.signal, seq: mine };
50
+ told();
51
+ for (const [kind, fn] of providers) {
52
+ let answer;
53
+ try {
54
+ answer = Promise.resolve(fn(input));
55
+ } catch (err) {
56
+ answer = Promise.reject(err);
57
+ }
58
+ const stale = () => destroyed || mine !== seq || controller.signal.aborted;
59
+ answer.then((value) => {
60
+ if (stale()) { onDropped?.({ kind, seq: mine }); return; }
61
+ pending.delete(kind);
62
+ onAnswer?.({ kind, answer: value, seq: mine });
63
+ told();
64
+ }, (error) => {
65
+ if (stale()) { onDropped?.({ kind, seq: mine }); return; }
66
+ pending.delete(kind);
67
+ onError?.({ kind, error, seq: mine });
68
+ told();
69
+ });
70
+ }
71
+ }
72
+
73
+ return {
74
+ get enabled() { return providers.length > 0; },
75
+ get seq() { return seq; },
76
+ get state() { return state(); },
77
+ /** An edit: everything asked before it is stale; ask again `delayMs` after the last one. */
78
+ schedule({ immediate = false } = {}) {
79
+ if (destroyed || !providers.length) return;
80
+ seq += 1;
81
+ if (live) {
82
+ live.controller.abort();
83
+ live = null;
84
+ }
85
+ if (timer !== null) timers.clear(timer);
86
+ timer = timers.set(send, immediate ? 0 : delayMs);
87
+ told();
88
+ },
89
+ /** Ask now, without waiting for the debounce. */
90
+ now() {
91
+ if (destroyed || !providers.length) return;
92
+ seq += 1;
93
+ if (live) {
94
+ live.controller.abort();
95
+ live = null;
96
+ }
97
+ if (timer !== null) timers.clear(timer);
98
+ timer = null;
99
+ send();
100
+ },
101
+ destroy() {
102
+ destroyed = true;
103
+ if (timer !== null) timers.clear(timer);
104
+ timer = null;
105
+ live?.controller.abort();
106
+ live = null;
107
+ },
108
+ };
109
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * EVERY WORD THE LANE EDITOR DRAWS, over the kit's (36 §3.13). English, and
3
+ * naming no domain: a step, a source, a lane, a value. A consumer lays its own
4
+ * words over these with the editor's `strings` option.
5
+ */
6
+
7
+ const count = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString('en-US') : String(n));
8
+ const plural = (n, one, many) => (Number(n) === 1 ? one : many.replace('{n}', count(n)));
9
+
10
+ export const LANE_STRINGS = Object.freeze({
11
+ // ── the lanes ──────────────────────────────────────────────────────
12
+ lanesLabel: 'The flow, left to right',
13
+ laneName: (n, title) => `Lane ${n}: ${title}`,
14
+ laneNameUnconnected: (n, title) => `Lane ${n}: ${title} — not connected`,
15
+ notConnected: 'Not connected',
16
+ cardLabel: (kind, title, status) => (status ? `${kind}: ${title}, ${status}` : `${kind}: ${title}`),
17
+ addAfter: (title) => `Add a step after ${title}`,
18
+ addSourceTitle: 'Add a source',
19
+ startsLane: 'starts a new lane',
20
+ afterStep: (title) => `after ${title}`,
21
+ addHint: '“+” on a step adds the next one. A join takes its other input from another lane; '
22
+ + 'the lines are drawn for you, never by hand.',
23
+ emptyFlow: 'Nothing here yet. A source starts the first lane.',
24
+ stepMenu: (title) => `${title}: what to do`,
25
+ openSettings: 'Open its settings',
26
+ addStepAfter: 'Add a step after it',
27
+
28
+ // ── the toolbar ────────────────────────────────────────────────────
29
+ toolbar: 'Flow',
30
+ readOnlyTitle: 'This flow is open to read only.',
31
+
32
+ // ── parameters ─────────────────────────────────────────────────────
33
+ parameters: 'Parameters',
34
+ addParameter: 'Add parameter',
35
+ parameterTitle: 'Parameter',
36
+ parameterLine: (type, value) => (value === undefined ? String(type ?? '') : `${type ?? ''} · default ${value}`),
37
+ parameterAria: (name, line) => `Parameter ${name}: ${line}`,
38
+ parameterHelp: (ref) => `Steps read it as ${ref}.`,
39
+ parameterSupplied: 'Whatever starts the flow supplies it.',
40
+ parameterName: 'Name',
41
+ parameterType: 'Type',
42
+ parameterDefault: 'Default',
43
+ parameterNoDefault: 'No default',
44
+ parameterDescription: 'Description',
45
+ parameterSave: 'Save',
46
+ parameterCancel: 'Cancel',
47
+ parameterRemove: 'Remove parameter',
48
+ parameterNameRule: 'A name starts with a letter or _ and goes on with letters, digits or _.',
49
+ parameterNameTaken: (name) => `There is already a parameter called ${name}.`,
50
+ parameterNotNumber: 'The default must be a number.',
51
+ parameterNotInteger: 'The default must be a whole number.',
52
+
53
+ // ── a step's settings ──────────────────────────────────────────────
54
+ thisLane: 'This lane',
55
+ thisLaneHelp: 'The step before it, in its own lane.',
56
+ connectIt: 'Choose the step it follows.',
57
+ joinedWith: 'Joined with',
58
+ joinedWithPort: (label) => `Joined with (${label})`,
59
+ joinedWithHelp: 'Any step in another lane, to the left of this one.',
60
+ laneRelation: (delta, lane) => (delta === 0 ? 'this lane' : delta === 1 ? 'the lane below'
61
+ : delta === -1 ? 'the lane above' : `lane ${lane}`),
62
+ candidate: (title, relation) => `${title} — ${relation}`,
63
+ chooseStep: 'Choose a step to see its settings.',
64
+ flowTab: 'The flow',
65
+ flowTitle: 'The flow',
66
+ flowType: 'Settings',
67
+ stepsLabel: 'Steps',
68
+
69
+ // ── the dock ───────────────────────────────────────────────────────
70
+ dataLabel: 'What this step gives',
71
+ previewTab: 'Preview',
72
+ columnsTab: (n) => `Columns · ${count(n)}`,
73
+ rejectsTab: (n) => `Would be rejected · ${count(n)}`,
74
+ previewOn: (ms) => `Preview on · refreshed ${count(ms)} ms after your last change`,
75
+ previewing: 'Previewing…',
76
+ previewFailed: 'The preview could not be read.',
77
+ previewOff: 'No preview here.',
78
+ noPreview: 'Nothing previewed for this step yet.',
79
+ previewCaption: (shown, total) => `First ${count(shown)} of ${count(total)} previewed rows.`,
80
+ columnsFromDescription: (n) => `${plural(n, '1 column', '{n} columns')}, from the step’s description — no run needed.`,
81
+ columnsFromPreview: (n) => `${plural(n, '1 column', '{n} columns')}, from the preview.`,
82
+ columnsUnknown: 'No columns known for this step yet.',
83
+ columnNew: 'new',
84
+ columnChanged: 'changed',
85
+ rejectsCaption: (n) => `${plural(n, '1 of the previewed rows would be rejected.', '{n} of the previewed rows would be rejected.')}`,
86
+ noRejects: 'No previewed row would be rejected.',
87
+ rejectReason: (column, reason) => (column ? `${column}: ${reason}` : String(reason ?? '')),
88
+ noRows: 'No rows.',
89
+
90
+ // ── what a card's status line says ─────────────────────────────────
91
+ rows: (n) => plural(n, '1 row', '{n} rows'),
92
+ rejected: (n) => `${count(n)} rejected`,
93
+ skipped: 'skipped',
94
+ stepFailed: 'failed in the preview',
95
+ notRun: 'Not in the last run',
96
+ noRun: 'No run to show',
97
+ pending: '…',
98
+ });