@selvajs/ui 4.4.0 → 4.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 (28) hide show
  1. package/dist/components/compute/AppLayout.svelte +3 -3
  2. package/dist/components/compute/AppLayout.svelte.d.ts +1 -1
  3. package/dist/components/preview/InputControl.svelte +82 -3
  4. package/dist/components/preview/InputControl.svelte.d.ts +2 -0
  5. package/dist/components/preview/OutputDisplay.svelte +48 -45
  6. package/dist/components/preview/TabContent.svelte +13 -2
  7. package/dist/components/preview/TabContent.svelte.d.ts +2 -0
  8. package/dist/components/preview/TabLayout.svelte +6 -7
  9. package/dist/components/preview/TabLayout.svelte.d.ts +1 -1
  10. package/dist/components/preview/inputs/ChecklistInput.svelte +1 -1
  11. package/dist/components/preview/inputs/DropdownInput.svelte +1 -1
  12. package/dist/components/preview/inputs/FileInput.svelte +11 -0
  13. package/dist/index.d.ts +3 -1
  14. package/dist/index.js +5 -0
  15. package/dist/schema/dynamic-value-list.d.ts +17 -0
  16. package/dist/schema/dynamic-value-list.js +76 -0
  17. package/package.json +6 -5
  18. package/src/lib/components/compute/AppLayout.svelte +3 -3
  19. package/src/lib/components/preview/InputControl.svelte +82 -3
  20. package/src/lib/components/preview/OutputDisplay.svelte +48 -45
  21. package/src/lib/components/preview/TabContent.svelte +13 -2
  22. package/src/lib/components/preview/TabLayout.svelte +6 -7
  23. package/src/lib/components/preview/inputs/ChecklistInput.svelte +1 -1
  24. package/src/lib/components/preview/inputs/DropdownInput.svelte +1 -1
  25. package/src/lib/components/preview/inputs/FileInput.svelte +11 -0
  26. package/src/lib/index.ts +14 -1
  27. package/src/lib/schema/dynamic-value-list.test.ts +133 -0
  28. package/src/lib/schema/dynamic-value-list.ts +102 -0
@@ -25,7 +25,7 @@
25
25
  oncalculate?: () => void;
26
26
  values: Record<string, unknown>;
27
27
  onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
28
- onLoadValues?: () => void | Promise<void>;
28
+ onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
29
29
  panelActions?: ActionButton[];
30
30
  showSaveButton?: boolean;
31
31
  showLoadButton?: boolean;
@@ -99,7 +99,7 @@
99
99
 
100
100
  async function handleLoadValues(loadedValues: Record<string, unknown>) {
101
101
  Object.assign(values, loadedValues);
102
- await onLoadValues?.();
102
+ await onLoadValues?.(loadedValues);
103
103
  }
104
104
 
105
105
  let _touchStartY = 0;
@@ -125,7 +125,7 @@
125
125
  )}
126
126
  <div class="panel-content-wrapper">
127
127
  {#if schema.layout.type === 'tabbed'}
128
- <TabLayout {schema} bind:values {onValueChange} {panelFilter} {requestedTabId} />
128
+ <TabLayout {schema} {values} {onValueChange} {panelFilter} {requestedTabId} />
129
129
  {/if}
130
130
  {#if showParameterStateManager || (!isMobile && showCalculateButton && schema.instanceSolve === false)}
131
131
  <div class="panel-footer px-3">
@@ -13,7 +13,7 @@ interface Props {
13
13
  oncalculate?: () => void;
14
14
  values: Record<string, unknown>;
15
15
  onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
16
- onLoadValues?: () => void | Promise<void>;
16
+ onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
17
17
  panelActions?: ActionButton[];
18
18
  showSaveButton?: boolean;
19
19
  showLoadButton?: boolean;
@@ -1,9 +1,16 @@
1
1
  <script lang="ts">
2
- import type { InputLayoutItem, FileInputWidgetConfig, SupportedTypes } from '@selvajs/schemas';
2
+ import type {
3
+ InputLayoutItem,
4
+ FileInputWidgetConfig,
5
+ DynamicValueListWidgetConfig,
6
+ DropdownWidgetConfig,
7
+ SupportedTypes
8
+ } from '@selvajs/schemas';
3
9
  import {
4
10
  isNumberWidget,
5
11
  isTextWidget,
6
12
  isDropdownWidget,
13
+ isDynamicValueListWidget,
7
14
  isCheckboxWidget,
8
15
  isFileWidget,
9
16
  isColorWidget
@@ -28,6 +35,8 @@
28
35
  displayName?: string;
29
36
  onChange: (paramId: string, value: SupportedTypes) => void;
30
37
  disabled?: boolean;
38
+ /** Runtime-computed options for a dynamic value list input (name -> value). */
39
+ dynamicOptions?: Record<string, string>;
31
40
  }
32
41
 
33
42
  let {
@@ -35,7 +44,8 @@
35
44
  value = $bindable(undefined),
36
45
  displayName,
37
46
  onChange,
38
- disabled = false
47
+ disabled = false,
48
+ dynamicOptions
39
49
  }: Props = $props();
40
50
 
41
51
  const inputId = $derived(`input-${item.paramId}`);
@@ -68,6 +78,50 @@
68
78
 
69
79
  const showRangeInLabel = $derived(isNumberWidget(item) && numberRangeHint !== null);
70
80
 
81
+ // Dynamic value list: computed options (from the last solve) take precedence over the
82
+ // author's seed list. Empty until the first solve produces options, unless a default is set.
83
+ const dynamicListConfig = $derived(
84
+ isDynamicValueListWidget(item)
85
+ ? (item.config as DynamicValueListWidgetConfig | undefined)
86
+ : undefined
87
+ );
88
+ const dynamicListOptions = $derived<Record<string, string>>(
89
+ dynamicOptions && Object.keys(dynamicOptions).length > 0
90
+ ? dynamicOptions
91
+ : (Object.fromEntries(
92
+ Object.entries(dynamicListConfig?.defaultOptions ?? {}).filter(
93
+ (entry): entry is [string, string] => entry[1] !== undefined
94
+ )
95
+ ) as Record<string, string>)
96
+ );
97
+ const dynamicListHasOptions = $derived(Object.keys(dynamicListOptions).length > 0);
98
+ // As a dropdown config so DropdownInput/ChecklistInput can consume it unchanged.
99
+ const dynamicListAsDropdownConfig = $derived<DropdownWidgetConfig>({
100
+ options: dynamicListOptions,
101
+ displayAs: dynamicListConfig?.displayAs ?? 'dropdown'
102
+ });
103
+ const hideDynamicListWhenEmpty = $derived(
104
+ isDynamicValueListWidget(item) &&
105
+ !dynamicListHasOptions &&
106
+ (dynamicListConfig?.emptyBehavior ?? 'hide') === 'hide'
107
+ );
108
+
109
+ // When a dynamic value list recomputes, a previously-selected value may no longer be an
110
+ // available option. Prune the stale selection so the control shows a valid option (or empty)
111
+ // instead of rendering the orphaned raw value as its own label.
112
+ $effect(() => {
113
+ if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
114
+ const validValues = new Set(Object.values(dynamicListOptions));
115
+ // Route through onChange (not commit) — value is a one-way prop here, so writing it
116
+ // directly from an effect trips Svelte's binding-ownership check.
117
+ if (Array.isArray(value)) {
118
+ const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
119
+ if (pruned.length !== value.length) onChange(item.paramId, pruned);
120
+ } else if (typeof value === 'string' && value && !validValues.has(value)) {
121
+ onChange(item.paramId, '');
122
+ }
123
+ });
124
+
71
125
  function commit(newValue: SupportedTypes) {
72
126
  value = newValue;
73
127
  onChange(item.paramId, newValue);
@@ -83,7 +137,7 @@
83
137
  value
84
138
  })}
85
139
  {/if}
86
- {:else}
140
+ {:else if !hideDynamicListWhenEmpty}
87
141
  <Field.Field>
88
142
  <Field.Label for={inputId} class="gap-2 flex items-center">
89
143
  {label}
@@ -152,6 +206,31 @@
152
206
  {disabled}
153
207
  />
154
208
  {/if}
209
+ {:else if isDynamicValueListWidget(item)}
210
+ {#if dynamicListHasOptions}
211
+ {#if dynamicListAsDropdownConfig.displayAs === 'checklist'}
212
+ <ChecklistInput
213
+ {inputId}
214
+ value={Array.isArray(value)
215
+ ? (value as string[])
216
+ : typeof value === 'string' && value
217
+ ? [value]
218
+ : []}
219
+ config={dynamicListAsDropdownConfig}
220
+ onChange={commit}
221
+ {disabled}
222
+ />
223
+ {:else}
224
+ <DropdownInput
225
+ value={typeof value === 'string' ? value : ''}
226
+ config={dynamicListAsDropdownConfig}
227
+ onChange={commit}
228
+ {disabled}
229
+ />
230
+ {/if}
231
+ {:else}
232
+ <p class="text-sm text-muted-foreground">No options available yet.</p>
233
+ {/if}
155
234
  {:else if isFileWidget(item)}
156
235
  {@const config = item.config as FileInputWidgetConfig}
157
236
  <FileInput
@@ -5,6 +5,8 @@ interface Props {
5
5
  displayName?: string;
6
6
  onChange: (paramId: string, value: SupportedTypes) => void;
7
7
  disabled?: boolean;
8
+ /** Runtime-computed options for a dynamic value list input (name -> value). */
9
+ dynamicOptions?: Record<string, string>;
8
10
  }
9
11
  declare const InputControl: import("svelte").Component<Props, {}, "value">;
10
12
  type InputControl = ReturnType<typeof InputControl>;
@@ -288,49 +288,52 @@
288
288
  {/if}
289
289
  {/snippet}
290
290
 
291
- <div class="gap-2 flex flex-col">
292
- {@render fieldHeader()}
291
+ <!-- Dynamic value list outputs are routing sinks (their options feed an input), not displayed. -->
292
+ {#if item.widgetType !== 'dynamicValueList'}
293
+ <div class="gap-2 flex flex-col">
294
+ {@render fieldHeader()}
293
295
 
294
- {#if item.widgetType === 'chart'}
295
- <ChartOutput
296
- {item}
297
- value={typeof value === 'string' ? value : value != null ? JSON.stringify(value) : ''}
298
- />
299
- {:else if item.widgetType === 'image'}
300
- <ImageOutput {item} {value} />
301
- {:else if item.widgetType === 'file'}
302
- {@render fileDisplay()}
303
- {:else if item.widgetType === 'number'}
304
- <div class="{boxClass} flex items-center bg-muted/50 wrap-break-word">
305
- {#if hasValue}
306
- <span class="font-bold text-primary">{formattedValue}</span>
307
- {:else}
308
- {@render placeholder()}
309
- {/if}
310
- </div>
311
- {:else if item.widgetType === 'text'}
312
- <div class="group relative">
313
- {#if isObjectValue}
314
- <pre
315
- class="{boxClass} overflow-wrap-anywhere max-h-96 overflow-auto bg-muted/10 text-foreground">{formattedValue}</pre>
316
- {:else}
317
- <div
318
- class="{boxClass} overflow-wrap-anywhere bg-muted/10 wrap-break-word whitespace-pre-wrap text-foreground"
319
- >
320
- {#if hasValue}{value}{:else}{@render placeholder()}{/if}
321
- </div>
322
- {/if}
323
- {#if hasValue}
324
- <Button
325
- onclick={copyToClipboard}
326
- class="right-2 top-2 absolute transition-opacity {copied
327
- ? 'opacity-100'
328
- : 'opacity-0 group-hover:opacity-100'}"
329
- size="sm"
330
- >
331
- {copied ? 'Copied!' : 'Copy'}
332
- </Button>
333
- {/if}
334
- </div>
335
- {/if}
336
- </div>
296
+ {#if item.widgetType === 'chart'}
297
+ <ChartOutput
298
+ {item}
299
+ value={typeof value === 'string' ? value : value != null ? JSON.stringify(value) : ''}
300
+ />
301
+ {:else if item.widgetType === 'image'}
302
+ <ImageOutput {item} {value} />
303
+ {:else if item.widgetType === 'file'}
304
+ {@render fileDisplay()}
305
+ {:else if item.widgetType === 'number'}
306
+ <div class="{boxClass} flex items-center bg-muted/50 wrap-break-word">
307
+ {#if hasValue}
308
+ <span class="font-bold text-primary">{formattedValue}</span>
309
+ {:else}
310
+ {@render placeholder()}
311
+ {/if}
312
+ </div>
313
+ {:else if item.widgetType === 'text'}
314
+ <div class="group relative">
315
+ {#if isObjectValue}
316
+ <pre
317
+ class="{boxClass} overflow-wrap-anywhere max-h-96 overflow-auto bg-muted/10 text-foreground">{formattedValue}</pre>
318
+ {:else}
319
+ <div
320
+ class="{boxClass} overflow-wrap-anywhere bg-muted/10 wrap-break-word whitespace-pre-wrap text-foreground"
321
+ >
322
+ {#if hasValue}{value}{:else}{@render placeholder()}{/if}
323
+ </div>
324
+ {/if}
325
+ {#if hasValue}
326
+ <Button
327
+ onclick={copyToClipboard}
328
+ class="right-2 top-2 absolute transition-opacity {copied
329
+ ? 'opacity-100'
330
+ : 'opacity-0 group-hover:opacity-100'}"
331
+ size="sm"
332
+ >
333
+ {copied ? 'Copied!' : 'Copy'}
334
+ </Button>
335
+ {/if}
336
+ </div>
337
+ {/if}
338
+ </div>
339
+ {/if}
@@ -23,10 +23,20 @@
23
23
  onValueChange: (paramId: string, value: SupportedTypes) => void;
24
24
  inputs: SchemaInput[];
25
25
  outputs: DiscoveredOutput[];
26
+ /** Computed value list options keyed by the target dynamic-value-list input id. */
27
+ dynamicOptions?: Record<string, Record<string, string>>;
26
28
  }
27
29
 
28
- let { tab, values, collapsedGroups, onToggleGroup, onValueChange, inputs, outputs }: Props =
29
- $props();
30
+ let {
31
+ tab,
32
+ values,
33
+ collapsedGroups,
34
+ onToggleGroup,
35
+ onValueChange,
36
+ inputs,
37
+ outputs,
38
+ dynamicOptions = {}
39
+ }: Props = $props();
30
40
 
31
41
  function getInputById(paramId: string): SchemaInput | undefined {
32
42
  return inputs.find((i) => i.id === paramId);
@@ -49,6 +59,7 @@
49
59
  displayName={layoutItem.displayName}
50
60
  onChange={onValueChange}
51
61
  disabled={visibility.disabled}
62
+ dynamicOptions={dynamicOptions[input.id]}
52
63
  />
53
64
  {/if}
54
65
  {/snippet}
@@ -7,6 +7,8 @@ interface Props {
7
7
  onValueChange: (paramId: string, value: SupportedTypes) => void;
8
8
  inputs: SchemaInput[];
9
9
  outputs: DiscoveredOutput[];
10
+ /** Computed value list options keyed by the target dynamic-value-list input id. */
11
+ dynamicOptions?: Record<string, Record<string, string>>;
10
12
  }
11
13
  declare const TabContent: import("svelte").Component<Props, {}, "">;
12
14
  type TabContent = ReturnType<typeof TabContent>;
@@ -5,6 +5,7 @@
5
5
  import TabBar from './TabBar.svelte';
6
6
  import TabContent from './TabContent.svelte';
7
7
  import { buildVisibilityMap, itemKey } from '../../schema/visibility-rules';
8
+ import { buildDynamicValueListOptions } from '../../schema/dynamic-value-list';
8
9
 
9
10
  interface Props {
10
11
  schema: UISchema;
@@ -16,13 +17,7 @@
16
17
  requestedTabId?: string | null;
17
18
  }
18
19
 
19
- let {
20
- schema,
21
- values = $bindable(),
22
- onValueChange,
23
- panelFilter,
24
- requestedTabId = null
25
- }: Props = $props();
20
+ let { schema, values, onValueChange, panelFilter, requestedTabId = null }: Props = $props();
26
21
 
27
22
  let activeTabId = $state('');
28
23
  let collapsedGroups = $state<Record<string, boolean>>({});
@@ -38,6 +33,9 @@
38
33
 
39
34
  const showTabBar = $derived(visibleTabs.length > 1);
40
35
 
36
+ // Computed value list options keyed by the target input id, derived from solved outputs.
37
+ const dynamicOptions = $derived(buildDynamicValueListOptions(schema, values));
38
+
41
39
  // Tab selection
42
40
  $effect(() => {
43
41
  if (requestedTabId && visibleTabs.some((t) => t.id === requestedTabId)) {
@@ -104,6 +102,7 @@
104
102
  {onValueChange}
105
103
  inputs={schema.inputs}
106
104
  outputs={schema.outputs}
105
+ {dynamicOptions}
107
106
  />
108
107
  {/each}
109
108
  </Tabs.Root>
@@ -8,6 +8,6 @@ interface Props {
8
8
  /** Externally request a specific tab to be active (e.g. from collapsed strip click) */
9
9
  requestedTabId?: string | null;
10
10
  }
11
- declare const TabLayout: import("svelte").Component<Props, {}, "values">;
11
+ declare const TabLayout: import("svelte").Component<Props, {}, "">;
12
12
  type TabLayout = ReturnType<typeof TabLayout>;
13
13
  export default TabLayout;
@@ -28,7 +28,7 @@
28
28
  </script>
29
29
 
30
30
  <div class="divide-y divide-border/60 overflow-hidden rounded-md border border-input">
31
- {#each Object.entries(options) as [name, expr] (expr ?? name)}
31
+ {#each Object.entries(options) as [name, expr] (name)}
32
32
  {@const optionValue = expr ?? name}
33
33
  {@const optionId = `${inputId}-${optionValue}`}
34
34
  {@const isSelected = selected.has(optionValue)}
@@ -33,7 +33,7 @@
33
33
  {currentLabel || 'Select an option...'}
34
34
  </Select.Trigger>
35
35
  <Select.Content>
36
- {#each Object.entries(options) as [name, expr] (expr ?? name)}
36
+ {#each Object.entries(options) as [name, expr] (name)}
37
37
  <Select.Item value={expr ?? name} label={name} />
38
38
  {/each}
39
39
  </Select.Content>
@@ -201,6 +201,17 @@
201
201
  return;
202
202
  }
203
203
 
204
+ // Guard the upload size client-side. The file is base64-embedded into the
205
+ // compute request body, so an oversize file would otherwise be rejected
206
+ // server-side with an opaque 413 (see COMPUTE_REQUEST_MAX_BYTES). The URL
207
+ // import path has the same check; keep the two in sync.
208
+ if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
209
+ alert(
210
+ `File too large: ${(file.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB).`
211
+ );
212
+ return;
213
+ }
214
+
204
215
  uploadedFileName = file.name;
205
216
 
206
217
  const reader = new FileReader();
package/dist/index.d.ts CHANGED
@@ -6,8 +6,10 @@ export * from './components/primitives';
6
6
  export { default as StateDisplay } from './components/primitives/StateDisplay.svelte';
7
7
  export { default as Viewer } from './components/viewer/Viewer.svelte';
8
8
  export * from './schema/defaults';
9
+ export * from './schema/dynamic-value-list';
9
10
  export * from './schema/traversal';
10
11
  export * from './compute/solving.svelte';
12
+ export { createSolveSession, createRequestResponseDriver, type SolveSession, type SolveSessionArgs, type SolveDriver, type SolveReporter } from './compute/createSolveSession.svelte';
11
13
  export * from './external/storage';
12
14
  export * from './contexts/footerContext.svelte';
13
15
  export * from './contexts/clientSlotContext.svelte';
@@ -15,5 +17,5 @@ export * from './composables/useFooterItem.svelte';
15
17
  export * from './utils';
16
18
  export { randomId } from './utils/randomId';
17
19
  export type { ActionButton } from './types/actionButton';
18
- export type { SolveFn } from './types/solveFn';
20
+ export type { SolveFn, SolveResult } from './types/solveFn';
19
21
  export { DEFAULT_PRESET_LABELS, type PresetLabels } from './types/presetLabels';
package/dist/index.js CHANGED
@@ -12,8 +12,13 @@ export { default as StateDisplay } from './components/primitives/StateDisplay.sv
12
12
  export { default as Viewer } from './components/viewer/Viewer.svelte';
13
13
  // Utilities
14
14
  export * from './schema/defaults';
15
+ export * from './schema/dynamic-value-list';
15
16
  export * from './schema/traversal';
16
17
  export * from './compute/solving.svelte';
18
+ // Solve Session seam (transport-agnostic value/lifecycle state machine + its driver
19
+ // interface). Exported so transports outside this package — e.g. plugin-ui's WebSocket
20
+ // driver — can satisfy SolveDriver and drive a session. See CONTEXT.md.
21
+ export { createSolveSession, createRequestResponseDriver } from './compute/createSolveSession.svelte';
17
22
  // External-input transit storage (used by routes that wire pre-step producers)
18
23
  export * from './external/storage';
19
24
  // Contexts & Composables
@@ -0,0 +1,17 @@
1
+ import type { UISchema } from '@selvajs/schemas';
2
+ /**
3
+ * The runtime payload a dynamic value list output produces, keyed by the output's id in `values`.
4
+ * Routed back into the dynamic value list input identified by `targetInputId`.
5
+ */
6
+ export interface DynamicValueListPayload {
7
+ targetInputId?: string | null;
8
+ options?: Record<string, string>;
9
+ }
10
+ /**
11
+ * Build a map of `inputId -> computed options` from the solved output values.
12
+ *
13
+ * Reads each dynamicValueList source's `{ targetInputId, options }` payload from `values` and routes
14
+ * the options to the targeted input. The payload's own `targetInputId` wins; the schema-side
15
+ * `targetInputId` is the fallback when the payload omits it.
16
+ */
17
+ export declare function buildDynamicValueListOptions(schema: UISchema, values: Record<string, unknown>): Record<string, Record<string, string>>;
@@ -0,0 +1,76 @@
1
+ import { getLayoutItems } from '@selvajs/schemas';
2
+ function isDynamicValueListPayload(value) {
3
+ return (typeof value === 'object' && value !== null && ('targetInputId' in value || 'options' in value));
4
+ }
5
+ /**
6
+ * Normalize a raw output value into a payload object.
7
+ *
8
+ * The local/WebSocket path delivers a real object; the Rhino.Compute path delivers the
9
+ * component's JSON output as a string (possibly double-encoded by the compute layer), so try
10
+ * to parse strings before giving up.
11
+ */
12
+ function coercePayload(value) {
13
+ if (isDynamicValueListPayload(value))
14
+ return value;
15
+ let candidate = value;
16
+ // Unwrap up to two layers of JSON string encoding (compute may quote the string output).
17
+ for (let i = 0; i < 2 && typeof candidate === 'string'; i++) {
18
+ try {
19
+ candidate = JSON.parse(candidate);
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ if (isDynamicValueListPayload(candidate))
25
+ return candidate;
26
+ }
27
+ return null;
28
+ }
29
+ /**
30
+ * Every dynamicValueList output reference in the schema.
31
+ *
32
+ * Canonical location is `schema.outputs[]` — the plugin's SchemaSynchronizer enforces that every
33
+ * dynamicValueList layout item is mirrored there (see CanonicalizeDynamicValueListOutputs). We ALSO
34
+ * scan the layout purely as back-compat defense for schemas persisted by an older plugin that lacked
35
+ * that invariant; for current schemas the layout pass finds nothing new.
36
+ * Deduped by id, outputs[] winning so the canonical record's targetInputId takes precedence.
37
+ */
38
+ function collectDynamicValueListSources(schema) {
39
+ const byId = new Map();
40
+ for (const item of getLayoutItems(schema)) {
41
+ if (item.type !== 'output' || item.widgetType !== 'dynamicValueList')
42
+ continue;
43
+ const dvl = item;
44
+ if (typeof dvl.paramId !== 'string')
45
+ continue;
46
+ byId.set(dvl.paramId, { id: dvl.paramId, targetInputId: dvl.config?.targetInputId });
47
+ }
48
+ for (const output of schema.outputs ?? []) {
49
+ if (output.type !== 'dynamicValueList')
50
+ continue;
51
+ byId.set(output.id, { id: output.id, targetInputId: output.targetInputId });
52
+ }
53
+ return [...byId.values()];
54
+ }
55
+ /**
56
+ * Build a map of `inputId -> computed options` from the solved output values.
57
+ *
58
+ * Reads each dynamicValueList source's `{ targetInputId, options }` payload from `values` and routes
59
+ * the options to the targeted input. The payload's own `targetInputId` wins; the schema-side
60
+ * `targetInputId` is the fallback when the payload omits it.
61
+ */
62
+ export function buildDynamicValueListOptions(schema, values) {
63
+ const result = {};
64
+ for (const source of collectDynamicValueListSources(schema)) {
65
+ const payload = coercePayload(values[source.id]);
66
+ if (!payload)
67
+ continue;
68
+ const targetInputId = payload.targetInputId ?? source.targetInputId;
69
+ if (!targetInputId)
70
+ continue;
71
+ if (payload.options && typeof payload.options === 'object') {
72
+ result[targetInputId] = payload.options;
73
+ }
74
+ }
75
+ return result;
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "4.4.0",
3
+ "version": "4.6.0",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -39,7 +39,7 @@
39
39
  "svelte": "^5",
40
40
  "tailwind-variants": "^3.2.2",
41
41
  "three": "^0.184.0",
42
- "@selvajs/schemas": "^4.4.0"
42
+ "@selvajs/schemas": "^4.5.0"
43
43
  },
44
44
  "peerDependenciesMeta": {
45
45
  "three": {
@@ -63,15 +63,16 @@
63
63
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
64
64
  "@types/three": "^0.184.0",
65
65
  "bits-ui": "^2.18.0",
66
+ "rimraf": "^6.0.1",
66
67
  "svelte": "5.55.5",
67
68
  "tailwind-variants": "^3.2.2",
68
69
  "vitest": "^3.2.4",
69
- "@selvajs/schemas": "4.4.0",
70
- "@selvajs/config": "0.0.0"
70
+ "@selvajs/config": "0.0.0",
71
+ "@selvajs/schemas": "4.5.0"
71
72
  },
72
73
  "scripts": {
73
74
  "dev": "vite dev",
74
- "build": "rm -rf dist && pnpm prepack",
75
+ "build": "rimraf dist && pnpm prepack",
75
76
  "build:fast": "svelte-kit sync && svelte-package",
76
77
  "build:watch": "svelte-kit sync && svelte-package --watch",
77
78
  "preview": "vite preview",
@@ -25,7 +25,7 @@
25
25
  oncalculate?: () => void;
26
26
  values: Record<string, unknown>;
27
27
  onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
28
- onLoadValues?: () => void | Promise<void>;
28
+ onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
29
29
  panelActions?: ActionButton[];
30
30
  showSaveButton?: boolean;
31
31
  showLoadButton?: boolean;
@@ -99,7 +99,7 @@
99
99
 
100
100
  async function handleLoadValues(loadedValues: Record<string, unknown>) {
101
101
  Object.assign(values, loadedValues);
102
- await onLoadValues?.();
102
+ await onLoadValues?.(loadedValues);
103
103
  }
104
104
 
105
105
  let _touchStartY = 0;
@@ -125,7 +125,7 @@
125
125
  )}
126
126
  <div class="panel-content-wrapper">
127
127
  {#if schema.layout.type === 'tabbed'}
128
- <TabLayout {schema} bind:values {onValueChange} {panelFilter} {requestedTabId} />
128
+ <TabLayout {schema} {values} {onValueChange} {panelFilter} {requestedTabId} />
129
129
  {/if}
130
130
  {#if showParameterStateManager || (!isMobile && showCalculateButton && schema.instanceSolve === false)}
131
131
  <div class="panel-footer px-3">
@@ -1,9 +1,16 @@
1
1
  <script lang="ts">
2
- import type { InputLayoutItem, FileInputWidgetConfig, SupportedTypes } from '@selvajs/schemas';
2
+ import type {
3
+ InputLayoutItem,
4
+ FileInputWidgetConfig,
5
+ DynamicValueListWidgetConfig,
6
+ DropdownWidgetConfig,
7
+ SupportedTypes
8
+ } from '@selvajs/schemas';
3
9
  import {
4
10
  isNumberWidget,
5
11
  isTextWidget,
6
12
  isDropdownWidget,
13
+ isDynamicValueListWidget,
7
14
  isCheckboxWidget,
8
15
  isFileWidget,
9
16
  isColorWidget
@@ -28,6 +35,8 @@
28
35
  displayName?: string;
29
36
  onChange: (paramId: string, value: SupportedTypes) => void;
30
37
  disabled?: boolean;
38
+ /** Runtime-computed options for a dynamic value list input (name -> value). */
39
+ dynamicOptions?: Record<string, string>;
31
40
  }
32
41
 
33
42
  let {
@@ -35,7 +44,8 @@
35
44
  value = $bindable(undefined),
36
45
  displayName,
37
46
  onChange,
38
- disabled = false
47
+ disabled = false,
48
+ dynamicOptions
39
49
  }: Props = $props();
40
50
 
41
51
  const inputId = $derived(`input-${item.paramId}`);
@@ -68,6 +78,50 @@
68
78
 
69
79
  const showRangeInLabel = $derived(isNumberWidget(item) && numberRangeHint !== null);
70
80
 
81
+ // Dynamic value list: computed options (from the last solve) take precedence over the
82
+ // author's seed list. Empty until the first solve produces options, unless a default is set.
83
+ const dynamicListConfig = $derived(
84
+ isDynamicValueListWidget(item)
85
+ ? (item.config as DynamicValueListWidgetConfig | undefined)
86
+ : undefined
87
+ );
88
+ const dynamicListOptions = $derived<Record<string, string>>(
89
+ dynamicOptions && Object.keys(dynamicOptions).length > 0
90
+ ? dynamicOptions
91
+ : (Object.fromEntries(
92
+ Object.entries(dynamicListConfig?.defaultOptions ?? {}).filter(
93
+ (entry): entry is [string, string] => entry[1] !== undefined
94
+ )
95
+ ) as Record<string, string>)
96
+ );
97
+ const dynamicListHasOptions = $derived(Object.keys(dynamicListOptions).length > 0);
98
+ // As a dropdown config so DropdownInput/ChecklistInput can consume it unchanged.
99
+ const dynamicListAsDropdownConfig = $derived<DropdownWidgetConfig>({
100
+ options: dynamicListOptions,
101
+ displayAs: dynamicListConfig?.displayAs ?? 'dropdown'
102
+ });
103
+ const hideDynamicListWhenEmpty = $derived(
104
+ isDynamicValueListWidget(item) &&
105
+ !dynamicListHasOptions &&
106
+ (dynamicListConfig?.emptyBehavior ?? 'hide') === 'hide'
107
+ );
108
+
109
+ // When a dynamic value list recomputes, a previously-selected value may no longer be an
110
+ // available option. Prune the stale selection so the control shows a valid option (or empty)
111
+ // instead of rendering the orphaned raw value as its own label.
112
+ $effect(() => {
113
+ if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
114
+ const validValues = new Set(Object.values(dynamicListOptions));
115
+ // Route through onChange (not commit) — value is a one-way prop here, so writing it
116
+ // directly from an effect trips Svelte's binding-ownership check.
117
+ if (Array.isArray(value)) {
118
+ const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
119
+ if (pruned.length !== value.length) onChange(item.paramId, pruned);
120
+ } else if (typeof value === 'string' && value && !validValues.has(value)) {
121
+ onChange(item.paramId, '');
122
+ }
123
+ });
124
+
71
125
  function commit(newValue: SupportedTypes) {
72
126
  value = newValue;
73
127
  onChange(item.paramId, newValue);
@@ -83,7 +137,7 @@
83
137
  value
84
138
  })}
85
139
  {/if}
86
- {:else}
140
+ {:else if !hideDynamicListWhenEmpty}
87
141
  <Field.Field>
88
142
  <Field.Label for={inputId} class="gap-2 flex items-center">
89
143
  {label}
@@ -152,6 +206,31 @@
152
206
  {disabled}
153
207
  />
154
208
  {/if}
209
+ {:else if isDynamicValueListWidget(item)}
210
+ {#if dynamicListHasOptions}
211
+ {#if dynamicListAsDropdownConfig.displayAs === 'checklist'}
212
+ <ChecklistInput
213
+ {inputId}
214
+ value={Array.isArray(value)
215
+ ? (value as string[])
216
+ : typeof value === 'string' && value
217
+ ? [value]
218
+ : []}
219
+ config={dynamicListAsDropdownConfig}
220
+ onChange={commit}
221
+ {disabled}
222
+ />
223
+ {:else}
224
+ <DropdownInput
225
+ value={typeof value === 'string' ? value : ''}
226
+ config={dynamicListAsDropdownConfig}
227
+ onChange={commit}
228
+ {disabled}
229
+ />
230
+ {/if}
231
+ {:else}
232
+ <p class="text-sm text-muted-foreground">No options available yet.</p>
233
+ {/if}
155
234
  {:else if isFileWidget(item)}
156
235
  {@const config = item.config as FileInputWidgetConfig}
157
236
  <FileInput
@@ -288,49 +288,52 @@
288
288
  {/if}
289
289
  {/snippet}
290
290
 
291
- <div class="gap-2 flex flex-col">
292
- {@render fieldHeader()}
291
+ <!-- Dynamic value list outputs are routing sinks (their options feed an input), not displayed. -->
292
+ {#if item.widgetType !== 'dynamicValueList'}
293
+ <div class="gap-2 flex flex-col">
294
+ {@render fieldHeader()}
293
295
 
294
- {#if item.widgetType === 'chart'}
295
- <ChartOutput
296
- {item}
297
- value={typeof value === 'string' ? value : value != null ? JSON.stringify(value) : ''}
298
- />
299
- {:else if item.widgetType === 'image'}
300
- <ImageOutput {item} {value} />
301
- {:else if item.widgetType === 'file'}
302
- {@render fileDisplay()}
303
- {:else if item.widgetType === 'number'}
304
- <div class="{boxClass} flex items-center bg-muted/50 wrap-break-word">
305
- {#if hasValue}
306
- <span class="font-bold text-primary">{formattedValue}</span>
307
- {:else}
308
- {@render placeholder()}
309
- {/if}
310
- </div>
311
- {:else if item.widgetType === 'text'}
312
- <div class="group relative">
313
- {#if isObjectValue}
314
- <pre
315
- class="{boxClass} overflow-wrap-anywhere max-h-96 overflow-auto bg-muted/10 text-foreground">{formattedValue}</pre>
316
- {:else}
317
- <div
318
- class="{boxClass} overflow-wrap-anywhere bg-muted/10 wrap-break-word whitespace-pre-wrap text-foreground"
319
- >
320
- {#if hasValue}{value}{:else}{@render placeholder()}{/if}
321
- </div>
322
- {/if}
323
- {#if hasValue}
324
- <Button
325
- onclick={copyToClipboard}
326
- class="right-2 top-2 absolute transition-opacity {copied
327
- ? 'opacity-100'
328
- : 'opacity-0 group-hover:opacity-100'}"
329
- size="sm"
330
- >
331
- {copied ? 'Copied!' : 'Copy'}
332
- </Button>
333
- {/if}
334
- </div>
335
- {/if}
336
- </div>
296
+ {#if item.widgetType === 'chart'}
297
+ <ChartOutput
298
+ {item}
299
+ value={typeof value === 'string' ? value : value != null ? JSON.stringify(value) : ''}
300
+ />
301
+ {:else if item.widgetType === 'image'}
302
+ <ImageOutput {item} {value} />
303
+ {:else if item.widgetType === 'file'}
304
+ {@render fileDisplay()}
305
+ {:else if item.widgetType === 'number'}
306
+ <div class="{boxClass} flex items-center bg-muted/50 wrap-break-word">
307
+ {#if hasValue}
308
+ <span class="font-bold text-primary">{formattedValue}</span>
309
+ {:else}
310
+ {@render placeholder()}
311
+ {/if}
312
+ </div>
313
+ {:else if item.widgetType === 'text'}
314
+ <div class="group relative">
315
+ {#if isObjectValue}
316
+ <pre
317
+ class="{boxClass} overflow-wrap-anywhere max-h-96 overflow-auto bg-muted/10 text-foreground">{formattedValue}</pre>
318
+ {:else}
319
+ <div
320
+ class="{boxClass} overflow-wrap-anywhere bg-muted/10 wrap-break-word whitespace-pre-wrap text-foreground"
321
+ >
322
+ {#if hasValue}{value}{:else}{@render placeholder()}{/if}
323
+ </div>
324
+ {/if}
325
+ {#if hasValue}
326
+ <Button
327
+ onclick={copyToClipboard}
328
+ class="right-2 top-2 absolute transition-opacity {copied
329
+ ? 'opacity-100'
330
+ : 'opacity-0 group-hover:opacity-100'}"
331
+ size="sm"
332
+ >
333
+ {copied ? 'Copied!' : 'Copy'}
334
+ </Button>
335
+ {/if}
336
+ </div>
337
+ {/if}
338
+ </div>
339
+ {/if}
@@ -23,10 +23,20 @@
23
23
  onValueChange: (paramId: string, value: SupportedTypes) => void;
24
24
  inputs: SchemaInput[];
25
25
  outputs: DiscoveredOutput[];
26
+ /** Computed value list options keyed by the target dynamic-value-list input id. */
27
+ dynamicOptions?: Record<string, Record<string, string>>;
26
28
  }
27
29
 
28
- let { tab, values, collapsedGroups, onToggleGroup, onValueChange, inputs, outputs }: Props =
29
- $props();
30
+ let {
31
+ tab,
32
+ values,
33
+ collapsedGroups,
34
+ onToggleGroup,
35
+ onValueChange,
36
+ inputs,
37
+ outputs,
38
+ dynamicOptions = {}
39
+ }: Props = $props();
30
40
 
31
41
  function getInputById(paramId: string): SchemaInput | undefined {
32
42
  return inputs.find((i) => i.id === paramId);
@@ -49,6 +59,7 @@
49
59
  displayName={layoutItem.displayName}
50
60
  onChange={onValueChange}
51
61
  disabled={visibility.disabled}
62
+ dynamicOptions={dynamicOptions[input.id]}
52
63
  />
53
64
  {/if}
54
65
  {/snippet}
@@ -5,6 +5,7 @@
5
5
  import TabBar from './TabBar.svelte';
6
6
  import TabContent from './TabContent.svelte';
7
7
  import { buildVisibilityMap, itemKey } from '$lib/schema/visibility-rules';
8
+ import { buildDynamicValueListOptions } from '$lib/schema/dynamic-value-list';
8
9
 
9
10
  interface Props {
10
11
  schema: UISchema;
@@ -16,13 +17,7 @@
16
17
  requestedTabId?: string | null;
17
18
  }
18
19
 
19
- let {
20
- schema,
21
- values = $bindable(),
22
- onValueChange,
23
- panelFilter,
24
- requestedTabId = null
25
- }: Props = $props();
20
+ let { schema, values, onValueChange, panelFilter, requestedTabId = null }: Props = $props();
26
21
 
27
22
  let activeTabId = $state('');
28
23
  let collapsedGroups = $state<Record<string, boolean>>({});
@@ -38,6 +33,9 @@
38
33
 
39
34
  const showTabBar = $derived(visibleTabs.length > 1);
40
35
 
36
+ // Computed value list options keyed by the target input id, derived from solved outputs.
37
+ const dynamicOptions = $derived(buildDynamicValueListOptions(schema, values));
38
+
41
39
  // Tab selection
42
40
  $effect(() => {
43
41
  if (requestedTabId && visibleTabs.some((t) => t.id === requestedTabId)) {
@@ -104,6 +102,7 @@
104
102
  {onValueChange}
105
103
  inputs={schema.inputs}
106
104
  outputs={schema.outputs}
105
+ {dynamicOptions}
107
106
  />
108
107
  {/each}
109
108
  </Tabs.Root>
@@ -28,7 +28,7 @@
28
28
  </script>
29
29
 
30
30
  <div class="divide-y divide-border/60 overflow-hidden rounded-md border border-input">
31
- {#each Object.entries(options) as [name, expr] (expr ?? name)}
31
+ {#each Object.entries(options) as [name, expr] (name)}
32
32
  {@const optionValue = expr ?? name}
33
33
  {@const optionId = `${inputId}-${optionValue}`}
34
34
  {@const isSelected = selected.has(optionValue)}
@@ -33,7 +33,7 @@
33
33
  {currentLabel || 'Select an option...'}
34
34
  </Select.Trigger>
35
35
  <Select.Content>
36
- {#each Object.entries(options) as [name, expr] (expr ?? name)}
36
+ {#each Object.entries(options) as [name, expr] (name)}
37
37
  <Select.Item value={expr ?? name} label={name} />
38
38
  {/each}
39
39
  </Select.Content>
@@ -201,6 +201,17 @@
201
201
  return;
202
202
  }
203
203
 
204
+ // Guard the upload size client-side. The file is base64-embedded into the
205
+ // compute request body, so an oversize file would otherwise be rejected
206
+ // server-side with an opaque 413 (see COMPUTE_REQUEST_MAX_BYTES). The URL
207
+ // import path has the same check; keep the two in sync.
208
+ if (file.size > APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_BYTES) {
209
+ alert(
210
+ `File too large: ${(file.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB).`
211
+ );
212
+ return;
213
+ }
214
+
204
215
  uploadedFileName = file.name;
205
216
 
206
217
  const reader = new FileReader();
package/src/lib/index.ts CHANGED
@@ -17,9 +17,22 @@ export { default as Viewer } from './components/viewer/Viewer.svelte';
17
17
 
18
18
  // Utilities
19
19
  export * from './schema/defaults';
20
+ export * from './schema/dynamic-value-list';
20
21
  export * from './schema/traversal';
21
22
  export * from './compute/solving.svelte';
22
23
 
24
+ // Solve Session seam (transport-agnostic value/lifecycle state machine + its driver
25
+ // interface). Exported so transports outside this package — e.g. plugin-ui's WebSocket
26
+ // driver — can satisfy SolveDriver and drive a session. See CONTEXT.md.
27
+ export {
28
+ createSolveSession,
29
+ createRequestResponseDriver,
30
+ type SolveSession,
31
+ type SolveSessionArgs,
32
+ type SolveDriver,
33
+ type SolveReporter
34
+ } from './compute/createSolveSession.svelte';
35
+
23
36
  // External-input transit storage (used by routes that wire pre-step producers)
24
37
  export * from './external/storage';
25
38
 
@@ -34,5 +47,5 @@ export { randomId } from './utils/randomId';
34
47
 
35
48
  // UI-specific runtime types (not from schema)
36
49
  export type { ActionButton } from './types/actionButton';
37
- export type { SolveFn } from './types/solveFn';
50
+ export type { SolveFn, SolveResult } from './types/solveFn';
38
51
  export { DEFAULT_PRESET_LABELS, type PresetLabels } from './types/presetLabels';
@@ -0,0 +1,133 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { describe, expect, it } from 'vitest';
4
+ import type { UISchema } from '@selvajs/schemas';
5
+ import { buildDynamicValueListOptions } from './dynamic-value-list';
6
+
7
+ // The collector keys `values` by the ContextBake GUID. A dynamicValueList output can live in
8
+ // schema.outputs[] OR only in the layout (a routing sink). These pin that BOTH are honoured —
9
+ // the layout-only case is the bug where the C# collector sent the payload but the UI threw it away.
10
+
11
+ const BAKE = 'bake-guid';
12
+ const TARGET = 'target-input-guid';
13
+
14
+ function schemaWith(opts: { outputs?: UISchema['outputs']; layoutItems?: unknown[] }): UISchema {
15
+ return {
16
+ outputs: opts.outputs ?? [],
17
+ layout: {
18
+ type: 'tabbed',
19
+ tabs: [
20
+ { id: 't1', groups: [{ id: 'g1', label: 'g1', order: 0, items: opts.layoutItems ?? [] }] }
21
+ ]
22
+ }
23
+ } as unknown as UISchema;
24
+ }
25
+
26
+ const layoutItem = (paramId: string, targetInputId: string) =>
27
+ ({
28
+ id: 'li1',
29
+ type: 'output',
30
+ widgetType: 'dynamicValueList',
31
+ paramId,
32
+ config: { targetInputId }
33
+ }) as unknown;
34
+
35
+ const payload = (targetInputId: string | null, options: Record<string, string>) => ({
36
+ targetInputId,
37
+ options
38
+ });
39
+
40
+ describe('buildDynamicValueListOptions', () => {
41
+ it('routes options from a schema.outputs[] source', () => {
42
+ const schema = schemaWith({
43
+ outputs: [{ id: BAKE, type: 'dynamicValueList', targetInputId: TARGET }] as never
44
+ });
45
+
46
+ const result = buildDynamicValueListOptions(schema, {
47
+ [BAKE]: payload(TARGET, { A: '1', B: '2' })
48
+ });
49
+
50
+ expect(result[TARGET]).toEqual({ A: '1', B: '2' });
51
+ });
52
+
53
+ it('routes options from a LAYOUT-only source (the dropped-data bug)', () => {
54
+ const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
55
+
56
+ const result = buildDynamicValueListOptions(schema, {
57
+ [BAKE]: payload(TARGET, { Sphere: '0', Box: '1' })
58
+ });
59
+
60
+ expect(result[TARGET]).toEqual({ Sphere: '0', Box: '1' });
61
+ });
62
+
63
+ it("prefers the payload's targetInputId over the schema fallback", () => {
64
+ const schema = schemaWith({ layoutItems: [layoutItem(BAKE, 'stale-target')] });
65
+
66
+ const result = buildDynamicValueListOptions(schema, {
67
+ [BAKE]: payload('live-target', { A: '1' })
68
+ });
69
+
70
+ expect(result['live-target']).toEqual({ A: '1' });
71
+ expect(result['stale-target']).toBeUndefined();
72
+ });
73
+
74
+ it('falls back to the schema targetInputId when the payload omits it', () => {
75
+ const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
76
+
77
+ const result = buildDynamicValueListOptions(schema, {
78
+ [BAKE]: payload(null, { A: '1' })
79
+ });
80
+
81
+ expect(result[TARGET]).toEqual({ A: '1' });
82
+ });
83
+
84
+ it('parses a JSON-string payload (Rhino.Compute path)', () => {
85
+ const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
86
+
87
+ const result = buildDynamicValueListOptions(schema, {
88
+ [BAKE]: JSON.stringify(payload(TARGET, { A: '1' }))
89
+ });
90
+
91
+ expect(result[TARGET]).toEqual({ A: '1' });
92
+ });
93
+
94
+ it('dedupes outputs[] over layout for the same id', () => {
95
+ const schema = schemaWith({
96
+ outputs: [{ id: BAKE, type: 'dynamicValueList', targetInputId: 'from-outputs' }] as never,
97
+ layoutItems: [layoutItem(BAKE, 'from-layout')]
98
+ });
99
+
100
+ // Payload omits targetInputId -> the outputs[] fallback wins (it's set last in the dedupe map).
101
+ const result = buildDynamicValueListOptions(schema, {
102
+ [BAKE]: payload(null, { A: '1' })
103
+ });
104
+
105
+ expect(result['from-outputs']).toEqual({ A: '1' });
106
+ expect(result['from-layout']).toBeUndefined();
107
+ });
108
+
109
+ it('ignores values with no matching source', () => {
110
+ const schema = schemaWith({ layoutItems: [layoutItem(BAKE, TARGET)] });
111
+
112
+ const result = buildDynamicValueListOptions(schema, {
113
+ 'unrelated-id': payload(TARGET, { A: '1' })
114
+ });
115
+
116
+ expect(Object.keys(result)).toHaveLength(0);
117
+ });
118
+
119
+ // The SAME json file the C# DynamicValueListPayload test loads. If C# and TS stop agreeing on
120
+ // this shape, one side's CI goes red — that's the cross-stack drift guard.
121
+ it('routes the shared cross-stack golden fixture', () => {
122
+ const fixturePath = fileURLToPath(
123
+ new URL('../../../../schemas/fixtures/dynamic-value-list-payload.json', import.meta.url)
124
+ );
125
+ const fixture = JSON.parse(readFileSync(fixturePath, 'utf-8'));
126
+ const schema = schemaWith({ layoutItems: [layoutItem(BAKE, fixture.targetInputId)] });
127
+
128
+ const result = buildDynamicValueListOptions(schema, { [BAKE]: fixture });
129
+
130
+ expect(result[fixture.targetInputId]).toEqual(fixture.options);
131
+ expect(result[fixture.targetInputId]).toEqual({ Sphere: '0', Box: '1', Cone: '2' });
132
+ });
133
+ });
@@ -0,0 +1,102 @@
1
+ import type { UISchema, OutputDynamicValueListLayoutItem } from '@selvajs/schemas';
2
+ import { getLayoutItems } from '@selvajs/schemas';
3
+
4
+ /**
5
+ * The runtime payload a dynamic value list output produces, keyed by the output's id in `values`.
6
+ * Routed back into the dynamic value list input identified by `targetInputId`.
7
+ */
8
+ export interface DynamicValueListPayload {
9
+ targetInputId?: string | null;
10
+ options?: Record<string, string>;
11
+ }
12
+
13
+ function isDynamicValueListPayload(value: unknown): value is DynamicValueListPayload {
14
+ return (
15
+ typeof value === 'object' && value !== null && ('targetInputId' in value || 'options' in value)
16
+ );
17
+ }
18
+
19
+ /**
20
+ * Normalize a raw output value into a payload object.
21
+ *
22
+ * The local/WebSocket path delivers a real object; the Rhino.Compute path delivers the
23
+ * component's JSON output as a string (possibly double-encoded by the compute layer), so try
24
+ * to parse strings before giving up.
25
+ */
26
+ function coercePayload(value: unknown): DynamicValueListPayload | null {
27
+ if (isDynamicValueListPayload(value)) return value;
28
+
29
+ let candidate = value;
30
+ // Unwrap up to two layers of JSON string encoding (compute may quote the string output).
31
+ for (let i = 0; i < 2 && typeof candidate === 'string'; i++) {
32
+ try {
33
+ candidate = JSON.parse(candidate);
34
+ } catch {
35
+ return null;
36
+ }
37
+ if (isDynamicValueListPayload(candidate)) return candidate;
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ /** One DynVL routing source: the id keying `values`, plus the schema-side target fallback. */
44
+ interface DynamicValueListSource {
45
+ id: string;
46
+ targetInputId?: string | null;
47
+ }
48
+
49
+ /**
50
+ * Every dynamicValueList output reference in the schema.
51
+ *
52
+ * Canonical location is `schema.outputs[]` — the plugin's SchemaSynchronizer enforces that every
53
+ * dynamicValueList layout item is mirrored there (see CanonicalizeDynamicValueListOutputs). We ALSO
54
+ * scan the layout purely as back-compat defense for schemas persisted by an older plugin that lacked
55
+ * that invariant; for current schemas the layout pass finds nothing new.
56
+ * Deduped by id, outputs[] winning so the canonical record's targetInputId takes precedence.
57
+ */
58
+ function collectDynamicValueListSources(schema: UISchema): DynamicValueListSource[] {
59
+ const byId = new Map<string, DynamicValueListSource>();
60
+
61
+ for (const item of getLayoutItems(schema)) {
62
+ if (item.type !== 'output' || item.widgetType !== 'dynamicValueList') continue;
63
+ const dvl = item as OutputDynamicValueListLayoutItem;
64
+ if (typeof dvl.paramId !== 'string') continue;
65
+ byId.set(dvl.paramId, { id: dvl.paramId, targetInputId: dvl.config?.targetInputId });
66
+ }
67
+
68
+ for (const output of schema.outputs ?? []) {
69
+ if (output.type !== 'dynamicValueList') continue;
70
+ byId.set(output.id, { id: output.id, targetInputId: output.targetInputId });
71
+ }
72
+
73
+ return [...byId.values()];
74
+ }
75
+
76
+ /**
77
+ * Build a map of `inputId -> computed options` from the solved output values.
78
+ *
79
+ * Reads each dynamicValueList source's `{ targetInputId, options }` payload from `values` and routes
80
+ * the options to the targeted input. The payload's own `targetInputId` wins; the schema-side
81
+ * `targetInputId` is the fallback when the payload omits it.
82
+ */
83
+ export function buildDynamicValueListOptions(
84
+ schema: UISchema,
85
+ values: Record<string, unknown>
86
+ ): Record<string, Record<string, string>> {
87
+ const result: Record<string, Record<string, string>> = {};
88
+
89
+ for (const source of collectDynamicValueListSources(schema)) {
90
+ const payload = coercePayload(values[source.id]);
91
+ if (!payload) continue;
92
+
93
+ const targetInputId = payload.targetInputId ?? source.targetInputId;
94
+ if (!targetInputId) continue;
95
+
96
+ if (payload.options && typeof payload.options === 'object') {
97
+ result[targetInputId] = payload.options;
98
+ }
99
+ }
100
+
101
+ return result;
102
+ }