@selvajs/ui 4.6.2-beta.1 → 4.7.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.
@@ -24,7 +24,7 @@
24
24
  isViewerFullscreen?: boolean;
25
25
  oncalculate?: () => void;
26
26
  values: Record<string, unknown>;
27
- onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
27
+ onValueChange: (id: string, val: SupportedTypes, forceSolve?: boolean) => void | Promise<void>;
28
28
  onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
29
29
  panelActions?: ActionButton[];
30
30
  showSaveButton?: boolean;
@@ -12,7 +12,7 @@ interface Props {
12
12
  isViewerFullscreen?: boolean;
13
13
  oncalculate?: () => void;
14
14
  values: Record<string, unknown>;
15
- onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
15
+ onValueChange: (id: string, val: SupportedTypes, forceSolve?: boolean) => void | Promise<void>;
16
16
  onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
17
17
  panelActions?: ActionButton[];
18
18
  showSaveButton?: boolean;
@@ -133,8 +133,8 @@
133
133
  });
134
134
  });
135
135
 
136
- function handleValueChange(id: string, val: unknown) {
137
- session.setValue(id, val);
136
+ function handleValueChange(id: string, val: unknown, forceSolve?: boolean) {
137
+ session.setValue(id, val, forceSolve);
138
138
  }
139
139
 
140
140
  function handleCalculate() {
@@ -33,7 +33,12 @@
33
33
  item: InputLayoutItem;
34
34
  value?: SupportedTypes;
35
35
  displayName?: string;
36
- onChange: (paramId: string, value: SupportedTypes) => void;
36
+ /**
37
+ * Commit a value. `forceSolve` requests a solve even in manual-solve mode — used for
38
+ * system-initiated reconciliation (e.g. pruning a vanished dynamic-list selection) where
39
+ * leaving the previous output on screen would misrepresent the now-changed input.
40
+ */
41
+ onChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
37
42
  disabled?: boolean;
38
43
  /** Runtime-computed options for a dynamic value list input (name -> value). */
39
44
  dynamicOptions?: Record<string, string>;
@@ -108,7 +113,10 @@
108
113
 
109
114
  // When a dynamic value list recomputes, a previously-selected value may no longer be an
110
115
  // 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.
116
+ // instead of rendering the orphaned raw value as its own label. This is a system-initiated
117
+ // change (the user didn't pick the new option), so force a solve — otherwise manual-solve
118
+ // schemas would keep the prior output on screen, making it look like the auto-picked option
119
+ // produced it.
112
120
  $effect(() => {
113
121
  if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
114
122
  const validValues = new Set(Object.values(dynamicListOptions));
@@ -116,9 +124,17 @@
116
124
  // directly from an effect trips Svelte's binding-ownership check.
117
125
  if (Array.isArray(value)) {
118
126
  const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
119
- if (pruned.length !== value.length) onChange(item.paramId, pruned);
127
+ if (pruned.length !== value.length) {
128
+ // Fall back to first option when checklist becomes fully empty after pruning
129
+ onChange(
130
+ item.paramId,
131
+ pruned.length > 0 ? pruned : [Object.values(dynamicListOptions)[0]],
132
+ true
133
+ );
134
+ }
120
135
  } else if (typeof value === 'string' && value && !validValues.has(value)) {
121
- onChange(item.paramId, '');
136
+ // Fall back to first available option instead of clearing to empty
137
+ onChange(item.paramId, Object.values(dynamicListOptions)[0], true);
122
138
  }
123
139
  });
124
140
 
@@ -3,7 +3,12 @@ interface Props {
3
3
  item: InputLayoutItem;
4
4
  value?: SupportedTypes;
5
5
  displayName?: string;
6
- onChange: (paramId: string, value: SupportedTypes) => void;
6
+ /**
7
+ * Commit a value. `forceSolve` requests a solve even in manual-solve mode — used for
8
+ * system-initiated reconciliation (e.g. pruning a vanished dynamic-list selection) where
9
+ * leaving the previous output on screen would misrepresent the now-changed input.
10
+ */
11
+ onChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
7
12
  disabled?: boolean;
8
13
  /** Runtime-computed options for a dynamic value list input (name -> value). */
9
14
  dynamicOptions?: Record<string, string>;
@@ -20,7 +20,7 @@
20
20
  values: Record<string, unknown>;
21
21
  collapsedGroups: Record<string, boolean>;
22
22
  onToggleGroup: (groupId: string) => void;
23
- onValueChange: (paramId: string, value: SupportedTypes) => void;
23
+ onValueChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
24
24
  inputs: SchemaInput[];
25
25
  outputs: DiscoveredOutput[];
26
26
  /** Computed value list options keyed by the target dynamic-value-list input id. */
@@ -4,7 +4,7 @@ interface Props {
4
4
  values: Record<string, unknown>;
5
5
  collapsedGroups: Record<string, boolean>;
6
6
  onToggleGroup: (groupId: string) => void;
7
- onValueChange: (paramId: string, value: SupportedTypes) => void;
7
+ onValueChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
8
8
  inputs: SchemaInput[];
9
9
  outputs: DiscoveredOutput[];
10
10
  /** Computed value list options keyed by the target dynamic-value-list input id. */
@@ -10,7 +10,7 @@
10
10
  interface Props {
11
11
  schema: UISchema;
12
12
  values: Record<string, unknown>;
13
- onValueChange: (paramId: string, value: SupportedTypes) => void;
13
+ onValueChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
14
14
  /** Filter tabs by panel position. 'left' shows unpositioned + left tabs, 'right' shows right tabs only. */
15
15
  panelFilter?: 'left' | 'right';
16
16
  /** Externally request a specific tab to be active (e.g. from collapsed strip click) */
@@ -2,7 +2,7 @@ import type { UISchema, SupportedTypes } from '@selvajs/schemas';
2
2
  interface Props {
3
3
  schema: UISchema;
4
4
  values: Record<string, unknown>;
5
- onValueChange: (paramId: string, value: SupportedTypes) => void;
5
+ onValueChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
6
6
  /** Filter tabs by panel position. 'left' shows unpositioned + left tabs, 'right' shows right tabs only. */
7
7
  panelFilter?: 'left' | 'right';
8
8
  /** Externally request a specific tab to be active (e.g. from collapsed strip click) */
@@ -10,10 +10,17 @@
10
10
 
11
11
  let { scene = $bindable(), sceneVersion = 0 }: Props = $props();
12
12
 
13
+ // Viewer aids (grid, floor, measurement overlay, CSS2D label layer) are tagged with these ids by
14
+ // @selvajs/compute. They aren't scene content, so they're hidden from the object list.
15
+ const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
16
+
13
17
  const getSceneObjects = () => {
14
18
  void sceneVersion;
15
19
  return scene.children.filter(
16
- (obj) => !(obj instanceof THREE.Camera) && !(obj instanceof THREE.Light)
20
+ (obj) =>
21
+ !(obj instanceof THREE.Camera) &&
22
+ !(obj instanceof THREE.Light) &&
23
+ !HELPER_IDS.has(obj.userData?.id)
17
24
  );
18
25
  };
19
26
 
@@ -8,7 +8,8 @@
8
8
  type CameraController,
9
9
  type CameraProjection,
10
10
  type MeasureTool,
11
- type ViewPreset
11
+ type ViewPreset,
12
+ type Grid
12
13
  } from '@selvajs/compute/visualization';
13
14
  import {
14
15
  Maximize,
@@ -20,6 +21,7 @@
20
21
  Square,
21
22
  Frame,
22
23
  Ruler,
24
+ Grid3x3,
23
25
  Check,
24
26
  ChevronRight
25
27
  } from '@lucide/svelte';
@@ -35,6 +37,8 @@
35
37
  showFullscreenButton?: boolean;
36
38
  showSceneManager?: boolean;
37
39
  showToolsMenu?: boolean;
40
+ /** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
41
+ showGridToggle?: boolean;
38
42
  enableMeshClick?: boolean;
39
43
  backgroundColor?: string;
40
44
  }
@@ -53,6 +57,7 @@
53
57
  showFullscreenButton: true,
54
58
  showSceneManager: true,
55
59
  showToolsMenu: true,
60
+ showGridToggle: true,
56
61
  enableMeshClick: true,
57
62
  backgroundColor: '#E6E6E6'
58
63
  };
@@ -74,6 +79,7 @@
74
79
  let controls: OrbitControls | null = null;
75
80
  let cameraController: CameraController | null = null;
76
81
  let measureTool: MeasureTool | null = null;
82
+ let grid: Grid | null = null;
77
83
  let fitToView: (() => void) | null = null;
78
84
  let viewerInitialized = false;
79
85
  let sceneVersion = $state(0);
@@ -81,6 +87,7 @@
81
87
  let sceneManagerOpen = $state(false);
82
88
  let projection: CameraProjection = $state('perspective');
83
89
  let measureActive = $state(false);
90
+ let gridVisible = $state(false);
84
91
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
85
92
  let selectedMeshName: string | null = $state(null);
86
93
 
@@ -112,7 +119,8 @@
112
119
  const opts: ThreeInitializerOptions = {
113
120
  environment: { backgroundColor: config.backgroundColor },
114
121
  controls: {},
115
- grid: { enabled: config.showToolsMenu },
122
+ // Build the grid so it can be toggled at runtime, but start hidden (off by default).
123
+ grid: { enabled: config.showToolsMenu && config.showGridToggle },
116
124
  gizmo: { enabled: false },
117
125
  measure: { enabled: config.showToolsMenu },
118
126
  events: {
@@ -133,6 +141,8 @@
133
141
  controls = init.controls;
134
142
  cameraController = init.cameraController;
135
143
  measureTool = init.measureTool;
144
+ grid = init.grid;
145
+ grid?.setVisible(gridVisible);
136
146
  fitToView = init.fitToView;
137
147
  projection = init.cameraController.getProjection();
138
148
 
@@ -156,6 +166,12 @@
156
166
  measureTool.setEnabled(measureActive);
157
167
  }
158
168
 
169
+ function toggleGrid() {
170
+ if (!grid) return;
171
+ gridVisible = !gridVisible;
172
+ grid.setVisible(gridVisible);
173
+ }
174
+
159
175
  $effect(() => {
160
176
  if (scene && camera && controls) {
161
177
  updateScene(scene, meshes, camera, controls, viewerInitialized);
@@ -309,6 +325,20 @@
309
325
  {/if}
310
326
  </DropdownMenu.Item>
311
327
 
328
+ {#if config.showGridToggle}
329
+ <DropdownMenu.Item
330
+ closeOnSelect={false}
331
+ class="{itemClass} {gridVisible ? 'text-primary' : ''}"
332
+ onSelect={toggleGrid}
333
+ >
334
+ <Grid3x3 class="h-4 w-4" />
335
+ <span class="flex-1">Grid</span>
336
+ {#if gridVisible}
337
+ <Check class="h-4 w-4" />
338
+ {/if}
339
+ </DropdownMenu.Item>
340
+ {/if}
341
+
312
342
  <!-- Scene tools -->
313
343
  {#if config.showSceneManager || config.showScreenshotButton || config.showFullscreenButton}
314
344
  <DropdownMenu.Separator class="my-1 h-px bg-border" />
@@ -3,6 +3,8 @@ export interface ViewerConfig {
3
3
  showFullscreenButton?: boolean;
4
4
  showSceneManager?: boolean;
5
5
  showToolsMenu?: boolean;
6
+ /** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
7
+ showGridToggle?: boolean;
6
8
  enableMeshClick?: boolean;
7
9
  backgroundColor?: string;
8
10
  }
@@ -19,8 +19,12 @@ export interface SolveSession {
19
19
  readonly hasPendingChanges: boolean;
20
20
  readonly hasNeverSolved: boolean;
21
21
  readonly isSolving: boolean;
22
- /** Records a value change and dispatches a solve unless the schema is manual-solve. */
23
- setValue(id: string, value: unknown): void;
22
+ /**
23
+ * Records a value change and dispatches a solve unless the schema is manual-solve.
24
+ * `forceSolve` overrides manual mode for system-initiated reconciliation (e.g. a dynamic
25
+ * value list pruning a vanished selection), so the output can't lag behind the new value.
26
+ */
27
+ setValue(id: string, value: unknown, forceSolve?: boolean): void;
24
28
  /** Explicit "calculate" — dispatches a solve with the current values. */
25
29
  solve(): void;
26
30
  /** Merges incoming values, then solves (auto) or marks dirty (manual). */
@@ -46,10 +46,16 @@ export function createSolveSession(args) {
46
46
  get isSolving() {
47
47
  return args.driver.isSolving;
48
48
  },
49
- setValue(id, value) {
49
+ setValue(id, value, forceSolve = false) {
50
50
  const { shouldSolve } = applyValueChange(state, id, value, currentSchema?.instanceSolve);
51
- if (shouldSolve)
51
+ if (shouldSolve || forceSolve) {
52
+ // A forced solve reconciles the deferred output; clear the dirty flags it raised.
53
+ if (forceSolve && !shouldSolve) {
54
+ state.pendingValues = {};
55
+ state.hasPendingChanges = false;
56
+ }
52
57
  dispatch();
58
+ }
53
59
  },
54
60
  solve() {
55
61
  dispatch();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "4.6.2-beta.1",
3
+ "version": "4.7.0",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -37,7 +37,7 @@
37
37
  "**/*.css"
38
38
  ],
39
39
  "peerDependencies": {
40
- "@selvajs/compute": "^2.1.0-beta.7",
40
+ "@selvajs/compute": "^2.1.0",
41
41
  "@sveltejs/kit": "^2",
42
42
  "bits-ui": "^2.18.0",
43
43
  "svelte": "^5",
@@ -71,9 +71,9 @@
71
71
  "rimraf": "^6.0.1",
72
72
  "svelte": "5.55.5",
73
73
  "tailwind-variants": "^3.2.2",
74
- "vitest": "^3.2.4",
75
- "@selvajs/schemas": "4.5.0",
76
- "@selvajs/config": "0.0.0"
74
+ "vitest": "^3.2.6",
75
+ "@selvajs/config": "0.0.1",
76
+ "@selvajs/schemas": "4.5.0"
77
77
  },
78
78
  "scripts": {
79
79
  "dev": "vite dev",
@@ -24,7 +24,7 @@
24
24
  isViewerFullscreen?: boolean;
25
25
  oncalculate?: () => void;
26
26
  values: Record<string, unknown>;
27
- onValueChange: (id: string, val: SupportedTypes) => void | Promise<void>;
27
+ onValueChange: (id: string, val: SupportedTypes, forceSolve?: boolean) => void | Promise<void>;
28
28
  onLoadValues?: (values: Record<string, unknown>) => void | Promise<void>;
29
29
  panelActions?: ActionButton[];
30
30
  showSaveButton?: boolean;
@@ -133,8 +133,8 @@
133
133
  });
134
134
  });
135
135
 
136
- function handleValueChange(id: string, val: unknown) {
137
- session.setValue(id, val);
136
+ function handleValueChange(id: string, val: unknown, forceSolve?: boolean) {
137
+ session.setValue(id, val, forceSolve);
138
138
  }
139
139
 
140
140
  function handleCalculate() {
@@ -33,7 +33,12 @@
33
33
  item: InputLayoutItem;
34
34
  value?: SupportedTypes;
35
35
  displayName?: string;
36
- onChange: (paramId: string, value: SupportedTypes) => void;
36
+ /**
37
+ * Commit a value. `forceSolve` requests a solve even in manual-solve mode — used for
38
+ * system-initiated reconciliation (e.g. pruning a vanished dynamic-list selection) where
39
+ * leaving the previous output on screen would misrepresent the now-changed input.
40
+ */
41
+ onChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
37
42
  disabled?: boolean;
38
43
  /** Runtime-computed options for a dynamic value list input (name -> value). */
39
44
  dynamicOptions?: Record<string, string>;
@@ -108,7 +113,10 @@
108
113
 
109
114
  // When a dynamic value list recomputes, a previously-selected value may no longer be an
110
115
  // 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.
116
+ // instead of rendering the orphaned raw value as its own label. This is a system-initiated
117
+ // change (the user didn't pick the new option), so force a solve — otherwise manual-solve
118
+ // schemas would keep the prior output on screen, making it look like the auto-picked option
119
+ // produced it.
112
120
  $effect(() => {
113
121
  if (!isDynamicValueListWidget(item) || !dynamicListHasOptions) return;
114
122
  const validValues = new Set(Object.values(dynamicListOptions));
@@ -116,9 +124,17 @@
116
124
  // directly from an effect trips Svelte's binding-ownership check.
117
125
  if (Array.isArray(value)) {
118
126
  const pruned = value.filter((v) => typeof v === 'string' && validValues.has(v));
119
- if (pruned.length !== value.length) onChange(item.paramId, pruned);
127
+ if (pruned.length !== value.length) {
128
+ // Fall back to first option when checklist becomes fully empty after pruning
129
+ onChange(
130
+ item.paramId,
131
+ pruned.length > 0 ? pruned : [Object.values(dynamicListOptions)[0]],
132
+ true
133
+ );
134
+ }
120
135
  } else if (typeof value === 'string' && value && !validValues.has(value)) {
121
- onChange(item.paramId, '');
136
+ // Fall back to first available option instead of clearing to empty
137
+ onChange(item.paramId, Object.values(dynamicListOptions)[0], true);
122
138
  }
123
139
  });
124
140
 
@@ -20,7 +20,7 @@
20
20
  values: Record<string, unknown>;
21
21
  collapsedGroups: Record<string, boolean>;
22
22
  onToggleGroup: (groupId: string) => void;
23
- onValueChange: (paramId: string, value: SupportedTypes) => void;
23
+ onValueChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
24
24
  inputs: SchemaInput[];
25
25
  outputs: DiscoveredOutput[];
26
26
  /** Computed value list options keyed by the target dynamic-value-list input id. */
@@ -10,7 +10,7 @@
10
10
  interface Props {
11
11
  schema: UISchema;
12
12
  values: Record<string, unknown>;
13
- onValueChange: (paramId: string, value: SupportedTypes) => void;
13
+ onValueChange: (paramId: string, value: SupportedTypes, forceSolve?: boolean) => void;
14
14
  /** Filter tabs by panel position. 'left' shows unpositioned + left tabs, 'right' shows right tabs only. */
15
15
  panelFilter?: 'left' | 'right';
16
16
  /** Externally request a specific tab to be active (e.g. from collapsed strip click) */
@@ -10,10 +10,17 @@
10
10
 
11
11
  let { scene = $bindable(), sceneVersion = 0 }: Props = $props();
12
12
 
13
+ // Viewer aids (grid, floor, measurement overlay, CSS2D label layer) are tagged with these ids by
14
+ // @selvajs/compute. They aren't scene content, so they're hidden from the object list.
15
+ const HELPER_IDS = new Set(['grid', 'floor', 'label-layer', 'measure']);
16
+
13
17
  const getSceneObjects = () => {
14
18
  void sceneVersion;
15
19
  return scene.children.filter(
16
- (obj) => !(obj instanceof THREE.Camera) && !(obj instanceof THREE.Light)
20
+ (obj) =>
21
+ !(obj instanceof THREE.Camera) &&
22
+ !(obj instanceof THREE.Light) &&
23
+ !HELPER_IDS.has(obj.userData?.id)
17
24
  );
18
25
  };
19
26
 
@@ -8,7 +8,8 @@
8
8
  type CameraController,
9
9
  type CameraProjection,
10
10
  type MeasureTool,
11
- type ViewPreset
11
+ type ViewPreset,
12
+ type Grid
12
13
  } from '@selvajs/compute/visualization';
13
14
  import {
14
15
  Maximize,
@@ -20,6 +21,7 @@
20
21
  Square,
21
22
  Frame,
22
23
  Ruler,
24
+ Grid3x3,
23
25
  Check,
24
26
  ChevronRight
25
27
  } from '@lucide/svelte';
@@ -35,6 +37,8 @@
35
37
  showFullscreenButton?: boolean;
36
38
  showSceneManager?: boolean;
37
39
  showToolsMenu?: boolean;
40
+ /** Expose the grid show/hide toggle in the tools menu. Grid starts hidden. */
41
+ showGridToggle?: boolean;
38
42
  enableMeshClick?: boolean;
39
43
  backgroundColor?: string;
40
44
  }
@@ -53,6 +57,7 @@
53
57
  showFullscreenButton: true,
54
58
  showSceneManager: true,
55
59
  showToolsMenu: true,
60
+ showGridToggle: true,
56
61
  enableMeshClick: true,
57
62
  backgroundColor: '#E6E6E6'
58
63
  };
@@ -74,6 +79,7 @@
74
79
  let controls: OrbitControls | null = null;
75
80
  let cameraController: CameraController | null = null;
76
81
  let measureTool: MeasureTool | null = null;
82
+ let grid: Grid | null = null;
77
83
  let fitToView: (() => void) | null = null;
78
84
  let viewerInitialized = false;
79
85
  let sceneVersion = $state(0);
@@ -81,6 +87,7 @@
81
87
  let sceneManagerOpen = $state(false);
82
88
  let projection: CameraProjection = $state('perspective');
83
89
  let measureActive = $state(false);
90
+ let gridVisible = $state(false);
84
91
  let selectedMeshMetadata: Record<string, any> | null = $state(null);
85
92
  let selectedMeshName: string | null = $state(null);
86
93
 
@@ -112,7 +119,8 @@
112
119
  const opts: ThreeInitializerOptions = {
113
120
  environment: { backgroundColor: config.backgroundColor },
114
121
  controls: {},
115
- grid: { enabled: config.showToolsMenu },
122
+ // Build the grid so it can be toggled at runtime, but start hidden (off by default).
123
+ grid: { enabled: config.showToolsMenu && config.showGridToggle },
116
124
  gizmo: { enabled: false },
117
125
  measure: { enabled: config.showToolsMenu },
118
126
  events: {
@@ -133,6 +141,8 @@
133
141
  controls = init.controls;
134
142
  cameraController = init.cameraController;
135
143
  measureTool = init.measureTool;
144
+ grid = init.grid;
145
+ grid?.setVisible(gridVisible);
136
146
  fitToView = init.fitToView;
137
147
  projection = init.cameraController.getProjection();
138
148
 
@@ -156,6 +166,12 @@
156
166
  measureTool.setEnabled(measureActive);
157
167
  }
158
168
 
169
+ function toggleGrid() {
170
+ if (!grid) return;
171
+ gridVisible = !gridVisible;
172
+ grid.setVisible(gridVisible);
173
+ }
174
+
159
175
  $effect(() => {
160
176
  if (scene && camera && controls) {
161
177
  updateScene(scene, meshes, camera, controls, viewerInitialized);
@@ -309,6 +325,20 @@
309
325
  {/if}
310
326
  </DropdownMenu.Item>
311
327
 
328
+ {#if config.showGridToggle}
329
+ <DropdownMenu.Item
330
+ closeOnSelect={false}
331
+ class="{itemClass} {gridVisible ? 'text-primary' : ''}"
332
+ onSelect={toggleGrid}
333
+ >
334
+ <Grid3x3 class="h-4 w-4" />
335
+ <span class="flex-1">Grid</span>
336
+ {#if gridVisible}
337
+ <Check class="h-4 w-4" />
338
+ {/if}
339
+ </DropdownMenu.Item>
340
+ {/if}
341
+
312
342
  <!-- Scene tools -->
313
343
  {#if config.showSceneManager || config.showScreenshotButton || config.showFullscreenButton}
314
344
  <DropdownMenu.Separator class="my-1 h-px bg-border" />
@@ -35,8 +35,12 @@ export interface SolveSession {
35
35
  readonly hasPendingChanges: boolean;
36
36
  readonly hasNeverSolved: boolean;
37
37
  readonly isSolving: boolean;
38
- /** Records a value change and dispatches a solve unless the schema is manual-solve. */
39
- setValue(id: string, value: unknown): void;
38
+ /**
39
+ * Records a value change and dispatches a solve unless the schema is manual-solve.
40
+ * `forceSolve` overrides manual mode for system-initiated reconciliation (e.g. a dynamic
41
+ * value list pruning a vanished selection), so the output can't lag behind the new value.
42
+ */
43
+ setValue(id: string, value: unknown, forceSolve?: boolean): void;
40
44
  /** Explicit "calculate" — dispatches a solve with the current values. */
41
45
  solve(): void;
42
46
  /** Merges incoming values, then solves (auto) or marks dirty (manual). */
@@ -100,9 +104,16 @@ export function createSolveSession(args: SolveSessionArgs): SolveSession {
100
104
  return args.driver.isSolving;
101
105
  },
102
106
 
103
- setValue(id, value) {
107
+ setValue(id, value, forceSolve = false) {
104
108
  const { shouldSolve } = applyValueChange(state, id, value, currentSchema?.instanceSolve);
105
- if (shouldSolve) dispatch();
109
+ if (shouldSolve || forceSolve) {
110
+ // A forced solve reconciles the deferred output; clear the dirty flags it raised.
111
+ if (forceSolve && !shouldSolve) {
112
+ state.pendingValues = {};
113
+ state.hasPendingChanges = false;
114
+ }
115
+ dispatch();
116
+ }
106
117
  },
107
118
 
108
119
  solve() {
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createSolveSession, type SolveDriver } from './createSolveSession.svelte';
3
+ import type { UISchema } from '@selvajs/schemas';
4
+
5
+ // Covers the reactive wrapper's dispatch decisions — specifically the `forceSolve` path
6
+ // added for dynamic-value-list reconciliation. The pure transition logic is pinned in
7
+ // solve-session-core.test.ts; this file pins how the rune shell turns those decisions into
8
+ // driver.solve() calls. Rune module, so it runs under the svelte vitest plugin.
9
+
10
+ function schema(instanceSolve?: boolean): UISchema {
11
+ return {
12
+ id: 'test',
13
+ name: 'Test',
14
+ instanceSolve,
15
+ inputs: [{ id: 'a', paramId: 'a', paramType: 'text', default: 'x' }],
16
+ outputs: [{ id: 'out' }],
17
+ layout: { type: 'flat', groups: [{ items: [] }] }
18
+ } as unknown as UISchema;
19
+ }
20
+
21
+ // Records every solve dispatch with the snapshot of values it received.
22
+ function recordingDriver(): SolveDriver & { solves: Record<string, unknown>[] } {
23
+ const solves: Record<string, unknown>[] = [];
24
+ return {
25
+ solves,
26
+ solve(values) {
27
+ solves.push(values);
28
+ },
29
+ cancel() {},
30
+ get isSolving() {
31
+ return false;
32
+ }
33
+ };
34
+ }
35
+
36
+ describe('createSolveSession.setValue', () => {
37
+ it('auto-solve mode dispatches on every value change', () => {
38
+ const driver = recordingDriver();
39
+ const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
40
+ session.setValue('a', 'y');
41
+ expect(driver.solves.length).toBe(1);
42
+ expect(driver.solves[0].a).toBe('y');
43
+ });
44
+
45
+ it('manual mode defers: marks pending, no dispatch', () => {
46
+ const driver = recordingDriver();
47
+ const session = createSolveSession({ schema: schema(false), scopeKey: 's', driver });
48
+ session.setValue('a', 'y');
49
+ expect(driver.solves.length).toBe(0);
50
+ expect(session.hasPendingChanges).toBe(true);
51
+ });
52
+
53
+ it('forceSolve dispatches even in manual mode and clears the dirty flags', () => {
54
+ const driver = recordingDriver();
55
+ const session = createSolveSession({ schema: schema(false), scopeKey: 's', driver });
56
+ // Reconcile a system-initiated change (e.g. a pruned dynamic-list selection).
57
+ session.setValue('a', 'reconciled', true);
58
+ expect(driver.solves.length).toBe(1);
59
+ expect(driver.solves[0].a).toBe('reconciled');
60
+ // The forced solve produces the matching output, so nothing is left pending.
61
+ expect(session.hasPendingChanges).toBe(false);
62
+ });
63
+
64
+ it('forceSolve in auto mode still dispatches exactly once', () => {
65
+ const driver = recordingDriver();
66
+ const session = createSolveSession({ schema: schema(true), scopeKey: 's', driver });
67
+ session.setValue('a', 'y', true);
68
+ expect(driver.solves.length).toBe(1);
69
+ });
70
+ });