@selvajs/ui 4.7.0-beta.2 → 4.7.1

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) */
@@ -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/dist/public.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { default as AppLayout } from './components/compute/AppLayout.svelte';
2
2
  export { default as ComputeApp } from './components/compute/ComputeApp.svelte';
3
+ export { default as Viewer, type ViewerConfig } from './components/viewer/Viewer.svelte';
3
4
  export { default as ErrorScreen } from './components/ErrorScreen.svelte';
4
5
  export { createSolveSession, createRequestResponseDriver, type SolveSession, type SolveSessionArgs, type SolveDriver, type SolveReporter } from './compute/createSolveSession.svelte';
5
6
  export type { ClientSlotArgs, ClientSlot } from './contexts/clientSlotContext.svelte';
package/dist/public.js CHANGED
@@ -20,6 +20,10 @@
20
20
  // Compute app (schema + viewer + solve controls composed into a runnable app)
21
21
  export { default as AppLayout } from './components/compute/AppLayout.svelte';
22
22
  export { default as ComputeApp } from './components/compute/ComputeApp.svelte';
23
+ // Standalone 3D viewer. Render Grasshopper meshes on their own, outside a
24
+ // ComputeApp host — external apps drive it directly with a `meshes` array and
25
+ // an optional `viewerConfig`.
26
+ export { default as Viewer } from './components/viewer/Viewer.svelte';
23
27
  // Full-screen states a host app renders
24
28
  export { default as ErrorScreen } from './components/ErrorScreen.svelte';
25
29
  // Solve Session seam (transport-agnostic value/lifecycle state machine + its
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "4.7.0-beta.2",
3
+ "version": "4.7.1",
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,8 +71,8 @@
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/config": "0.0.0",
74
+ "vitest": "^3.2.6",
75
+ "@selvajs/config": "0.0.1",
76
76
  "@selvajs/schemas": "4.5.0"
77
77
  },
78
78
  "scripts": {
@@ -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) */
@@ -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
+ });
package/src/lib/public.ts CHANGED
@@ -22,6 +22,11 @@
22
22
  export { default as AppLayout } from './components/compute/AppLayout.svelte';
23
23
  export { default as ComputeApp } from './components/compute/ComputeApp.svelte';
24
24
 
25
+ // Standalone 3D viewer. Render Grasshopper meshes on their own, outside a
26
+ // ComputeApp host — external apps drive it directly with a `meshes` array and
27
+ // an optional `viewerConfig`.
28
+ export { default as Viewer, type ViewerConfig } from './components/viewer/Viewer.svelte';
29
+
25
30
  // Full-screen states a host app renders
26
31
  export { default as ErrorScreen } from './components/ErrorScreen.svelte';
27
32