@selvajs/ui 4.2.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 (85) 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 +54 -133
  4. package/dist/components/compute/ComputeApp.svelte.d.ts +2 -9
  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/InputControl.svelte +96 -72
  12. package/dist/components/preview/OutputDisplay.svelte +6 -6
  13. package/dist/components/preview/TabLayout.svelte +6 -5
  14. package/dist/components/preview/inputs/FileInput.svelte +10 -19
  15. package/dist/composables/useFooterItem.svelte.d.ts +10 -12
  16. package/dist/composables/useFooterItem.svelte.js +9 -16
  17. package/dist/compute/computeThrottle.svelte.js +5 -2
  18. package/dist/compute/createSolveSession.svelte.d.ts +56 -0
  19. package/dist/compute/createSolveSession.svelte.js +122 -0
  20. package/dist/compute/solve-session-core.d.ts +45 -0
  21. package/dist/compute/solve-session-core.js +69 -0
  22. package/dist/constants.d.ts +0 -2
  23. package/dist/constants.js +0 -2
  24. package/dist/contexts/clientSlotContext.svelte.d.ts +13 -0
  25. package/dist/contexts/clientSlotContext.svelte.js +8 -0
  26. package/dist/contexts/footerContext.svelte.d.ts +17 -5
  27. package/dist/contexts/footerContext.svelte.js +10 -2
  28. package/dist/external/storage.js +7 -23
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.js +2 -0
  31. package/dist/schema/param-exporter.d.ts +25 -1
  32. package/dist/schema/param-exporter.js +30 -27
  33. package/dist/schema/traversal.d.ts +1 -0
  34. package/dist/schema/traversal.js +5 -0
  35. package/dist/schema/visibility-rules.d.ts +12 -0
  36. package/dist/schema/visibility-rules.js +20 -0
  37. package/dist/types/solveFn.d.ts +1 -2
  38. package/dist/utils/file-download.d.ts +5 -17
  39. package/dist/utils/file-download.js +12 -19
  40. package/dist/utils/randomId.js +3 -19
  41. package/package.json +10 -26
  42. package/src/lib/components/compute/AppLayout.svelte +1 -1
  43. package/src/lib/components/compute/ComputeApp.svelte +54 -133
  44. package/src/lib/components/compute/ComputeMessagesDialog.svelte +125 -0
  45. package/src/lib/components/compute/ParameterPresetManager.svelte +25 -33
  46. package/src/lib/components/layout/PageFooter.svelte +22 -144
  47. package/src/lib/components/preview/Group.svelte +6 -3
  48. package/src/lib/components/preview/ImageOutput.svelte +1 -13
  49. package/src/lib/components/preview/InputControl.svelte +96 -72
  50. package/src/lib/components/preview/OutputDisplay.svelte +6 -6
  51. package/src/lib/components/preview/TabLayout.svelte +6 -5
  52. package/src/lib/components/preview/inputs/FileInput.svelte +10 -19
  53. package/src/lib/composables/useFooterItem.svelte.ts +25 -25
  54. package/src/lib/compute/computeThrottle.svelte.ts +5 -2
  55. package/src/lib/compute/computeThrottle.test.ts +106 -0
  56. package/src/lib/compute/createSolveSession.svelte.ts +188 -0
  57. package/src/lib/compute/solve-session-core.test.ts +153 -0
  58. package/src/lib/compute/solve-session-core.ts +102 -0
  59. package/src/lib/constants.ts +0 -2
  60. package/src/lib/contexts/clientSlotContext.svelte.ts +33 -0
  61. package/src/lib/contexts/footerContext.svelte.ts +29 -14
  62. package/src/lib/external/storage.test.ts +99 -0
  63. package/src/lib/external/storage.ts +6 -22
  64. package/src/lib/index.ts +2 -0
  65. package/src/lib/schema/param-exporter.test.ts +136 -0
  66. package/src/lib/schema/param-exporter.ts +44 -30
  67. package/src/lib/schema/traversal.test.ts +92 -0
  68. package/src/lib/schema/traversal.ts +5 -0
  69. package/src/lib/schema/visibility-rules.test.ts +173 -0
  70. package/src/lib/schema/visibility-rules.ts +25 -0
  71. package/src/lib/types/solveFn.ts +1 -1
  72. package/src/lib/utils/file-download.ts +13 -20
  73. package/src/lib/utils/randomId.ts +3 -19
  74. package/dist/components/compute/ComputeMessages.svelte +0 -169
  75. package/dist/components/compute/ComputeMessages.svelte.d.ts +0 -7
  76. package/dist/components/compute/index.d.ts +0 -5
  77. package/dist/components/compute/index.js +0 -5
  78. package/dist/components/preview/index.d.ts +0 -3
  79. package/dist/components/preview/index.js +0 -4
  80. package/dist/components/viewer/index.d.ts +0 -3
  81. package/dist/components/viewer/index.js +0 -3
  82. package/src/lib/components/compute/ComputeMessages.svelte +0 -169
  83. package/src/lib/components/compute/index.ts +0 -5
  84. package/src/lib/components/preview/index.ts +0 -4
  85. package/src/lib/components/viewer/index.ts +0 -3
@@ -1,10 +1,8 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import * as Dialog from '../primitives/dialog';
4
- import * as Collapsible from '../primitives/collapsible';
5
- import { CircleAlert, TriangleAlert, ChevronDown, ChevronRight } from '@lucide/svelte';
6
- import { SvelteMap } from 'svelte/reactivity';
3
+ import { CircleAlert, TriangleAlert } from '@lucide/svelte';
7
4
  import { useFooter } from '../../contexts/footerContext.svelte';
5
+ import ComputeMessagesDialog from '../compute/ComputeMessagesDialog.svelte';
8
6
  import FooterItemRenderer from './FooterItemRenderer.svelte';
9
7
 
10
8
  interface Props {
@@ -48,31 +46,10 @@
48
46
  );
49
47
 
50
48
  let _currentYear = new Date().getFullYear();
51
- let dialogOpen = $state(false);
52
- let showErrors = $state(true);
53
- let showWarnings = $state(false);
54
49
 
55
50
  const hasMessages = $derived(errors.length > 0 || warnings.length > 0);
56
51
  const totalCount = $derived(errors.length + warnings.length);
57
52
 
58
- function groupMessages(messages: string[]): Array<{ message: string; count: number }> {
59
- const grouped = new SvelteMap<string, number>();
60
-
61
- for (const msg of messages) {
62
- // Extract the base message without the component GUID
63
- const baseMsg = msg.replace(/\([a-f0-9-]{36}\)$/i, '(...)').trim();
64
- const currentCount = grouped.get(baseMsg) || 0;
65
- grouped.set(baseMsg, currentCount + 1);
66
- }
67
-
68
- return Array.from(grouped.entries())
69
- .map(([message, count]) => ({ message, count }))
70
- .sort((a, b) => b.count - a.count);
71
- }
72
-
73
- const groupedErrors = $derived(groupMessages(errors));
74
- const groupedWarnings = $derived(groupMessages(warnings));
75
-
76
53
  const copyrightLine = $derived(
77
54
  footerText
78
55
  ? footerText.replace('{name}', copyrightName).replace('{year}', String(_currentYear))
@@ -91,131 +68,32 @@
91
68
  {/each}
92
69
 
93
70
  {#if hasMessages}
94
- <Dialog.Root bind:open={dialogOpen}>
95
- <Dialog.Trigger
96
- class="gap-1.5 px-2 py-1 rounded flex cursor-pointer items-center transition-colors hover:bg-muted {errors.length >
97
- 0
98
- ? 'text-destructive hover:bg-destructive/10'
99
- : 'text-warning hover:bg-warning/10'}"
100
- title={`${totalCount} ${totalCount === 1 ? 'issue' : 'issues'}`}
101
- >
102
- {#if errors.length > 0}
103
- <CircleAlert class="h-3.5 w-3.5" />
104
- <span class="font-medium">{errors.length} Error{errors.length !== 1 ? 's' : ''}</span>
105
- {/if}
106
-
107
- {#if warnings.length > 0}
71
+ <ComputeMessagesDialog {errors} {warnings}>
72
+ {#snippet trigger()}
73
+ <div
74
+ class="gap-1.5 px-2 py-1 rounded flex cursor-pointer items-center transition-colors hover:bg-muted {errors.length >
75
+ 0
76
+ ? 'text-destructive hover:bg-destructive/10'
77
+ : 'text-warning hover:bg-warning/10'}"
78
+ title={`${totalCount} ${totalCount === 1 ? 'issue' : 'issues'}`}
79
+ >
108
80
  {#if errors.length > 0}
109
- <span class="text-border">•</span>
81
+ <CircleAlert class="h-3.5 w-3.5" />
82
+ <span class="font-medium">{errors.length} Error{errors.length !== 1 ? 's' : ''}</span>
110
83
  {/if}
111
- <TriangleAlert class="h-3.5 w-3.5" />
112
- <span class="font-medium"
113
- >{warnings.length} Warning{warnings.length !== 1 ? 's' : ''}</span
114
- >
115
- {/if}
116
- </Dialog.Trigger>
117
84
 
118
- <Dialog.Content class="max-w-2xl max-h-[80vh]">
119
- <Dialog.Header>
120
- <Dialog.Title class="gap-2 flex items-center">
121
- <CircleAlert class="h-5 w-5" />
122
- Compute Messages
123
- </Dialog.Title>
124
- <Dialog.Description>
125
- {totalCount}
126
- {totalCount === 1 ? 'issue' : 'issues'} detected during solve
127
- </Dialog.Description>
128
- </Dialog.Header>
129
-
130
- <div class="space-y-3 pr-2 overflow-y-auto" style="max-height: calc(80vh - 180px);">
131
- <!-- Errors Section -->
132
- {#if errors.length > 0}
133
- <Collapsible.Root bind:open={showErrors}>
134
- <div class="overflow-hidden rounded-lg border border-destructive bg-card">
135
- <div class="px-4 py-3 flex items-center">
136
- <Collapsible.Trigger
137
- class="-mx-4 -my-3 gap-3 px-4 py-3 flex flex-1 items-center text-left transition-colors hover:bg-destructive/5"
138
- >
139
- {#if showErrors}
140
- <ChevronDown class="h-4 w-4 shrink-0 text-destructive" />
141
- {:else}
142
- <ChevronRight class="h-4 w-4 shrink-0 text-destructive" />
143
- {/if}
144
- <CircleAlert class="h-4 w-4 shrink-0 text-destructive" />
145
- <span class="text-sm font-medium text-destructive">
146
- {errors.length === 1 ? '1 Error' : `${errors.length} Errors`}
147
- </span>
148
- </Collapsible.Trigger>
149
- </div>
150
-
151
- <Collapsible.Content class="space-y-0">
152
- <div
153
- class="max-h-60 px-4 py-3 overflow-y-auto border-t border-destructive bg-card"
154
- >
155
- <ul class="space-y-2">
156
- {#each groupedErrors as { message, count } (message)}
157
- <li class="gap-2 text-sm flex text-destructive/90">
158
- <span class="shrink-0">•</span>
159
- <span class="flex-1">
160
- {message}
161
- {#if count > 1}
162
- <span class="ml-1 font-medium text-destructive/70">×{count} </span>
163
- {/if}
164
- </span>
165
- </li>
166
- {/each}
167
- </ul>
168
- </div>
169
- </Collapsible.Content>
170
- </div>
171
- </Collapsible.Root>
172
- {/if}
173
-
174
- <!-- Warnings Section -->
175
85
  {#if warnings.length > 0}
176
- <Collapsible.Root bind:open={showWarnings}>
177
- <div class="overflow-hidden rounded-lg border border-warning/50 bg-card">
178
- <div class="px-4 py-3 flex items-center">
179
- <Collapsible.Trigger
180
- class="-mx-4 -my-3 gap-3 px-4 py-3 flex flex-1 items-center text-left transition-colors hover:bg-warning/5"
181
- >
182
- {#if showWarnings}
183
- <ChevronDown class="h-4 w-4 shrink-0 text-warning" />
184
- {:else}
185
- <ChevronRight class="h-4 w-4 shrink-0 text-warning" />
186
- {/if}
187
- <TriangleAlert class="h-4 w-4 shrink-0 text-warning" />
188
- <span class="text-sm font-medium text-warning">
189
- {warnings.length === 1 ? '1 Warning' : `${warnings.length} Warnings`}
190
- </span>
191
- </Collapsible.Trigger>
192
- </div>
193
-
194
- <Collapsible.Content class="space-y-0">
195
- <div
196
- class="max-h-60 px-4 py-3 overflow-y-auto border-t border-warning/50 bg-card"
197
- >
198
- <ul class="space-y-2">
199
- {#each groupedWarnings as { message, count } (message)}
200
- <li class="gap-2 text-sm flex text-muted-foreground">
201
- <span class="shrink-0">•</span>
202
- <span class="flex-1">
203
- {message}
204
- {#if count > 1}
205
- <span class="ml-1 font-medium text-warning/70">×{count}</span>
206
- {/if}
207
- </span>
208
- </li>
209
- {/each}
210
- </ul>
211
- </div>
212
- </Collapsible.Content>
213
- </div>
214
- </Collapsible.Root>
86
+ {#if errors.length > 0}
87
+ <span class="text-border">•</span>
88
+ {/if}
89
+ <TriangleAlert class="h-3.5 w-3.5" />
90
+ <span class="font-medium"
91
+ >{warnings.length} Warning{warnings.length !== 1 ? 's' : ''}</span
92
+ >
215
93
  {/if}
216
94
  </div>
217
- </Dialog.Content>
218
- </Dialog.Root>
95
+ {/snippet}
96
+ </ComputeMessagesDialog>
219
97
  {/if}
220
98
 
221
99
  {#if children}
@@ -3,7 +3,7 @@
3
3
  import type { Snippet } from 'svelte';
4
4
  import * as Card from '../primitives/card';
5
5
  import { ChevronDown } from '@lucide/svelte';
6
- import { evaluateVisibility } from '../../schema/visibility-rules';
6
+ import { buildVisibilityMap, itemKey } from '../../schema/visibility-rules';
7
7
 
8
8
  interface Props {
9
9
  label: string;
@@ -44,6 +44,9 @@
44
44
  }
45
45
  }
46
46
 
47
+ // One visibility evaluation per item per render; columnStarts and gridItem both read it.
48
+ const visibilityMap = $derived(buildVisibilityMap(items, values));
49
+
47
50
  /**
48
51
  * Compute the column position (0-indexed) where each item starts, accounting
49
52
  * for spans and linebreak resets. Items hidden by visibility don't consume
@@ -58,7 +61,7 @@
58
61
  col = 0;
59
62
  continue;
60
63
  }
61
- const visibility = evaluateVisibility(item, values);
64
+ const visibility = visibilityMap[itemKey(item)];
62
65
  if (!visibility.visible) {
63
66
  positions.push(0);
64
67
  continue;
@@ -120,7 +123,7 @@
120
123
  {#if layoutItem.type === 'linebreak'}
121
124
  <div style="grid-column: 1 / -1" class="h-px bg-border" aria-hidden="true"></div>
122
125
  {:else}
123
- {@const visibility = evaluateVisibility(layoutItem, values)}
126
+ {@const visibility = visibilityMap[itemKey(layoutItem)]}
124
127
  {@const span = Math.min(Math.max(1, layoutItem.span ?? 1), cols)}
125
128
  {#if visibility.visible}
126
129
  {#if layoutItem.type === 'input'}
@@ -2,7 +2,7 @@
2
2
  import type { OutputImageLayoutItem } from '@selvajs/schemas';
3
3
  import type { FileData } from '@selvajs/compute';
4
4
  import { Download, Maximize, Minimize } from '@lucide/svelte';
5
- import { downloadFiles } from '../../utils/file-download';
5
+ import { downloadFiles, isFileData, MIME_BY_EXT } from '../../utils/file-download';
6
6
 
7
7
  interface Props {
8
8
  item: OutputImageLayoutItem;
@@ -12,18 +12,6 @@
12
12
  let { item, value }: Props = $props();
13
13
 
14
14
  const SUPPORTED_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg']);
15
- const MIME_BY_EXT: Record<string, string> = {
16
- '.png': 'image/png',
17
- '.jpg': 'image/jpeg',
18
- '.jpeg': 'image/jpeg',
19
- '.webp': 'image/webp',
20
- '.gif': 'image/gif',
21
- '.svg': 'image/svg+xml'
22
- };
23
-
24
- function isFileData(x: unknown): x is FileData {
25
- return !!x && typeof x === 'object' && 'fileName' in x && 'data' in x && 'fileType' in x;
26
- }
27
15
 
28
16
  const file = $derived.by<FileData | null>(() => {
29
17
  if (Array.isArray(value)) return value.find(isFileData) ?? null;
@@ -20,6 +20,7 @@
20
20
  NumberInput,
21
21
  TextInput
22
22
  } from './inputs';
23
+ import { getClientSlot } from '../../contexts/clientSlotContext.svelte';
23
24
 
24
25
  interface Props {
25
26
  item: InputLayoutItem;
@@ -40,6 +41,18 @@
40
41
  const inputId = $derived(`input-${item.paramId}`);
41
42
  const label = $derived(displayName || item.displayName || item.paramId);
42
43
 
44
+ // Client-sourced input set to render a host element in its place. The 'hidden'
45
+ // presentation never reaches here (visible:false filters it out upstream), so a
46
+ // client slot source means presentation === 'slot'.
47
+ const clientSlotConfig = $derived.by(() => {
48
+ const source = (
49
+ item as { source?: { kind?: string; client?: { presentation?: string; slotLabel?: string } } }
50
+ ).source;
51
+ if (source?.kind !== 'client' || source.client?.presentation !== 'slot') return null;
52
+ return { slotLabel: source.client.slotLabel };
53
+ });
54
+ const clientSlot = getClientSlot();
55
+
43
56
  // Number range hint — shown next to label for sliders, under the input for plain number fields.
44
57
  const numberRangeHint = $derived.by(() => {
45
58
  if (!isNumberWidget(item)) return null;
@@ -61,87 +74,98 @@
61
74
  }
62
75
  </script>
63
76
 
64
- <Field.Field>
65
- <Field.Label for={inputId} class="gap-2 flex items-center">
66
- {label}
67
- {#if item.description}
68
- <Dialog.Root>
69
- <Dialog.Trigger class="p-1 cursor-help opacity-60 transition-opacity hover:opacity-100">
70
- <HelpCircle size={16} />
71
- </Dialog.Trigger>
72
- <Dialog.Content class="sm:max-w-md">
73
- <Dialog.Header>
74
- <Dialog.Title>{label}</Dialog.Title>
75
- <Dialog.Description>{item.description}</Dialog.Description>
76
- </Dialog.Header>
77
- </Dialog.Content>
78
- </Dialog.Root>
79
- {/if}
80
- {#if showRangeInLabel}
81
- <span class="text-xs font-normal text-muted-foreground">{numberRangeHint}</span>
82
- {/if}
83
- </Field.Label>
77
+ {#if clientSlotConfig}
78
+ {#if clientSlot}
79
+ {@render clientSlot({
80
+ inputId: item.paramId,
81
+ displayName: label,
82
+ slotLabel: clientSlotConfig.slotLabel,
83
+ value
84
+ })}
85
+ {/if}
86
+ {:else}
87
+ <Field.Field>
88
+ <Field.Label for={inputId} class="gap-2 flex items-center">
89
+ {label}
90
+ {#if item.description}
91
+ <Dialog.Root>
92
+ <Dialog.Trigger class="p-1 cursor-help opacity-60 transition-opacity hover:opacity-100">
93
+ <HelpCircle size={16} />
94
+ </Dialog.Trigger>
95
+ <Dialog.Content class="sm:max-w-md">
96
+ <Dialog.Header>
97
+ <Dialog.Title>{label}</Dialog.Title>
98
+ <Dialog.Description>{item.description}</Dialog.Description>
99
+ </Dialog.Header>
100
+ </Dialog.Content>
101
+ </Dialog.Root>
102
+ {/if}
103
+ {#if showRangeInLabel}
104
+ <span class="text-xs font-normal text-muted-foreground">{numberRangeHint}</span>
105
+ {/if}
106
+ </Field.Label>
84
107
 
85
- {#if isNumberWidget(item)}
86
- {@const config = item.config}
87
- <NumberInput
88
- {inputId}
89
- value={typeof value === 'number' ? value : undefined}
90
- {config}
91
- onChange={commit}
92
- {disabled}
93
- />
94
- {:else if isCheckboxWidget(item)}
95
- <CheckboxInput
96
- {inputId}
97
- value={typeof value === 'boolean' ? value : undefined}
98
- onChange={commit}
99
- {disabled}
100
- />
101
- {:else if isTextWidget(item)}
102
- {@const config = item.config}
103
- <TextInput
104
- {inputId}
105
- value={typeof value === 'string' ? value : ''}
106
- {config}
107
- onChange={commit}
108
- {disabled}
109
- />
110
- {:else if isDropdownWidget(item)}
111
- {@const config = item.config}
112
- {#if config.displayAs === 'checklist'}
113
- <ChecklistInput
108
+ {#if isNumberWidget(item)}
109
+ {@const config = item.config}
110
+ <NumberInput
114
111
  {inputId}
115
- value={Array.isArray(value)
116
- ? (value as string[])
117
- : typeof value === 'string' && value
118
- ? [value]
119
- : []}
112
+ value={typeof value === 'number' ? value : undefined}
120
113
  {config}
121
114
  onChange={commit}
122
115
  {disabled}
123
116
  />
124
- {:else}
125
- <DropdownInput
117
+ {:else if isCheckboxWidget(item)}
118
+ <CheckboxInput
119
+ {inputId}
120
+ value={typeof value === 'boolean' ? value : undefined}
121
+ onChange={commit}
122
+ {disabled}
123
+ />
124
+ {:else if isTextWidget(item)}
125
+ {@const config = item.config}
126
+ <TextInput
127
+ {inputId}
126
128
  value={typeof value === 'string' ? value : ''}
127
129
  {config}
128
130
  onChange={commit}
129
131
  {disabled}
130
132
  />
133
+ {:else if isDropdownWidget(item)}
134
+ {@const config = item.config}
135
+ {#if config.displayAs === 'checklist'}
136
+ <ChecklistInput
137
+ {inputId}
138
+ value={Array.isArray(value)
139
+ ? (value as string[])
140
+ : typeof value === 'string' && value
141
+ ? [value]
142
+ : []}
143
+ {config}
144
+ onChange={commit}
145
+ {disabled}
146
+ />
147
+ {:else}
148
+ <DropdownInput
149
+ value={typeof value === 'string' ? value : ''}
150
+ {config}
151
+ onChange={commit}
152
+ {disabled}
153
+ />
154
+ {/if}
155
+ {:else if isFileWidget(item)}
156
+ {@const config = item.config as FileInputWidgetConfig}
157
+ <FileInput
158
+ value={typeof value === 'string' ? value : ''}
159
+ acceptedFormats={config?.acceptedFormats ?? []}
160
+ onChange={(newValue) => commit(newValue)}
161
+ defaultInputMode={config?.defaultInputMode}
162
+ allowedInputModes={config?.allowedInputModes}
163
+ />
164
+ {:else if isColorWidget(item)}
165
+ <ColorInput
166
+ value={typeof value === 'string' ? value : '#000000'}
167
+ onChange={(newValue) => commit(newValue)}
168
+ />
131
169
  {/if}
132
- {:else if isFileWidget(item)}
133
- {@const config = item.config as FileInputWidgetConfig}
134
- <FileInput
135
- value={typeof value === 'string' ? value : ''}
136
- acceptedFormats={config?.acceptedFormats ?? []}
137
- onChange={(newValue) => commit(newValue)}
138
- defaultInputMode={config?.defaultInputMode}
139
- allowedInputModes={config?.allowedInputModes}
140
- />
141
- {:else if isColorWidget(item)}
142
- <ColorInput
143
- value={typeof value === 'string' ? value : '#000000'}
144
- onChange={(newValue) => commit(newValue)}
145
- />
146
- {/if}
147
- </Field.Field>
170
+ </Field.Field>
171
+ {/if}
@@ -3,7 +3,12 @@
3
3
  import type { FileData } from '@selvajs/compute';
4
4
  import ChartOutput from './ChartOutput.svelte';
5
5
  import ImageOutput from './ImageOutput.svelte';
6
- import { downloadFiles, formatFileSize, getBase64FileSize } from '../../utils/file-download';
6
+ import {
7
+ downloadFiles,
8
+ formatFileSize,
9
+ getBase64FileSize,
10
+ isFileData
11
+ } from '../../utils/file-download';
7
12
  import { Button } from '../primitives';
8
13
  import * as Dialog from '../primitives/dialog';
9
14
  import { Info, Folder, FolderOpen, FileIcon, ChevronRight } from '@lucide/svelte';
@@ -46,10 +51,6 @@
46
51
  let downloading = $state(false);
47
52
  let downloadError = $state<string | null>(null);
48
53
 
49
- function isFileData(data: any): data is FileData {
50
- return data && typeof data === 'object' && 'fileName' in data && 'data' in data;
51
- }
52
-
53
54
  const filesArray = $derived(
54
55
  !value ? [] : Array.isArray(value) ? value.filter(isFileData) : isFileData(value) ? [value] : []
55
56
  );
@@ -114,7 +115,6 @@
114
115
  });
115
116
  const hasDuplicates = $derived(duplicatePaths.length > 0);
116
117
 
117
- // All folders expanded by default
118
118
  let expandedFolders = new SvelteSet<string>();
119
119
 
120
120
  $effect(() => {
@@ -4,7 +4,7 @@
4
4
  import * as Tabs from '../primitives/tabs';
5
5
  import TabBar from './TabBar.svelte';
6
6
  import TabContent from './TabContent.svelte';
7
- import { evaluateVisibility } from '../../schema/visibility-rules';
7
+ import { buildVisibilityMap, itemKey } from '../../schema/visibility-rules';
8
8
 
9
9
  interface Props {
10
10
  schema: UISchema;
@@ -64,17 +64,18 @@
64
64
  if (schema.layout.type !== 'tabbed') return;
65
65
  const updates: Record<string, unknown> = {};
66
66
  visibleTabs.forEach((tab) =>
67
- tab.groups.forEach((group) =>
67
+ tab.groups.forEach((group) => {
68
+ const visibilityMap = buildVisibilityMap(group.items, values);
68
69
  group.items.forEach((layoutItem) => {
69
70
  if (layoutItem.type === 'linebreak') return;
70
- const { visible, disabled, defaultValue } = evaluateVisibility(layoutItem, values);
71
+ const { visible, disabled, defaultValue } = visibilityMap[itemKey(layoutItem)];
71
72
  const input = schema.inputs.find((i) => i.id === layoutItem.paramId);
72
73
  if (!input || defaultValue === undefined) return;
73
74
  if ((!visible || disabled) && values[input.id] !== defaultValue) {
74
75
  updates[input.id] = defaultValue;
75
76
  }
76
- })
77
- )
77
+ });
78
+ })
78
79
  );
79
80
  if (Object.keys(updates).length > 0) Object.assign(values, updates);
80
81
  });
@@ -41,7 +41,7 @@
41
41
  let urlInput = $state('');
42
42
  let isDragging = $state(false);
43
43
  let isLoading = $state(false);
44
- let urlError = $state<{ message: string; isCors: boolean } | null>(null);
44
+ let urlError = $state<{ message: string; isCors: boolean; hint?: string } | null>(null);
45
45
  let urlSuccess = $state('');
46
46
 
47
47
  // Parse existing value if it's JSON
@@ -88,26 +88,22 @@
88
88
  async function handleUrlChange() {
89
89
  if (!urlInput.trim()) return;
90
90
 
91
- // Reset all state before starting a new fetch
92
91
  isLoading = true;
93
92
  urlError = null;
94
93
  urlSuccess = '';
95
94
  uploadedFileName = '';
96
95
 
97
96
  try {
98
- // Validate URL format first
99
97
  let parsedUrl: URL;
100
98
  try {
101
99
  parsedUrl = new URL(urlInput);
102
100
  } catch {
103
101
  urlError = { message: 'Invalid URL — make sure it starts with https://', isCors: false };
104
- isLoading = false;
105
102
  return;
106
103
  }
107
104
 
108
105
  if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') {
109
106
  urlError = { message: 'Only http:// and https:// URLs are supported.', isCors: false };
110
- isLoading = false;
111
107
  return;
112
108
  }
113
109
 
@@ -117,7 +113,6 @@
117
113
  message: `Server returned ${response.status} ${response.statusText}. Check the URL is correct and publicly accessible.`,
118
114
  isCors: false
119
115
  };
120
- isLoading = false;
121
116
  return;
122
117
  }
123
118
 
@@ -129,7 +124,6 @@
129
124
  message: `File format "${fileEnding}" is not accepted. Allowed: ${acceptedFormats.join(', ')}`,
130
125
  isCors: false
131
126
  };
132
- isLoading = false;
133
127
  return;
134
128
  }
135
129
 
@@ -139,24 +133,22 @@
139
133
  message: `File too large: ${(blob.size / 1024 / 1024).toFixed(2)}MB (max ${APP_DEFAULTS.FILE_UPLOAD.MAX_SIZE_MB}MB). Download it and use Upload instead.`,
140
134
  isCors: false
141
135
  };
142
- isLoading = false;
143
136
  return;
144
137
  }
145
138
 
146
- await new Promise<void>((resolve, reject) => {
139
+ const base64Data = await new Promise<string>((resolve, reject) => {
147
140
  const reader = new FileReader();
148
141
  reader.onload = (e) => {
149
- const base64 = e.target?.result as string;
150
- const base64Data = base64.includes(',') ? base64.split(',')[1] : base64;
151
- const data = JSON.stringify({ file: base64Data, type: 'base64', fileEnding });
152
- value = data;
153
- urlSuccess = `Loaded ${fileEnding} (${(blob.size / 1024).toFixed(0)} KB)`;
154
- onChange(data);
155
- resolve();
142
+ const result = e.target?.result as string;
143
+ resolve(result.includes(',') ? result.split(',')[1] : result);
156
144
  };
157
145
  reader.onerror = () => reject(new Error('Failed to read the downloaded file.'));
158
146
  reader.readAsDataURL(blob);
159
147
  });
148
+ const data = JSON.stringify({ file: base64Data, type: 'base64', fileEnding });
149
+ value = data;
150
+ urlSuccess = `Loaded ${fileEnding} (${(blob.size / 1024).toFixed(0)} KB)`;
151
+ onChange(data);
160
152
  } catch (error) {
161
153
  if (isCorsError(error)) {
162
154
  const host = (() => {
@@ -184,7 +176,6 @@
184
176
  urlError = {
185
177
  message: `Could not reach "${host}" — the file may not exist, or the server does not allow browser access.`,
186
178
  isCors: true,
187
- // @ts-expect-error hint is properly typed at runtime
188
179
  hint
189
180
  };
190
181
  } else {
@@ -328,8 +319,8 @@
328
319
  <CircleAlert size={13} class="mt-0.5 shrink-0 text-destructive" />
329
320
  <p class="text-xs leading-snug text-destructive">{urlError.message}</p>
330
321
  </div>
331
- {#if urlError.isCors && (urlError as any).hint}
332
- <p class="text-xs leading-snug pl-5 text-muted-foreground">{(urlError as any).hint}</p>
322
+ {#if urlError.isCors && urlError.hint}
323
+ <p class="text-xs leading-snug pl-5 text-muted-foreground">{urlError.hint}</p>
333
324
  {/if}
334
325
  </div>
335
326
  {/if}
@@ -1,15 +1,13 @@
1
+ import { type Component } from 'svelte';
2
+ import { type FooterItem } from '../contexts/footerContext.svelte';
3
+ /** Config for a footer-item registration; generic over the component's props. */
4
+ export type FooterItemConfig<P extends Record<string, unknown> = Record<string, unknown>> = Omit<FooterItem<P>, 'position' | 'priority'> & Partial<Pick<FooterItem<P>, 'position' | 'priority'>>;
1
5
  /**
2
- * Register a footer item component that reactively updates its props.
3
- * The `getProps` function is called on every render, so returning reactive state
4
- * (e.g. `() => ({ status: myState.status })`) will keep the footer in sync.
6
+ * Register a footer item for the lifetime of the calling component.
5
7
  *
6
- * Registration is deferred to onMount (client-side only) to avoid SSR context errors.
7
- *
8
- * @param id - Unique identifier for this footer item
9
- * @param component - Svelte component to render
10
- * @param getProps - Function returning current props (called reactively)
11
- * @param position - Where to display ('left' or 'right'), defaults to 'left'
12
- * @param priority - Higher values render first within their position
13
- * @param onClick - Optional click handler (e.g. open a popup/dialog)
8
+ * Registration is deferred to onMount (client-side only) to avoid SSR context errors;
9
+ * it's unregistered on destroy. A no-op when no footer context is present (e.g. a
10
+ * component rendered outside the root layout) or no component is supplied.
14
11
  */
15
- export declare function useFooterItem(id: string, component: any, getProps: () => Record<string, any>, position?: 'left' | 'right', priority?: number, onClick?: () => void): void;
12
+ export declare function useFooterItem<P extends Record<string, unknown>>(config: FooterItemConfig<P>): void;
13
+ export type { Component };