@selvajs/ui 4.3.0 → 4.4.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 (80) hide show
  1. package/dist/components/compute/AppLayout.svelte +1 -1
  2. package/dist/components/compute/AppLayout.svelte.d.ts +1 -1
  3. package/dist/components/compute/ComputeApp.svelte +47 -140
  4. package/dist/components/compute/ComputeApp.svelte.d.ts +0 -16
  5. package/dist/components/compute/ComputeMessagesDialog.svelte +125 -0
  6. package/dist/components/compute/ComputeMessagesDialog.svelte.d.ts +10 -0
  7. package/dist/components/compute/ParameterPresetManager.svelte +25 -33
  8. package/dist/components/layout/PageFooter.svelte +22 -144
  9. package/dist/components/preview/Group.svelte +6 -3
  10. package/dist/components/preview/ImageOutput.svelte +1 -13
  11. package/dist/components/preview/OutputDisplay.svelte +6 -6
  12. package/dist/components/preview/TabLayout.svelte +6 -5
  13. package/dist/components/preview/inputs/FileInput.svelte +10 -19
  14. package/dist/composables/useFooterItem.svelte.d.ts +10 -12
  15. package/dist/composables/useFooterItem.svelte.js +9 -16
  16. package/dist/compute/computeThrottle.svelte.js +5 -2
  17. package/dist/compute/createSolveSession.svelte.d.ts +56 -0
  18. package/dist/compute/createSolveSession.svelte.js +122 -0
  19. package/dist/compute/solve-session-core.d.ts +45 -0
  20. package/dist/compute/solve-session-core.js +69 -0
  21. package/dist/constants.d.ts +0 -2
  22. package/dist/constants.js +0 -2
  23. package/dist/contexts/footerContext.svelte.d.ts +17 -5
  24. package/dist/contexts/footerContext.svelte.js +10 -2
  25. package/dist/external/storage.js +7 -23
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.js +1 -0
  28. package/dist/schema/param-exporter.d.ts +25 -1
  29. package/dist/schema/param-exporter.js +30 -27
  30. package/dist/schema/traversal.d.ts +1 -0
  31. package/dist/schema/traversal.js +5 -0
  32. package/dist/schema/visibility-rules.d.ts +12 -0
  33. package/dist/schema/visibility-rules.js +20 -0
  34. package/dist/types/solveFn.d.ts +1 -2
  35. package/dist/utils/file-download.d.ts +5 -17
  36. package/dist/utils/file-download.js +12 -19
  37. package/dist/utils/randomId.js +3 -19
  38. package/package.json +11 -27
  39. package/src/lib/components/compute/AppLayout.svelte +1 -1
  40. package/src/lib/components/compute/ComputeApp.svelte +47 -140
  41. package/src/lib/components/compute/ComputeMessagesDialog.svelte +125 -0
  42. package/src/lib/components/compute/ParameterPresetManager.svelte +25 -33
  43. package/src/lib/components/layout/PageFooter.svelte +22 -144
  44. package/src/lib/components/preview/Group.svelte +6 -3
  45. package/src/lib/components/preview/ImageOutput.svelte +1 -13
  46. package/src/lib/components/preview/OutputDisplay.svelte +6 -6
  47. package/src/lib/components/preview/TabLayout.svelte +6 -5
  48. package/src/lib/components/preview/inputs/FileInput.svelte +10 -19
  49. package/src/lib/composables/useFooterItem.svelte.ts +25 -25
  50. package/src/lib/compute/computeThrottle.svelte.ts +5 -2
  51. package/src/lib/compute/computeThrottle.test.ts +106 -0
  52. package/src/lib/compute/createSolveSession.svelte.ts +188 -0
  53. package/src/lib/compute/solve-session-core.test.ts +153 -0
  54. package/src/lib/compute/solve-session-core.ts +102 -0
  55. package/src/lib/constants.ts +0 -2
  56. package/src/lib/contexts/footerContext.svelte.ts +29 -14
  57. package/src/lib/external/storage.test.ts +99 -0
  58. package/src/lib/external/storage.ts +6 -22
  59. package/src/lib/index.ts +1 -0
  60. package/src/lib/schema/param-exporter.test.ts +136 -0
  61. package/src/lib/schema/param-exporter.ts +44 -30
  62. package/src/lib/schema/traversal.test.ts +92 -0
  63. package/src/lib/schema/traversal.ts +5 -0
  64. package/src/lib/schema/visibility-rules.test.ts +173 -0
  65. package/src/lib/schema/visibility-rules.ts +25 -0
  66. package/src/lib/types/solveFn.ts +1 -1
  67. package/src/lib/utils/file-download.ts +13 -20
  68. package/src/lib/utils/randomId.ts +3 -19
  69. package/dist/components/compute/ComputeMessages.svelte +0 -169
  70. package/dist/components/compute/ComputeMessages.svelte.d.ts +0 -7
  71. package/dist/components/compute/index.d.ts +0 -5
  72. package/dist/components/compute/index.js +0 -5
  73. package/dist/components/preview/index.d.ts +0 -3
  74. package/dist/components/preview/index.js +0 -4
  75. package/dist/components/viewer/index.d.ts +0 -3
  76. package/dist/components/viewer/index.js +0 -3
  77. package/src/lib/components/compute/ComputeMessages.svelte +0 -169
  78. package/src/lib/components/compute/index.ts +0 -5
  79. package/src/lib/components/preview/index.ts +0 -4
  80. package/src/lib/components/viewer/index.ts +0 -3
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { validateSavedState, extractLoadableValues, loadPreset } from './param-exporter';
3
+ import type { UISchema, ParameterPreset } from '@selvajs/schemas';
4
+
5
+ // These tests cover the load path a user hits when restoring a preset against a
6
+ // schema that has drifted. The severity of each mismatch decides whether the
7
+ // preset can load at all (error blocks, warning allows), so those are the cases
8
+ // worth locking in — not the happy path.
9
+
10
+ function schema(over: Partial<UISchema> = {}): UISchema {
11
+ return {
12
+ id: 'schema-1',
13
+ documentId: 'doc-1',
14
+ inputs: [
15
+ { id: 'a', nickname: 'A', paramType: 'number' },
16
+ { id: 'b', nickname: 'B', paramType: 'number' }
17
+ ],
18
+ ...over
19
+ } as unknown as UISchema;
20
+ }
21
+
22
+ function preset(over: Partial<ParameterPreset> = {}): ParameterPreset {
23
+ return {
24
+ id: 'p1',
25
+ name: 'My Preset',
26
+ schemaId: 'schema-1',
27
+ documentId: 'doc-1',
28
+ parameters: [
29
+ { paramId: 'a', nickname: 'A', value: 1 },
30
+ { paramId: 'b', nickname: 'B', value: 2 }
31
+ ],
32
+ ...over
33
+ } as unknown as ParameterPreset;
34
+ }
35
+
36
+ describe('validateSavedState', () => {
37
+ it('document-ID mismatch is a blocking error', () => {
38
+ const result = validateSavedState(preset({ documentId: 'other-doc' }), schema());
39
+ expect(result.canLoad).toBe(false);
40
+ expect(result.issues.some((i) => i.severity === 'error' && i.paramId === '__document__')).toBe(
41
+ true
42
+ );
43
+ });
44
+
45
+ it('schema-ID change is a warning that still allows loading', () => {
46
+ const result = validateSavedState(preset({ schemaId: 'old-schema' }), schema());
47
+ expect(result.canLoad).toBe(true);
48
+ expect(result.isValid).toBe(false);
49
+ expect(result.issues.some((i) => i.severity === 'warning' && i.paramId === '__schema__')).toBe(
50
+ true
51
+ );
52
+ });
53
+
54
+ it('a parameter missing from the schema is a blocking error', () => {
55
+ const p = preset({
56
+ parameters: [
57
+ { paramId: 'a', nickname: 'A', value: 1 },
58
+ { paramId: 'gone', nickname: 'Gone', value: 9 }
59
+ ] as ParameterPreset['parameters']
60
+ });
61
+ const result = validateSavedState(p, schema());
62
+ expect(result.canLoad).toBe(false);
63
+ expect(result.issues.some((i) => i.severity === 'error' && i.paramId === 'gone')).toBe(true);
64
+ });
65
+
66
+ it('a renamed nickname is a warning, not a block', () => {
67
+ const renamed = schema({
68
+ inputs: [
69
+ { id: 'a', nickname: 'A renamed', paramType: 'number' },
70
+ { id: 'b', nickname: 'B', paramType: 'number' }
71
+ ]
72
+ } as unknown as Partial<UISchema>);
73
+ const result = validateSavedState(preset(), renamed);
74
+ expect(result.canLoad).toBe(true);
75
+ expect(result.issues.some((i) => i.severity === 'warning' && i.paramId === 'a')).toBe(true);
76
+ });
77
+
78
+ it('a fully matching preset is valid with no issues', () => {
79
+ const result = validateSavedState(preset(), schema());
80
+ expect(result).toEqual({ isValid: true, issues: [], canLoad: true });
81
+ });
82
+ });
83
+
84
+ describe('extractLoadableValues', () => {
85
+ it('drops params that no longer exist in the schema, keeps the rest', () => {
86
+ const p = preset({
87
+ parameters: [
88
+ { paramId: 'a', nickname: 'A', value: 1 },
89
+ { paramId: 'gone', nickname: 'Gone', value: 9 } // not in schema
90
+ ] as ParameterPreset['parameters']
91
+ });
92
+ expect(extractLoadableValues(p, schema())).toEqual({ a: 1 });
93
+ });
94
+
95
+ it('keeps a warned-but-valid param (nickname drift) on load', () => {
96
+ const renamed = schema({
97
+ inputs: [
98
+ { id: 'a', nickname: 'A renamed', paramType: 'number' },
99
+ { id: 'b', nickname: 'B', paramType: 'number' }
100
+ ]
101
+ } as unknown as Partial<UISchema>);
102
+ expect(extractLoadableValues(preset(), renamed)).toEqual({ a: 1, b: 2 });
103
+ });
104
+ });
105
+
106
+ describe('loadPreset', () => {
107
+ it('fuses validation and extraction: valid preset loads cleanly', () => {
108
+ const result = loadPreset(preset(), schema());
109
+ expect(result.isValid).toBe(true);
110
+ expect(result.canLoad).toBe(true);
111
+ expect(result.issues).toEqual([]);
112
+ expect(result.values).toEqual({ a: 1, b: 2 });
113
+ });
114
+
115
+ it('blocks on a document mismatch but still returns loadable values', () => {
116
+ const result = loadPreset(preset({ documentId: 'other-doc' }), schema());
117
+ expect(result.canLoad).toBe(false);
118
+ expect(result.issues.some((i) => i.severity === 'error')).toBe(true);
119
+ // values are still computed (the caller decides whether to apply them)
120
+ expect(result.values).toEqual({ a: 1, b: 2 });
121
+ });
122
+
123
+ it('allows load with warnings (schema drift) and drops missing params', () => {
124
+ const p = preset({
125
+ parameters: [
126
+ { paramId: 'a', nickname: 'A', value: 1 },
127
+ { paramId: 'gone', nickname: 'Gone', value: 9 }
128
+ ] as ParameterPreset['parameters'],
129
+ schemaId: 'old-schema'
130
+ });
131
+ const result = loadPreset(p, schema());
132
+ expect(result.isValid).toBe(false);
133
+ expect(result.canLoad).toBe(false); // missing param is an error
134
+ expect(result.values).toEqual({ a: 1 });
135
+ });
136
+ });
@@ -1,5 +1,5 @@
1
- import { APP_DEFAULTS } from '../constants';
2
1
  import { randomId } from '../utils/randomId';
2
+ import { getGroups } from '@selvajs/schemas';
3
3
  import type {
4
4
  UISchema,
5
5
  ParameterPreset,
@@ -7,13 +7,6 @@ import type {
7
7
  ValidationIssueMessage
8
8
  } from '@selvajs/schemas';
9
9
 
10
- function getGroups(schema: UISchema) {
11
- if (!schema.layout) return [];
12
- if (schema.layout.type === 'tabbed') return schema.layout.tabs.flatMap((t) => t.groups ?? []);
13
- if (schema.layout.type === 'flat') return schema.layout.groups ?? [];
14
- return [];
15
- }
16
-
17
10
  export function createSavedState(
18
11
  schema: UISchema,
19
12
  currentValues: Record<string, unknown>,
@@ -113,43 +106,64 @@ export function validateSavedState(
113
106
  return { isValid: issues.length === 0, issues, canLoad: !hasErrors };
114
107
  }
115
108
 
109
+ /**
110
+ * The subset of a preset's parameters that can safely be applied to the current schema:
111
+ * every param that still exists as an input. A param that no longer exists is dropped
112
+ * (it's the same condition `validateSavedState` flags as an error), so this derives its
113
+ * own "loadable" rule from the schema rather than re-reading validation issues — no
114
+ * coupling to how sentinel ids are encoded.
115
+ */
116
116
  export function extractLoadableValues(
117
117
  savedState: ParameterPreset,
118
- currentSchema: UISchema,
119
- validation: ReturnType<typeof validateSavedState>
118
+ currentSchema: UISchema
120
119
  ): Record<string, unknown> {
121
- const errorParamIds = new Set(
122
- validation.issues
123
- .filter((i) => i.severity === 'error' && !i.paramId.startsWith('__'))
124
- .map((i) => i.paramId)
125
- );
126
-
127
120
  const values: Record<string, unknown> = {};
128
121
  for (const paramState of savedState.parameters) {
129
122
  const exists = currentSchema.inputs.some((i) => i.id === paramState.paramId);
130
- if (!errorParamIds.has(paramState.paramId) && exists) {
123
+ if (exists) {
131
124
  values[paramState.paramId] = paramState.value;
132
125
  }
133
126
  }
134
127
  return values;
135
128
  }
136
129
 
130
+ /** The full outcome of loading a preset: what to apply, plus the diagnostics to surface. */
131
+ export interface PresetLoadResult {
132
+ /** Values safe to apply now (params that still exist). */
133
+ values: Record<string, unknown>;
134
+ /** All validation issues (errors + warnings) for the load dialog. */
135
+ issues: ValidationIssueMessage[];
136
+ /** No issues at all — load silently. */
137
+ isValid: boolean;
138
+ /** No blocking errors — load is allowed (warnings are fine). */
139
+ canLoad: boolean;
140
+ }
141
+
142
+ /**
143
+ * Single entry point for applying a preset: validates against the current schema and, in
144
+ * the same pass, computes the loadable values. Callers get one object instead of
145
+ * threading a validation result back into a separate extraction step.
146
+ */
147
+ export function loadPreset(savedState: ParameterPreset, currentSchema: UISchema): PresetLoadResult {
148
+ const validation = validateSavedState(savedState, currentSchema);
149
+ return {
150
+ values: extractLoadableValues(savedState, currentSchema),
151
+ issues: validation.issues,
152
+ isValid: validation.isValid,
153
+ canLoad: validation.canLoad
154
+ };
155
+ }
156
+
137
157
  export function exportStateAsJson(savedState: ParameterPreset): void {
138
158
  const blob = new Blob([JSON.stringify(savedState, null, 2)], { type: 'application/json' });
139
- const url = URL.createObjectURL(blob);
159
+ const safeName = savedState.name.replace(/[^a-z0-9]/gi, '_');
160
+ const date = savedState.timestamp.split('T')[0].replace(/-/g, '_');
161
+
140
162
  const link = document.createElement('a');
141
- link.href = url;
142
- link.download = `${savedState.name.replace(/[^a-z0-9]/gi, '_')}_${savedState.timestamp.split('T')[0].replace(/-/g, '_')}.sps`;
143
- link.style.display = 'none';
144
- document.body.appendChild(link);
145
-
146
- setTimeout(() => {
147
- link.click();
148
- setTimeout(() => {
149
- document.body.removeChild(link);
150
- URL.revokeObjectURL(url);
151
- }, APP_DEFAULTS.TIMEOUTS.PARAM_EXPORT_DELAY);
152
- }, 0);
163
+ link.href = URL.createObjectURL(blob);
164
+ link.download = `${safeName}_${date}.sps`;
165
+ link.click();
166
+ URL.revokeObjectURL(link.href);
153
167
  }
154
168
 
155
169
  export async function importStateFromJson(file: File): Promise<ParameterPreset> {
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getGroups, getLayoutItems, getInputItems } from '@selvajs/schemas';
3
+ import type { UISchema } from '@selvajs/schemas';
4
+
5
+ // These pin the layout-union discrimination (tabbed vs flat) and the defensive contract
6
+ // (missing layout / groups / items yield empty, never throw), since this module is the
7
+ // single place every caller relies on to walk a schema.
8
+
9
+ const input = (id: string, source?: { kind: string }) =>
10
+ ({ type: 'input', paramId: id, displayName: id, ...(source ? { source } : {}) }) as never;
11
+ const linebreak = (id: string) => ({ type: 'linebreak', id }) as never;
12
+ const group = (id: string, items: unknown[]) => ({ id, label: id, items }) as never;
13
+
14
+ function schema(layout: unknown): UISchema {
15
+ return { layout } as unknown as UISchema;
16
+ }
17
+
18
+ describe('getGroups — layout discrimination', () => {
19
+ it('flattens tabs into a single group list, tab order preserved', () => {
20
+ const s = schema({
21
+ type: 'tabbed',
22
+ tabs: [
23
+ { id: 't1', label: 't1', groups: [group('g1', []), group('g2', [])] },
24
+ { id: 't2', label: 't2', groups: [group('g3', [])] }
25
+ ]
26
+ });
27
+ expect(getGroups(s).map((g) => g.id)).toEqual(['g1', 'g2', 'g3']);
28
+ });
29
+
30
+ it('returns flat groups directly', () => {
31
+ const s = schema({ type: 'flat', groups: [group('g1', []), group('g2', [])] });
32
+ expect(getGroups(s).map((g) => g.id)).toEqual(['g1', 'g2']);
33
+ });
34
+ });
35
+
36
+ describe('getGroups — defensive contract', () => {
37
+ it('returns [] when layout is missing', () => {
38
+ expect(getGroups({} as UISchema)).toEqual([]);
39
+ });
40
+
41
+ it('returns [] for an unknown layout type', () => {
42
+ expect(getGroups(schema({ type: 'mystery' }))).toEqual([]);
43
+ });
44
+
45
+ it('tolerates missing tabs / groups arrays', () => {
46
+ expect(getGroups(schema({ type: 'tabbed' }))).toEqual([]);
47
+ expect(getGroups(schema({ type: 'flat' }))).toEqual([]);
48
+ });
49
+
50
+ it('tolerates a tab with no groups', () => {
51
+ const s = schema({ type: 'tabbed', tabs: [{ id: 't1', label: 't1' }] });
52
+ expect(getGroups(s)).toEqual([]);
53
+ });
54
+ });
55
+
56
+ describe('getLayoutItems', () => {
57
+ it('collects items across all groups', () => {
58
+ const s = schema({
59
+ type: 'flat',
60
+ groups: [group('g1', [input('a'), linebreak('lb')]), group('g2', [input('b')])]
61
+ });
62
+ expect(getLayoutItems(s).map((i) => (i.type === 'linebreak' ? i.id : i.paramId))).toEqual([
63
+ 'a',
64
+ 'lb',
65
+ 'b'
66
+ ]);
67
+ });
68
+
69
+ it('tolerates a group with no items', () => {
70
+ const s = schema({ type: 'flat', groups: [{ id: 'g1', label: 'g1' }] });
71
+ expect(getLayoutItems(s)).toEqual([]);
72
+ });
73
+ });
74
+
75
+ describe('getInputItems', () => {
76
+ it('keeps only input items, dropping outputs and linebreaks', () => {
77
+ const s = schema({
78
+ type: 'flat',
79
+ groups: [group('g1', [input('a'), linebreak('lb'), { type: 'output', paramId: 'out' }])]
80
+ });
81
+ expect(getInputItems(s).map((i) => i.paramId)).toEqual(['a']);
82
+ });
83
+
84
+ it('preserves the source field so client-input filters work', () => {
85
+ const s = schema({
86
+ type: 'flat',
87
+ groups: [group('g1', [input('a', { kind: 'client' }), input('b')])]
88
+ });
89
+ const clients = getInputItems(s).filter((i) => i.source?.kind === 'client');
90
+ expect(clients.map((i) => i.paramId)).toEqual(['a']);
91
+ });
92
+ });
@@ -0,0 +1,5 @@
1
+ // Schema layout traversal now lives in @selvajs/schemas (next to the types it walks) so
2
+ // any package depending on the schema can traverse it without pulling in @selvajs/ui.
3
+ // Re-exported here to keep it part of @selvajs/ui's published surface for existing
4
+ // consumers.
5
+ export { getGroups, getLayoutItems, getInputItems, type InputLayoutItem } from '@selvajs/schemas';
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ evaluateRule,
4
+ evaluateVisibility,
5
+ evaluateGroupVisibility,
6
+ buildVisibilityMap,
7
+ itemKey
8
+ } from './visibility-rules';
9
+ import type { GroupVisibilityCondition, LayoutItem, VisibilityRule } from '@selvajs/schemas';
10
+
11
+ // These tests pin the non-obvious branches: operator edge cases that are easy to
12
+ // break in a refactor, and the action/short-circuit precedence in evaluateVisibility.
13
+ // Trivial passthroughs (equals true === true) are omitted on purpose.
14
+
15
+ // `operator` is widened to string so tests can exercise the unknown-operator path.
16
+ function rule(partial: {
17
+ operator: string;
18
+ paramId: string;
19
+ value?: unknown;
20
+ values?: unknown[];
21
+ }): VisibilityRule {
22
+ return partial as unknown as VisibilityRule;
23
+ }
24
+
25
+ describe('evaluateRule — operator edge cases', () => {
26
+ it('between requires exactly two values and is inclusive', () => {
27
+ const r = rule({ operator: 'between', paramId: 'x', values: [10, 20] });
28
+ expect(evaluateRule(r, { x: 10 })).toBe(true); // lower bound inclusive
29
+ expect(evaluateRule(r, { x: 20 })).toBe(true); // upper bound inclusive
30
+ expect(evaluateRule(r, { x: 21 })).toBe(false);
31
+ // Wrong arity must fail closed, not throw or pass.
32
+ expect(evaluateRule(rule({ operator: 'between', paramId: 'x', values: [10] }), { x: 15 })).toBe(
33
+ false
34
+ );
35
+ });
36
+
37
+ it('matches returns false for an invalid regex instead of throwing', () => {
38
+ const r = rule({ operator: 'matches', paramId: 'x', value: '(' });
39
+ expect(evaluateRule(r, { x: 'anything' })).toBe(false);
40
+ });
41
+
42
+ it('contains / containsAny coerce scalars and arrays through toStringArray', () => {
43
+ // scalar value is treated as a single-element array
44
+ expect(evaluateRule(rule({ operator: 'contains', paramId: 'x', value: '5' }), { x: 5 })).toBe(
45
+ true
46
+ );
47
+ expect(
48
+ evaluateRule(rule({ operator: 'containsAny', paramId: 'x', values: ['a', 'b'] }), {
49
+ x: ['b', 'c']
50
+ })
51
+ ).toBe(true);
52
+ });
53
+
54
+ it('isEmpty treats empty string, null, and [] as empty but not a non-empty value', () => {
55
+ const empty = rule({ operator: 'isEmpty', paramId: 'x' });
56
+ expect(evaluateRule(empty, { x: '' })).toBe(true);
57
+ expect(evaluateRule(empty, { x: null })).toBe(true);
58
+ expect(evaluateRule(empty, { x: [] })).toBe(true);
59
+ expect(evaluateRule(empty, { x: '0' })).toBe(false);
60
+ });
61
+
62
+ it('unknown operator fails closed', () => {
63
+ expect(evaluateRule(rule({ operator: 'definitelyNotAnOp', paramId: 'x' }), { x: 1 })).toBe(
64
+ false
65
+ );
66
+ });
67
+ });
68
+
69
+ describe('evaluateVisibility — action & short-circuit precedence', () => {
70
+ const condition = {
71
+ rules: [{ operator: 'equals', paramId: 'mode', value: 'advanced' }],
72
+ mode: 'all'
73
+ };
74
+
75
+ it('explicit visible:false beats any visibility condition', () => {
76
+ const item = {
77
+ type: 'input',
78
+ paramId: 'p',
79
+ visible: false,
80
+ visibilityCondition: { ...condition, action: 'show' }
81
+ } as unknown as LayoutItem;
82
+ // Condition is met, but visible:false wins.
83
+ expect(evaluateVisibility(item, { mode: 'advanced' })).toEqual({
84
+ visible: false,
85
+ disabled: false
86
+ });
87
+ });
88
+
89
+ it('hide action inverts visibility and only carries defaultValue', () => {
90
+ const item = {
91
+ type: 'input',
92
+ paramId: 'p',
93
+ visibilityCondition: { ...condition, action: 'hide', defaultValue: 7 }
94
+ } as unknown as LayoutItem;
95
+ expect(evaluateVisibility(item, { mode: 'advanced' })).toEqual({
96
+ visible: false,
97
+ disabled: false,
98
+ defaultValue: 7
99
+ });
100
+ expect(evaluateVisibility(item, { mode: 'basic' }).visible).toBe(true);
101
+ });
102
+
103
+ it('disable action keeps item visible and only applies defaultValue when the condition is met', () => {
104
+ const item = {
105
+ type: 'input',
106
+ paramId: 'p',
107
+ visibilityCondition: { ...condition, action: 'disable', defaultValue: 'X' }
108
+ } as unknown as LayoutItem;
109
+ expect(evaluateVisibility(item, { mode: 'advanced' })).toEqual({
110
+ visible: true,
111
+ disabled: true,
112
+ defaultValue: 'X'
113
+ });
114
+ expect(evaluateVisibility(item, { mode: 'basic' })).toEqual({
115
+ visible: true,
116
+ disabled: false,
117
+ defaultValue: undefined
118
+ });
119
+ });
120
+
121
+ it('item without a condition is visible and enabled', () => {
122
+ const item = { type: 'input', paramId: 'p' } as unknown as LayoutItem;
123
+ expect(evaluateVisibility(item, {})).toEqual({ visible: true, disabled: false });
124
+ });
125
+ });
126
+
127
+ describe('buildVisibilityMap', () => {
128
+ const cond = {
129
+ rules: [{ operator: 'equals', paramId: 'mode', value: 'advanced' }],
130
+ mode: 'all'
131
+ };
132
+
133
+ it('keys each item by paramId (inputs/outputs) or id (linebreak) and matches evaluateVisibility', () => {
134
+ const items = [
135
+ { type: 'input', paramId: 'a' },
136
+ { type: 'linebreak', id: 'lb1' },
137
+ {
138
+ type: 'input',
139
+ paramId: 'b',
140
+ visibilityCondition: { ...cond, action: 'show' }
141
+ }
142
+ ] as unknown as LayoutItem[];
143
+ const values = { mode: 'basic' };
144
+
145
+ const map = buildVisibilityMap(items, values);
146
+ expect(map.a).toEqual(evaluateVisibility(items[0], values));
147
+ expect(map.lb1).toEqual(evaluateVisibility(items[1], values));
148
+ // 'b' shows only when mode === 'advanced'; here it's hidden.
149
+ expect(map.b.visible).toBe(false);
150
+ expect(map.b).toEqual(evaluateVisibility(items[2], values));
151
+ });
152
+
153
+ it('itemKey returns paramId for controls and id for linebreaks', () => {
154
+ expect(itemKey({ type: 'input', paramId: 'p1' } as unknown as LayoutItem)).toBe('p1');
155
+ expect(itemKey({ type: 'linebreak', id: 'lb' } as unknown as LayoutItem)).toBe('lb');
156
+ });
157
+ });
158
+
159
+ describe('evaluateGroupVisibility', () => {
160
+ it('hide action inverts, show is the default, no condition means visible', () => {
161
+ const cond = (action?: string): { visibilityCondition: GroupVisibilityCondition } => ({
162
+ visibilityCondition: {
163
+ action,
164
+ mode: 'all',
165
+ rules: [{ operator: 'equals', paramId: 'show', value: true }]
166
+ } as unknown as GroupVisibilityCondition
167
+ });
168
+ expect(evaluateGroupVisibility(cond('hide'), { show: true })).toBe(false);
169
+ expect(evaluateGroupVisibility(cond('show'), { show: true })).toBe(true);
170
+ expect(evaluateGroupVisibility(cond(), { show: true })).toBe(true);
171
+ expect(evaluateGroupVisibility({}, {})).toBe(true);
172
+ });
173
+ });
@@ -84,6 +84,31 @@ export function evaluateVisibility(
84
84
  return actionFn(met, defaultValue);
85
85
  }
86
86
 
87
+ /**
88
+ * Stable key for a layout item: `paramId` for inputs/outputs, `id` for linebreaks.
89
+ * Mirrors the key the renderers use in their `{#each}` blocks.
90
+ */
91
+ export function itemKey(item: LayoutItem): string {
92
+ return item.type === 'linebreak' ? item.id : item.paramId;
93
+ }
94
+
95
+ /**
96
+ * Evaluates every item's visibility once against the current values, keyed by itemKey.
97
+ * Callers that touch the same items more than once per render (column layout + cell
98
+ * render, plus the default-value sweep) read from this single map instead of
99
+ * re-evaluating per access — one source of truth for "what's visible right now".
100
+ */
101
+ export function buildVisibilityMap(
102
+ items: LayoutItem[],
103
+ values: Record<string, unknown>
104
+ ): Record<string, VisibilityResult> {
105
+ const map: Record<string, VisibilityResult> = {};
106
+ for (const item of items) {
107
+ map[itemKey(item)] = evaluateVisibility(item, values);
108
+ }
109
+ return map;
110
+ }
111
+
87
112
  export function evaluateGroupVisibility(
88
113
  group: { visibilityCondition?: GroupVisibilityCondition },
89
114
  values: Record<string, unknown>
@@ -6,7 +6,7 @@
6
6
  * @property errors - Optional array of error messages that occurred
7
7
  * @property warnings - Optional array of warning messages from the computation
8
8
  */
9
- interface SolveResult {
9
+ export interface SolveResult {
10
10
  outputs: Record<string, unknown>;
11
11
  meshes?: any[];
12
12
  errors?: string[];
@@ -1,12 +1,8 @@
1
- /**
2
- * Wrapper utilities for file downloads from Grasshopper outputs
3
- * Uses the core package implementation for file handling
4
- */
5
-
6
1
  import { downloadFileData, type FileData } from '@selvajs/compute';
2
+ import { SvelteMap } from 'svelte/reactivity';
7
3
  import { APP_DEFAULTS } from '../constants';
8
4
 
9
- const MIME_BY_EXT: Record<string, string> = {
5
+ export const MIME_BY_EXT: Record<string, string> = {
10
6
  '.png': 'image/png',
11
7
  '.jpg': 'image/jpeg',
12
8
  '.jpeg': 'image/jpeg',
@@ -45,10 +41,6 @@ function saveSingleFile(file: FileData): void {
45
41
  URL.revokeObjectURL(a.href);
46
42
  }
47
43
 
48
- /**
49
- * Download file(s) from Grasshopper outputs
50
- * Single files are downloaded directly, multiple files are packaged as ZIP
51
- */
52
44
  export async function downloadFiles(
53
45
  fileData: FileData | FileData[],
54
46
  fileName: string = 'grasshopper-output'
@@ -73,9 +65,6 @@ export async function downloadFiles(
73
65
  }
74
66
  }
75
67
 
76
- /**
77
- * Check if data is FileData
78
- */
79
68
  export function isFileData(data: unknown): data is FileData {
80
69
  return (
81
70
  typeof data === 'object' &&
@@ -86,9 +75,17 @@ export function isFileData(data: unknown): data is FileData {
86
75
  );
87
76
  }
88
77
 
89
- /**
90
- * Format file size for display
91
- */
78
+ export function groupMessages(messages: string[]): Array<{ message: string; count: number }> {
79
+ const grouped = new SvelteMap<string, number>();
80
+ for (const msg of messages) {
81
+ const baseMsg = msg.replace(/\([a-f0-9-]{36}\)$/i, '(...)').trim();
82
+ grouped.set(baseMsg, (grouped.get(baseMsg) ?? 0) + 1);
83
+ }
84
+ return Array.from(grouped.entries())
85
+ .map(([message, count]) => ({ message, count }))
86
+ .sort((a, b) => b.count - a.count);
87
+ }
88
+
92
89
  export function formatFileSize(bytes: number): string {
93
90
  if (bytes === 0) return '0 Bytes';
94
91
 
@@ -99,10 +96,6 @@ export function formatFileSize(bytes: number): string {
99
96
  return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
100
97
  }
101
98
 
102
- /**
103
- * Get approximate file size from base64 string
104
- */
105
99
  export function getBase64FileSize(base64: string): number {
106
- // Base64 encoded string is approximately 33% larger than original
107
100
  return Math.ceil((base64.length * 3) / 4);
108
101
  }
@@ -1,22 +1,6 @@
1
- // Generate a UUIDv4-shaped string in any browser context.
2
- //
3
- // `crypto.randomUUID()` requires a secure context (HTTPS or localhost).
4
- // Plain-HTTP deployments — common for first-time self-hosters trying things
5
- // out — get `crypto.randomUUID is not a function`, which is a confusing
6
- // browser-level error nobody can diagnose without devtools open.
7
- //
8
- // We try in order:
9
- // 1. crypto.randomUUID() — fastest path, available in secure contexts
10
- // 2. crypto.getRandomValues() — Web Crypto's lower-level entropy, works
11
- // in any context that ships Web Crypto
12
- // 3. Math.random() fallback — last resort; non-cryptographic, but the
13
- // IDs we generate here are only used as
14
- // collision-resistant keys for client-side
15
- // form rows, not as security tokens.
16
- //
17
- // Callers that need cryptographic randomness (tokens, secrets) must NOT use
18
- // this — they should fail loudly in insecure contexts instead of silently
19
- // downgrading.
1
+ // Falls back through crypto.randomUUID crypto.getRandomValues Math.random so
2
+ // plain-HTTP deployments don't crash. Math.random IDs are non-cryptographic —
3
+ // fine for collision-resistant UI keys, not for tokens or secrets.
20
4
  export function randomId(): string {
21
5
  const c = typeof crypto !== 'undefined' ? crypto : undefined;
22
6
  if (c?.randomUUID) return c.randomUUID();