flexdesk 0.5.0 → 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 (62) hide show
  1. package/README.md +621 -1
  2. package/css/base.css +2835 -0
  3. package/css/flexdesk.css +2835 -0
  4. package/css/tokens.css +72 -0
  5. package/dist/canvas.js +390 -0
  6. package/dist/canvas.js.map +7 -0
  7. package/dist/{chunk-24MS7W5Y.js → chunk-6NG7YVBG.js} +2 -470
  8. package/dist/chunk-6NG7YVBG.js.map +7 -0
  9. package/dist/chunk-H4LT43RS.js +475 -0
  10. package/dist/chunk-H4LT43RS.js.map +7 -0
  11. package/dist/chunk-SN4LSOAR.js +532 -0
  12. package/dist/chunk-SN4LSOAR.js.map +7 -0
  13. package/dist/flexdesk.css +2835 -0
  14. package/dist/flow.js +10030 -0
  15. package/dist/flow.js.map +7 -0
  16. package/dist/tiles.js +9 -9
  17. package/dist/tokens.css +72 -0
  18. package/dist/widgets.js +6 -4
  19. package/dist/widgets.js.map +1 -1
  20. package/dist/wm.js +4 -2
  21. package/dist/wm.js.map +1 -1
  22. package/package.json +9 -1
  23. package/src/canvas/canvas_adapter.js +523 -0
  24. package/src/canvas/edge_router.js +392 -0
  25. package/src/canvas/graph/connector_router.js +189 -0
  26. package/src/canvas/graph/history_service.js +170 -0
  27. package/src/canvas/graph/node_platform.js +116 -0
  28. package/src/canvas/index.js +16 -0
  29. package/src/flow/canvas_editor/editor.js +1081 -0
  30. package/src/flow/canvas_editor/index.js +16 -0
  31. package/src/flow/canvas_editor/layout.js +118 -0
  32. package/src/flow/canvas_editor/strings.js +37 -0
  33. package/src/flow/kit/always_before.js +325 -0
  34. package/src/flow/kit/catalogue.js +170 -0
  35. package/src/flow/kit/chip_input.js +461 -0
  36. package/src/flow/kit/dom.js +205 -0
  37. package/src/flow/kit/findings.js +129 -0
  38. package/src/flow/kit/history.js +173 -0
  39. package/src/flow/kit/index.js +38 -0
  40. package/src/flow/kit/keys.js +127 -0
  41. package/src/flow/kit/logic_graph.js +210 -0
  42. package/src/flow/kit/references.js +199 -0
  43. package/src/flow/kit/settings_panel.js +357 -0
  44. package/src/flow/kit/settings_schema.js +175 -0
  45. package/src/flow/kit/step_picker.js +209 -0
  46. package/src/flow/kit/strings.js +102 -0
  47. package/src/flow/kit/value_picker.js +238 -0
  48. package/src/flow/kit/widgets.js +763 -0
  49. package/src/flow/lanes/editor.js +1469 -0
  50. package/src/flow/lanes/index.js +26 -0
  51. package/src/flow/lanes/layout.js +525 -0
  52. package/src/flow/lanes/pipeline.js +327 -0
  53. package/src/flow/lanes/preview.js +109 -0
  54. package/src/flow/lanes/strings.js +98 -0
  55. package/src/flow/outline/editor.js +1511 -0
  56. package/src/flow/outline/index.js +20 -0
  57. package/src/flow/outline/mapping.js +286 -0
  58. package/src/flow/outline/menu.js +154 -0
  59. package/src/flow/outline/operations.js +738 -0
  60. package/src/flow/outline/recognise.js +755 -0
  61. package/src/flow/outline/strings.js +135 -0
  62. package/dist/chunk-24MS7W5Y.js.map +0 -7
@@ -0,0 +1,763 @@
1
+ /**
2
+ * A STEP'S SETTINGS, DRAWN FROM ITS SCHEMA — the widget registry, the generic
3
+ * widgets and the field around each control (36 §3.3).
4
+ *
5
+ * - a plain JSON-Schema type draws the obvious control: an `enum` a select, a
6
+ * string a text box (`x-ui-multiline`: a growing textarea), a number a
7
+ * number box, a boolean a checkbox, an object with properties a group of
8
+ * its own fields (`x-ui-fold`: folded, with a one-line summary);
9
+ * - `x-ui-widget` names a control from the REGISTRY. The kit pre-registers the
10
+ * ten generic ones; a consumer registers its own, and a name nobody
11
+ * registered is REFUSED ON SCREEN, BY NAME, with its stored value kept
12
+ * untouched — a panel that fell back to nothing would lose the setting on
13
+ * the next save.
14
+ *
15
+ * A widget is `(spec, value, ctx) => {el, destroy?, focus?, caret?,
16
+ * restoreCaret?}` and reports through `ctx.set(value, {reshape?})`; `undefined`
17
+ * removes the setting. `reshape: true` asks the panel to redraw (a choice that
18
+ * changes which other settings exist).
19
+ *
20
+ * ══ A CONTROL NEVER WRITES A VALUE THE READER DID NOT GIVE IT ══════════
21
+ *
22
+ * An untouched optional setting stays ABSENT (the server's `default` applies),
23
+ * and emptying one removes it — which is what keeps a saved graph byte-
24
+ * identical when nothing was changed. And a value KEEPS ITS TYPE: an entry of a
25
+ * map or a list that is a number, a boolean, an object or null is written back
26
+ * as itself when another entry is edited, and read as JSON when it is edited
27
+ * itself (`typedValue`). An enum's choice is written as the enum's own value,
28
+ * never as the text of its `<option>`.
29
+ */
30
+
31
+ import { button, el, uid } from './dom.js';
32
+ import { createChipInput } from './chip_input.js';
33
+ import { resolveSyntaxes } from './references.js';
34
+ import { admittedTypes, enumLabel, fieldsFromSchema, primaryType, titleOf, visibleFields, whenKeys }
35
+ from './settings_schema.js';
36
+ import { createStrings, say } from './strings.js';
37
+
38
+ // ── values that keep their type ──────────────────────────────────────────
39
+
40
+ /** A stored value as a one-line box shows it: a string as itself, anything else as JSON. */
41
+ export const shownValue = (v) => (v === undefined ? '' : typeof v === 'string' ? v : JSON.stringify(v));
42
+
43
+ /**
44
+ * The text typed into a box, as a value OF THE TYPE IT WAS:
45
+ * - an untouched box writes back what was stored, exactly;
46
+ * - an edited box whose stored value was NOT a string is parsed as JSON, and
47
+ * written as the parsed value when it parses (`5` → 5, `false` → false);
48
+ * text that does not parse is written as typed — the reader typed text;
49
+ * - a box whose stored value was a string, and a new row, stay text.
50
+ */
51
+ export function typedValue(text, stored) {
52
+ if (text === shownValue(stored) && stored !== undefined) return stored;
53
+ if (stored === undefined || typeof stored === 'string') return text;
54
+ try {
55
+ return JSON.parse(text);
56
+ } catch {
57
+ return text;
58
+ }
59
+ }
60
+
61
+ const clean = (v) => (typeof v === 'string' && v.trim() === '' ? undefined : v);
62
+
63
+ // ── small builders ───────────────────────────────────────────────────────
64
+
65
+ function textBox(value, { type = 'text', placeholder = '', label = '' } = {}) {
66
+ const input = el('input', 'twm-flow-input');
67
+ input.type = type;
68
+ input.value = value ?? '';
69
+ if (placeholder) input.placeholder = placeholder;
70
+ if (label) input.setAttribute('aria-label', label);
71
+ return input;
72
+ }
73
+
74
+ /** A `<select>` over `options` (`{value, label, disabled?}`), keeping a stored value it lacks. */
75
+ function selectBox(options, value, { empty = null, label = '', notFound = (v) => `${v} (not found)` } = {}) {
76
+ const box = el('select', 'twm-flow-input twm-flow-input--select');
77
+ if (label) box.setAttribute('aria-label', label);
78
+ const values = [];
79
+ if (empty !== null) {
80
+ box.appendChild(new Option(empty, ''));
81
+ values.push(undefined);
82
+ }
83
+ options.forEach((opt) => {
84
+ const o = new Option(opt.label, String(values.length));
85
+ if (opt.disabled) o.disabled = true;
86
+ box.appendChild(o);
87
+ values.push(opt.value);
88
+ });
89
+ const at = values.findIndex((v, i) => i >= (empty !== null ? 1 : 0) && v === value);
90
+ if (value !== undefined && value !== null && value !== '' && at < 0) {
91
+ // A stored value the list no longer offers is KEPT as its own option:
92
+ // it must not silently become the first entry on the next save.
93
+ box.appendChild(new Option(notFound(shownValue(value)), String(values.length)));
94
+ values.push(value);
95
+ box.value = String(values.length - 1);
96
+ } else {
97
+ box.value = at >= 0 ? String(at) : (empty !== null ? '' : '0');
98
+ }
99
+ return { box, valueOf: () => (box.value === '' ? undefined : values[Number(box.value)]) };
100
+ }
101
+
102
+ /**
103
+ * A textarea as tall as its text — wrapped lines included — up to `maxLines`,
104
+ * then it scrolls. Measured from `scrollHeight` once laid out (a
105
+ * ResizeObserver, which also re-measures when the width changes) and on every
106
+ * edit; with no layout (a test DOM) nothing is measured and `rows` stands.
107
+ * Returns a disconnect, for the widget's `destroy`.
108
+ */
109
+ export function fitToText(area, maxLines = 12) {
110
+ let width = -1;
111
+ const fit = () => {
112
+ if (!area.isConnected || !area.clientWidth) return;
113
+ const style = getComputedStyle(area);
114
+ const line = parseFloat(style.lineHeight) || 16;
115
+ const pad = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
116
+ const border = (parseFloat(style.borderTopWidth) || 0) + (parseFloat(style.borderBottomWidth) || 0);
117
+ area.style.height = 'auto';
118
+ const want = Math.min(area.scrollHeight, Math.ceil(line * maxLines + pad));
119
+ area.style.height = `${want + border}px`;
120
+ };
121
+ area.addEventListener('input', fit);
122
+ if (typeof ResizeObserver !== 'function') return () => area.removeEventListener('input', fit);
123
+ const watch = new ResizeObserver(() => {
124
+ if (area.clientWidth === width) return;
125
+ width = area.clientWidth;
126
+ fit();
127
+ });
128
+ watch.observe(area);
129
+ return () => { watch.disconnect(); area.removeEventListener('input', fit); };
130
+ }
131
+
132
+ /** Rows of editable lines with Add and Remove — the shape the list widgets share. */
133
+ function rowList({ items, draw, blank, onChange, addLabel, removeLabel, readOnly }) {
134
+ const root = el('div', 'twm-flow-field__rows');
135
+ root.setAttribute('role', 'group');
136
+ const list = items.map((it) => ({ ...it }));
137
+ const cleanups = [];
138
+ const paint = () => {
139
+ cleanups.splice(0).forEach((f) => f());
140
+ root.replaceChildren();
141
+ list.forEach((item, idx) => {
142
+ const row = el('div', 'twm-flow-field__row');
143
+ const drawn = draw(item, (next) => { list[idx] = next; onChange(list); });
144
+ const parts = Array.isArray(drawn) ? drawn : drawn.parts;
145
+ if (!Array.isArray(drawn) && drawn.destroy) cleanups.push(drawn.destroy);
146
+ row.append(...parts);
147
+ if (!readOnly) {
148
+ row.appendChild(button({ label: removeLabel(item, idx), icon: 'close', iconOnly: true,
149
+ className: 'twm-flow-field__remove',
150
+ onClick: () => { list.splice(idx, 1); onChange(list); paint(); } }));
151
+ }
152
+ root.appendChild(row);
153
+ });
154
+ if (!readOnly) {
155
+ root.appendChild(button({ label: addLabel, icon: 'add', className: 'twm-flow-field__add',
156
+ onClick: () => { list.push(blank()); onChange(list); paint(); } }));
157
+ }
158
+ };
159
+ paint();
160
+ return { el: root, destroy: () => cleanups.splice(0).forEach((f) => f()) };
161
+ }
162
+
163
+ /** A chip input with its *Insert a value* button beside it. */
164
+ function refBox(ctx, { value, multiline, mono, placeholder, label, onInput }) {
165
+ const S = ctx.strings;
166
+ const chip = createChipInput({
167
+ value, syntaxes: ctx.references, readOnly: ctx.readOnly, multiline, mono, placeholder,
168
+ label, describeContext: ctx.describeContext, onInput,
169
+ });
170
+ const box = el('div', `twm-flow-refbox${mono ? ' twm-flow-refbox--mono' : ''}`);
171
+ box.appendChild(chip.el);
172
+ if (ctx.openValuePicker && !ctx.readOnly) {
173
+ const insert = el('button', 'twm-flow-refbox__insert', say(S, 'insertValueGlyph'));
174
+ insert.type = 'button';
175
+ insert.setAttribute('aria-label', say(S, 'insertValue'));
176
+ insert.title = say(S, 'insertValue');
177
+ insert.setAttribute('aria-haspopup', 'dialog');
178
+ insert.addEventListener('click', () => ctx.openValuePicker({ anchor: insert, insert: (t) => chip.insert(t) }));
179
+ box.appendChild(insert);
180
+ }
181
+ return { box, chip };
182
+ }
183
+
184
+ const placeholderOf = (spec, S) => spec['x-ui-placeholder'] ?? say(S, 'textOrValue');
185
+
186
+ // ── the generic widgets ──────────────────────────────────────────────────
187
+
188
+ /** Text with reference chips. A setting that may also BE a list or an object
189
+ * (`["string", "array"]`) is shown as JSON and written back as the list when
190
+ * the text parses as one; text that does not parse is kept as typed, for the
191
+ * server to judge, and a setting that is only ever text is never parsed. */
192
+ function templateWidget(spec, value, ctx) {
193
+ const admits = admittedTypes(spec);
194
+ const shown = value && typeof value === 'object' ? JSON.stringify(value, null, 2) : (value ?? '');
195
+ const { box, chip } = refBox(ctx, {
196
+ value: String(shown), multiline: true, placeholder: placeholderOf(spec, ctx.strings), label: ctx.label,
197
+ onInput: (text) => {
198
+ if (/^\s*[[{]/.test(text) && (admits.includes('array') || admits.includes('object'))) {
199
+ try {
200
+ const parsed = JSON.parse(text);
201
+ const kind = Array.isArray(parsed) ? 'array' : 'object';
202
+ if (parsed && typeof parsed === 'object' && admits.includes(kind)) { ctx.set(parsed); return; }
203
+ } catch { /* not JSON yet: kept as typed */ }
204
+ }
205
+ ctx.set(clean(text));
206
+ },
207
+ });
208
+ return { el: box, focus: () => chip.focus(), caret: chip.caret, restoreCaret: chip.restoreCaret,
209
+ destroy: chip.destroy };
210
+ }
211
+
212
+ /** A formula: monospace, chips for its syntaxes. Plain text here; a consumer
213
+ * replaces it with its own editor (`register('expression', …, {replace: true})`). */
214
+ function expressionWidget(spec, value, ctx) {
215
+ const { box, chip } = refBox(ctx, {
216
+ value: String(value ?? ''), multiline: true, mono: true,
217
+ placeholder: spec['x-ui-placeholder'] ?? '', label: ctx.label,
218
+ onInput: (text) => ctx.set(clean(text)),
219
+ });
220
+ return { el: box, focus: () => chip.focus(), caret: chip.caret, restoreCaret: chip.restoreCaret,
221
+ destroy: chip.destroy };
222
+ }
223
+
224
+ /** A request body: JSON when it parses as an object or a list, text otherwise. */
225
+ function jsonBodyWidget(spec, value, ctx) {
226
+ const shown = value && typeof value === 'object' ? JSON.stringify(value, null, 2) : (value ?? '');
227
+ const { box, chip } = refBox(ctx, {
228
+ value: String(shown), multiline: true, mono: true, placeholder: spec['x-ui-placeholder'] ?? '',
229
+ label: ctx.label,
230
+ onInput: (text) => {
231
+ if (/^\s*[[{]/.test(text)) {
232
+ try { ctx.set(JSON.parse(text)); return; } catch { /* kept as text */ }
233
+ }
234
+ ctx.set(clean(text));
235
+ },
236
+ });
237
+ return { el: box, focus: () => chip.focus(), caret: chip.caret, restoreCaret: chip.restoreCaret,
238
+ destroy: chip.destroy };
239
+ }
240
+
241
+ /** An object of names to values; each value keeps its stored type. */
242
+ function keyValueMapWidget(spec, value, ctx) {
243
+ const S = ctx.strings;
244
+ const entries = Object.entries(value && typeof value === 'object' && !Array.isArray(value) ? value : {})
245
+ .map(([k, v]) => ({ k, v: shownValue(v), stored: v }));
246
+ const emit = (list) => {
247
+ const out = {};
248
+ for (const { k, v, stored } of list) if (k) out[k] = typedValue(v ?? '', stored);
249
+ ctx.set(Object.keys(out).length ? out : undefined);
250
+ };
251
+ const rows = rowList({
252
+ items: entries, onChange: emit, readOnly: ctx.readOnly,
253
+ addLabel: spec['x-ui-add-label'] ?? say(S, 'add'),
254
+ removeLabel: (item) => say(S, 'removeNamed', item.k || say(S, 'name').toLowerCase()),
255
+ blank: () => ({ k: '', v: '' }),
256
+ draw: (item, put) => {
257
+ const key = textBox(item.k, { placeholder: say(S, 'name'), label: say(S, 'name') });
258
+ key.disabled = Boolean(ctx.readOnly);
259
+ key.addEventListener('input', () => put({ ...item, k: (item.k = key.value) }));
260
+ const { box, chip } = refBox(ctx, {
261
+ value: item.v, multiline: false, placeholder: say(S, 'value'), label: say(S, 'value'),
262
+ onInput: (text) => put({ ...item, v: (item.v = text) }),
263
+ });
264
+ return { parts: [key, box], destroy: chip.destroy };
265
+ },
266
+ });
267
+ return { el: rows.el, destroy: rows.destroy };
268
+ }
269
+
270
+ /** An ordered list of assignments — `{variable, value}` or `{variable, expression}`;
271
+ * a value keeps its type, a formula is its text. */
272
+ function keyValueListWidget(spec, value, ctx) {
273
+ const S = ctx.strings;
274
+ const formulaSyntaxes = resolveSyntaxes(spec['x-ui-formula-references'] ?? ['formula'], ctx.referenceMap);
275
+ const items = (Array.isArray(value) ? value : []).map((a) => ({
276
+ variable: a?.variable ?? '',
277
+ mode: a?.expression !== undefined ? 'expression' : 'value',
278
+ text: a?.expression ?? shownValue(a?.value),
279
+ stored: a?.expression !== undefined ? undefined : a?.value,
280
+ }));
281
+ const emit = (list) => {
282
+ const out = list.filter((a) => a.variable).map((a) => (a.mode === 'expression'
283
+ ? { variable: a.variable, expression: a.text }
284
+ : { variable: a.variable, value: typedValue(a.text, a.stored) }));
285
+ ctx.set(out.length ? out : undefined);
286
+ };
287
+ const rows = rowList({
288
+ items, onChange: emit, readOnly: ctx.readOnly,
289
+ addLabel: spec['x-ui-add-label'] ?? say(S, 'addAssignment'),
290
+ removeLabel: (item) => say(S, 'removeNamed', item.variable || say(S, 'value').toLowerCase()),
291
+ blank: () => ({ variable: '', mode: 'value', text: '' }),
292
+ draw: (item, put) => {
293
+ const name = textBox(item.variable, { placeholder: say(S, 'name'), label: say(S, 'name') });
294
+ name.disabled = Boolean(ctx.readOnly);
295
+ name.addEventListener('input', () => put({ ...item, variable: (item.variable = name.value) }));
296
+ const { box: mode, valueOf } = selectBox([{ value: 'value', label: say(S, 'value') },
297
+ { value: 'expression', label: say(S, 'formula') }],
298
+ item.mode, { label: say(S, 'value') });
299
+ mode.disabled = Boolean(ctx.readOnly);
300
+ const slot = el('span', 'twm-flow-field__slot');
301
+ let chip = null;
302
+ const drawText = () => {
303
+ chip?.destroy();
304
+ const sub = item.mode === 'expression'
305
+ ? { ...ctx, references: formulaSyntaxes } : ctx;
306
+ const made = refBox(sub, {
307
+ value: item.text, multiline: false, mono: item.mode === 'expression',
308
+ placeholder: item.mode === 'expression' ? '' : say(S, 'textOrValue'), label: say(S, 'value'),
309
+ onInput: (text) => put({ ...item, text: (item.text = text) }),
310
+ });
311
+ chip = made.chip;
312
+ slot.replaceChildren(made.box);
313
+ };
314
+ drawText();
315
+ mode.addEventListener('change', () => {
316
+ item.mode = valueOf();
317
+ put({ ...item });
318
+ drawText();
319
+ });
320
+ return { parts: [name, mode, slot], destroy: () => chip?.destroy() };
321
+ },
322
+ });
323
+ return { el: rows.el, destroy: rows.destroy };
324
+ }
325
+
326
+ function stringListWidget(spec, value, ctx) {
327
+ const S = ctx.strings;
328
+ const items = (Array.isArray(value) ? value : []).map((s) => ({ s: String(s) }));
329
+ const emit = (list) => {
330
+ const out = list.map((x) => x.s).filter(Boolean);
331
+ ctx.set(out.length ? out : undefined);
332
+ };
333
+ const rows = rowList({
334
+ items, onChange: emit, readOnly: ctx.readOnly,
335
+ addLabel: spec['x-ui-add-label'] ?? say(S, 'add'),
336
+ removeLabel: (item) => say(S, 'removeNamed', item.s || say(S, 'value').toLowerCase()),
337
+ blank: () => ({ s: '' }),
338
+ draw: (item, put) => {
339
+ const box = textBox(item.s, { label: ctx.label, placeholder: spec['x-ui-placeholder'] ?? '' });
340
+ box.disabled = Boolean(ctx.readOnly);
341
+ box.addEventListener('input', () => put({ s: (item.s = box.value) }));
342
+ return [box];
343
+ },
344
+ });
345
+ return { el: rows.el, destroy: rows.destroy };
346
+ }
347
+
348
+ /** Toggle chips over a fixed list; the stored list follows the list's order,
349
+ * and a stored entry the list lacks is kept, marked, until it is toggled off. */
350
+ function toggleChips({ options, value, label, readOnly, onChange, notFound }) {
351
+ const root = el('div', 'twm-flow-togglechips');
352
+ root.setAttribute('role', 'group');
353
+ if (label) root.setAttribute('aria-label', label);
354
+ const known = options.map((o) => o.value);
355
+ const chosen = new Set((Array.isArray(value) ? value : []).filter((v) => known.includes(v)));
356
+ const strays = (Array.isArray(value) ? value : []).filter((v) => !known.includes(v));
357
+ const emit = () => {
358
+ const out = [...known.filter((v) => chosen.has(v)), ...strays];
359
+ onChange(out.length ? out : undefined);
360
+ };
361
+ const chipFor = (text, pressed, toggle, stray = false) => {
362
+ const b = el('button', `twm-flow-togglechip${stray ? ' twm-flow-togglechip--missing' : ''}`, text);
363
+ b.type = 'button';
364
+ b.setAttribute('aria-pressed', pressed ? 'true' : 'false');
365
+ b.disabled = Boolean(readOnly);
366
+ b.addEventListener('click', () => {
367
+ const on = toggle();
368
+ b.setAttribute('aria-pressed', on ? 'true' : 'false');
369
+ emit();
370
+ });
371
+ return b;
372
+ };
373
+ for (const o of options) {
374
+ root.appendChild(chipFor(o.label, chosen.has(o.value), () => {
375
+ if (chosen.has(o.value)) chosen.delete(o.value); else chosen.add(o.value);
376
+ return chosen.has(o.value);
377
+ }));
378
+ }
379
+ for (const v of [...strays]) {
380
+ const b = chipFor(notFound(shownValue(v)), true, () => {
381
+ const i = strays.indexOf(v);
382
+ if (i >= 0) { strays.splice(i, 1); return false; }
383
+ strays.push(v);
384
+ return true;
385
+ }, true);
386
+ root.appendChild(b);
387
+ }
388
+ return root;
389
+ }
390
+
391
+ /** A multi-select from `items.enum`, drawn as chips. */
392
+ function enumChipsWidget(spec, value, ctx) {
393
+ const S = ctx.strings;
394
+ const itemSpec = spec.items && typeof spec.items === 'object' ? spec.items : {};
395
+ const values = Array.isArray(itemSpec.enum) ? itemSpec.enum : (Array.isArray(spec.enum) ? spec.enum : []);
396
+ const words = (v) => (itemSpec['x-ui-enum-labels'] ? enumLabel(itemSpec, v) : enumLabel(spec, v));
397
+ return {
398
+ el: toggleChips({
399
+ options: values.map((v) => ({ value: v, label: words(v) })), value, label: ctx.label,
400
+ readOnly: ctx.readOnly, onChange: (v) => ctx.set(v), notFound: (v) => say(S, 'notFound', v),
401
+ }),
402
+ };
403
+ }
404
+
405
+ /** A one-of-N as radio rows, each with its words — and a sentence under it,
406
+ * from `x-ui-enum-descriptions`, when the schema gives one. */
407
+ function choiceCardsWidget(spec, value, ctx) {
408
+ const values = Array.isArray(spec.enum) ? spec.enum : [];
409
+ const descriptions = spec['x-ui-enum-descriptions'] && typeof spec['x-ui-enum-descriptions'] === 'object'
410
+ ? spec['x-ui-enum-descriptions'] : {};
411
+ const root = el('div', 'twm-flow-choices');
412
+ root.setAttribute('role', 'radiogroup');
413
+ if (ctx.label) root.setAttribute('aria-label', ctx.label);
414
+ const name = uid('choice');
415
+ const current = value !== undefined ? value : spec.default;
416
+ const radios = [];
417
+ for (const v of values) {
418
+ const row = el('label', 'twm-flow-choice');
419
+ const radio = el('input', 'twm-flow-choice__radio');
420
+ radio.type = 'radio';
421
+ radio.name = name;
422
+ radio.checked = v === current;
423
+ radio.disabled = Boolean(ctx.readOnly);
424
+ radio.addEventListener('change', () => { if (radio.checked) ctx.set(v); });
425
+ radios.push(radio);
426
+ const words = el('span', 'twm-flow-choice__words');
427
+ words.appendChild(el('span', 'twm-flow-choice__label', enumLabel(spec, v)));
428
+ if (descriptions[v] !== undefined) words.appendChild(el('span', 'twm-flow-choice__sub', descriptions[v]));
429
+ row.append(radio, words);
430
+ root.appendChild(row);
431
+ }
432
+ if (current !== undefined && !values.includes(current)) {
433
+ root.appendChild(el('p', 'twm-flow-field__note', say(ctx.strings, 'notFound', shownValue(current))));
434
+ }
435
+ return { el: root, focus: () => (radios.find((r) => r.checked) || radios[0])?.focus({ preventScroll: true }) };
436
+ }
437
+
438
+ const columnWords = (c) => (c.type ? `${c.name} · ${c.type}` : String(c.name));
439
+
440
+ /** One column of the step's input, by NAME. */
441
+ function upstreamColumnWidget(spec, value, ctx) {
442
+ const S = ctx.strings;
443
+ const columns = ctx.columns?.();
444
+ if (!Array.isArray(columns)) {
445
+ const note = el('p', 'twm-flow-field__note', say(S, 'noColumns'));
446
+ if (value !== undefined) note.appendChild(el('span', 'twm-flow-field__kept', ` ${shownValue(value)}`));
447
+ return { el: note };
448
+ }
449
+ const { box, valueOf } = selectBox(columns.map((c) => ({ value: String(c.name), label: columnWords(c) })),
450
+ value, { empty: say(S, 'choose'), label: ctx.label,
451
+ notFound: (v) => say(S, 'notFound', v) });
452
+ box.disabled = Boolean(ctx.readOnly);
453
+ box.addEventListener('change', () => ctx.set(clean(valueOf())));
454
+ return { el: box };
455
+ }
456
+
457
+ /** Several columns of the step's input, by name, as chips in the input's order. */
458
+ function upstreamColumnsWidget(spec, value, ctx) {
459
+ const S = ctx.strings;
460
+ const columns = ctx.columns?.();
461
+ if (!Array.isArray(columns)) {
462
+ const note = el('p', 'twm-flow-field__note', say(S, 'noColumns'));
463
+ if (Array.isArray(value) && value.length) {
464
+ note.appendChild(el('span', 'twm-flow-field__kept', ` ${value.map(shownValue).join(', ')}`));
465
+ }
466
+ return { el: note };
467
+ }
468
+ return {
469
+ el: toggleChips({
470
+ options: columns.map((c) => ({ value: String(c.name), label: String(c.name) })), value,
471
+ label: ctx.label, readOnly: ctx.readOnly, onChange: (v) => ctx.set(v),
472
+ notFound: (v) => say(S, 'notFound', v),
473
+ }),
474
+ };
475
+ }
476
+
477
+ /** The ten generic widgets the kit ships (36 §3.3). */
478
+ export const GENERIC_WIDGETS = Object.freeze({
479
+ template: templateWidget,
480
+ expression: expressionWidget,
481
+ 'json-body': jsonBodyWidget,
482
+ 'key-value-map': keyValueMapWidget,
483
+ 'key-value-list': keyValueListWidget,
484
+ 'string-list': stringListWidget,
485
+ 'enum-chips': enumChipsWidget,
486
+ 'choice-cards': choiceCardsWidget,
487
+ 'upstream-column': upstreamColumnWidget,
488
+ 'upstream-columns': upstreamColumnsWidget,
489
+ });
490
+
491
+ /** Which reference syntaxes a widget recognises when its schema names none. */
492
+ export const DEFAULT_WIDGET_REFERENCES = Object.freeze({
493
+ template: ['template'],
494
+ expression: ['formula'],
495
+ 'json-body': ['template'],
496
+ 'key-value-map': ['template'],
497
+ 'key-value-list': ['template'],
498
+ });
499
+
500
+ /**
501
+ * A registry of widgets by name, the generic ones pre-registered. Registering
502
+ * a name that exists THROWS; replacing one must be said (`{replace: true}`).
503
+ */
504
+ export function createWidgetRegistry() {
505
+ const table = new Map(Object.entries(GENERIC_WIDGETS));
506
+ return Object.freeze({
507
+ register(name, widget, { replace = false } = {}) {
508
+ if (typeof name !== 'string' || !name) throw new Error('A widget needs a name.');
509
+ if (typeof widget !== 'function') throw new Error(`Widget "${name}" must be a function (spec, value, ctx).`);
510
+ if (table.has(name) && !replace) {
511
+ throw new Error(`A widget called "${name}" is already registered; pass {replace: true} to replace it.`);
512
+ }
513
+ table.set(name, widget);
514
+ },
515
+ has: (name) => table.has(name),
516
+ get: (name) => table.get(name) ?? null,
517
+ names: () => [...table.keys()],
518
+ });
519
+ }
520
+
521
+ // ── plain controls, for a spec with no widget ────────────────────────────
522
+
523
+ function plainControl(spec, value, ctx) {
524
+ const S = ctx.strings;
525
+ const type = primaryType(spec);
526
+ if (Array.isArray(spec.enum)) {
527
+ const { box, valueOf } = selectBox(spec.enum.map((v) => ({ value: v, label: enumLabel(spec, v) })), value, {
528
+ empty: spec.default !== undefined
529
+ ? (spec['x-ui-enum-labels']?.[spec.default] ?? say(S, 'defaultOption', shownValue(spec.default)))
530
+ : say(S, 'choose'),
531
+ label: ctx.label, notFound: (v) => say(S, 'notFound', v),
532
+ });
533
+ box.disabled = Boolean(ctx.readOnly);
534
+ box.addEventListener('change', () => ctx.set(valueOf()));
535
+ return { el: box };
536
+ }
537
+ if (type === 'boolean') {
538
+ const box = el('input', 'twm-flow-checkbox');
539
+ box.type = 'checkbox';
540
+ box.checked = value === undefined ? Boolean(spec.default) : Boolean(value);
541
+ box.disabled = Boolean(ctx.readOnly);
542
+ if (ctx.label) box.setAttribute('aria-label', ctx.label);
543
+ box.addEventListener('change', () => ctx.set(box.checked === Boolean(spec.default) ? undefined : box.checked));
544
+ return { el: box };
545
+ }
546
+ if (type === 'integer' || type === 'number') {
547
+ const box = textBox(value ?? '', {
548
+ type: 'number', label: ctx.label,
549
+ placeholder: spec['x-ui-placeholder'] ?? (spec.default !== undefined ? String(spec.default) : ''),
550
+ });
551
+ if (spec.minimum !== undefined) box.min = String(spec.minimum);
552
+ if (spec.maximum !== undefined) box.max = String(spec.maximum);
553
+ if (type === 'integer') box.step = '1';
554
+ box.disabled = Boolean(ctx.readOnly);
555
+ box.addEventListener('input', () => {
556
+ const n = box.value === '' ? undefined : Number(box.value);
557
+ ctx.set(Number.isFinite(n) ? n : undefined);
558
+ });
559
+ return { el: box };
560
+ }
561
+ if ((type === 'string' || type === undefined) && spec['x-ui-multiline']) {
562
+ const area = el('textarea', 'twm-flow-input twm-flow-input--area');
563
+ area.value = value ?? '';
564
+ area.rows = 3;
565
+ area.placeholder = spec['x-ui-placeholder'] ?? '';
566
+ if (ctx.label) area.setAttribute('aria-label', ctx.label);
567
+ area.readOnly = Boolean(ctx.readOnly);
568
+ const unfit = fitToText(area, 12);
569
+ area.addEventListener('input', () => ctx.set(clean(area.value)));
570
+ return { el: area, destroy: unfit };
571
+ }
572
+ if (type === 'string' || type === undefined) {
573
+ const box = textBox(value ?? '', { label: ctx.label, placeholder: spec['x-ui-placeholder'] ?? '' });
574
+ box.readOnly = Boolean(ctx.readOnly);
575
+ box.addEventListener('input', () => ctx.set(clean(box.value)));
576
+ return { el: box };
577
+ }
578
+ return null;
579
+ }
580
+
581
+ /** A folded group's one line: the settings it holds that are set, else "Defaults". */
582
+ function foldSummary(spec, value, S) {
583
+ const parts = [];
584
+ for (const f of fieldsFromSchema(spec)) {
585
+ const v = value?.[f.key];
586
+ if (v === undefined) continue;
587
+ parts.push(`${titleOf(f.key, f.spec)} ${Array.isArray(f.spec.enum) ? enumLabel(f.spec, v) : shownValue(v)}`);
588
+ }
589
+ return parts.length ? parts.join(' · ') : say(S, 'foldDefaults');
590
+ }
591
+
592
+ const focusable = (root) => root?.querySelector?.('select, input, textarea, button, [contenteditable="true"]')
593
+ || null;
594
+
595
+ /**
596
+ * One setting: its label, its control, its help, and a line for a finding.
597
+ *
598
+ * `ctx`: `{widgets, set(key, value, opts), path, step, type, readOnly,
599
+ * referenceMap, openValuePicker({anchor, insert, path}), columns, services,
600
+ * strings, describeContext}` — the panel builds it. Returns `{el, key, path,
601
+ * setError(message, severity), focus(), caret(), restoreCaret(c), paths(),
602
+ * fieldAt(path), destroy()}`.
603
+ */
604
+ export function renderField({ key, spec = {}, required = false }, value, ctx) {
605
+ const S = ctx.strings || createStrings();
606
+ const path = ctx.path ? `${ctx.path}.${key}` : String(key);
607
+ const title = titleOf(key, spec);
608
+ const root = el('div', 'twm-flow-field');
609
+ root.dataset.field = path;
610
+ const labelId = uid('label');
611
+ const errorId = uid('error');
612
+ const helpId = uid('help');
613
+
614
+ const labelCol = el('div', 'twm-flow-field__label');
615
+ const label = el('label', 'twm-flow-field__name', title);
616
+ label.id = labelId;
617
+ labelCol.appendChild(label);
618
+ if (required) {
619
+ const mark = el('span', 'twm-flow-field__required', say(S, 'required'));
620
+ mark.setAttribute('aria-label', say(S, 'requiredLabel'));
621
+ labelCol.appendChild(mark);
622
+ }
623
+ const body = el('div', 'twm-flow-field__body');
624
+ const error = el('p', 'twm-flow-field__error');
625
+ error.id = errorId;
626
+ error.hidden = true;
627
+ const children = [];
628
+ let control = null;
629
+ const widgetName = spec['x-ui-widget'];
630
+ const syntaxNames = spec['x-ui-references'] ?? DEFAULT_WIDGET_REFERENCES[widgetName] ?? [];
631
+ const sub = {
632
+ key, path, step: ctx.step ?? null, type: ctx.type ?? null, readOnly: Boolean(ctx.readOnly),
633
+ label: title,
634
+ references: resolveSyntaxes(syntaxNames, ctx.referenceMap),
635
+ referenceMap: ctx.referenceMap,
636
+ openValuePicker: ctx.openValuePicker
637
+ ? ({ anchor, insert }) => ctx.openValuePicker({ anchor, insert, path, key })
638
+ : null,
639
+ columns: ctx.columns || (() => null),
640
+ services: ctx.services,
641
+ strings: S,
642
+ describeContext: { ...(ctx.describeContext || {}), field: path },
643
+ set: (v, opts) => ctx.set(key, v, opts),
644
+ };
645
+
646
+ if (widgetName !== undefined) {
647
+ const make = ctx.widgets?.get?.(widgetName);
648
+ if (make) {
649
+ try {
650
+ control = make(spec, value, sub);
651
+ } catch (err) {
652
+ control = { el: el('p', 'twm-flow-field__refusal', `${say(S, 'noControl')} (${err?.message || err})`) };
653
+ }
654
+ } else {
655
+ // REFUSED BY NAME, and the stored value is kept: nothing here writes this key.
656
+ control = { el: el('p', 'twm-flow-field__refusal', say(S, 'unknownWidget', widgetName)) };
657
+ root.dataset.refusal = widgetName;
658
+ }
659
+ } else if (primaryType(spec) === 'object' && spec.properties && typeof spec.properties === 'object') {
660
+ const group = el('details', 'twm-flow-field__group');
661
+ group.open = !spec['x-ui-fold'];
662
+ const summary = el('summary', 'twm-flow-field__summary');
663
+ summary.appendChild(el('span', 'twm-flow-field__summary-text', foldSummary(spec, value, S)));
664
+ group.appendChild(summary);
665
+ const inner = { ...(value && typeof value === 'object' && !Array.isArray(value) ? value : {}) };
666
+ const innerFields = fieldsFromSchema(spec);
667
+ const repaintSummary = () => {
668
+ summary.firstChild.textContent = foldSummary(spec, inner, S);
669
+ };
670
+ for (const f of visibleFields(innerFields, inner)) {
671
+ const child = renderField(f, inner[f.key], {
672
+ ...ctx, path,
673
+ set: (k, v, opts) => {
674
+ if (v === undefined) delete inner[k]; else inner[k] = v;
675
+ repaintSummary();
676
+ ctx.set(key, Object.keys(inner).length ? { ...inner } : undefined,
677
+ { ...opts, reshape: opts?.reshape || whenKeys(innerFields).has(k) });
678
+ },
679
+ });
680
+ children.push(child);
681
+ group.appendChild(child.el);
682
+ }
683
+ control = { el: group };
684
+ root.classList.add('twm-flow-field--group');
685
+ } else {
686
+ control = plainControl(spec, value, sub)
687
+ || { el: el('p', 'twm-flow-field__refusal', say(S, 'noControl')) };
688
+ }
689
+
690
+ // Name the control by its label, and describe it by its help and its finding:
691
+ // a box by `for`, a chip input inside its refbox and a group of rows or
692
+ // choices by `aria-labelledby`.
693
+ const target = control.el.matches?.('input, select, textarea') ? control.el : null;
694
+ const labelled = target ? null
695
+ : control.el.matches?.('[role="textbox"], [role="group"], [role="radiogroup"]') ? control.el
696
+ : control.el.classList?.contains('twm-flow-refbox') ? control.el.querySelector('[role="textbox"]') : null;
697
+ if (target) {
698
+ target.id ||= uid('control');
699
+ label.htmlFor = target.id;
700
+ target.removeAttribute('aria-label');
701
+ } else if (labelled) {
702
+ labelled.setAttribute('aria-labelledby', labelId);
703
+ labelled.removeAttribute('aria-label');
704
+ }
705
+ const describedBy = [errorId];
706
+ body.appendChild(control.el);
707
+ body.appendChild(error);
708
+ if (spec.description) {
709
+ const help = el('p', 'twm-flow-field__help', spec.description);
710
+ help.id = helpId;
711
+ body.appendChild(help);
712
+ describedBy.push(helpId);
713
+ }
714
+ const described = target || labelled || control.el;
715
+ described.setAttribute?.('aria-describedby', describedBy.join(' '));
716
+ root.append(labelCol, body);
717
+
718
+ return {
719
+ el: root,
720
+ key,
721
+ path,
722
+ control,
723
+ setError(message, severity = 'error') {
724
+ error.replaceChildren();
725
+ if (message) {
726
+ const glyph = el('span', 'material-symbols-outlined twm-flow-field__error-icon',
727
+ severity === 'warning' ? 'warning' : 'error');
728
+ glyph.setAttribute('aria-hidden', 'true');
729
+ error.append(glyph, el('span', 'twm-flow-field__error-text', message));
730
+ }
731
+ error.hidden = !message;
732
+ root.classList.toggle('twm-flow-field--error', Boolean(message) && severity !== 'warning');
733
+ root.classList.toggle('twm-flow-field--warning', Boolean(message) && severity === 'warning');
734
+ described.setAttribute?.('aria-invalid', message && severity !== 'warning' ? 'true' : 'false');
735
+ },
736
+ focus() {
737
+ // A folded group holding this field opens, and so does this one.
738
+ for (let d = root.parentElement?.closest('details'); d; d = d.parentElement?.closest('details')) d.open = true;
739
+ if (control.el.tagName === 'DETAILS') control.el.open = true;
740
+ if (typeof control.focus === 'function') { control.focus(); return true; }
741
+ const f = target || focusable(control.el);
742
+ if (f) { f.focus({ preventScroll: true }); return true; }
743
+ return false;
744
+ },
745
+ caret: () => (typeof control.caret === 'function' ? control.caret() : null),
746
+ restoreCaret: (c) => control.restoreCaret?.(c),
747
+ /** Every field path this field draws, its own and its children's. */
748
+ paths: () => [path, ...children.flatMap((c) => c.paths())],
749
+ /** The field (this one or a child) drawn for exactly `p`. */
750
+ fieldAt(p) {
751
+ if (p === path) return this;
752
+ for (const c of children) {
753
+ const hit = c.fieldAt(p);
754
+ if (hit) return hit;
755
+ }
756
+ return null;
757
+ },
758
+ destroy() {
759
+ control.destroy?.();
760
+ children.forEach((c) => c.destroy());
761
+ },
762
+ };
763
+ }