@flowdrop/flowdrop 1.13.0 → 1.15.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 (51) hide show
  1. package/README.md +5 -0
  2. package/dist/components/ConfigForm.svelte +41 -21
  3. package/dist/components/ConfigPanel.svelte +7 -1
  4. package/dist/components/NodeSwapPicker.svelte +5 -1
  5. package/dist/components/PipelineStatus.svelte +11 -2
  6. package/dist/components/SchemaForm.svelte +28 -16
  7. package/dist/components/SettingsPanel.svelte +5 -1
  8. package/dist/components/WorkflowEditor.svelte +5 -1
  9. package/dist/components/chat/AIChatPanel.svelte +1 -5
  10. package/dist/components/form/FormAutocomplete.svelte +23 -12
  11. package/dist/components/interrupt/ChoicePrompt.svelte +5 -1
  12. package/dist/components/interrupt/InterruptBubble.svelte +4 -5
  13. package/dist/components/nodes/AtomNode.svelte +280 -0
  14. package/dist/components/nodes/AtomNode.svelte.d.ts +26 -0
  15. package/dist/components/playground/ChatBubble.svelte +6 -8
  16. package/dist/components/playground/ChatInput.svelte +11 -5
  17. package/dist/components/playground/ControlPanel.svelte +42 -29
  18. package/dist/components/playground/ExecutionConsole.svelte +5 -1
  19. package/dist/components/playground/ExecutionConsole.svelte.d.ts +2 -0
  20. package/dist/components/playground/ExecutionList.svelte +7 -2
  21. package/dist/components/playground/LogRow.svelte +2 -1
  22. package/dist/components/playground/MessageBubble.svelte +1 -4
  23. package/dist/components/playground/MessageCard.svelte +2 -1
  24. package/dist/components/playground/MessageMarkdown.svelte +15 -5
  25. package/dist/components/playground/MessageNotice.svelte +2 -1
  26. package/dist/components/playground/MessageStream.svelte +138 -17
  27. package/dist/components/playground/MessageStream.svelte.d.ts +5 -0
  28. package/dist/components/playground/MessageTagChip.svelte +24 -6
  29. package/dist/components/playground/PipelineKanbanView.svelte +40 -11
  30. package/dist/components/playground/PipelinePanel.svelte +5 -1
  31. package/dist/components/playground/PipelineTableView.svelte +20 -6
  32. package/dist/components/playground/Playground.svelte +94 -27
  33. package/dist/components/playground/PlaygroundStudio.svelte +21 -7
  34. package/dist/components/playground/pipelineViewUtils.svelte.js +11 -4
  35. package/dist/helpers/proximityConnect.d.ts +4 -1
  36. package/dist/helpers/proximityConnect.js +17 -1
  37. package/dist/openapi/v1/openapi.yaml +6466 -0
  38. package/dist/playground/mount.js +2 -2
  39. package/dist/registry/builtinNodes.d.ts +1 -1
  40. package/dist/registry/builtinNodes.js +13 -0
  41. package/dist/schemas/v1/workflow.schema.json +86 -3
  42. package/dist/services/playgroundService.d.ts +23 -4
  43. package/dist/services/playgroundService.js +22 -9
  44. package/dist/stores/playgroundStore.svelte.d.ts +29 -2
  45. package/dist/stores/playgroundStore.svelte.js +120 -35
  46. package/dist/types/index.d.ts +38 -3
  47. package/dist/types/playground.d.ts +36 -2
  48. package/dist/utils/formMerge.d.ts +36 -0
  49. package/dist/utils/formMerge.js +70 -0
  50. package/dist/utils/nodeTypes.js +1 -0
  51. package/package.json +7 -1
package/README.md CHANGED
@@ -158,6 +158,11 @@ FlowDrop provides tree-shakeable sub-module exports so you can import only what
158
158
  | `@flowdrop/flowdrop/settings` | SettingsPanel, stores, services |
159
159
  | `@flowdrop/flowdrop/styles` | Base CSS stylesheet |
160
160
  | `@flowdrop/flowdrop/schema` | Workflow JSON schema |
161
+ | `@flowdrop/flowdrop/openapi` | OpenAPI spec (YAML) for the FlowDrop backend API |
162
+
163
+ ### OpenAPI spec
164
+
165
+ The full OpenAPI spec for the FlowDrop backend API ships with the package, version-matched to your installed release. It defines the node-config / form-element schema (`ConfigProperty`), playground messages, and every endpoint. Resolve it from `@flowdrop/flowdrop/openapi`, or read it directly at `node_modules/@flowdrop/flowdrop/dist/openapi/v1/openapi.yaml`. Point your AI assistant at that file when authoring node config schemas. The latest spec is also browsable at [api.flowdrop.io](https://api.flowdrop.io).
161
166
 
162
167
  ## Integration
163
168
 
@@ -47,6 +47,7 @@
47
47
  import { logger } from '../utils/logger.js';
48
48
  import { getDataTypeColorToken, getPortBackgroundColor } from '../utils/colors.js';
49
49
  import { applyPortOrder } from '../utils/portUtils.js';
50
+ import { mergeWithDefaults, cascadeClearAutocompleteDependents } from '../utils/formMerge.js';
50
51
 
51
52
  interface Props {
52
53
  /** Optional workflow node (if provided, schema and values are derived from it) */
@@ -187,12 +188,30 @@
187
188
  const initialConfig = $derived(values ?? node?.data.config ?? {});
188
189
 
189
190
  /**
190
- * Create reactive configuration values using $state
191
- * This fixes the Svelte 5 reactivity warnings
191
+ * User edits to config only keys the user has touched since the current
192
+ * schema was loaded. configValues is derived from props + edits, so children
193
+ * mount with the correct values already in place (no parent→child race
194
+ * during the initial flush). Never assign to configValues directly.
192
195
  */
193
- let configValues = $state<Record<string, unknown>>({});
196
+ let edits = $state<Record<string, unknown>>({});
197
+
198
+ const configValues = $derived(mergeWithDefaults(configSchema, initialConfig, edits));
199
+
194
200
  setContext<() => Record<string, unknown>>('flowdrop:getFormValues', () => configValues);
195
201
 
202
+ // Drop edits when the schema reference changes — covers dynamic-schema load
203
+ // (configSchema flips from undefined → loaded) and "different node opened"
204
+ // (node prop change → metadata.configSchema reference change). Identity
205
+ // comparison only — value churn in `initialConfig` preserves in-flight edits.
206
+ // svelte-ignore state_referenced_locally — capturing the initial derived reference is intentional; later changes are picked up by the effect below
207
+ let prevSchemaRef = configSchema;
208
+ $effect.pre(() => {
209
+ if (configSchema !== prevSchemaRef) {
210
+ prevSchemaRef = configSchema;
211
+ edits = {};
212
+ }
213
+ });
214
+
196
215
  /**
197
216
  * UI Extension values for display settings
198
217
  * Merges node type defaults with instance overrides
@@ -294,22 +313,6 @@
294
313
  }
295
314
  });
296
315
 
297
- /**
298
- * Initialize config values when node/schema changes
299
- */
300
- $effect(() => {
301
- if (configSchema?.properties) {
302
- const mergedConfig: Record<string, unknown> = {};
303
- Object.entries(configSchema.properties).forEach(([key, field]) => {
304
- const fieldConfig = field as Record<string, unknown>;
305
- // Use existing value if available, otherwise use default
306
- mergedConfig[key] =
307
- initialConfig[key] !== undefined ? initialConfig[key] : fieldConfig.default;
308
- });
309
- configValues = mergedConfig;
310
- }
311
- });
312
-
313
316
  /**
314
317
  * Initialize UI extension values when node changes
315
318
  */
@@ -419,10 +422,22 @@
419
422
  }
420
423
 
421
424
  /**
422
- * Handle field value changes from FormField components
425
+ * Handle field value changes from FormField components.
426
+ *
427
+ * When a field changes, also clear any sibling autocomplete that declared
428
+ * this field in its `autocomplete.params` map — its previous value was
429
+ * computed against the old dependency value and is now stale. The cascade
430
+ * runs only on user-driven edits via this codepath; undo/redo and external
431
+ * config replacement flow through `initialConfig` and don't trigger it (#33).
423
432
  */
424
433
  function handleFieldChange(key: string, value: unknown): void {
425
- configValues[key] = value;
434
+ const previous = configValues[key];
435
+ edits[key] = value;
436
+ if (previous === value) return;
437
+ const dependents = cascadeClearAutocompleteDependents(configSchema, key);
438
+ for (const [depKey, depValue] of Object.entries(dependents)) {
439
+ edits[depKey] = depValue;
440
+ }
426
441
  }
427
442
 
428
443
  /**
@@ -434,6 +449,11 @@
434
449
  if (onChange) {
435
450
  const extensions = showUIExtensions && node ? uiExtensionValues : undefined;
436
451
  onChange({ ...configValues }, extensions);
452
+ // Discharge the edits buffer at the commit boundary. Subsequent prop
453
+ // changes (parent absorbing the commit, undo/redo, collaboration) then
454
+ // flow through `initialConfig` cleanly instead of being shadowed by a
455
+ // stale local edit.
456
+ edits = {};
437
457
  }
438
458
  }
439
459
 
@@ -77,7 +77,13 @@
77
77
  <Icon icon="heroicons:arrows-right-left" />
78
78
  </button>
79
79
  {/if}
80
- <button class="config-panel__close" onclick={onClose} aria-label={m().layout.closeConfigPanel}> × </button>
80
+ <button
81
+ class="config-panel__close"
82
+ onclick={onClose}
83
+ aria-label={m().layout.closeConfigPanel}
84
+ >
85
+ ×
86
+ </button>
81
87
  </div>
82
88
  </div>
83
89
 
@@ -81,7 +81,11 @@
81
81
  <div class="swap-picker">
82
82
  <!-- Header -->
83
83
  <div class="swap-picker__header">
84
- <button class="swap-picker__back" onclick={onCancel} aria-label={m().layout.backToConfiguration}>
84
+ <button
85
+ class="swap-picker__back"
86
+ onclick={onCancel}
87
+ aria-label={m().layout.backToConfiguration}
88
+ >
85
89
  <Icon icon="heroicons:arrow-left" />
86
90
  </button>
87
91
  <h2 class="swap-picker__title">Swap Node</h2>
@@ -37,8 +37,17 @@
37
37
  ) => void;
38
38
  }
39
39
 
40
- let { pipelineId, workflow, apiClient, baseUrl, endpointConfig, onActionsReady, runLabel, isEmbedded = false, refreshTrigger = 0 }: Props =
41
- $props();
40
+ let {
41
+ pipelineId,
42
+ workflow,
43
+ apiClient,
44
+ baseUrl,
45
+ endpointConfig,
46
+ onActionsReady,
47
+ runLabel,
48
+ isEmbedded = false,
49
+ refreshTrigger = 0
50
+ }: Props = $props();
42
51
 
43
52
  // Track previous trigger value so the $effect only fires on increments, not on initial mount.
44
53
  // svelte-ignore state_referenced_locally
@@ -62,6 +62,7 @@
62
62
  import FormUISchemaRenderer from './form/FormUISchemaRenderer.svelte';
63
63
  import type { FieldSchema } from './form/index.js';
64
64
  import { m, warnDeprecatedProp } from '../messages/index.js';
65
+ import { mergeWithDefaults, cascadeClearAutocompleteDependents } from '../utils/formMerge.js';
65
66
 
66
67
  /**
67
68
  * Props interface for SchemaForm component
@@ -194,24 +195,24 @@
194
195
  let formRef: HTMLFormElement | undefined = $state();
195
196
 
196
197
  /**
197
- * Internal reactive state for form values
198
+ * User edits only keys the user has changed since the current schema
199
+ * was loaded. formValues is derived from props + edits, never synchronised
200
+ * via an effect, so children mount with the correct values already in place.
198
201
  */
199
- let formValues = $state<Record<string, unknown>>({});
202
+ let edits = $state<Record<string, unknown>>({});
203
+
204
+ const formValues = $derived(mergeWithDefaults(schema, values, edits));
205
+
200
206
  setContext<() => Record<string, unknown>>('flowdrop:getFormValues', () => formValues);
201
207
 
202
- /**
203
- * Initialize form values when schema or values change
204
- * Merges default values from schema with provided values
205
- */
206
- $effect(() => {
207
- if (schema?.properties) {
208
- const mergedValues: Record<string, unknown> = {};
209
- Object.entries(schema.properties).forEach(([key, field]) => {
210
- const fieldConfig = field as Record<string, unknown>;
211
- // Use provided value if available, otherwise use schema default
212
- mergedValues[key] = values[key] !== undefined ? values[key] : fieldConfig.default;
213
- });
214
- formValues = mergedValues;
208
+ // Drop edits when the schema reference changes (different form mounted).
209
+ // Identity comparison only value churn in `values` preserves in-flight edits.
210
+ // svelte-ignore state_referenced_locally capturing the initial prop reference is intentional; later changes are picked up by the effect below
211
+ let prevSchemaRef = schema;
212
+ $effect.pre(() => {
213
+ if (schema !== prevSchemaRef) {
214
+ prevSchemaRef = schema;
215
+ edits = {};
215
216
  }
216
217
  });
217
218
 
@@ -234,7 +235,18 @@
234
235
  * @param value - New field value
235
236
  */
236
237
  function handleFieldChange(key: string, value: unknown): void {
237
- formValues[key] = value;
238
+ const previous = formValues[key];
239
+ edits[key] = value;
240
+
241
+ // Cascade-clear any autocomplete whose `params` references this field.
242
+ // Only runs on user-driven edits; undo/redo and external value-prop
243
+ // replacement flow through `values` and bypass this path (#33).
244
+ if (previous !== value) {
245
+ const dependents = cascadeClearAutocompleteDependents(schema, key);
246
+ for (const [depKey, depValue] of Object.entries(dependents)) {
247
+ edits[depKey] = depValue;
248
+ }
249
+ }
238
250
 
239
251
  // Notify parent of the change
240
252
  if (onChange) {
@@ -362,7 +362,11 @@
362
362
 
363
363
  <div class="flowdrop-settings-panel {className}">
364
364
  <!-- Tab Navigation -->
365
- <div class="flowdrop-settings-panel__tabs" role="tablist" aria-label={m().layout.settingsCategories}>
365
+ <div
366
+ class="flowdrop-settings-panel__tabs"
367
+ role="tablist"
368
+ aria-label={m().layout.settingsCategories}
369
+ >
366
370
  {#each categories as category, index (category)}
367
371
  <button
368
372
  class="flowdrop-settings-panel__tab"
@@ -277,7 +277,11 @@
277
277
  const rawStatus = statuses[node.id];
278
278
  if (!rawStatus) return node;
279
279
 
280
- const existing = node.data.executionInfo ?? { status: 'idle' as const, executionCount: 0, isExecuting: false };
280
+ const existing = node.data.executionInfo ?? {
281
+ status: 'idle' as const,
282
+ executionCount: 0,
283
+ isExecuting: false
284
+ };
281
285
  return {
282
286
  ...node,
283
287
  data: {
@@ -288,11 +288,7 @@
288
288
  return;
289
289
  }
290
290
 
291
- if (
292
- getBehaviorSettings().chatAutoRetry &&
293
- workflowId &&
294
- autoRetryCount < MAX_AUTO_RETRIES
295
- ) {
291
+ if (getBehaviorSettings().chatAutoRetry && workflowId && autoRetryCount < MAX_AUTO_RETRIES) {
296
292
  autoRetryCount++;
297
293
  const errorText = buildBatchErrorMessage(
298
294
  completedCount,
@@ -121,6 +121,7 @@
121
121
  // Stable fingerprint — any change triggers selection clearing.
122
122
  // JSON.stringify gives a canonical string without null-byte ambiguity.
123
123
  const depFingerprint = $derived(JSON.stringify(depParamValues));
124
+ // svelte-ignore state_referenced_locally — intentional initial snapshot; the effect below tracks subsequent changes
124
125
  let prevDepFingerprint = depFingerprint;
125
126
 
126
127
  $effect(() => {
@@ -308,10 +309,9 @@
308
309
  // Open dropdown
309
310
  showDropdown();
310
311
 
311
- // If allowFreeText and single mode, update the value immediately
312
- if (allowFreeText && !multiple) {
313
- onChange(inputValue);
314
- }
312
+ // Free-text single mode commits on intent signals (Enter, blur, option select),
313
+ // NOT per keystroke — see issue #32. Treating the search-box text as the field's
314
+ // committed value pollutes the parent form's edits buffer and history.
315
315
 
316
316
  // Fetch suggestions with debounce
317
317
  debouncedFetch(inputValue);
@@ -352,6 +352,19 @@
352
352
  inputValue = '';
353
353
  }
354
354
  }
355
+
356
+ // Free-text single mode — commit on blur if the user typed something
357
+ // that differs from the current value. An empty inputValue is just the
358
+ // resting state of the search box (the value lives in the chip), so it
359
+ // is NOT an intent signal; clearing happens via the X button or
360
+ // Backspace-on-empty. Replaces the per-keystroke commit dropped from
361
+ // handleInput; see issue #32.
362
+ if (allowFreeText && !multiple && inputValue !== '') {
363
+ const currentSingle = selectedValues[0] ?? '';
364
+ if (inputValue !== currentSingle) {
365
+ onChange(inputValue);
366
+ }
367
+ }
355
368
  }, 200);
356
369
  }
357
370
 
@@ -591,12 +604,14 @@
591
604
  const current = depFingerprint;
592
605
  if (current === prevDepFingerprint) return;
593
606
  prevDepFingerprint = current;
594
- // A dependency field changed — abort any in-flight fetch, clear state
607
+ // A dependency field changed — abort any in-flight fetch and invalidate
608
+ // cached suggestions/labels (they were keyed by the previous dependency
609
+ // values). Do NOT clear this field's value here: that policy lives in the
610
+ // parent form's handleFieldChange so it only fires on user-driven changes,
611
+ // not on undo/redo, programmatic resets, or collaborative edits (#33).
595
612
  abortController?.abort();
596
613
  suggestions = [];
597
614
  labelCache = new Map();
598
- if (multiple) onChange([]);
599
- else onChange('');
600
615
  });
601
616
 
602
617
  /**
@@ -721,11 +736,7 @@
721
736
  style={popoverStyle}
722
737
  onmousedown={(e) => e.preventDefault()}
723
738
  >
724
- <ul
725
- class="form-autocomplete__listbox"
726
- role="listbox"
727
- aria-label={t.suggestions}
728
- >
739
+ <ul class="form-autocomplete__listbox" role="listbox" aria-label={t.suggestions}>
729
740
  {#if isLoading}
730
741
  <li class="form-autocomplete__status form-autocomplete__status--loading">
731
742
  <Icon icon="heroicons:arrow-path" class="form-autocomplete__status-icon" />
@@ -135,7 +135,11 @@
135
135
  {/if}
136
136
 
137
137
  <!-- Options -->
138
- <div class="choice-prompt__options" role={isMultiple ? 'group' : 'radiogroup'} aria-label={config.message}>
138
+ <div
139
+ class="choice-prompt__options"
140
+ role={isMultiple ? 'group' : 'radiogroup'}
141
+ aria-label={config.message}
142
+ >
139
143
  {#each config.options as option (option.value)}
140
144
  {@const isChecked = isResolved ? isOptionResolved(option) : selectedValues.has(option.value)}
141
145
  <label
@@ -16,10 +16,7 @@
16
16
  import ReviewPrompt from './ReviewPrompt.svelte';
17
17
  import MessageTagStrip from '../playground/MessageTagStrip.svelte';
18
18
  import HierarchyTrail from '../playground/HierarchyTrail.svelte';
19
- import type {
20
- MessageHierarchyItem,
21
- MessageTag
22
- } from '../../types/playground.js';
19
+ import type { MessageHierarchyItem, MessageTag } from '../../types/playground.js';
23
20
  import type {
24
21
  Interrupt,
25
22
  InterruptType,
@@ -304,7 +301,9 @@
304
301
  <time
305
302
  class="interrupt-bubble__timestamp"
306
303
  datetime={currentInterrupt.resolvedAt ?? currentInterrupt.createdAt}
307
- aria-label="sent at {formatTimestamp(currentInterrupt.resolvedAt ?? currentInterrupt.createdAt)}"
304
+ aria-label="sent at {formatTimestamp(
305
+ currentInterrupt.resolvedAt ?? currentInterrupt.createdAt
306
+ )}"
308
307
  >
309
308
  {formatTimestamp(currentInterrupt.resolvedAt ?? currentInterrupt.createdAt)}
310
309
  </time>
@@ -0,0 +1,280 @@
1
+ <!--
2
+ Atom Node Component
3
+ Minimalist, label-only node for "supplies a value" atoms (Constant now, Cast later).
4
+ Renders as a pill that hugs its content with handles driven by the node's ports.
5
+
6
+ The body text and the output port's dataType are both driven by config via
7
+ `extensions.ui.atom` (see AtomUIConfig). This component owns no domain semantics —
8
+ meaning comes entirely from the node's NodeMetadata.
9
+ -->
10
+
11
+ <script lang="ts">
12
+ import { Position, Handle } from '@xyflow/svelte';
13
+ import type {
14
+ ConfigValues,
15
+ ConfigSchema,
16
+ NodeMetadata,
17
+ NodeExtensions,
18
+ NodePort,
19
+ AtomUIConfig,
20
+ WorkflowNode as WorkflowNodeType
21
+ } from '../../types/index.js';
22
+ import { getDataTypeColor } from '../../utils/colors.js';
23
+ import { getConnectedHandles } from '../../stores/workflowStore.svelte.js';
24
+ import { applyPortOrder, isPortVisible } from '../../utils/portUtils.js';
25
+ import { ProximityConnectHelper } from '../../helpers/proximityConnect.js';
26
+
27
+ interface AtomNodeData {
28
+ label: string;
29
+ config: ConfigValues;
30
+ metadata: NodeMetadata;
31
+ nodeId?: string;
32
+ extensions?: NodeExtensions;
33
+ onConfigOpen?: (node: {
34
+ id: string;
35
+ type: string;
36
+ data: { label: string; config: ConfigValues; metadata: NodeMetadata };
37
+ }) => void;
38
+ }
39
+
40
+ interface Props {
41
+ data: AtomNodeData;
42
+ selected?: boolean;
43
+ isProcessing?: boolean;
44
+ isError?: boolean;
45
+ }
46
+
47
+ let { data, selected, isProcessing, isError }: Props = $props();
48
+
49
+ const nodeId = $derived(data.nodeId ?? 'unknown');
50
+ const nodeType = $derived(data.metadata?.type ?? 'atom');
51
+
52
+ // Instance extensions override node-type defaults.
53
+ const atomCfg = $derived<AtomUIConfig>(
54
+ data.extensions?.ui?.atom ?? data.metadata?.extensions?.ui?.atom ?? {}
55
+ );
56
+ const hideUnconnectedHandles = $derived(
57
+ data.extensions?.ui?.hideUnconnectedHandles ??
58
+ data.metadata?.extensions?.ui?.hideUnconnectedHandles ??
59
+ false
60
+ );
61
+ const hiddenPorts = $derived(
62
+ data.extensions?.ui?.hiddenPorts ?? data.metadata?.extensions?.ui?.hiddenPorts ?? {}
63
+ );
64
+ const portOrder = $derived(
65
+ data.extensions?.ui?.portOrder ?? data.metadata?.extensions?.ui?.portOrder ?? {}
66
+ );
67
+
68
+ // Optional, server-driven accent. When unset the inline custom property is
69
+ // omitted, so CSS falls back to the neutral border — uncolored atoms are
70
+ // unchanged. Mirrors ToolNode: node definition (metadata) wins over instance.
71
+ const atomColor = $derived(data.metadata?.color ?? (data.config?.color as string | undefined));
72
+ const isRect = $derived(atomCfg.shape === 'rectangle');
73
+
74
+ // Only the dynamic bits live inline; max-width and accent both optional.
75
+ const nodeStyle = $derived(
76
+ [
77
+ atomCfg.maxWidth ? `max-width: ${atomCfg.maxWidth}px` : '',
78
+ atomColor ? `--fd-atom-node-color: ${atomColor}` : ''
79
+ ]
80
+ .filter(Boolean)
81
+ .join('; ')
82
+ );
83
+
84
+ // The node slice getAllPorts needs — keeps port resolution in one place,
85
+ // shared with proximity-connect and coordinate/validation logic. No cast: the
86
+ // helper's signature documents that `type` + `data` are all it reads.
87
+ const nodeLike = $derived<Pick<WorkflowNodeType, 'type' | 'data'>>({ type: nodeType, data });
88
+
89
+ const inPorts = $derived(
90
+ applyPortOrder(ProximityConnectHelper.getAllPorts(nodeLike, 'input'), portOrder.inputs).filter(
91
+ (p: NodePort) =>
92
+ isPortVisible(
93
+ p,
94
+ 'input',
95
+ hiddenPorts,
96
+ hideUnconnectedHandles,
97
+ getConnectedHandles(),
98
+ nodeId
99
+ )
100
+ )
101
+ );
102
+ const outPorts = $derived(
103
+ applyPortOrder(
104
+ ProximityConnectHelper.getAllPorts(nodeLike, 'output'),
105
+ portOrder.outputs
106
+ ).filter((p: NodePort) =>
107
+ isPortVisible(p, 'output', hiddenPorts, hideUnconnectedHandles, getConnectedHandles(), nodeId)
108
+ )
109
+ );
110
+
111
+ /** Friendly label for a value, using the field's oneOf titles when present. */
112
+ function resolveDisplay(schema: ConfigSchema | undefined, key: string, raw: unknown): string {
113
+ const prop = schema?.properties?.[key] as
114
+ | { oneOf?: Array<{ const: unknown; title?: string }> }
115
+ | undefined;
116
+ const match = prop?.oneOf?.find((o) => o.const === raw);
117
+ if (match?.title) return match.title;
118
+ if (typeof raw === 'boolean') return raw ? 'true' : 'false';
119
+ return String(raw);
120
+ }
121
+
122
+ // Body text: config[valueKey] (label-resolved), else node label. When neither
123
+ // resolves, fall back to the placeholder and render it dimmed.
124
+ const display = $derived.by(() => {
125
+ const key = atomCfg.valueKey;
126
+ const raw = key ? data.config?.[key] : undefined;
127
+ if (key && raw !== undefined && raw !== null && raw !== '') {
128
+ return { text: resolveDisplay(data.metadata?.configSchema, key, raw), empty: false };
129
+ }
130
+ if (data.label) return { text: data.label, empty: false };
131
+ return { text: atomCfg.placeholder ?? '', empty: true };
132
+ });
133
+
134
+ // Pill height is content-driven (~28px), so fixed px offsets don't fit.
135
+ // Distribute handles as a % of node height: 50% for one port, evenly otherwise.
136
+ function portTopPct(index: number, count: number): number {
137
+ return ((index + 1) / (count + 1)) * 100;
138
+ }
139
+
140
+ function openConfig(): void {
141
+ data.onConfigOpen?.({ id: nodeId, type: nodeType, data });
142
+ }
143
+ function handleKeydown(event: KeyboardEvent): void {
144
+ if (event.key === 'Enter' || event.key === ' ') {
145
+ event.preventDefault();
146
+ openConfig();
147
+ }
148
+ }
149
+ </script>
150
+
151
+ {#each inPorts as port, index}
152
+ <Handle
153
+ type="target"
154
+ position={Position.Left}
155
+ id={`${nodeId}-input-${port.id}`}
156
+ style="--fd-handle-fill: var(--fd-port-skin-color, {getDataTypeColor(
157
+ port.dataType
158
+ )}); top: {portTopPct(index, inPorts.length)}%;"
159
+ />
160
+ {/each}
161
+
162
+ <div
163
+ class="flowdrop-atom-node"
164
+ class:flowdrop-atom-node--selected={selected}
165
+ class:flowdrop-atom-node--processing={isProcessing}
166
+ class:flowdrop-atom-node--error={isError}
167
+ class:flowdrop-atom-node--empty={display.empty}
168
+ class:flowdrop-atom-node--rect={isRect}
169
+ style={nodeStyle}
170
+ onclick={openConfig}
171
+ onkeydown={handleKeydown}
172
+ role="button"
173
+ tabindex="0"
174
+ >
175
+ {#if atomCfg.prefix && !display.empty}
176
+ <span class="flowdrop-atom-node__prefix" aria-hidden="true">{atomCfg.prefix}</span>
177
+ {/if}
178
+ <span class="flowdrop-atom-node__body" title={display.text}>{display.text}</span>
179
+ </div>
180
+
181
+ {#each outPorts as port, index}
182
+ <Handle
183
+ type="source"
184
+ position={Position.Right}
185
+ id={`${nodeId}-output-${port.id}`}
186
+ style="--fd-handle-fill: var(--fd-port-skin-color, {getDataTypeColor(
187
+ port.dataType
188
+ )}); top: {portTopPct(index, outPorts.length)}%;"
189
+ />
190
+ {/each}
191
+
192
+ <style>
193
+ .flowdrop-atom-node {
194
+ position: relative;
195
+ display: inline-flex;
196
+ align-items: center;
197
+ width: fit-content;
198
+ min-width: 2rem;
199
+ min-height: 28px;
200
+ padding: 2px var(--fd-space-sm);
201
+ background-color: var(--fd-card);
202
+ /* --fd-atom-node-color is set inline only when the server provides a color;
203
+ otherwise it falls back to the neutral border token. */
204
+ border: 1.5px solid var(--fd-atom-node-color, var(--fd-node-border));
205
+ border-radius: 999px;
206
+ box-shadow: var(--fd-shadow-sm);
207
+ color: var(--fd-foreground);
208
+ cursor: pointer;
209
+ transition:
210
+ box-shadow var(--fd-transition-fast),
211
+ border-color var(--fd-transition-fast);
212
+ z-index: 10;
213
+ }
214
+
215
+ .flowdrop-atom-node--rect {
216
+ border-radius: var(--fd-radius-md);
217
+ }
218
+
219
+ .flowdrop-atom-node:hover {
220
+ box-shadow: var(--fd-shadow-md);
221
+ border-color: var(--fd-atom-node-color, var(--fd-node-border-hover));
222
+ }
223
+
224
+ .flowdrop-atom-node--selected {
225
+ box-shadow:
226
+ 0 0 0 2px color-mix(in srgb, var(--fd-atom-node-color, var(--fd-primary)) 30%, transparent),
227
+ var(--fd-shadow-md);
228
+ border-color: var(--fd-atom-node-color, var(--fd-primary));
229
+ }
230
+
231
+ .flowdrop-atom-node:focus-visible {
232
+ outline: 2px solid var(--fd-ring);
233
+ outline-offset: 2px;
234
+ }
235
+
236
+ .flowdrop-atom-node--processing {
237
+ opacity: 0.7;
238
+ }
239
+
240
+ .flowdrop-atom-node--error {
241
+ border-color: var(--fd-error) !important;
242
+ background-color: var(--fd-error-muted) !important;
243
+ }
244
+
245
+ .flowdrop-atom-node--empty .flowdrop-atom-node__body {
246
+ color: var(--fd-muted-foreground);
247
+ font-style: italic;
248
+ }
249
+
250
+ .flowdrop-atom-node__prefix {
251
+ flex-shrink: 0;
252
+ margin-right: 2px;
253
+ color: var(--fd-muted-foreground);
254
+ font-size: var(--fd-text-sm);
255
+ line-height: 1.2;
256
+ }
257
+
258
+ .flowdrop-atom-node__body {
259
+ /* min-width:0 lets the body ellipsize as a flex sibling of the prefix */
260
+ min-width: 0;
261
+ font-size: var(--fd-text-sm);
262
+ line-height: 1.2;
263
+ white-space: nowrap;
264
+ overflow: hidden;
265
+ text-overflow: ellipsis;
266
+ }
267
+
268
+ /* `top` is set inline (dynamic, must beat svelte-flow defaults); transform and
269
+ stacking live here so the hover rule can compose instead of fighting !important. */
270
+ :global(.svelte-flow__node-atom .svelte-flow__handle) {
271
+ --fd-handle-border-color: var(--fd-handle-border);
272
+ transform: translateY(-50%);
273
+ z-index: 20 !important;
274
+ pointer-events: auto !important;
275
+ }
276
+
277
+ :global(.svelte-flow__node-atom .svelte-flow__handle:hover) {
278
+ transform: translateY(-50%) scale(1.2);
279
+ }
280
+ </style>